- {/* view header */}
- {/* hero */}
Hum it, or just describe it.
@@ -40,45 +59,76 @@ export function ComposeView() {
- {/* prompt bar */}
-
- a warm lo-fi beat, 90 bpm, dusty drums, rhodes chords, vinyl crackle...
-
+
- {/* style chips */}
-
- {STYLE_CHIPS.map((chip, i) => (
+ {needsBackend && (
+
+
Install a music generation backend (musicgpt, musicgen, or stable-audio-open) to compose tracks.
- {chip}
+ Open Store
- ))}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {STYLE_CHIPS.map((chip) => {
+ const active = style === chip;
+ return (
+ onStyleChange(active ? null : chip)}
+ className={`rounded-full border px-3.5 py-[7px] text-[11.5px] font-semibold ${
+ active
+ ? "border-accent bg-accent/20 text-accent"
+ : "border-shell-border bg-shell-surface text-shell-text-secondary"
+ }`}
+ >
+ {chip}
+
+ );
+ })}
- {/* generated results */}
- {RESULTS.map((result, i) => (
+ {results.length === 0 && !generating && (
+
+ Generated tracks will appear here.
+
+ )}
+ {results.map((result) => (
-
- {result.bars.map((h, bi) => (
-
- ))}
+
- {result.bpm} BPM - {result.duration}
+ {result.duration}s
-
Open
))}
);
-}
+}
\ No newline at end of file
diff --git a/desktop/src/apps/officesuite/WriteView.tsx b/desktop/src/apps/officesuite/WriteView.tsx
index bbf57e1f8..8f9639ca3 100644
--- a/desktop/src/apps/officesuite/WriteView.tsx
+++ b/desktop/src/apps/officesuite/WriteView.tsx
@@ -1,3 +1,4 @@
+import { useCallback, useEffect, useState } from "react";
import {
Sparkles,
AlignLeft,
@@ -6,6 +7,8 @@ import {
Scissors,
ArrowRight,
AlignJustify,
+ Plus,
+ Save,
} from "lucide-react";
const AI_OPTIONS: { label: string; desc: string; Icon: typeof Sparkles }[] = [
@@ -15,10 +18,115 @@ const AI_OPTIONS: { label: string; desc: string; Icon: typeof Sparkles }[] = [
{ label: "Change tone", desc: "Friendly, formal, punchy", Icon: AlignJustify },
];
+type OfficeDocListItem = {
+ id: string;
+ kind: string;
+ title: string;
+ updated_at?: number;
+};
+
+type OfficeDoc = OfficeDocListItem & {
+ content: string;
+};
+
+function formatUpdated(ts?: number): string {
+ if (!ts) return "Draft";
+ const d = new Date(ts * 1000);
+ return `Updated ${d.toLocaleString()}`;
+}
+
export function WriteView() {
+ const [docs, setDocs] = useState
([]);
+ const [activeId, setActiveId] = useState(null);
+ const [title, setTitle] = useState("Untitled document");
+ const [content, setContent] = useState("");
+ const [updatedAt, setUpdatedAt] = useState();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadList = useCallback(async () => {
+ const res = await fetch("/api/office/docs", { credentials: "include" });
+ if (!res.ok) throw new Error("Could not load documents");
+ const items = (await res.json()) as OfficeDocListItem[];
+ setDocs(items.filter((d) => d.kind === "write"));
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ await loadList();
+ if (!cancelled) setError(null);
+ } catch (e) {
+ if (!cancelled) setError(e instanceof Error ? e.message : "Load failed");
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [loadList]);
+
+ const openDoc = async (docId: string) => {
+ setError(null);
+ try {
+ const res = await fetch(`/api/office/docs/${encodeURIComponent(docId)}`, {
+ credentials: "include",
+ });
+ if (!res.ok) throw new Error("Could not open document");
+ const doc = (await res.json()) as OfficeDoc;
+ setActiveId(doc.id);
+ setTitle(doc.title);
+ setContent(doc.content);
+ setUpdatedAt(doc.updated_at);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Open failed");
+ }
+ };
+
+ const newDoc = () => {
+ setActiveId(null);
+ setTitle("Untitled document");
+ setContent("");
+ setUpdatedAt(undefined);
+ setError(null);
+ };
+
+ const saveDoc = async () => {
+ setSaving(true);
+ setError(null);
+ try {
+ const payload = { kind: "write", title: title.trim() || "Untitled document", content };
+ const url = activeId
+ ? `/api/office/docs/${encodeURIComponent(activeId)}`
+ : "/api/office/docs";
+ const res = await fetch(url, {
+ method: activeId ? "PUT" : "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error((body as { error?: string }).error || "Save failed");
+ }
+ const saved = (await res.json()) as OfficeDoc;
+ setActiveId(saved.id);
+ setTitle(saved.title);
+ setContent(saved.content);
+ setUpdatedAt(saved.updated_at);
+ await loadList();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Save failed");
+ } finally {
+ setSaving(false);
+ }
+ };
+
return (
- {/* formatting toolbar */}
Sohne
▾
@@ -60,19 +168,59 @@ export function WriteView() {
>
-
-
-
- Assist
-
+
+
+
+ New
+
+
+
+ {saving ? "Saving..." : "Save"}
+
+
- {/* document area + AI panel */}
- {/* document scroll area */}
+
+
-
setTitle(e.target.value)}
+ aria-label="Document title"
+ className="mb-1.5 w-full border-0 bg-transparent font-extrabold leading-tight tracking-tight outline-none"
style={{ fontSize: 27, letterSpacing: "-0.02em" }}
- >
- taOS Studios launch note
-
+ />
- Draft · updated just now
-
-
- taOS now ships a family of creative studios, each a focused workspace that runs
- entirely on hardware you already own.{" "}
-
- Whether you are building a business, a hobby project, or something just for the
- house
-
- , there is a studio with the tools you need.
-
-
- What is ready today
-
-
- Images Studio and Game Studio are available now. Coding Studio is rolling out, with
- Design, Music, App, and Office studios close behind. Each one installs from the Store
- in a single click.
-
-
- Everything runs offline by default, on your cluster, with nothing leaving your network
- unless you choose to share it.
+ {formatUpdated(updatedAt)}
+
- {/* AI panel */}
@@ -149,4 +283,4 @@ export function WriteView() {
);
-}
+}
\ No newline at end of file
diff --git a/desktop/src/components/CodeBlock.test.tsx b/desktop/src/components/CodeBlock.test.tsx
new file mode 100644
index 000000000..aafe4c37e
--- /dev/null
+++ b/desktop/src/components/CodeBlock.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { CodeBlock } from "./CodeBlock";
+
+describe("CodeBlock", () => {
+ it("renders the code text", () => {
+ render(
);
+ expect(screen.getByText("const x = 1;")).toBeInTheDocument();
+ });
+
+ it("shows a copy button with the correct aria-label", () => {
+ render(
);
+ const button = screen.getByRole("button", { name: /copy code/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it("copies code to clipboard and shows Copied state", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, { clipboard: { writeText } });
+
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /copy code/i }));
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith("copy me"));
+ expect(screen.getByRole("button", { name: /copied/i })).toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/DockIcon.test.tsx b/desktop/src/components/DockIcon.test.tsx
new file mode 100644
index 000000000..4a179cdfb
--- /dev/null
+++ b/desktop/src/components/DockIcon.test.tsx
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: (sel: (s: Record
) => unknown) =>
+ sel({
+ windows: [],
+ focusWindow: vi.fn(),
+ restoreWindow: vi.fn(),
+ minimizeWindow: vi.fn(),
+ maximizeWindow: vi.fn(),
+ recenterWindow: vi.fn(),
+ closeWindow: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/dock-store", () => ({
+ useDockStore: (sel: (s: Record) => unknown) =>
+ sel({
+ pinned: [],
+ pin: vi.fn(),
+ unpin: vi.fn(),
+ }),
+}));
+
+vi.mock("@/hooks/use-is-mobile", () => ({
+ useIsMobile: () => false,
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getApp: (id: string) => ({
+ id,
+ name: id === "messages" ? "Messages" : "Test App",
+ icon: "message-circle",
+ category: "platform",
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 400, h: 300 },
+ singleton: true,
+ pinned: true,
+ launchpadOrder: 1,
+ }),
+ prefetchApp: vi.fn(),
+}));
+
+import { DockIcon } from "./DockIcon";
+
+describe("DockIcon", () => {
+ it("renders the app name as accessible label and title", () => {
+ render( );
+ const button = screen.getByRole("button", { name: /open messages/i });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute("title", "Messages");
+ });
+
+ it("shows the running indicator when isRunning is true", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: /open messages/i }).querySelector(".bg-accent")
+ ).toBeInTheDocument();
+ });
+
+ it("does not show the running indicator when isRunning is false", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: /open messages/i }).querySelector(".bg-accent")
+ ).toBeNull();
+ });
+
+ it("calls onClick when the dock icon is clicked", () => {
+ const onClick = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /open messages/i }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/components/DockIcon.tsx b/desktop/src/components/DockIcon.tsx
index edef12579..774d6a16a 100644
--- a/desktop/src/components/DockIcon.tsx
+++ b/desktop/src/components/DockIcon.tsx
@@ -22,6 +22,7 @@ export function DockIcon({ appId, isRunning, onClick }: Props) {
const maximizeWindow = useProcessStore((s) => s.maximizeWindow);
const recenterWindow = useProcessStore((s) => s.recenterWindow);
const closeWindow = useProcessStore((s) => s.closeWindow);
+ const openWindow = useProcessStore((s) => s.openWindow);
const pinned = useDockStore((s) => s.pinned);
const pin = useDockStore((s) => s.pin);
const unpin = useDockStore((s) => s.unpin);
@@ -42,6 +43,15 @@ export function DockIcon({ appId, isRunning, onClick }: Props) {
.join("") as keyof typeof icons;
const IconComponent = (icons[iconName] as icons.LucideIcon) ?? icons.HelpCircle;
+ // Multi-window apps (singleton: false) can spawn a second, independent
+ // window from the dock, the way macOS offers File > New Window.
+ const multiWindow = app.singleton === false;
+ const newWindowItem: MenuItem = {
+ label: "New Window",
+ icon: ,
+ action: () => openWindow(appId, app.defaultSize, undefined, { forceNew: true }),
+ };
+
const items: MenuItem[] = win
? [
{
@@ -49,6 +59,7 @@ export function DockIcon({ appId, isRunning, onClick }: Props) {
icon: ,
action: () => (win.minimized ? restoreWindow(win.id) : focusWindow(win.id)),
},
+ ...(multiWindow ? [newWindowItem] : []),
...(!win.minimized
? [{ label: "Minimise", icon: , action: () => minimizeWindow(win.id) }]
: []),
diff --git a/desktop/src/components/InstallHelperPanel.test.tsx b/desktop/src/components/InstallHelperPanel.test.tsx
new file mode 100644
index 000000000..f6cb71605
--- /dev/null
+++ b/desktop/src/components/InstallHelperPanel.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { InstallHelperPanel } from "./InstallHelperPanel";
+
+describe("InstallHelperPanel", () => {
+ beforeEach(() => {
+ Object.defineProperty(window, "location", {
+ value: { origin: "http://localhost:3000" },
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("renders with appId and appName in heading", () => {
+ render(
+
+ );
+ expect(screen.getByText("Install My App")).toBeInTheDocument();
+ });
+
+ it("shows the correct install URL", () => {
+ render(
+
+ );
+ expect(
+ screen.getByDisplayValue("http://localhost:3000/app.html?app=myapp")
+ ).toBeInTheDocument();
+ });
+
+ it("Copy uses the clipboard API in a secure context", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: { writeText },
+ userAgent: "Chrome",
+ platform: "Win32",
+ maxTouchPoints: 0,
+ });
+ Object.defineProperty(window, "isSecureContext", {
+ value: true,
+ configurable: true,
+ });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText("Copy"));
+ expect(writeText).toHaveBeenCalledWith(
+ expect.stringContaining("/app.html?app=myapp")
+ );
+ });
+
+ it("Copy falls back to execCommand on a non-secure origin (HTTP)", async () => {
+ // Plain-HTTP origins (LAN / Tailscale IP) don't expose navigator.clipboard;
+ // the button must still copy via the execCommand fallback rather than throw.
+ vi.stubGlobal("navigator", {
+ ...navigator,
+ clipboard: undefined,
+ userAgent: "Chrome",
+ platform: "Win32",
+ maxTouchPoints: 0,
+ });
+ Object.defineProperty(window, "isSecureContext", {
+ value: false,
+ configurable: true,
+ });
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(document, "execCommand", {
+ value: execCommand,
+ configurable: true,
+ writable: true,
+ });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText("Copy"));
+ expect(execCommand).toHaveBeenCalledWith("copy");
+ });
+
+ it("onClose fires when Done button is clicked", () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText("Done"));
+ expect(onClose).toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/components/InstallHelperPanel.tsx b/desktop/src/components/InstallHelperPanel.tsx
new file mode 100644
index 000000000..ce7f0712f
--- /dev/null
+++ b/desktop/src/components/InstallHelperPanel.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { isIOS } from "@/lib/platform";
+
+interface Props {
+ appId: string;
+ appName: string;
+ onClose: () => void;
+}
+
+export function InstallHelperPanel({ appId, appName, onClose }: Props) {
+ const [copied, setCopied] = useState(false);
+
+ const url = `${window.location.origin}/app.html?app=${encodeURIComponent(appId)}`;
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [onClose]);
+
+ function fallbackCopy(): boolean {
+ // Non-secure contexts (plain HTTP on a LAN / Tailscale IP) don't expose
+ // navigator.clipboard. Operate on the already-visible URL input rather than
+ // a hidden textarea: iOS Safari refuses to copy from opacity:0 elements and
+ // will not select a readonly field, so toggle readonly off, select the real
+ // on-screen input, copy, then restore. The definitive fix is HTTPS (taOSgo
+ // or Tailscale Serve), where navigator.clipboard works directly.
+ const el = document.getElementById("install-helper-url-input") as HTMLInputElement | null;
+ if (!el) return false;
+ const wasReadOnly = el.readOnly;
+ try {
+ el.readOnly = false;
+ el.focus();
+ el.setSelectionRange(0, el.value.length);
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ el.readOnly = wasReadOnly;
+ }
+ }
+
+ async function handleCopy() {
+ let ok = false;
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(url);
+ ok = true;
+ }
+ } catch {
+ ok = false;
+ }
+ if (!ok) ok = fallbackCopy();
+ if (ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else {
+ // Couldn't copy programmatically — select the visible field so the
+ // user can copy it by hand.
+ const el = document.getElementById("install-helper-url-input") as HTMLInputElement | null;
+ el?.focus();
+ el?.setSelectionRange(0, url.length);
+ }
+ }
+
+ const platformHint = isIOS()
+ ? "In Safari, tap the Share button, then Add to Home Screen."
+ : "In Chrome, open the menu and choose Install app or Add to Home screen.";
+
+ // Portal to body so the fixed overlay is not mispositioned when mounted
+ // inside a transformed ancestor (the desktop window uses CSS transforms).
+ return createPortal(
+ { if (e.key === "Escape") onClose(); }}
+ tabIndex={-1}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="install-helper-title"
+ >
+
e.stopPropagation()}
+ >
+
+ Install {appName}
+
+
+
+ To use {appName} as its own app, open the link below in your browser,
+ then add it to your home screen.
+
+
+
(e.target as HTMLInputElement).select()}
+ aria-label="Install URL"
+ />
+
+
+ {platformHint}
+
+
+
+
+ {copied ? "Copied!" : "Copy"}
+
+
+ Done
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/desktop/src/components/Launchpad.tsx b/desktop/src/components/Launchpad.tsx
index da06404ee..fe1682610 100644
--- a/desktop/src/components/Launchpad.tsx
+++ b/desktop/src/components/Launchpad.tsx
@@ -17,6 +17,7 @@ interface Props {
const CATEGORY_LABELS: Record = {
platform: "Platform",
+ studio: "Studio",
os: "Utilities",
streaming: "Streaming Apps",
game: "Games",
@@ -149,7 +150,7 @@ export function Launchpad({ open, onClose, onOpenApp }: Props) {
{filteredUserspace.length > 0 && (
- Apps
+ My Apps
{filteredUserspace.map((app) => (
diff --git a/desktop/src/components/LoginGate.test.tsx b/desktop/src/components/LoginGate.test.tsx
new file mode 100644
index 000000000..661ada114
--- /dev/null
+++ b/desktop/src/components/LoginGate.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import { LoginGate } from "./LoginGate";
+
+describe("LoginGate host reachability", () => {
+ afterEach(() => { vi.restoreAllMocks(); });
+
+ it("shows the off-network screen when /auth/status is unreachable", async () => {
+ // A thrown fetch is a network failure (host unreachable), not an HTTP error.
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"));
+ render(
+
+ the desktop shell
+ ,
+ );
+ expect(await screen.findByText("Can't reach your taOS")).toBeInTheDocument();
+ // The broken shell must NOT render behind it.
+ expect(screen.queryByText("the desktop shell")).not.toBeInTheDocument();
+ });
+
+ it("renders the app shell when authenticated and reachable", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ configured: true, authenticated: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ render(
+
+ the desktop shell
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("the desktop shell")).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/desktop/src/components/LoginGate.tsx b/desktop/src/components/LoginGate.tsx
index d470140c7..d1e603d2e 100644
--- a/desktop/src/components/LoginGate.tsx
+++ b/desktop/src/components/LoginGate.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { Lock } from "lucide-react";
import { OnboardingScreen } from "./OnboardingScreen";
+import { OffNetworkScreen } from "./OffNetworkScreen";
import { SESSION_EXPIRED_EVENT } from "@/lib/auth-guard";
interface Props {
@@ -12,6 +13,7 @@ type AuthStatus =
| { phase: "onboarding" }
| { phase: "invite"; username: string; inviteCode: string; multiUser: boolean }
| { phase: "login"; legacy: boolean; multiUser: boolean }
+ | { phase: "unreachable" }
| { phase: "ready" };
export function LoginGate({ children }: Props) {
@@ -48,7 +50,10 @@ export function LoginGate({ children }: Props) {
setStatus({ phase: "login", legacy: !data.user, multiUser: !!data.multi_user });
}
} catch {
- setStatus({ phase: "ready" });
+ // A thrown fetch (network failure, not an HTTP error) means the host is
+ // unreachable -- e.g. the PWA was opened off the host's network. Offer
+ // taOSgo rather than load the shell into a broken, data-less state.
+ setStatus({ phase: "unreachable" });
}
}, []);
@@ -119,6 +124,10 @@ export function LoginGate({ children }: Props) {
);
}
+ if (status.phase === "unreachable") {
+ return
;
+ }
+
if (status.phase === "onboarding") {
return
;
}
diff --git a/desktop/src/components/NotificationToast.test.tsx b/desktop/src/components/NotificationToast.test.tsx
new file mode 100644
index 000000000..ddf919c83
--- /dev/null
+++ b/desktop/src/components/NotificationToast.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { NotificationToasts } from "./NotificationToast";
+
+const mockDismiss = vi.fn();
+const mockNotifications: Array<{
+ id: string;
+ source: string;
+ title: string;
+ body: string;
+ level: "info" | "success" | "warning" | "error";
+ read: boolean;
+ timestamp: number;
+}> = [];
+
+vi.mock("@/stores/notification-store", () => ({
+ useNotificationStore: (selector: (state: { notifications: typeof mockNotifications; dismiss: typeof mockDismiss }) => unknown) =>
+ selector({ notifications: mockNotifications, dismiss: mockDismiss }),
+}));
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: () => ({ openWindow: vi.fn() }),
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getApp: vi.fn(),
+}));
+
+describe("NotificationToasts", () => {
+ it("renders nothing when there are no notifications", () => {
+ const { container } = render(
);
+ expect(container.querySelector("[aria-label='Notifications']")?.children.length).toBe(0);
+ });
+
+ it("renders a notification toast with title and body", () => {
+ mockNotifications.length = 0;
+ mockNotifications.push({
+ id: "test-1",
+ source: "system",
+ title: "Update available",
+ body: "A new version of taOS is ready to install.",
+ level: "info",
+ read: false,
+ timestamp: Date.now(),
+ });
+ render(
);
+ expect(screen.getByText("Update available")).toBeInTheDocument();
+ expect(screen.getByText("A new version of taOS is ready to install.")).toBeInTheDocument();
+ });
+
+ it("calls dismiss when the close button is clicked", async () => {
+ mockNotifications.length = 0;
+ mockNotifications.push({
+ id: "test-2",
+ source: "system",
+ title: "Restart required",
+ body: "Please restart to apply changes.",
+ level: "warning",
+ read: false,
+ timestamp: Date.now(),
+ });
+ mockDismiss.mockClear();
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /dismiss notification/i }));
+ await waitFor(() => expect(mockDismiss).toHaveBeenCalledWith("test-2"));
+ });
+});
diff --git a/desktop/src/components/OffNetworkScreen.test.tsx b/desktop/src/components/OffNetworkScreen.test.tsx
new file mode 100644
index 000000000..4cfec4251
--- /dev/null
+++ b/desktop/src/components/OffNetworkScreen.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { OffNetworkScreen } from "./OffNetworkScreen";
+
+describe("OffNetworkScreen", () => {
+ it("renders the unreachable message and the taOSgo call to action", () => {
+ render(
);
+ expect(screen.getByText("Can't reach your taOS")).toBeInTheDocument();
+ const cta = screen.getByRole("link", { name: /get taosgo/i });
+ expect(cta).toHaveAttribute("href", "https://taos.my/taosgo");
+ });
+
+ it("calls onRetry when Try again is clicked", async () => {
+ const onRetry = vi.fn().mockResolvedValue(undefined);
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /try again/i }));
+ await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/desktop/src/components/OffNetworkScreen.tsx b/desktop/src/components/OffNetworkScreen.tsx
new file mode 100644
index 000000000..348c0c447
--- /dev/null
+++ b/desktop/src/components/OffNetworkScreen.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react";
+import { CloudOff, Plane, RefreshCw } from "lucide-react";
+
+interface Props {
+ /** Re-run the host reachability check. Should resolve when the check completes. */
+ onRetry: () => Promise
| void;
+}
+
+/**
+ * Shown when the taOS host cannot be reached from the current network (e.g. the
+ * PWA was opened off the host's LAN). Rather than load the shell into a broken,
+ * data-less state, we offer the way back in: taOSgo for secure access from
+ * anywhere, plus a retry for a transient blip.
+ */
+export function OffNetworkScreen({ onRetry }: Props) {
+ const [checking, setChecking] = useState(false);
+
+ const retry = async () => {
+ setChecking(true);
+ try {
+ await onRetry();
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
Can't reach your taOS
+
+ Your taOS isn't reachable from this network. taOSgo gives you secure access
+ from anywhere, with nothing to install.
+
+
+
+ Get taOSgo
+
+
+
+
+ {checking ? "Checking..." : "Try again"}
+
+
+
+ On your own Tailscale network? Reach your taOS through your tailnet.
+
+
+
+ );
+}
diff --git a/desktop/src/components/SafetyFloor.test.tsx b/desktop/src/components/SafetyFloor.test.tsx
new file mode 100644
index 000000000..8fce8691b
--- /dev/null
+++ b/desktop/src/components/SafetyFloor.test.tsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { SafetyFloor } from "./SafetyFloor";
+import { useTaosAgentStore } from "@/stores/taos-agent-store";
+import { useThemeStore } from "@/stores/theme-store";
+
+describe("SafetyFloor", () => {
+ beforeEach(() => {
+ useTaosAgentStore.setState({ isOpen: false });
+ useThemeStore.setState({ structure: {} });
+ });
+
+ it("renders nothing when the top bar is visible", () => {
+ useThemeStore.setState({ structure: { topBar: { variant: "standard" } } });
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the assistant button when the top bar is hidden", () => {
+ useThemeStore.setState({ structure: { topBar: { variant: "hidden" } } });
+ render( );
+ expect(screen.getByRole("button", { name: /taos assistant/i })).toBeInTheDocument();
+ });
+
+ it("calls openPanel when the button is clicked", () => {
+ const openPanel = vi.fn();
+ useTaosAgentStore.setState({ isOpen: false, openPanel });
+ useThemeStore.setState({ structure: { topBar: { variant: "hidden" } } });
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /taos assistant/i }));
+ expect(openPanel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/components/ScreenshotFlash.test.tsx b/desktop/src/components/ScreenshotFlash.test.tsx
new file mode 100644
index 000000000..c0b294b4e
--- /dev/null
+++ b/desktop/src/components/ScreenshotFlash.test.tsx
@@ -0,0 +1,32 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, fireEvent, waitFor } from "@testing-library/react";
+import { ScreenshotFlash } from "./ScreenshotFlash";
+
+describe("ScreenshotFlash", () => {
+ it("renders nothing before the flash event fires", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("shows the flash overlay with the correct attributes when the event fires", async () => {
+ const { container } = render( );
+ fireEvent(window, new CustomEvent("taos:screenshot-flash"));
+ await waitFor(() => {
+ const overlay = container.querySelector("[data-screenshot-exclude]");
+ expect(overlay).not.toBeNull();
+ });
+ const overlay = container.querySelector("[data-screenshot-exclude]");
+ expect(overlay).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("hides the overlay after the animation timeout", async () => {
+ const { container } = render( );
+ fireEvent(window, new CustomEvent("taos:screenshot-flash"));
+ await waitFor(() => {
+ expect(container.querySelector("[data-screenshot-exclude]")).not.toBeNull();
+ });
+ await waitFor(() => {
+ expect(container.innerHTML).toBe("");
+ }, { timeout: 2000 });
+ });
+});
diff --git a/desktop/src/components/TaosAgentCard.test.tsx b/desktop/src/components/TaosAgentCard.test.tsx
new file mode 100644
index 000000000..a1dde24f4
--- /dev/null
+++ b/desktop/src/components/TaosAgentCard.test.tsx
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { TaosAgentCard } from "./TaosAgentCard";
+
+const mockConfig = {
+ model: "claude-sonnet-4-20250514",
+ permitted_models: ["claude-sonnet-4-20250514", "gpt-4o"],
+ persona: "You are a helpful assistant.",
+ key_masked: "sk-...abcd",
+ framework: "opencode" as const,
+ system: true as const,
+};
+
+vi.mock("@/lib/taos-agent-api", () => ({
+ fetchTaosAgentConfig: vi.fn(),
+ setTaosAgentModel: vi.fn(),
+ setTaosAgentPermitted: vi.fn(),
+ setTaosAgentPersona: vi.fn(),
+}));
+
+vi.mock("@/components/ModelPickerModal", () => ({
+ ModelPickerModal: () => null,
+}));
+
+import { fetchTaosAgentConfig, setTaosAgentPermitted } from "@/lib/taos-agent-api";
+
+describe("TaosAgentCard", () => {
+ beforeEach(() => {
+ vi.mocked(fetchTaosAgentConfig).mockResolvedValue(mockConfig);
+ });
+
+ it("renders the agent card header, model, and permitted models", async () => {
+ render( );
+
+ expect(await screen.findByText("taOS agent")).toBeInTheDocument();
+ expect(screen.getAllByText("claude-sonnet-4-20250514").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText("opencode")).toBeInTheDocument();
+ expect(screen.getByText("System")).toBeInTheDocument();
+ expect(screen.getByText("sk-...abcd")).toBeInTheDocument();
+ expect(screen.getByText("Permitted models")).toBeInTheDocument();
+ expect(screen.getByText("gpt-4o")).toBeInTheDocument();
+ });
+
+ it("shows a loading skeleton before config resolves", () => {
+ vi.mocked(fetchTaosAgentConfig).mockReturnValue(new Promise(() => {}));
+ render( );
+ expect(screen.getByLabelText("Loading taOS agent")).toBeInTheDocument();
+ });
+
+ it("shows an error message when config fails to load", async () => {
+ vi.mocked(fetchTaosAgentConfig).mockRejectedValue(new Error("network down"));
+ render( );
+ expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load taOS agent config: network down");
+ });
+
+ it("removes a permitted model and shows the save button", async () => {
+ render( );
+ await screen.findByText("taOS agent");
+
+ fireEvent.click(screen.getByRole("button", { name: /remove gpt-4o from taos agent permitted models/i }));
+ expect(screen.queryByText("gpt-4o")).not.toBeInTheDocument();
+
+ const saveBtn = screen.getByRole("button", { name: /save taos agent permitted models/i });
+ expect(saveBtn).toBeInTheDocument();
+
+ vi.mocked(setTaosAgentPermitted).mockResolvedValue({
+ permitted_models: ["claude-sonnet-4-20250514"],
+ key_rescoped: false,
+ });
+ fireEvent.click(saveBtn);
+ await waitFor(() => expect(setTaosAgentPermitted).toHaveBeenCalledWith(["claude-sonnet-4-20250514"]));
+ });
+});
diff --git a/desktop/src/components/WallpaperTextOverlay.test.tsx b/desktop/src/components/WallpaperTextOverlay.test.tsx
new file mode 100644
index 000000000..55ef98c1c
--- /dev/null
+++ b/desktop/src/components/WallpaperTextOverlay.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { WallpaperTextOverlay } from "./WallpaperTextOverlay";
+
+describe("WallpaperTextOverlay", () => {
+ it("renders the provided text", () => {
+ render( );
+ expect(screen.getByText("Hello World")).toBeInTheDocument();
+ });
+
+ it("renders text in a centered overlay container", () => {
+ render( );
+ const container = screen.getByText("taOS").parentElement;
+ expect(container).toHaveClass("pointer-events-none", "absolute", "inset-0", "z-0", "grid", "place-items-center");
+ });
+
+ it("hides the overlay from assistive technology", () => {
+ render( );
+ const container = screen.getByText("hidden").parentElement;
+ expect(container).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("applies the expected text styling", () => {
+ render( );
+ const span = screen.getByText("styled");
+ expect(span).toHaveClass("font-semibold", "tracking-tight");
+ expect(span).toHaveStyle({ color: "rgba(236,236,238,0.96)" });
+ });
+});
diff --git a/desktop/src/components/Window.tsx b/desktop/src/components/Window.tsx
index 60a8be39b..1b4c1a5e7 100644
--- a/desktop/src/components/Window.tsx
+++ b/desktop/src/components/Window.tsx
@@ -6,6 +6,7 @@ import { getApp } from "@/registry/app-registry";
import { getSnapBounds } from "@/hooks/use-snap-zones";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { WindowContent } from "./WindowContent";
+import { InstallHelperPanel } from "./InstallHelperPanel";
interface Props {
win: WindowState;
@@ -30,6 +31,7 @@ function WindowImpl({ win, onDrag, onDragStop }: Props) {
const app = getApp(win.appId);
const preSnapRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
const isMobile = useIsMobile();
+ const [installOpen, setInstallOpen] = useState(false);
const reduceMotion = useReducedMotion();
// GPU drag hint: only promote the inner chrome to its own layer while the
// user is actively dragging/resizing. Permanent will-change bloats GPU
@@ -269,7 +271,31 @@ function WindowImpl({ win, onDrag, onDragStop }: Props) {
{app?.name ?? win.appId}
-
+
+ {app?.pwa && !isMobile && (
+
{
+ e.stopPropagation();
+ setInstallOpen(true);
+ }}
+ aria-label={`Install ${app.name} as app`}
+ title={`Install ${app.name}`}
+ >
+
+
+
+
+
+ )}
+ {installOpen && app && (
+
setInstallOpen(false)}
+ />
+ )}
+
{/* Content */}
diff --git a/desktop/src/components/__tests__/DockIcon.context-menu.test.tsx b/desktop/src/components/__tests__/DockIcon.context-menu.test.tsx
new file mode 100644
index 000000000..9d88f7aa9
--- /dev/null
+++ b/desktop/src/components/__tests__/DockIcon.context-menu.test.tsx
@@ -0,0 +1,81 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const openWindow = vi.fn(() => "win-new");
+
+vi.mock("@/registry/app-registry", () => ({
+ prefetchApp: vi.fn(),
+ getApp: (id: string) => {
+ const apps: Record
= {
+ browser: { id: "browser", name: "Browser", icon: "globe", singleton: false, defaultSize: { w: 1024, h: 700 } },
+ messages: { id: "messages", name: "Messages", icon: "message-circle", singleton: true, defaultSize: { w: 900, h: 600 } },
+ projects: { id: "projects", name: "Projects", icon: "folder-kanban", singleton: false, defaultSize: { w: 1100, h: 720 } },
+ };
+ return apps[id] ?? null;
+ },
+}));
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: (selector: (s: { windows: Array<{ id: string; appId: string; minimized: boolean; maximized: boolean }>; openWindow: typeof openWindow; focusWindow: () => void; restoreWindow: () => void; minimizeWindow: () => void; maximizeWindow: () => void; recenterWindow: () => void; closeWindow: () => void }) => unknown) =>
+ selector({
+ windows: [
+ { id: "win-1", appId: "browser", minimized: false, maximized: false },
+ { id: "win-2", appId: "messages", minimized: false, maximized: false },
+ { id: "win-3", appId: "projects", minimized: false, maximized: false },
+ ],
+ openWindow,
+ focusWindow: vi.fn(),
+ restoreWindow: vi.fn(),
+ minimizeWindow: vi.fn(),
+ maximizeWindow: vi.fn(),
+ recenterWindow: vi.fn(),
+ closeWindow: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/dock-store", () => ({
+ useDockStore: (selector: (s: { pinned: string[]; pin: () => void; unpin: () => void }) => unknown) =>
+ selector({ pinned: [], pin: vi.fn(), unpin: vi.fn() }),
+}));
+
+import { DockIcon } from "../DockIcon";
+
+describe("DockIcon context menu New Window", () => {
+ beforeEach(() => openWindow.mockClear());
+
+ it("shows New Window for a singleton:false app (browser)", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button", { name: "Open Browser" });
+ fireEvent.contextMenu(btn);
+ expect(screen.getByText("New Window")).toBeInTheDocument();
+ });
+
+ it("does NOT show New Window for a singleton:true app (messages)", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button", { name: "Open Messages" });
+ fireEvent.contextMenu(btn);
+ expect(screen.queryByText("New Window")).not.toBeInTheDocument();
+ });
+
+ it("shows New Window for projects (singleton:false)", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button", { name: "Open Projects" });
+ fireEvent.contextMenu(btn);
+ expect(screen.getByText("New Window")).toBeInTheDocument();
+ });
+
+ it("New Window calls openWindow with forceNew for a running multi-window app", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button", { name: "Open Browser" });
+ fireEvent.contextMenu(btn);
+ fireEvent.click(screen.getByText("New Window"));
+ expect(openWindow).toHaveBeenCalledWith("browser", { w: 1024, h: 700 }, undefined, { forceNew: true });
+ });
+
+ it("does not show New Window for a not-running singleton:true app", () => {
+ render( {}} />);
+ const btn = screen.getByRole("button", { name: "Open Messages" });
+ fireEvent.contextMenu(btn);
+ expect(screen.queryByText("New Window")).not.toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/__tests__/EmojiPicker.test.tsx b/desktop/src/components/__tests__/EmojiPicker.test.tsx
new file mode 100644
index 000000000..809370ce7
--- /dev/null
+++ b/desktop/src/components/__tests__/EmojiPicker.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeAll } from "vitest";
+import { EmojiPickerField } from "../EmojiPicker";
+
+beforeAll(() => {
+ class MockIntersectionObserver implements IntersectionObserver {
+ readonly root: Element | null = null;
+ readonly rootMargin: string = "";
+ readonly thresholds: ReadonlyArray = [];
+ constructor(public callback: IntersectionObserverCallback) {}
+ observe = (el: Element) => {
+ this.callback([{ isIntersecting: true, target: el, intersectionRatio: 1, boundingClientRect: {} as DOMRectReadOnly, intersectionRect: {} as DOMRectReadOnly, time: 0 }], this);
+ };
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ takeRecords = () => [];
+ };
+ Object.defineProperty(window, "IntersectionObserver", { configurable: true, writable: true, value: MockIntersectionObserver });
+ Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, writable: true, value: MockIntersectionObserver });
+});
+
+describe(" ", () => {
+ it("renders with minimal valid props and shows the value", () => {
+ render( {}} />);
+ expect(screen.getByRole("button", { name: /open emoji picker/i })).toBeInTheDocument();
+ expect(screen.getByText(":)")).toBeInTheDocument();
+ });
+
+ it("shows a plus sign when value is empty", () => {
+ render( {}} />);
+ expect(screen.getByText("+")).toBeInTheDocument();
+ });
+
+ it("toggles the picker open and closed on button click", () => {
+ render( {}} />);
+ const button = screen.getByRole("button", { name: /open emoji picker/i });
+ expect(screen.queryByRole("dialog", { name: /emoji picker/i })).toBeNull();
+ fireEvent.click(button);
+ expect(screen.getByRole("dialog", { name: /emoji picker/i })).toBeInTheDocument();
+ fireEvent.click(button);
+ expect(screen.queryByRole("dialog", { name: /emoji picker/i })).toBeNull();
+ });
+
+ it("calls onChange with an emoji string when an emoji is clicked", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /open emoji picker/i }));
+ const emojiButtons = screen.getAllByRole("button", { name: /grinning face/i });
+ expect(emojiButtons.length).toBeGreaterThan(0);
+ fireEvent.click(emojiButtons[0]);
+ expect(onChange).toHaveBeenCalled();
+ expect(typeof onChange.mock.calls[0][0]).toBe("string");
+ });
+});
diff --git a/desktop/src/components/__tests__/LaunchpadIcon.test.tsx b/desktop/src/components/__tests__/LaunchpadIcon.test.tsx
new file mode 100644
index 000000000..8bb155d5e
--- /dev/null
+++ b/desktop/src/components/__tests__/LaunchpadIcon.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { LaunchpadIcon } from "../LaunchpadIcon";
+
+vi.mock("@/registry/app-registry", () => ({
+ prefetchApp: vi.fn(),
+}));
+
+const minimalApp = {
+ id: "messages",
+ name: "Messages",
+ icon: "message-circle",
+ category: "platform" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 400, h: 300 },
+ singleton: true,
+ pinned: true,
+ launchpadOrder: 1,
+};
+
+describe(" ", () => {
+ it("renders the app name", () => {
+ render( );
+ expect(screen.getByText("Messages")).toBeInTheDocument();
+ });
+
+ it("has a button labelled with the app name", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: /open messages/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onClick when the button is clicked", () => {
+ const onClick = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /open messages/i }));
+ expect(onClick).toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/components/__tests__/LoginScreen.test.tsx b/desktop/src/components/__tests__/LoginScreen.test.tsx
new file mode 100644
index 000000000..bbabb9cb0
--- /dev/null
+++ b/desktop/src/components/__tests__/LoginScreen.test.tsx
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { LoginScreen } from "../LoginScreen";
+
+describe(" ", () => {
+ it("renders with minimal valid props and shows the Launch button", () => {
+ const onLaunch = vi.fn();
+ render( );
+ expect(screen.getByRole("button", { name: /Launch taOS/i })).toBeInTheDocument();
+ expect(screen.getByAltText("taOS")).toBeInTheDocument();
+ });
+
+ it("calls onLaunch after clicking the Launch button", () => {
+ vi.useFakeTimers();
+ const onLaunch = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /Launch taOS/i }));
+ expect(onLaunch).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(600);
+ expect(onLaunch).toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+});
diff --git a/desktop/src/components/__tests__/MigrationBanner.test.tsx b/desktop/src/components/__tests__/MigrationBanner.test.tsx
new file mode 100644
index 000000000..49cfb7eeb
--- /dev/null
+++ b/desktop/src/components/__tests__/MigrationBanner.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { MigrationBanner } from "../MigrationBanner";
+
+describe(" ", () => {
+ it("renders the migration message when agent has not migrated", () => {
+ render(
+
+ );
+ expect(
+ screen.getByText(/Memory upgraded/i)
+ ).toBeInTheDocument();
+ });
+
+ it("renders nothing when agent has migrated", () => {
+ const { container } = render(
+
+ );
+ expect(container.textContent).toBe("");
+ });
+
+ it("calls onAddPersona when the Add persona button is clicked", () => {
+ const onAddPersona = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /add persona/i }));
+ expect(onAddPersona).toHaveBeenCalled();
+ });
+
+ it("calls onDismiss when the Dismiss button is clicked", () => {
+ const onDismiss = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
+ expect(onDismiss).toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/components/__tests__/ModelPickerModal.test.tsx b/desktop/src/components/__tests__/ModelPickerModal.test.tsx
new file mode 100644
index 000000000..2c3eb174b
--- /dev/null
+++ b/desktop/src/components/__tests__/ModelPickerModal.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ModelPickerModal } from "../ModelPickerModal";
+
+const sampleModel = { id: "model-1", name: "Test Model", host: "localhost", hostKind: "controller" as const };
+
+describe("ModelPickerModal", () => {
+ it("renders the title when open", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Select Model")).toBeInTheDocument();
+ });
+
+ it("does not render when closed", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("calls onClose when the close button is clicked", () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: /close/i }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("calls onSelect when a model is selected", () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByText("Test Model"));
+ expect(onSelect).toHaveBeenCalledWith("model-1", sampleModel);
+ });
+
+ it("calls onClose when a model is selected", () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByText("Test Model"));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("renders a custom title", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Pick a model")).toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/mobile/MobileAppWindow.tsx b/desktop/src/components/mobile/MobileAppWindow.tsx
index 64fbea816..921168e9c 100644
--- a/desktop/src/components/mobile/MobileAppWindow.tsx
+++ b/desktop/src/components/mobile/MobileAppWindow.tsx
@@ -1,6 +1,7 @@
-import { Suspense, lazy, useMemo } from "react";
+import { Suspense, lazy, useMemo, useState } from "react";
import { X, Minus } from "lucide-react";
import { getApp } from "@/registry/app-registry";
+import { InstallHelperPanel } from "../InstallHelperPanel";
interface Props {
appId: string;
@@ -11,6 +12,7 @@ interface Props {
export function MobileAppWindow({ appId, windowId, onClose, onMinimise }: Props) {
const app = getApp(appId);
+ const [installOpen, setInstallOpen] = useState(false);
const LazyComponent = useMemo(() => {
if (!app) return null;
return lazy(app.component);
@@ -66,10 +68,45 @@ export function MobileAppWindow({ appId, windowId, onClose, onMinimise }: Props)
{app.name}
- {/* Right spacer for visual balance */}
-
+ {/* Right — Install (for pwa:true apps), else a spacer for balance. On
+ mobile there is no desktop title bar, so this is where a PWA app is
+ installed: it opens the standalone shell where the install prompt /
+ Add to Home Screen guide lives. */}
+ {app.pwa ? (
+ setInstallOpen(true)}
+ aria-label={`Install ${app.name} as app`}
+ className="flex items-center justify-center shrink-0 active:opacity-60"
+ style={{ width: "44px" }}
+ >
+
+
+
+
+
+ ) : (
+
+ )}
+ {installOpen && (
+
setInstallOpen(false)}
+ />
+ )}
+
{/* App content. Use the theme bg token (graphite); a hardcoded
rgba(15,15,35) here read as an indigo flash on the dark theme. */}
{
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns a formatted date string on initial render", () => {
+ const fixed = new Date(2025, 0, 15, 14, 30);
+ vi.setSystemTime(fixed);
+
+ const { result } = renderHook(() => useClock());
+
+ expect(result.current).toBe("Wed 15 Jan 14:30");
+ });
+
+ it("updates the formatted time after the interval elapses", () => {
+ const start = new Date(2025, 5, 20, 9, 0);
+ vi.setSystemTime(start);
+
+ const { result } = renderHook(() => useClock());
+ expect(result.current).toBe("Fri 20 Jun 09:00");
+
+ const later = new Date(2025, 5, 20, 9, 30);
+ act(() => {
+ vi.setSystemTime(later);
+ vi.advanceTimersByTime(30_000);
+ });
+
+ expect(result.current).toBe("Fri 20 Jun 09:30");
+ });
+
+ it("does not update before the interval elapses", () => {
+ const start = new Date(2025, 0, 1, 12, 0);
+ vi.setSystemTime(start);
+
+ const { result } = renderHook(() => useClock());
+ expect(result.current).toBe("Wed 1 Jan 12:00");
+
+ act(() => {
+ vi.advanceTimersByTime(29_999);
+ });
+
+ expect(result.current).toBe("Wed 1 Jan 12:00");
+ });
+});
diff --git a/desktop/src/hooks/use-device-mode.test.ts b/desktop/src/hooks/use-device-mode.test.ts
new file mode 100644
index 000000000..47f2d10c0
--- /dev/null
+++ b/desktop/src/hooks/use-device-mode.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useDeviceMode } from "./use-device-mode";
+
+function createMockMatchMedia(initialMatches: boolean) {
+ let matches = initialMatches;
+ const listeners: Array<(e: { matches: boolean }) => void> = [];
+ return {
+ get matches() { return matches; },
+ set matches(v: boolean) { matches = v; },
+ media: "(pointer: coarse)",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn().mockReturnValue(false),
+ } as unknown as MediaQueryList;
+}
+
+describe("useDeviceMode", () => {
+ const originalMatchMedia = window.matchMedia;
+ const originalInnerWidth = Object.getOwnPropertyDescriptor(window, "innerWidth");
+ const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
+
+ beforeEach(() => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ writable: true,
+ value: 1024,
+ });
+ Object.defineProperty(navigator, "maxTouchPoints", {
+ configurable: true,
+ writable: true,
+ value: 0,
+ });
+ });
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ if (originalInnerWidth) {
+ Object.defineProperty(window, "innerWidth", originalInnerWidth);
+ } else {
+ delete (window as Record
)["innerWidth"];
+ }
+ if (originalMaxTouchPoints) {
+ Object.defineProperty(navigator, "maxTouchPoints", originalMaxTouchPoints);
+ } else {
+ delete (navigator as Record)["maxTouchPoints"];
+ }
+ });
+
+ it("returns desktop on a wide viewport without touch", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("desktop");
+ });
+
+ it("returns mobile on a narrow viewport (width < 768)", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("mobile");
+ });
+
+ it("returns tablet on a medium viewport with coarse pointer", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 900 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(true));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("tablet");
+ });
+
+ it("returns tablet on a medium viewport with maxTouchPoints > 0", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 900 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 5 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("tablet");
+ });
+
+ it("returns desktop on a medium viewport without touch", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 900 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("desktop");
+ });
+
+ it("returns desktop on a wide viewport even with touch", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1200 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 5 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(true));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("desktop");
+ });
+
+ it("updates mode on resize from desktop to mobile", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("desktop");
+
+ act(() => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 500 });
+ window.dispatchEvent(new Event("resize"));
+ });
+
+ expect(result.current).toBe("mobile");
+ });
+
+ it("updates mode on resize from mobile to desktop", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
+ Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 0 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+
+ const { result } = renderHook(() => useDeviceMode());
+ expect(result.current).toBe("mobile");
+
+ act(() => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
+ window.dispatchEvent(new Event("resize"));
+ });
+
+ expect(result.current).toBe("desktop");
+ });
+});
diff --git a/desktop/src/hooks/use-focus-trap.test.ts b/desktop/src/hooks/use-focus-trap.test.ts
new file mode 100644
index 000000000..75ee44d71
--- /dev/null
+++ b/desktop/src/hooks/use-focus-trap.test.ts
@@ -0,0 +1,322 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { getFocusableElements, useFocusTrap } from "./use-focus-trap";
+
+function createContainer() {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ return container;
+}
+
+function createFocusableButton(label: string): HTMLButtonElement {
+ const btn = document.createElement("button");
+ btn.textContent = label;
+ return btn;
+}
+
+function createFocusableLink(href: string, text: string): HTMLAnchorElement {
+ const a = document.createElement("a");
+ a.href = href;
+ a.textContent = text;
+ return a;
+}
+
+function createFocusableInput(): HTMLInputElement {
+ const input = document.createElement("input");
+ input.type = "text";
+ return input;
+}
+
+describe("getFocusableElements", () => {
+ it("returns an empty array when container is null", () => {
+ expect(getFocusableElements(null)).toEqual([]);
+ });
+
+ it("returns focusable elements inside a container", () => {
+ const container = createContainer();
+ const btn1 = createFocusableButton("One");
+ const btn2 = createFocusableButton("Two");
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+
+ const result = getFocusableElements(container);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe(btn1);
+ expect(result[1]).toBe(btn2);
+
+ document.body.removeChild(container);
+ });
+
+ it("includes links, buttons, inputs, selects, textareas, and tabindex elements", () => {
+ const container = createContainer();
+ const link = createFocusableLink("#", "link");
+ const btn = createFocusableButton("btn");
+ const input = createFocusableInput();
+ const select = document.createElement("select");
+ const textarea = document.createElement("textarea");
+ const div = document.createElement("div");
+ div.setAttribute("tabindex", "0");
+
+ container.appendChild(link);
+ container.appendChild(btn);
+ container.appendChild(input);
+ container.appendChild(select);
+ container.appendChild(textarea);
+ container.appendChild(div);
+
+ const result = getFocusableElements(container);
+ expect(result).toHaveLength(6);
+
+ document.body.removeChild(container);
+ });
+
+ it("excludes disabled elements", () => {
+ const container = createContainer();
+ const btn = createFocusableButton("enabled");
+ const disabledBtn = createFocusableButton("disabled");
+ disabledBtn.disabled = true;
+ const input = createFocusableInput();
+ const disabledInput = createFocusableInput();
+ disabledInput.disabled = true;
+
+ container.appendChild(btn);
+ container.appendChild(disabledBtn);
+ container.appendChild(input);
+ container.appendChild(disabledInput);
+
+ const result = getFocusableElements(container);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe(btn);
+ expect(result[1]).toBe(input);
+
+ document.body.removeChild(container);
+ });
+
+ it("excludes elements with tabindex=-1", () => {
+ const container = createContainer();
+ const btn = createFocusableButton("ok");
+ const neg = document.createElement("div");
+ neg.setAttribute("tabindex", "-1");
+
+ container.appendChild(btn);
+ container.appendChild(neg);
+
+ const result = getFocusableElements(container);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toBe(btn);
+
+ document.body.removeChild(container);
+ });
+});
+
+describe("useFocusTrap", () => {
+ let originalActiveElement: typeof document.activeElement;
+
+ beforeEach(() => {
+ originalActiveElement = document.activeElement;
+ });
+
+ afterEach(() => {
+ // restore focus
+ if (originalActiveElement instanceof HTMLElement) {
+ originalActiveElement.focus();
+ }
+ });
+
+ it("focuses the first focusable element when active becomes true", () => {
+ const container = createContainer();
+ const btn1 = createFocusableButton("First");
+ const btn2 = createFocusableButton("Second");
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+
+ const ref = { current: container };
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(btn1);
+
+ document.body.removeChild(container);
+ });
+
+ it("does not focus anything when active is false", () => {
+ const container = createContainer();
+ const btn = createFocusableButton("btn");
+ container.appendChild(btn);
+
+ const ref = { current: container };
+ const prev = document.activeElement;
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: false } },
+ );
+
+ expect(document.activeElement).toBe(prev);
+
+ document.body.removeChild(container);
+ });
+
+ it("does not focus when ref.current is null", () => {
+ const ref = { current: null };
+ const prev = document.activeElement;
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(prev);
+ });
+
+ it("traps focus: Tab on last element wraps to first", () => {
+ const container = createContainer();
+ const btn1 = createFocusableButton("First");
+ const btn2 = createFocusableButton("Second");
+ const btn3 = createFocusableButton("Third");
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+ container.appendChild(btn3);
+
+ const ref = { current: container };
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(btn1);
+
+ // Tab to second
+ btn2.focus();
+ expect(document.activeElement).toBe(btn2);
+
+ // Tab to third
+ btn3.focus();
+ expect(document.activeElement).toBe(btn3);
+
+ // Tab on third should wrap to first
+ act(() => {
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ bubbles: true,
+ });
+ container.dispatchEvent(event);
+ });
+
+ expect(document.activeElement).toBe(btn1);
+
+ document.body.removeChild(container);
+ });
+
+ it("traps focus: Shift+Tab on first element wraps to last", () => {
+ const container = createContainer();
+ const btn1 = createFocusableButton("First");
+ const btn2 = createFocusableButton("Second");
+ const btn3 = createFocusableButton("Third");
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+ container.appendChild(btn3);
+
+ const ref = { current: container };
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(btn1);
+
+ // Shift+Tab on first should wrap to last
+ act(() => {
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey: true,
+ bubbles: true,
+ });
+ container.dispatchEvent(event);
+ });
+
+ expect(document.activeElement).toBe(btn3);
+
+ document.body.removeChild(container);
+ });
+
+ it("restores previous focus when active changes to false", () => {
+ const container = createContainer();
+ const btn1 = createFocusableButton("First");
+ const btn2 = createFocusableButton("Second");
+ container.appendChild(btn1);
+ container.appendChild(btn2);
+
+ const outsideBtn = createFocusableButton("Outside");
+ document.body.appendChild(outsideBtn);
+ outsideBtn.focus();
+ expect(document.activeElement).toBe(outsideBtn);
+
+ const ref = { current: container };
+
+ const { rerender } = renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(btn1);
+
+ rerender({ active: false });
+
+ expect(document.activeElement).toBe(outsideBtn);
+
+ document.body.removeChild(container);
+ document.body.removeChild(outsideBtn);
+ });
+
+ it("does not prevent default for non-Tab keys", () => {
+ const container = createContainer();
+ const btn = createFocusableButton("btn");
+ container.appendChild(btn);
+
+ const ref = { current: container };
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ let defaultPrevented = false;
+ act(() => {
+ const event = new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ defaultPrevented = !container.dispatchEvent(event);
+ });
+
+ expect(defaultPrevented).toBe(false);
+
+ document.body.removeChild(container);
+ });
+
+ it("does nothing when there are no focusable elements", () => {
+ const container = createContainer();
+ const div = document.createElement("div");
+ div.textContent = "no focusable elements";
+ container.appendChild(div);
+
+ const ref = { current: container };
+ const prev = document.activeElement;
+
+ renderHook(
+ ({ active }) => useFocusTrap(ref, active),
+ { initialProps: { active: true } },
+ );
+
+ expect(document.activeElement).toBe(prev);
+
+ document.body.removeChild(container);
+ });
+});
diff --git a/desktop/src/hooks/use-is-mobile.test.ts b/desktop/src/hooks/use-is-mobile.test.ts
new file mode 100644
index 000000000..d57939c62
--- /dev/null
+++ b/desktop/src/hooks/use-is-mobile.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useIsMobile } from "./use-is-mobile";
+
+function createMockMatchMedia(initialMatches: boolean) {
+ let matches = initialMatches;
+ const listeners: Array<(e: { matches: boolean }) => void> = [];
+ return {
+ get matches() { return matches; },
+ set matches(v: boolean) { matches = v; },
+ media: "(max-width: 767px)",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn().mockImplementation(
+ (_: string, cb: (e: { matches: boolean }) => void) => { listeners.push(cb); },
+ ),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn().mockReturnValue(false),
+ _fire(match: boolean) {
+ matches = match;
+ listeners.forEach((cb) => cb({ matches: match }));
+ },
+ } as unknown as MediaQueryList & { _fire: (m: boolean) => void };
+}
+
+describe("useIsMobile", () => {
+ const originalMatchMedia = window.matchMedia;
+ const originalInnerWidth = Object.getOwnPropertyDescriptor(window, "innerWidth");
+
+ beforeEach(() => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ writable: true,
+ value: 1024,
+ });
+ });
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia;
+ if (originalInnerWidth) {
+ Object.defineProperty(window, "innerWidth", originalInnerWidth);
+ } else {
+ delete (window as Record)["innerWidth"];
+ }
+ });
+
+ it("returns false on desktop viewport (width >= 768)", () => {
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(false);
+ });
+
+ it("returns true on mobile viewport (width < 768)", () => {
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 400 });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(true));
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(true);
+ });
+
+ it("updates when matchMedia change event fires", () => {
+ const mql = createMockMatchMedia(false);
+ window.matchMedia = vi.fn().mockReturnValue(mql);
+
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(false);
+
+ act(() => { (mql as unknown as { _fire: (m: boolean) => void })._fire(true); });
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/desktop/src/hooks/use-is-pwa.test.ts b/desktop/src/hooks/use-is-pwa.test.ts
new file mode 100644
index 000000000..20b8b2425
--- /dev/null
+++ b/desktop/src/hooks/use-is-pwa.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useIsPwa } from "./use-is-pwa";
+
+function createMockMatchMedia(initialMatches: boolean) {
+ let matches = initialMatches;
+ const listeners: Array<(e: { matches: boolean }) => void> = [];
+ return {
+ get matches() { return matches; },
+ set matches(v: boolean) { matches = v; },
+ media: "(display-mode: standalone)",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn().mockImplementation(
+ (_: string, cb: (e: { matches: boolean }) => void) => { listeners.push(cb); },
+ ),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn().mockReturnValue(false),
+ _fire(match: boolean) {
+ matches = match;
+ listeners.forEach((cb) => cb({ matches: match }));
+ },
+ } as unknown as MediaQueryList & { _fire: (m: boolean) => void };
+}
+
+describe("useIsPwa", () => {
+ beforeEach(() => {
+ Object.defineProperty(window, "navigator", {
+ configurable: true,
+ writable: true,
+ value: { ...window.navigator, standalone: false },
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns false when not in standalone mode", () => {
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+ const { result } = renderHook(() => useIsPwa());
+ expect(result.current).toBe(false);
+ });
+
+ it("returns true when matchMedia reports standalone", () => {
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(true));
+ const { result } = renderHook(() => useIsPwa());
+ expect(result.current).toBe(true);
+ });
+
+ it("returns true when navigator.standalone is true", () => {
+ Object.defineProperty(window, "navigator", {
+ configurable: true,
+ writable: true,
+ value: { ...window.navigator, standalone: true },
+ });
+ window.matchMedia = vi.fn().mockReturnValue(createMockMatchMedia(false));
+ const { result } = renderHook(() => useIsPwa());
+ expect(result.current).toBe(true);
+ });
+
+ it("updates when matchMedia change event fires to standalone", () => {
+ const mql = createMockMatchMedia(false);
+ window.matchMedia = vi.fn().mockReturnValue(mql);
+
+ const { result } = renderHook(() => useIsPwa());
+ expect(result.current).toBe(false);
+
+ act(() => { mql._fire(true); });
+ expect(result.current).toBe(true);
+ });
+
+ it("updates when matchMedia change event fires from standalone to browser", () => {
+ const mql = createMockMatchMedia(true);
+ window.matchMedia = vi.fn().mockReturnValue(mql);
+
+ const { result } = renderHook(() => useIsPwa());
+ expect(result.current).toBe(true);
+
+ act(() => { mql._fire(false); });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/desktop/src/hooks/use-list-nav.test.ts b/desktop/src/hooks/use-list-nav.test.ts
new file mode 100644
index 000000000..93de069df
--- /dev/null
+++ b/desktop/src/hooks/use-list-nav.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { computeNextIndex, useListNav } from "./use-list-nav";
+
+describe("computeNextIndex", () => {
+ it("returns -1 when total is 0", () => {
+ expect(computeNextIndex(0, 0, "ArrowDown")).toBe(-1);
+ expect(computeNextIndex(5, 0, "ArrowUp")).toBe(-1);
+ expect(computeNextIndex(0, 0, "Home")).toBe(-1);
+ });
+
+ it("moves down with ArrowDown", () => {
+ expect(computeNextIndex(0, 3, "ArrowDown")).toBe(1);
+ expect(computeNextIndex(1, 3, "ArrowDown")).toBe(2);
+ });
+
+ it("wraps from last to first with ArrowDown", () => {
+ expect(computeNextIndex(2, 3, "ArrowDown")).toBe(0);
+ });
+
+ it("moves up with ArrowUp", () => {
+ expect(computeNextIndex(2, 3, "ArrowUp")).toBe(1);
+ expect(computeNextIndex(1, 3, "ArrowUp")).toBe(0);
+ });
+
+ it("wraps from first to last with ArrowUp", () => {
+ expect(computeNextIndex(0, 3, "ArrowUp")).toBe(2);
+ });
+
+ it("jumps to first with Home", () => {
+ expect(computeNextIndex(2, 5, "Home")).toBe(0);
+ expect(computeNextIndex(0, 1, "Home")).toBe(0);
+ });
+
+ it("jumps to last with End", () => {
+ expect(computeNextIndex(0, 3, "End")).toBe(2);
+ expect(computeNextIndex(1, 5, "End")).toBe(4);
+ });
+
+ it("returns current for unhandled keys", () => {
+ expect(computeNextIndex(2, 5, "Tab")).toBe(2);
+ expect(computeNextIndex(2, 5, "a")).toBe(2);
+ expect(computeNextIndex(2, 5, "Escape")).toBe(2);
+ });
+
+ it("handles single-item list", () => {
+ expect(computeNextIndex(0, 1, "ArrowDown")).toBe(0);
+ expect(computeNextIndex(0, 1, "ArrowUp")).toBe(0);
+ expect(computeNextIndex(0, 1, "Home")).toBe(0);
+ expect(computeNextIndex(0, 1, "End")).toBe(0);
+ });
+});
+
+describe("useListNav", () => {
+ it("starts with selectedIndex at 0", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ expect(result.current.selectedIndex).toBe(0);
+ });
+
+ it("exposes setSelectedIndex", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => { result.current.setSelectedIndex(2); });
+ expect(result.current.selectedIndex).toBe(2);
+ });
+
+ it("ArrowDown in onKeyDown increments selectedIndex", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: "ArrowDown", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(result.current.selectedIndex).toBe(1);
+ });
+
+ it("ArrowUp in onKeyDown decrements selectedIndex with wrap", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: "ArrowUp", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(result.current.selectedIndex).toBe(2);
+ });
+
+ it("Home in onKeyDown sets selectedIndex to 0", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => { result.current.setSelectedIndex(2); });
+ act(() => {
+ result.current.onKeyDown({ key: "Home", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(result.current.selectedIndex).toBe(0);
+ });
+
+ it("End in onKeyDown sets selectedIndex to last", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: "End", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(result.current.selectedIndex).toBe(2);
+ });
+
+ it("Enter in onKeyDown calls onSelect with the selected item", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => { result.current.setSelectedIndex(1); });
+ act(() => {
+ result.current.onKeyDown({ key: "Enter", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(onSelect).toHaveBeenCalledWith("b");
+ });
+
+ it("Space in onKeyDown calls onSelect with the selected item", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: " ", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(onSelect).toHaveBeenCalledWith("a");
+ });
+
+ it("unhandled key does not call onSelect", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: "Tab", preventDefault: vi.fn() } as unknown as React.KeyboardEvent);
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("navigation keys call preventDefault", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const preventDefault = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+ act(() => {
+ result.current.onKeyDown({ key: "ArrowDown", preventDefault } as unknown as React.KeyboardEvent);
+ });
+ expect(preventDefault).toHaveBeenCalled();
+ });
+
+ it("Enter and Space call preventDefault", () => {
+ const items = ["a", "b", "c"];
+ const onSelect = vi.fn();
+ const { result } = renderHook(() => useListNav(items, onSelect));
+
+ const preventDefaultEnter = vi.fn();
+ act(() => {
+ result.current.onKeyDown({ key: "Enter", preventDefault: preventDefaultEnter } as unknown as React.KeyboardEvent);
+ });
+ expect(preventDefaultEnter).toHaveBeenCalled();
+
+ const preventDefaultSpace = vi.fn();
+ act(() => {
+ result.current.onKeyDown({ key: " ", preventDefault: preventDefaultSpace } as unknown as React.KeyboardEvent);
+ });
+ expect(preventDefaultSpace).toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/hooks/use-visual-viewport.test.ts b/desktop/src/hooks/use-visual-viewport.test.ts
new file mode 100644
index 000000000..64835c6fc
--- /dev/null
+++ b/desktop/src/hooks/use-visual-viewport.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useVisualViewport } from "./use-visual-viewport";
+
+function createMockVisualViewport(initialHeight: number, initialOffsetTop = 0) {
+ let height = initialHeight;
+ let offsetTop = initialOffsetTop;
+ const listeners: Record void>> = {};
+ return {
+ get height() { return height; },
+ set height(v: number) { height = v; },
+ get offsetTop() { return offsetTop; },
+ set offsetTop(v: number) { offsetTop = v; },
+ addEventListener: vi.fn().mockImplementation((type: string, cb: () => void) => {
+ (listeners[type] ||= []).push(cb);
+ }),
+ removeEventListener: vi.fn(),
+ _fire(type: string) {
+ (listeners[type] || []).forEach((cb) => cb());
+ },
+ } as unknown as VisualViewport & { _fire: (type: string) => void };
+}
+
+describe("useVisualViewport", () => {
+ const originalVisualViewport = window.visualViewport;
+ const originalInnerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
+
+ beforeEach(() => {
+ Object.defineProperty(window, "innerHeight", {
+ configurable: true,
+ writable: true,
+ value: 800,
+ });
+ });
+
+ afterEach(() => {
+ if (originalVisualViewport) {
+ (window as unknown as Record)["visualViewport"] = originalVisualViewport;
+ } else {
+ delete (window as Record)["visualViewport"];
+ }
+ if (originalInnerHeight) {
+ Object.defineProperty(window, "innerHeight", originalInnerHeight);
+ } else {
+ delete (window as Record)["innerHeight"];
+ }
+ });
+
+ it("returns visualViewport height when available", () => {
+ const vv = createMockVisualViewport(600);
+ (window as unknown as Record)["visualViewport"] = vv;
+ const { result } = renderHook(() => useVisualViewport());
+ expect(result.current).toEqual({ height: 600, keyboardInset: 200 });
+ });
+
+ it("computes keyboardInset accounting for offsetTop", () => {
+ const vv = createMockVisualViewport(500, 100);
+ (window as unknown as Record)["visualViewport"] = vv;
+ const { result } = renderHook(() => useVisualViewport());
+ expect(result.current).toEqual({ height: 500, keyboardInset: 200 });
+ });
+
+ it("falls back to innerHeight when visualViewport is null", () => {
+ (window as unknown as Record)["visualViewport"] = null;
+ const { result } = renderHook(() => useVisualViewport());
+ expect(result.current).toEqual({ height: 800, keyboardInset: 0 });
+ });
+
+ it("updates on resize event", () => {
+ const vv = createMockVisualViewport(600);
+ (window as unknown as Record)["visualViewport"] = vv;
+ const { result } = renderHook(() => useVisualViewport());
+ expect(result.current).toEqual({ height: 600, keyboardInset: 200 });
+
+ act(() => {
+ vv.height = 500;
+ vv._fire("resize");
+ });
+ expect(result.current).toEqual({ height: 500, keyboardInset: 300 });
+ });
+
+ it("updates on scroll event", () => {
+ const vv = createMockVisualViewport(600);
+ (window as unknown as Record)["visualViewport"] = vv;
+ const { result } = renderHook(() => useVisualViewport());
+ expect(result.current).toEqual({ height: 600, keyboardInset: 200 });
+
+ act(() => {
+ vv.offsetTop = 50;
+ vv._fire("scroll");
+ });
+ expect(result.current.keyboardInset).toBe(150);
+ });
+});
diff --git a/desktop/src/hooks/use-widget-size.test.ts b/desktop/src/hooks/use-widget-size.test.ts
new file mode 100644
index 000000000..e57e9c71d
--- /dev/null
+++ b/desktop/src/hooks/use-widget-size.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import React from "react";
+
+interface ResizeEntry { contentRect: { width: number; height: number } }
+type ResizeCallback = (entries: ResizeEntry[]) => void;
+
+function createMockElement(width: number, height: number) {
+ return {
+ offsetWidth: width,
+ offsetHeight: height,
+ } as unknown as HTMLDivElement;
+}
+
+const { mockRef } = vi.hoisted(() => {
+ const mockRef: React.MutableRefObject = { current: null };
+ return { mockRef };
+});
+
+vi.mock("react", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useRef: () => mockRef,
+ };
+});
+
+import { useWidgetSize } from "./use-widget-size";
+
+describe("useWidgetSize", () => {
+ const OriginalResizeObserver = globalThis.ResizeObserver;
+ let observerCallback: ResizeCallback | null = null;
+
+ beforeEach(() => {
+ observerCallback = null;
+ globalThis.ResizeObserver = class {
+ constructor(cb: ResizeCallback) { observerCallback = cb; }
+ observe = vi.fn();
+ disconnect = vi.fn();
+ unobserve = vi.fn();
+ } as unknown as new (cb: ResizeCallback) => { observe: typeof vi.fn; disconnect: typeof vi.fn; unobserve: typeof vi.fn };
+ mockRef.current = createMockElement(300, 200);
+ });
+
+ afterEach(() => {
+ globalThis.ResizeObserver = OriginalResizeObserver;
+ mockRef.current = null;
+ });
+
+ it("returns the initial size and tier from the measured element", () => {
+ const { result } = renderHook(() => useWidgetSize());
+
+ expect(result.current[1].width).toBe(300);
+ expect(result.current[1].height).toBe(200);
+ expect(result.current[1].tier).toBe("m");
+ });
+
+ it("updates size when ResizeObserver fires", () => {
+ const { result } = renderHook(() => useWidgetSize());
+
+ expect(result.current[1].width).toBe(300);
+
+ act(() => {
+ observerCallback!([{ contentRect: { width: 500, height: 400 } }]);
+ });
+
+ expect(result.current[1].width).toBe(500);
+ expect(result.current[1].height).toBe(400);
+ expect(result.current[1].tier).toBe("l");
+ });
+});
diff --git a/desktop/src/lib/account-client.test.ts b/desktop/src/lib/account-client.test.ts
new file mode 100644
index 000000000..e5216aa1c
--- /dev/null
+++ b/desktop/src/lib/account-client.test.ts
@@ -0,0 +1,247 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchAccount, login, register, logout, isAuthError } from "./account-client";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchAccount", () => {
+ it("returns signed-in state with account on 200", async () => {
+ const account = {
+ user_id: "u-1",
+ email: "a@b.test",
+ taosgo: { status: "active" },
+ };
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => account,
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchAccount();
+ expect(result).toEqual({ kind: "signed-in", account });
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/account/me");
+ expect(opts.method).toBe("GET");
+ expect(opts.credentials).toBe("include");
+ });
+
+ it("returns signed-out on 401", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 });
+ expect(await fetchAccount()).toEqual({ kind: "signed-out" });
+ });
+
+ it("returns unavailable on 500", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchAccount()).toEqual({ kind: "unavailable" });
+ });
+
+ it("returns unavailable on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ expect(await fetchAccount()).toEqual({ kind: "unavailable" });
+ });
+
+ it("returns unavailable when body is not a valid account", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ user_id: 123 }),
+ });
+ expect(await fetchAccount()).toEqual({ kind: "unavailable" });
+ });
+
+ it("returns unavailable when json parse fails", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => {
+ throw new Error("bad json");
+ },
+ });
+ expect(await fetchAccount()).toEqual({ kind: "unavailable" });
+ });
+});
+
+describe("login", () => {
+ it("returns account on 200", async () => {
+ const account = {
+ user_id: "u-1",
+ email: "a@b.test",
+ taosgo: { status: "active" },
+ };
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => account,
+ });
+ global.fetch = fetchMock;
+
+ const result = await login("a@b.test", "pw");
+ expect(result).toEqual(account);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/account/login");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.credentials).toBe("include");
+ expect(JSON.parse(opts.body)).toEqual({ email: "a@b.test", password: "pw" });
+ });
+
+ it("returns AuthError on 401", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({ error: "Invalid credentials" }),
+ });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Invalid credentials");
+ }
+ });
+
+ it("returns AuthError on 404", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("The account service is not available yet.");
+ }
+ });
+
+ it("returns AuthError on 503", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("The account service is not available yet.");
+ }
+ });
+
+ it("returns AuthError on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Could not reach the account service. Check your connection.");
+ }
+ });
+
+ it("returns AuthError when response body is not a valid account", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ user_id: 123 }),
+ });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Unexpected response from the account service.");
+ }
+ });
+
+ it("returns AuthError using detail when error is absent", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ detail: "bad input" }),
+ });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("bad input");
+ }
+ });
+
+ it("returns AuthError with status code default when body parse fails", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => {
+ throw new Error("bad json");
+ },
+ });
+ const result = await login("a@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Request failed (400).");
+ }
+ });
+});
+
+describe("register", () => {
+ it("returns account on 200", async () => {
+ const account = {
+ user_id: "u-2",
+ email: "new@b.test",
+ taosgo: { status: "trialing", trial_ends_at: "2026-07-01" },
+ };
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => account,
+ });
+ global.fetch = fetchMock;
+
+ const result = await register("new@b.test", "pw");
+ expect(result).toEqual(account);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/account/register");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("include");
+ expect(JSON.parse(opts.body)).toEqual({ email: "new@b.test", password: "pw" });
+ });
+
+ it("returns AuthError on 401", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({ error: "Email already in use" }),
+ });
+ const result = await register("new@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Email already in use");
+ }
+ });
+
+ it("returns AuthError on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await register("new@b.test", "pw");
+ expect(isAuthError(result)).toBe(true);
+ if (isAuthError(result)) {
+ expect(result.message).toBe("Could not reach the account service. Check your connection.");
+ }
+ });
+});
+
+describe("logout", () => {
+ it("posts to /logout with empty body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await logout();
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/account/logout");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("include");
+ expect(JSON.parse(opts.body)).toEqual({});
+ });
+
+ it("does not throw on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ await expect(logout()).resolves.toBeUndefined();
+ });
+
+ it("does not throw on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ await expect(logout()).resolves.toBeUndefined();
+ });
+});
diff --git a/desktop/src/lib/account-client.ts b/desktop/src/lib/account-client.ts
new file mode 100644
index 000000000..e00782045
--- /dev/null
+++ b/desktop/src/lib/account-client.ts
@@ -0,0 +1,131 @@
+/**
+ * Client for the taOS account / identity service (taOSgo P1).
+ *
+ * Calls are same-origin against the host at /api/account/*, which the controller
+ * proxies to taos.my. Same-origin keeps the taos.my base URL server-side and
+ * avoids CORS, and lets the host attach host-linking context later.
+ *
+ * The backend proxy may not exist yet; every call degrades to a clear state
+ * (signed-out / unavailable) rather than throwing, so the UI ships ahead of it.
+ */
+
+export type TaosgoStatus = "none" | "trialing" | "active" | "past_due";
+
+export interface TaosgoEntitlement {
+ status: TaosgoStatus;
+ trial_ends_at?: string | null;
+ current_period_end?: string | null;
+}
+
+export interface Account {
+ user_id: string;
+ email: string;
+ taosgo: TaosgoEntitlement;
+}
+
+export type AccountState =
+ | { kind: "loading" }
+ | { kind: "signed-out" }
+ | { kind: "signed-in"; account: Account }
+ | { kind: "unavailable" };
+
+export interface AuthError {
+ message: string;
+}
+
+const BASE = "/api/account";
+
+/** Validate an unknown payload is a well-formed Account before the UI trusts it.
+ * The backend is external (taos.my); a malformed /me must not crash the render. */
+function isAccount(x: unknown): x is Account {
+ if (!x || typeof x !== "object") return false;
+ const o = x as Record;
+ const t = o.taosgo as Record | undefined;
+ return (
+ typeof o.user_id === "string" &&
+ typeof o.email === "string" &&
+ !!t &&
+ typeof t === "object" &&
+ typeof t.status === "string"
+ );
+}
+
+async function call(path: string, body?: unknown): Promise {
+ return fetch(`${BASE}${path}`, {
+ method: body !== undefined ? "POST" : "GET",
+ headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ credentials: "include",
+ });
+}
+
+export async function fetchAccount(): Promise {
+ let r: Response;
+ try {
+ r = await call("/me");
+ } catch {
+ return { kind: "unavailable" };
+ }
+ if (r.status === 401) return { kind: "signed-out" };
+ if (!r.ok) return { kind: "unavailable" };
+ try {
+ const data: unknown = await r.json();
+ return isAccount(data)
+ ? { kind: "signed-in", account: data }
+ : { kind: "unavailable" };
+ } catch {
+ return { kind: "unavailable" };
+ }
+}
+
+async function authAction(
+ path: string,
+ email: string,
+ password: string,
+): Promise {
+ let r: Response;
+ try {
+ r = await call(path, { email, password });
+ } catch {
+ return { message: "Could not reach the account service. Check your connection." };
+ }
+ if (r.status === 404 || r.status === 503) {
+ return { message: "The account service is not available yet." };
+ }
+ if (!r.ok) {
+ let msg = `Request failed (${r.status}).`;
+ try {
+ const d = (await r.json()) as { error?: string; detail?: string };
+ if (d?.error || d?.detail) msg = String(d.error || d.detail);
+ } catch {
+ /* keep the status-code default */
+ }
+ return { message: msg };
+ }
+ try {
+ const data: unknown = await r.json();
+ return isAccount(data)
+ ? data
+ : { message: "Unexpected response from the account service." };
+ } catch {
+ return { message: "Unexpected response from the account service." };
+ }
+}
+
+export const login = (email: string, password: string) =>
+ authAction("/login", email, password);
+
+export const register = (email: string, password: string) =>
+ authAction("/register", email, password);
+
+export async function logout(): Promise {
+ try {
+ await call("/logout", {});
+ } catch {
+ /* signing out client-side is enough even if the call fails */
+ }
+}
+
+export function isAuthError(x: Account | AuthError): x is AuthError {
+ return (x as AuthError).message !== undefined;
+}
diff --git a/desktop/src/lib/agent-browsers.test.ts b/desktop/src/lib/agent-browsers.test.ts
new file mode 100644
index 000000000..8a8b4123b
--- /dev/null
+++ b/desktop/src/lib/agent-browsers.test.ts
@@ -0,0 +1,514 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ listProfiles,
+ createProfile,
+ deleteProfile,
+ deleteProfileData,
+ startBrowser,
+ stopBrowser,
+ getScreenshot,
+ getCookies,
+ getLoginStatus,
+ assignAgent,
+ moveToNode,
+} from "./agent-browsers";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("listProfiles", () => {
+ it("returns profiles array on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ profiles: [
+ { id: "p-1", agent_name: "a1", profile_name: "prof1", node: "local", status: "stopped", container_id: null, created_at: 1, updated_at: 2 },
+ { id: "p-2", agent_name: null, profile_name: "prof2", node: "remote", status: "running", container_id: "c1", created_at: 3, updated_at: 4 },
+ ],
+ }),
+ });
+
+ const result = await listProfiles();
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe("p-1");
+ expect(result[1].status).toBe("running");
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await listProfiles()).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ expect(await listProfiles()).toEqual([]);
+ });
+
+ it("returns [] when body.profiles is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ profiles: null }),
+ });
+ expect(await listProfiles()).toEqual([]);
+ });
+
+ it("includes agent_name as query param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ profiles: [] }),
+ });
+ global.fetch = fetchMock;
+ await listProfiles("my-agent");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("agent_name=my-agent");
+ });
+
+ it("encodes agent_name with spaces", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ profiles: [] }),
+ });
+ global.fetch = fetchMock;
+ await listProfiles("my agent");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("agent_name=my%20agent");
+ });
+});
+
+describe("createProfile", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-new", status: "stopped" }),
+ });
+
+ const result = await createProfile("prof1", "agent1", "node1");
+ expect(result).toEqual({ id: "p-new", status: "stopped" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
+ expect(await createProfile("prof1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await createProfile("prof1")).toBeNull();
+ });
+
+ it("returns null when content-type is not json", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "text/html"]]),
+ json: async () => ({ id: "p-new", status: "stopped" }),
+ });
+ expect(await createProfile("prof1")).toBeNull();
+ });
+
+ it("posts JSON body with profile_name, agent_name, node", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "stopped" }),
+ });
+ global.fetch = fetchMock;
+
+ await createProfile("my-prof", "my-agent", "my-node");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/agent-browsers/profiles");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.profile_name).toBe("my-prof");
+ expect(body.agent_name).toBe("my-agent");
+ expect(body.node).toBe("my-node");
+ });
+
+ it("defaults agent_name to null and node to local", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "stopped" }),
+ });
+ global.fetch = fetchMock;
+
+ await createProfile("my-prof");
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.agent_name).toBeNull();
+ expect(body.node).toBe("local");
+ });
+});
+
+describe("deleteProfile", () => {
+ it("returns true on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ expect(await deleteProfile("p-1")).toBe(true);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await deleteProfile("p-1")).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await deleteProfile("p-1")).toBe(false);
+ });
+
+ it("encodes id in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+ await deleteProfile("p/with/slashes");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("p%2Fwith%2Fslashes");
+ });
+});
+
+describe("deleteProfileData", () => {
+ it("returns true on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ expect(await deleteProfileData("p-1")).toBe(true);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/data");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await deleteProfileData("p-1")).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await deleteProfileData("p-1")).toBe(false);
+ });
+});
+
+describe("startBrowser", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "running" }),
+ });
+
+ const result = await startBrowser("p-1");
+ expect(result).toEqual({ id: "p-1", status: "running" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await startBrowser("p-1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await startBrowser("p-1")).toBeNull();
+ });
+
+ it("posts to the start endpoint with empty body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "running" }),
+ });
+ global.fetch = fetchMock;
+
+ await startBrowser("p-1");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/start");
+ expect(opts.method).toBe("POST");
+ expect(opts.body).toBe("{}");
+ });
+});
+
+describe("stopBrowser", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "stopped" }),
+ });
+
+ const result = await stopBrowser("p-1");
+ expect(result).toEqual({ id: "p-1", status: "stopped" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await stopBrowser("p-1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await stopBrowser("p-1")).toBeNull();
+ });
+
+ it("posts to the stop endpoint", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", status: "stopped" }),
+ });
+ global.fetch = fetchMock;
+
+ await stopBrowser("p-1");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/stop");
+ expect(opts.method).toBe("POST");
+ });
+});
+
+describe("getScreenshot", () => {
+ it("returns data string on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ data: "base64data" }),
+ });
+
+ const result = await getScreenshot("p-1");
+ expect(result).toBe("base64data");
+ });
+
+ it("returns null when data field is missing", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({}),
+ });
+
+ expect(await getScreenshot("p-1")).toBeNull();
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await getScreenshot("p-1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getScreenshot("p-1")).toBeNull();
+ });
+
+ it("encodes id in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ data: "x" }),
+ });
+ global.fetch = fetchMock;
+ await getScreenshot("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/screenshot");
+ });
+});
+
+describe("getCookies", () => {
+ it("returns cookies array on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ cookies: [
+ { name: "sid", value: "abc", domain: ".example.com", path: "/", expires: 100, httpOnly: true, secure: true },
+ ],
+ }),
+ });
+
+ const result = await getCookies("agent1", "prof1", ".example.com");
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("sid");
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await getCookies("agent1", "prof1", ".example.com")).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getCookies("agent1", "prof1", ".example.com")).toEqual([]);
+ });
+
+ it("returns [] when body.cookies is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ cookies: null }),
+ });
+ expect(await getCookies("agent1", "prof1", ".example.com")).toEqual([]);
+ });
+
+ it("encodes agent, profile, and domain in the URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ cookies: [] }),
+ });
+ global.fetch = fetchMock;
+ await getCookies("my agent", "my prof", ".example.com");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/my%20agent/my%20prof/cookies");
+ expect(url).toContain("domain=.example.com");
+ });
+});
+
+describe("getLoginStatus", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ x: true, github: false, youtube: true, reddit: false }),
+ });
+
+ const result = await getLoginStatus("p-1");
+ expect(result).toEqual({ x: true, github: false, youtube: true, reddit: false });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401 });
+ expect(await getLoginStatus("p-1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getLoginStatus("p-1")).toBeNull();
+ });
+
+ it("returns null when content-type is not json", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "text/html"]]),
+ json: async () => ({ x: true }),
+ });
+ expect(await getLoginStatus("p-1")).toBeNull();
+ });
+
+ it("encodes id in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ x: false, github: false, youtube: false, reddit: false }),
+ });
+ global.fetch = fetchMock;
+ await getLoginStatus("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/login-status");
+ });
+});
+
+describe("assignAgent", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", agent_name: "new-agent", profile_name: "prof1", node: "local", status: "running", container_id: null, created_at: 1, updated_at: 2 }),
+ });
+
+ const result = await assignAgent("p-1", "new-agent");
+ expect(result).toEqual(expect.objectContaining({ id: "p-1", agent_name: "new-agent" }));
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await assignAgent("p-1", "new-agent")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await assignAgent("p-1", "new-agent")).toBeNull();
+ });
+
+ it("puts agent_name in the body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", agent_name: "new-agent", profile_name: "prof1", node: "local", status: "running", container_id: null, created_at: 1, updated_at: 2 }),
+ });
+ global.fetch = fetchMock;
+
+ await assignAgent("p-1", "new-agent");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/assign");
+ expect(opts.method).toBe("PUT");
+ const body = JSON.parse(opts.body);
+ expect(body.agent_name).toBe("new-agent");
+ });
+});
+
+describe("moveToNode", () => {
+ it("returns parsed body on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", agent_name: "a1", profile_name: "prof1", node: "remote", status: "running", container_id: null, created_at: 1, updated_at: 2 }),
+ });
+
+ const result = await moveToNode("p-1", "remote");
+ expect(result).toEqual(expect.objectContaining({ id: "p-1", node: "remote" }));
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await moveToNode("p-1", "remote")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await moveToNode("p-1", "remote")).toBeNull();
+ });
+
+ it("puts node in the body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "p-1", agent_name: "a1", profile_name: "prof1", node: "remote", status: "running", container_id: null, created_at: 1, updated_at: 2 }),
+ });
+ global.fetch = fetchMock;
+
+ await moveToNode("p-1", "remote");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/agent-browsers/profiles/p-1/move");
+ expect(opts.method).toBe("PUT");
+ const body = JSON.parse(opts.body);
+ expect(body.node).toBe("remote");
+ });
+});
diff --git a/desktop/src/lib/agent-emoji.test.ts b/desktop/src/lib/agent-emoji.test.ts
new file mode 100644
index 000000000..28f17b32c
--- /dev/null
+++ b/desktop/src/lib/agent-emoji.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+import { resolveAgentEmoji } from "./agent-emoji";
+
+describe("resolveAgentEmoji", () => {
+ it("returns the agent emoji when provided", () => {
+ expect(resolveAgentEmoji("🦊", "openclaw")).toBe("🦊");
+ });
+
+ it("returns the agent emoji even when framework is null", () => {
+ expect(resolveAgentEmoji("🔥", null)).toBe("🔥");
+ });
+
+ it("trims whitespace from agent emoji before returning", () => {
+ expect(resolveAgentEmoji(" 🦊 ", "openclaw")).toBe("🦊");
+ });
+
+ it("falls back to the framework emoji for openclaw", () => {
+ expect(resolveAgentEmoji(undefined, "openclaw")).toBe("\u{1F916}");
+ });
+
+ it("falls back to the framework emoji for smolagents", () => {
+ expect(resolveAgentEmoji(undefined, "smolagents")).toBe("\u{1F9EA}");
+ });
+
+ it("falls back to the framework emoji for pocketflow", () => {
+ expect(resolveAgentEmoji(undefined, "pocketflow")).toBe("\u{1F517}");
+ });
+
+ it("falls back to the framework emoji for shibaclaw", () => {
+ expect(resolveAgentEmoji(undefined, "shibaclaw")).toBe("\u{1F436}");
+ });
+
+ it("falls back to the framework emoji for zeroclaw", () => {
+ expect(resolveAgentEmoji(undefined, "zeroclaw")).toBe("\u{1F300}");
+ });
+
+ it("returns the default emoji for an unrecognised framework", () => {
+ expect(resolveAgentEmoji(undefined, "unknown-framework")).toBe("\u{1F916}");
+ });
+
+ it("returns the default emoji when both args are undefined", () => {
+ expect(resolveAgentEmoji(undefined, undefined)).toBe("\u{1F916}");
+ });
+
+ it("returns the default emoji when both args are null", () => {
+ expect(resolveAgentEmoji(null, null)).toBe("\u{1F916}");
+ });
+
+ it("returns the default emoji for empty string agent emoji and no framework", () => {
+ expect(resolveAgentEmoji("", undefined)).toBe("\u{1F916}");
+ });
+
+ it("returns the default emoji for whitespace-only agent emoji and no framework", () => {
+ expect(resolveAgentEmoji(" ", null)).toBe("\u{1F916}");
+ });
+});
diff --git a/desktop/src/lib/app-event-bus.test.ts b/desktop/src/lib/app-event-bus.test.ts
new file mode 100644
index 000000000..127fbd1e7
--- /dev/null
+++ b/desktop/src/lib/app-event-bus.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, vi } from "vitest";
+import { emitAppEvent, onAppEvent, APP_INSTALLED, APP_OPTIONAL_CHANGED } from "./app-event-bus";
+
+describe("APP_INSTALLED", () => {
+ it("is the expected string constant", () => {
+ expect(APP_INSTALLED).toBe("app.installed");
+ });
+});
+
+describe("APP_OPTIONAL_CHANGED", () => {
+ it("is the expected string constant", () => {
+ expect(APP_OPTIONAL_CHANGED).toBe("app.optional.changed");
+ });
+});
+
+describe("emitAppEvent", () => {
+ it("dispatches an event with the given name and detail", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent("custom.event", fn);
+ emitAppEvent("custom.event", "payload-1");
+ expect(fn).toHaveBeenCalledWith("payload-1");
+ unsub();
+ });
+
+ it("dispatches an event with null detail when detail is omitted", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent("no-detail", fn);
+ emitAppEvent("no-detail");
+ expect(fn).toHaveBeenCalledWith(null);
+ unsub();
+ });
+
+ it("dispatches an event with empty string detail", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent("empty-detail", fn);
+ emitAppEvent("empty-detail", "");
+ expect(fn).toHaveBeenCalledWith("");
+ unsub();
+ });
+});
+
+describe("onAppEvent", () => {
+ it("returns an unsubscribe function that stops future calls", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent("toggle.event", fn);
+ emitAppEvent("toggle.event", "first");
+ expect(fn).toHaveBeenCalledTimes(1);
+ unsub();
+ emitAppEvent("toggle.event", "second");
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it("supports multiple subscribers on the same event", () => {
+ const fn1 = vi.fn();
+ const fn2 = vi.fn();
+ const unsub1 = onAppEvent("multi", fn1);
+ const unsub2 = onAppEvent("multi", fn2);
+ emitAppEvent("multi", "data");
+ expect(fn1).toHaveBeenCalledWith("data");
+ expect(fn2).toHaveBeenCalledWith("data");
+ unsub1();
+ unsub2();
+ });
+
+ it("does not call listener for a different event name", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent("wanted", fn);
+ emitAppEvent("unwanted", "data");
+ expect(fn).not.toHaveBeenCalled();
+ unsub();
+ });
+
+ it("passes detail through for the APP_INSTALLED constant", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent(APP_INSTALLED, fn);
+ emitAppEvent(APP_INSTALLED, "app-42");
+ expect(fn).toHaveBeenCalledWith("app-42");
+ unsub();
+ });
+
+ it("passes detail through for the APP_OPTIONAL_CHANGED constant", () => {
+ const fn = vi.fn();
+ const unsub = onAppEvent(APP_OPTIONAL_CHANGED, fn);
+ emitAppEvent(APP_OPTIONAL_CHANGED, "optional-app-7");
+ expect(fn).toHaveBeenCalledWith("optional-app-7");
+ unsub();
+ });
+});
diff --git a/desktop/src/lib/browser-site-permissions-api.test.ts b/desktop/src/lib/browser-site-permissions-api.test.ts
new file mode 100644
index 000000000..8b4003d21
--- /dev/null
+++ b/desktop/src/lib/browser-site-permissions-api.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { listSitePermissions, revokeSitePermission } from "./browser-site-permissions-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("listSitePermissions", () => {
+ it("returns grants array on 200", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ grants: [
+ { host_pattern: "https://a.test/*", permission: "camera", state: "allow" },
+ { host_pattern: "https://b.test/*", permission: "microphone", state: "deny" },
+ ],
+ }),
+ });
+
+ const result = await listSitePermissions("profile-1");
+ expect(result).toHaveLength(2);
+ expect(result[0].host_pattern).toBe("https://a.test/*");
+ expect(result[0].permission).toBe("camera");
+ expect(result[0].state).toBe("allow");
+ expect(result[1].state).toBe("deny");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ await expect(listSitePermissions("profile-1")).rejects.toThrow("HTTP 500");
+ });
+
+ it("throws on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ await expect(listSitePermissions("profile-1")).rejects.toThrow("network failure");
+ });
+
+ it("returns [] when body.grants is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ grants: null }),
+ });
+ expect(await listSitePermissions("profile-1")).toEqual([]);
+ });
+
+ it("includes credentials and profile_id param", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ grants: [] }),
+ });
+ global.fetch = fetchMock;
+ await listSitePermissions("profile-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("profile_id=profile-1");
+ expect(opts.credentials).toBe("include");
+ });
+
+ it("encodes profile_id with spaces", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ grants: [] }),
+ });
+ global.fetch = fetchMock;
+ await listSitePermissions("my profile");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("profile_id=my+profile");
+ });
+});
+
+describe("revokeSitePermission", () => {
+ it("returns true on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ expect(await revokeSitePermission("profile-1", "https://a.test/*", "camera")).toBe(true);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/desktop/browser/site-permissions");
+ expect(url).toContain("profile_id=profile-1");
+ expect(url).toContain("host_pattern=https%3A%2F%2Fa.test%2F*");
+ expect(url).toContain("permission=camera");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await revokeSitePermission("profile-1", "https://a.test/*", "camera")).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await revokeSitePermission("profile-1", "https://a.test/*", "camera")).toBe(false);
+ });
+
+ it("includes credentials", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+ await revokeSitePermission("profile-1", "https://a.test/*", "camera");
+ const [, opts] = fetchMock.mock.calls[0];
+ expect(opts.credentials).toBe("include");
+ });
+});
diff --git a/desktop/src/lib/channel-admin-api.test.ts b/desktop/src/lib/channel-admin-api.test.ts
new file mode 100644
index 000000000..1bef4262e
--- /dev/null
+++ b/desktop/src/lib/channel-admin-api.test.ts
@@ -0,0 +1,213 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ patchChannel,
+ addChannelMember,
+ removeChannelMember,
+ muteAgent,
+ unmuteAgent,
+} from "./channel-admin-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("patchChannel", () => {
+ it("sends PATCH with correct URL and body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await patchChannel("ch-1", { topic: "hello", max_hops: 3 });
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/chat/channels/ch-1");
+ expect(opts.method).toBe("PATCH");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.topic).toBe("hello");
+ expect(body.max_hops).toBe(3);
+ });
+
+ it("throws on non-ok with server error message", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "bad request" }),
+ });
+
+ await expect(patchChannel("ch-1", { topic: "x" })).rejects.toThrow(
+ "bad request",
+ );
+ });
+
+ it("throws on non-ok without error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => null,
+ });
+
+ await expect(patchChannel("ch-1", {})).rejects.toThrow("HTTP 500");
+ });
+
+ it("encodes channelId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await patchChannel("ch/with/slashes", {});
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("ch/with/slashes"));
+ });
+});
+
+describe("addChannelMember", () => {
+ it("sends POST with action add and correct slug", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await addChannelMember("ch-1", "agent-alpha");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/chat/channels/ch-1/members");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.action).toBe("add");
+ expect(body.slug).toBe("agent-alpha");
+ });
+
+ it("throws on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ error: "forbidden" }),
+ });
+
+ await expect(addChannelMember("ch-1", "agent-x")).rejects.toThrow(
+ "forbidden",
+ );
+ });
+
+ it("encodes channelId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await addChannelMember("ch/1", "slug");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("ch/1"));
+ });
+});
+
+describe("removeChannelMember", () => {
+ it("sends POST with action remove and correct slug", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await removeChannelMember("ch-1", "agent-beta");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/chat/channels/ch-1/members");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.action).toBe("remove");
+ expect(body.slug).toBe("agent-beta");
+ });
+
+ it("throws on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "not found" }),
+ });
+
+ await expect(removeChannelMember("ch-1", "agent-x")).rejects.toThrow(
+ "not found",
+ );
+ });
+
+ it("encodes channelId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await removeChannelMember("ch/1", "slug");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("ch/1"));
+ });
+});
+
+describe("muteAgent", () => {
+ it("sends POST to muted endpoint with action add", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await muteAgent("ch-1", "agent-gamma");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/chat/channels/ch-1/muted");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.action).toBe("add");
+ expect(body.slug).toBe("agent-gamma");
+ });
+
+ it("throws on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => null,
+ });
+
+ await expect(muteAgent("ch-1", "agent-x")).rejects.toThrow("HTTP 500");
+ });
+
+ it("encodes channelId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await muteAgent("ch/1", "slug");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("ch/1"));
+ });
+});
+
+describe("unmuteAgent", () => {
+ it("sends POST to muted endpoint with action remove", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await unmuteAgent("ch-1", "agent-delta");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/chat/channels/ch-1/muted");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.action).toBe("remove");
+ expect(body.slug).toBe("agent-delta");
+ });
+
+ it("throws on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 409,
+ json: async () => ({ error: "conflict" }),
+ });
+
+ await expect(unmuteAgent("ch-1", "agent-x")).rejects.toThrow("conflict");
+ });
+
+ it("encodes channelId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await unmuteAgent("ch/1", "slug");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("ch/1"));
+ });
+});
diff --git a/desktop/src/lib/chat-attachments-api.test.ts b/desktop/src/lib/chat-attachments-api.test.ts
new file mode 100644
index 000000000..0254500bf
--- /dev/null
+++ b/desktop/src/lib/chat-attachments-api.test.ts
@@ -0,0 +1,194 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { uploadDiskFile, attachmentFromPath } from "./chat-attachments-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("uploadDiskFile", () => {
+ it("POSTs to /api/chat/upload and returns normalized AttachmentRecord", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "att-1",
+ filename: "photo.png",
+ content_type: "image/png",
+ size: 1024,
+ url: "/files/att-1",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const file = new File(["dummy"], "photo.png", { type: "image/png" });
+ const result = await uploadDiskFile(file);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/upload");
+ expect(opts.method).toBe("POST");
+ expect(opts.body).toBeInstanceOf(FormData);
+
+ expect(result).toEqual({
+ filename: "photo.png",
+ mime_type: "image/png",
+ size: 1024,
+ url: "/files/att-1",
+ source: "disk",
+ });
+ });
+
+ it("includes channel_id in FormData when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "att-2",
+ filename: "doc.pdf",
+ content_type: "application/pdf",
+ size: 2048,
+ url: "/files/att-2",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const file = new File(["dummy"], "doc.pdf", { type: "application/pdf" });
+ await uploadDiskFile(file, "ch-42");
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const form = opts.body as FormData;
+ expect(form.get("channel_id")).toBe("ch-42");
+ });
+
+ it("falls back to mime_type field when content_type is absent", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "att-3",
+ filename: "data.bin",
+ mime_type: "application/octet-stream",
+ size: 512,
+ url: "/files/att-3",
+ }),
+ });
+
+ const file = new File(["dummy"], "data.bin");
+ const result = await uploadDiskFile(file);
+
+ expect(result.mime_type).toBe("application/octet-stream");
+ });
+
+ it("throws on non-ok response with error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "file too large" }),
+ });
+
+ const file = new File(["dummy"], "big.bin");
+ await expect(uploadDiskFile(file)).rejects.toThrow("file too large");
+ });
+
+ it("throws on non-ok response without error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ const file = new File(["dummy"], "x.bin");
+ await expect(uploadDiskFile(file)).rejects.toThrow("HTTP 500");
+ });
+});
+
+describe("attachmentFromPath", () => {
+ it("POSTs to /api/chat/attachments/from-path and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ filename: "readme.md",
+ mime_type: "text/markdown",
+ size: 4096,
+ url: "/workspace/readme.md",
+ source: "workspace",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await attachmentFromPath({
+ path: "/workspace/readme.md",
+ source: "workspace",
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/attachments/from-path");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.path).toBe("/workspace/readme.md");
+ expect(body.source).toBe("workspace");
+
+ expect(result).toEqual({
+ filename: "readme.md",
+ mime_type: "text/markdown",
+ size: 4096,
+ url: "/workspace/readme.md",
+ source: "workspace",
+ });
+ });
+
+ it("sends slug when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ filename: "notes.txt",
+ mime_type: "text/plain",
+ size: 128,
+ url: "/agent/notes.txt",
+ source: "agent-workspace",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ await attachmentFromPath({
+ path: "/agent/notes.txt",
+ source: "agent-workspace",
+ slug: "abc123",
+ });
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.slug).toBe("abc123");
+ });
+
+ it("throws on non-ok response with error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "path not found" }),
+ });
+
+ await expect(
+ attachmentFromPath({ path: "/missing.txt", source: "workspace" }),
+ ).rejects.toThrow("path not found");
+ });
+
+ it("throws on non-ok response without error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(
+ attachmentFromPath({ path: "/x.txt", source: "workspace" }),
+ ).rejects.toThrow("HTTP 500");
+ });
+});
diff --git a/desktop/src/lib/chat-messages-api.test.ts b/desktop/src/lib/chat-messages-api.test.ts
new file mode 100644
index 000000000..cae1d8e16
--- /dev/null
+++ b/desktop/src/lib/chat-messages-api.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ pinMessage,
+ unpinMessage,
+ listPins,
+ editMessage,
+ deleteMessage,
+ markUnread,
+} from "./chat-messages-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("pinMessage", () => {
+ it("calls POST /api/chat/messages/:id/pin and returns void on success", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await pinMessage("msg-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/messages/msg-1/pin");
+ expect(opts.method).toBe("POST");
+ });
+
+ it("throws on non-ok response with error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "already pinned" }),
+ });
+
+ await expect(pinMessage("msg-1")).rejects.toThrow("already pinned");
+ });
+
+ it("throws with HTTP status when body has no error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(pinMessage("msg-1")).rejects.toThrow("HTTP 500");
+ });
+});
+
+describe("unpinMessage", () => {
+ it("calls DELETE /api/chat/messages/:id/pin and returns void on success", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await unpinMessage("msg-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/messages/msg-1/pin");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "not found" }),
+ });
+
+ await expect(unpinMessage("msg-1")).rejects.toThrow("not found");
+ });
+});
+
+describe("listPins", () => {
+ it("calls GET /api/chat/channels/:id/pins and returns pins array", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ pins: ["msg-1", "msg-2"] }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listPins("ch-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/channels/ch-1/pins");
+ expect(result).toEqual(["msg-1", "msg-2"]);
+ });
+
+ it("returns empty array when pins is falsy", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ pins: null }),
+ });
+
+ const result = await listPins("ch-1");
+ expect(result).toEqual([]);
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ error: "forbidden" }),
+ });
+
+ await expect(listPins("ch-1")).rejects.toThrow("forbidden");
+ });
+});
+
+describe("editMessage", () => {
+ it("calls PATCH /api/chat/messages/:id with content and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ id: "msg-1", content: "updated" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await editMessage("msg-1", "updated");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/messages/msg-1");
+ expect(opts.method).toBe("PATCH");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.content).toBe("updated");
+ expect(result).toEqual({ id: "msg-1", content: "updated" });
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "edit failed" }),
+ });
+
+ await expect(editMessage("msg-1", "x")).rejects.toThrow("edit failed");
+ });
+});
+
+describe("deleteMessage", () => {
+ it("calls DELETE /api/chat/messages/:id and returns void on success", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await deleteMessage("msg-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/messages/msg-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "not found" }),
+ });
+
+ await expect(deleteMessage("msg-1")).rejects.toThrow("not found");
+ });
+});
+
+describe("markUnread", () => {
+ it("calls POST /api/chat/channels/:id/read-cursor/rewind with before_message_id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ await markUnread("ch-1", "msg-5");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/chat/channels/ch-1/read-cursor/rewind");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.before_message_id).toBe("msg-5");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "server error" }),
+ });
+
+ await expect(markUnread("ch-1", "msg-5")).rejects.toThrow("server error");
+ });
+});
diff --git a/desktop/src/lib/csrf.test.ts b/desktop/src/lib/csrf.test.ts
new file mode 100644
index 000000000..c76e5de1f
--- /dev/null
+++ b/desktop/src/lib/csrf.test.ts
@@ -0,0 +1,156 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getCsrfToken, withCsrf } from "./csrf";
+
+describe("getCsrfToken", () => {
+ const originalDocument = global.document;
+
+ afterEach(() => {
+ vi.stubGlobal("document", originalDocument);
+ vi.restoreAllMocks();
+ });
+
+ it("returns the token when csrf_token cookie is present", () => {
+ vi.stubGlobal("document", {
+ cookie: "session=abc123; csrf_token=token456; other=val",
+ } as unknown as Document);
+ expect(getCsrfToken()).toBe("token456");
+ });
+
+ it("returns null when no csrf_token cookie exists", () => {
+ vi.stubGlobal("document", {
+ cookie: "session=abc123; other=val",
+ } as unknown as Document);
+ expect(getCsrfToken()).toBeNull();
+ });
+
+ it("returns null when document is undefined (SSR)", () => {
+ vi.stubGlobal("document", undefined);
+ expect(getCsrfToken()).toBeNull();
+ });
+
+ it("returns null for empty cookie string", () => {
+ vi.stubGlobal("document", { cookie: "" } as unknown as Document);
+ expect(getCsrfToken()).toBeNull();
+ });
+
+ it("decodes a URL-encoded token", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=abc%2Fdef%3D",
+ } as unknown as Document);
+ expect(getCsrfToken()).toBe("abc/def=");
+ });
+
+ it("returns null when csrf_token value is empty", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=; other=val",
+ } as unknown as Document);
+ expect(getCsrfToken()).toBeNull();
+ });
+});
+
+describe("withCsrf", () => {
+ const originalDocument = global.document;
+
+ afterEach(() => {
+ vi.stubGlobal("document", originalDocument);
+ vi.restoreAllMocks();
+ });
+
+ it("attaches X-CSRF-Token header on POST when token exists", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ const result = withCsrf({ method: "POST" });
+ expect(result).toBeDefined();
+ expect(result!.headers).toBeInstanceOf(Headers);
+ expect(result!.headers.get("X-CSRF-Token")).toBe("mytoken");
+ });
+
+ it("returns init unchanged for GET even with token present", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ const init: RequestInit = { method: "GET" };
+ expect(withCsrf(init)).toBe(init);
+ });
+
+ it("returns init unchanged when no csrf_token cookie", () => {
+ vi.stubGlobal("document", {
+ cookie: "session=abc",
+ } as unknown as Document);
+ const init: RequestInit = { method: "POST" };
+ expect(withCsrf(init)).toBe(init);
+ });
+
+ it("attaches header for PUT method", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=puttoken",
+ } as unknown as Document);
+ const result = withCsrf({ method: "PUT" });
+ expect(result!.headers.get("X-CSRF-Token")).toBe("puttoken");
+ });
+
+ it("attaches header for PATCH method", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=patchtoken",
+ } as unknown as Document);
+ const result = withCsrf({ method: "PATCH" });
+ expect(result!.headers.get("X-CSRF-Token")).toBe("patchtoken");
+ });
+
+ it("attaches header for DELETE method", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=deletetoken",
+ } as unknown as Document);
+ const result = withCsrf({ method: "DELETE" });
+ expect(result!.headers.get("X-CSRF-Token")).toBe("deletetoken");
+ });
+
+ it("defaults to GET when no method specified", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ const init: RequestInit = {};
+ expect(withCsrf(init)).toBe(init);
+ });
+
+ it("preserves existing headers when adding CSRF token", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ const init: RequestInit = {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ };
+ const result = withCsrf(init);
+ expect(result!.headers.get("Content-Type")).toBe("application/json");
+ expect(result!.headers.get("X-CSRF-Token")).toBe("mytoken");
+ });
+
+ it("does not overwrite an existing X-CSRF-Token header", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=cookietoken",
+ } as unknown as Document);
+ const init: RequestInit = {
+ method: "POST",
+ headers: { "X-CSRF-Token": "existing-token" },
+ };
+ const result = withCsrf(init);
+ expect(result!.headers.get("X-CSRF-Token")).toBe("existing-token");
+ });
+
+ it("returns undefined when init is undefined and method defaults to GET", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ expect(withCsrf(undefined)).toBeUndefined();
+ });
+
+ it("handles lowercase method by uppercasing it", () => {
+ vi.stubGlobal("document", {
+ cookie: "csrf_token=mytoken",
+ } as unknown as Document);
+ const result = withCsrf({ method: "post" });
+ expect(result!.headers.get("X-CSRF-Token")).toBe("mytoken");
+ });
+});
diff --git a/desktop/src/lib/framework-api.test.ts b/desktop/src/lib/framework-api.test.ts
new file mode 100644
index 000000000..01d63b4a4
--- /dev/null
+++ b/desktop/src/lib/framework-api.test.ts
@@ -0,0 +1,276 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchFrameworkState,
+ startFrameworkUpdate,
+ fetchLatestFrameworks,
+ fetchPermittedModels,
+ setPermittedModels,
+} from "./framework-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchFrameworkState", () => {
+ it("calls the right URL and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ framework: "openclaw",
+ installed: { tag: "v1.0.0", sha: "abc123" },
+ latest: { tag: "v1.1.0", sha: "def456", published_at: "2025-01-01" },
+ update_available: true,
+ update_status: "idle",
+ update_started_at: null,
+ last_error: null,
+ last_snapshot: null,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const slug = "openclaw";
+ const result = await fetchFrameworkState(slug);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `/api/agents/${encodeURIComponent(slug)}/framework`,
+ );
+ expect(result.framework).toBe("openclaw");
+ expect(result.installed.tag).toBe("v1.0.0");
+ expect(result.latest?.tag).toBe("v1.1.0");
+ expect(result.update_available).toBe(true);
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ await expect(fetchFrameworkState("openclaw")).rejects.toThrow(
+ "framework fetch 500",
+ );
+ });
+
+ it("encodes slug with special characters", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ framework: "test",
+ installed: { tag: null, sha: null },
+ latest: null,
+ update_available: false,
+ update_status: "idle",
+ update_started_at: null,
+ last_error: null,
+ last_snapshot: null,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchFrameworkState("my agent");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain(encodeURIComponent("my agent"));
+ });
+});
+
+describe("startFrameworkUpdate", () => {
+ it("POSTs without target version when none given", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ const slug = "openclaw";
+ await startFrameworkUpdate(slug);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe(
+ `/api/agents/${encodeURIComponent(slug)}/framework/update`,
+ );
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(opts.body)).toEqual({});
+ });
+
+ it("POSTs with target version when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await startFrameworkUpdate("openclaw", "v2.0.0");
+
+ const [, opts] = fetchMock.mock.calls[0];
+ expect(JSON.parse(opts.body)).toEqual({
+ target_version: "v2.0.0",
+ });
+ });
+
+ it("throws body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "already updating" }),
+ });
+
+ await expect(startFrameworkUpdate("openclaw")).rejects.toThrow(
+ "already updating",
+ );
+ });
+
+ it("throws fallback message when non-ok and no body error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(startFrameworkUpdate("openclaw")).rejects.toThrow(
+ "update start 500",
+ );
+ });
+});
+
+describe("fetchLatestFrameworks", () => {
+ it("fetches without refresh by default", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ openclaw: { tag: "v1.1.0", sha: "def456", published_at: "2025-01-01" },
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchLatestFrameworks();
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/frameworks/latest");
+ expect(result.openclaw.tag).toBe("v1.1.0");
+ });
+
+ it("appends refresh=true when refresh is true", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({}),
+ });
+ global.fetch = fetchMock;
+
+ await fetchLatestFrameworks(true);
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/frameworks/latest?refresh=true");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ });
+
+ await expect(fetchLatestFrameworks()).rejects.toThrow(
+ "latest frameworks 503",
+ );
+ });
+});
+
+describe("fetchPermittedModels", () => {
+ it("calls the right URL and returns parsed state", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ permitted: ["gpt-4", "claude-3"],
+ current: "gpt-4",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const name = "my-agent";
+ const result = await fetchPermittedModels(name);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `/api/agents/${encodeURIComponent(name)}/permitted-models`,
+ );
+ expect(result.permitted).toEqual(["gpt-4", "claude-3"]);
+ expect(result.current).toBe("gpt-4");
+ });
+
+ it("throws body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "agent not found" }),
+ });
+
+ await expect(fetchPermittedModels("unknown")).rejects.toThrow(
+ "agent not found",
+ );
+ });
+
+ it("throws fallback when non-ok and no body error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(fetchPermittedModels("agent")).rejects.toThrow(
+ "permitted-models fetch 500",
+ );
+ });
+});
+
+describe("setPermittedModels", () => {
+ it("PUTs models and returns updated state", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ permitted: ["claude-3"],
+ current: "claude-3",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const name = "my-agent";
+ const models = ["claude-3"];
+ const result = await setPermittedModels(name, models);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe(
+ `/api/agents/${encodeURIComponent(name)}/permitted-models`,
+ );
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(opts.body)).toEqual({ models: ["claude-3"] });
+ expect(result.current).toBe("claude-3");
+ });
+
+ it("throws body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ error: "forbidden" }),
+ });
+
+ await expect(setPermittedModels("agent", [])).rejects.toThrow("forbidden");
+ });
+
+ it("throws fallback when non-ok and no body error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(setPermittedModels("agent", [])).rejects.toThrow(
+ "permitted-models set 500",
+ );
+ });
+});
diff --git a/desktop/src/lib/github.test.ts b/desktop/src/lib/github.test.ts
new file mode 100644
index 000000000..d59075fa3
--- /dev/null
+++ b/desktop/src/lib/github.test.ts
@@ -0,0 +1,528 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchStarred,
+ fetchNotifications,
+ fetchRepo,
+ fetchIssues,
+ fetchIssue,
+ fetchReleases,
+ getAuthStatus,
+ saveToLibrary,
+ startDeviceFlow,
+ pollDeviceFlow,
+ listIdentities,
+ deleteIdentity,
+} from "./github";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchStarred", () => {
+ it("returns repos and total on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ repos: [
+ { owner: "a", name: "b", description: "d", stars: 1, forks: 2, language: "ts", license: "MIT", updated_at: "2024-01-01", topics: [] },
+ ],
+ total: 1,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchStarred(1);
+ expect(result.repos).toHaveLength(1);
+ expect(result.repos[0].owner).toBe("a");
+ expect(result.total).toBe(1);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/github/starred");
+ expect(url).toContain("page=1");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns empty result on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await fetchStarred();
+ expect(result).toEqual({ repos: [], total: 0 });
+ });
+
+ it("returns empty result on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ const result = await fetchStarred();
+ expect(result).toEqual({ repos: [], total: 0 });
+ });
+
+ it("omits page param when not provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ repos: [], total: 0 }),
+ });
+ global.fetch = fetchMock;
+ await fetchStarred();
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/starred");
+ });
+});
+
+describe("fetchNotifications", () => {
+ it("returns notifications and unread_count on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ notifications: [
+ { number: 1, title: "bug", state: "open", author: "user", body: "", labels: [], comments: [], created_at: "2024-01-01", repo: "o/r", is_pull_request: false },
+ ],
+ unread_count: 5,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchNotifications();
+ expect(result.notifications).toHaveLength(1);
+ expect(result.notifications[0].title).toBe("bug");
+ expect(result.unread_count).toBe(5);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/notifications");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns empty result on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ headers: new Map(),
+ });
+ const result = await fetchNotifications();
+ expect(result).toEqual({ notifications: [], unread_count: 0 });
+ });
+
+ it("returns empty result on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await fetchNotifications();
+ expect(result).toEqual({ notifications: [], unread_count: 0 });
+ });
+});
+
+describe("fetchRepo", () => {
+ it("returns repo object on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ owner: "octocat", name: "hello-world", description: "Hi", stars: 10,
+ forks: 2, language: "Python", license: "MIT", updated_at: "2024-01-01", topics: ["demo"],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchRepo("octocat", "hello-world");
+ expect(result).not.toBeNull();
+ expect(result!.owner).toBe("octocat");
+ expect(result!.name).toBe("hello-world");
+ expect(result!.stars).toBe(10);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/repo/octocat/hello-world");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ headers: new Map(),
+ });
+ expect(await fetchRepo("octocat", "nope")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await fetchRepo("octocat", "hello-world")).toBeNull();
+ });
+
+ it("encodes owner and repo in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ owner: "a/b", name: "c d", description: "", stars: 0,
+ forks: 0, language: "", license: "", updated_at: "", topics: [],
+ }),
+ });
+ global.fetch = fetchMock;
+ await fetchRepo("a/b", "c d");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("a%2Fb");
+ expect(url).toContain("c%20d");
+ });
+});
+
+describe("fetchIssues", () => {
+ it("returns issues and total on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ issues: [
+ { number: 1, title: "issue", state: "open", author: "u", body: "", labels: [], comments: [], created_at: "2024-01-01", repo: "o/r", is_pull_request: false },
+ ],
+ total: 1,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchIssues("octocat", "hello-world", "open", 2);
+ expect(result.issues).toHaveLength(1);
+ expect(result.total).toBe(1);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/github/repo/octocat/hello-world/issues");
+ expect(url).toContain("state=open");
+ expect(url).toContain("page=2");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns empty result on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await fetchIssues("octocat", "hello-world");
+ expect(result).toEqual({ issues: [], total: 0 });
+ });
+
+ it("returns empty result on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await fetchIssues("octocat", "hello-world");
+ expect(result).toEqual({ issues: [], total: 0 });
+ });
+
+ it("omits optional params when not provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ issues: [], total: 0 }),
+ });
+ global.fetch = fetchMock;
+ await fetchIssues("octocat", "hello-world");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/repo/octocat/hello-world/issues");
+ });
+});
+
+describe("fetchIssue", () => {
+ it("returns issue object on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ number: 42, title: "bug", state: "open", author: "u", body: "details", labels: ["bug"],
+ comments: [], created_at: "2024-01-01", repo: "o/r", is_pull_request: false,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchIssue("octocat", "hello-world", 42);
+ expect(result).not.toBeNull();
+ expect(result!.number).toBe(42);
+ expect(result!.title).toBe("bug");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/repo/octocat/hello-world/issues/42");
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ headers: new Map(),
+ });
+ expect(await fetchIssue("octocat", "hello-world", 999)).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await fetchIssue("octocat", "hello-world", 1)).toBeNull();
+ });
+});
+
+describe("fetchReleases", () => {
+ it("returns releases array on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ releases: [
+ { tag: "v1.0", name: "v1.0", body: "release", author: "u", published_at: "2024-01-01", assets: [], prerelease: false },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchReleases("octocat", "hello-world");
+ expect(result).toHaveLength(1);
+ expect(result[0].tag).toBe("v1.0");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/repo/octocat/hello-world/releases");
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await fetchReleases("octocat", "hello-world");
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await fetchReleases("octocat", "hello-world");
+ expect(result).toEqual([]);
+ });
+});
+
+describe("getAuthStatus", () => {
+ it("returns auth status on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ authenticated: true, username: "octocat", method: "oauth" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await getAuthStatus();
+ expect(result.authenticated).toBe(true);
+ expect(result.username).toBe("octocat");
+ expect(result.method).toBe("oauth");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/auth/status");
+ });
+
+ it("returns default on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await getAuthStatus();
+ expect(result).toEqual({ authenticated: false });
+ });
+
+ it("returns default on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await getAuthStatus();
+ expect(result).toEqual({ authenticated: false });
+ });
+});
+
+describe("saveToLibrary", () => {
+ it("returns id and status on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "lib-1", status: "ok" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await saveToLibrary("https://github.com/octocat/hello-world");
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe("lib-1");
+ expect(result!.status).toBe("ok");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/ingest");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.url).toBe("https://github.com/octocat/hello-world");
+ expect(body.source).toBe("github-browser");
+ expect(body.title).toBe("");
+ expect(body.text).toBe("");
+ expect(body.categories).toEqual([]);
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await saveToLibrary("https://example.com");
+ expect(result).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await saveToLibrary("https://example.com");
+ expect(result).toBeNull();
+ });
+});
+
+describe("startDeviceFlow", () => {
+ it("returns device start data on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ user_code: "ABCD-1234",
+ verification_uri: "https://github.com/login/device",
+ device_code: "dev-code",
+ interval: 5,
+ expires_in: 900,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await startDeviceFlow();
+ expect(result.user_code).toBe("ABCD-1234");
+ expect(result.verification_uri).toBe("https://github.com/login/device");
+ expect(result.interval).toBe(5);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/oauth/device/start");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ await expect(startDeviceFlow()).rejects.toThrow("Failed to start GitHub connect");
+ });
+});
+
+describe("pollDeviceFlow", () => {
+ it("returns identity on connected", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ status: "connected",
+ identity: { id: "gid-1", login: "octocat", avatar_url: "https://img", created_at: 1700000000 },
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await pollDeviceFlow("dev-code");
+ expect(result.status).toBe("connected");
+ if (result.status === "connected") {
+ expect(result.identity.login).toBe("octocat");
+ }
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/oauth/device/poll");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.device_code).toBe("dev-code");
+ });
+
+ it("returns error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await pollDeviceFlow("dev-code");
+ expect(result).toEqual({ status: "error", error: "poll_failed" });
+ });
+});
+
+describe("listIdentities", () => {
+ it("returns identities array on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ([
+ { id: "gid-1", login: "octocat", avatar_url: "https://img", created_at: 1700000000 },
+ ]),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listIdentities();
+ expect(result).toHaveLength(1);
+ expect(result[0].login).toBe("octocat");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/identities");
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: new Map(),
+ });
+ const result = await listIdentities();
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await listIdentities();
+ expect(result).toEqual([]);
+ });
+});
+
+describe("deleteIdentity", () => {
+ it("returns true on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map(),
+ });
+ global.fetch = fetchMock;
+
+ const result = await deleteIdentity("gid-1");
+ expect(result).toBe(true);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/github/identities/gid-1");
+ expect(opts.method).toBe("DELETE");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ headers: new Map(),
+ });
+ const result = await deleteIdentity("gid-1");
+ expect(result).toBe(false);
+ });
+
+ it("encodes id in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map(),
+ });
+ global.fetch = fetchMock;
+ await deleteIdentity("id/with/slashes");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("id%2Fwith%2Fslashes");
+ });
+});
diff --git a/desktop/src/lib/hw-detect.test.ts b/desktop/src/lib/hw-detect.test.ts
new file mode 100644
index 000000000..220f180ea
--- /dev/null
+++ b/desktop/src/lib/hw-detect.test.ts
@@ -0,0 +1,144 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const originalFetch = global.fetch;
+
+let detectHwClass: () => Promise<"rk3588" | "gpu" | "cpu">;
+
+beforeEach(async () => {
+ vi.resetModules();
+ const mod = await import("./hw-detect");
+ detectHwClass = mod.detectHwClass;
+});
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("detectHwClass", () => {
+ it("calls GET /api/cluster/workers", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => [],
+ });
+ global.fetch = fetchMock;
+
+ await detectHwClass();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/cluster/workers");
+ });
+
+ it("returns 'cpu' when response is not ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await detectHwClass()).toBe("cpu");
+ });
+
+ it("returns 'cpu' on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ expect(await detectHwClass()).toBe("cpu");
+ });
+
+ it("returns 'rk3588' when a worker has rk3588 capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [
+ { capabilities: ["cpu"] },
+ { capabilities: ["rk3588"] },
+ ],
+ });
+ expect(await detectHwClass()).toBe("rk3588");
+ });
+
+ it("returns 'rk3588' when a worker has npu capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["npu"] }],
+ });
+ expect(await detectHwClass()).toBe("rk3588");
+ });
+
+ it("returns 'gpu' when a worker has cuda capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["cuda"] }],
+ });
+ expect(await detectHwClass()).toBe("gpu");
+ });
+
+ it("returns 'gpu' when a worker has rocm capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["rocm"] }],
+ });
+ expect(await detectHwClass()).toBe("gpu");
+ });
+
+ it("returns 'gpu' when a worker has metal capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["metal"] }],
+ });
+ expect(await detectHwClass()).toBe("gpu");
+ });
+
+ it("returns 'gpu' when a worker has gpu capability", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["gpu"] }],
+ });
+ expect(await detectHwClass()).toBe("gpu");
+ });
+
+ it("returns 'cpu' when no workers have npu/gpu capabilities", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [
+ { capabilities: ["cpu"] },
+ { capabilities: [] },
+ ],
+ });
+ expect(await detectHwClass()).toBe("cpu");
+ });
+
+ it("returns 'cpu' when workers array is empty", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [],
+ });
+ expect(await detectHwClass()).toBe("cpu");
+ });
+
+ it("returns 'cpu' when workers lack capabilities field", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{}, { id: "w-1" }],
+ });
+ expect(await detectHwClass()).toBe("cpu");
+ });
+
+ it("returns gpu when a gpu worker comes before any rk3588 worker", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [
+ { capabilities: ["cuda"] },
+ { capabilities: ["rk3588"] },
+ ],
+ });
+ expect(await detectHwClass()).toBe("gpu");
+ });
+
+ it("caches the result and does not fetch again", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => [{ capabilities: ["cuda"] }],
+ });
+ global.fetch = fetchMock;
+
+ expect(await detectHwClass()).toBe("gpu");
+ expect(await detectHwClass()).toBe("gpu");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/lib/knowledge.test.ts b/desktop/src/lib/knowledge.test.ts
new file mode 100644
index 000000000..f0564dbfe
--- /dev/null
+++ b/desktop/src/lib/knowledge.test.ts
@@ -0,0 +1,543 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ listItems,
+ getItem,
+ deleteItem,
+ searchItems,
+ ingestUrl,
+ listSnapshots,
+ listRules,
+ createRule,
+ deleteRule,
+ listSubscriptions,
+ setSubscription,
+ deleteSubscription,
+} from "./knowledge";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("listItems", () => {
+ it("calls GET /api/knowledge/items and returns items + count", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ items: [
+ { id: "ki-1", title: "First", source_type: "web" },
+ { id: "ki-2", title: "Second", source_type: "web" },
+ ],
+ count: 2,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listItems();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/items");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result.items).toHaveLength(2);
+ expect(result.items[0].id).toBe("ki-1");
+ expect(result.count).toBe(2);
+ });
+
+ it("returns empty items on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ const result = await listItems();
+ expect(result).toEqual({ items: [], count: 0 });
+ });
+
+ it("returns empty items on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await listItems();
+ expect(result).toEqual({ items: [], count: 0 });
+ });
+
+ it("builds query params from ListItemsParams", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ items: [], count: 0 }),
+ });
+ global.fetch = fetchMock;
+
+ await listItems({ source_type: "web", status: "active", category: "tech", limit: 10, offset: 5 });
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("source_type=web");
+ expect(url).toContain("status=active");
+ expect(url).toContain("category=tech");
+ expect(url).toContain("limit=10");
+ expect(url).toContain("offset=5");
+ });
+
+ it("returns empty items when data.items is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ items: null, count: 0 }),
+ });
+ const result = await listItems();
+ expect(result.items).toEqual([]);
+ });
+});
+
+describe("getItem", () => {
+ it("calls GET /api/knowledge/items/:id and returns parsed object", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "ki-1", title: "Hello", source_type: "web" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await getItem("ki-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/items/ki-1");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toEqual({ id: "ki-1", title: "Hello", source_type: "web" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await getItem("ki-1")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getItem("ki-1")).toBeNull();
+ });
+
+ it("encodes id in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "ki/a" }),
+ });
+ global.fetch = fetchMock;
+ await getItem("ki/a");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/items/ki%2Fa");
+ });
+});
+
+describe("deleteItem", () => {
+ it("calls DELETE /api/knowledge/items/:id and returns true on ok", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ const result = await deleteItem("ki-1");
+
+ expect(result).toBe(true);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/items/ki-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await deleteItem("ki-1")).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await deleteItem("ki-1")).toBe(false);
+ });
+});
+
+describe("searchItems", () => {
+ it("calls GET /api/knowledge/search with q, mode, limit and returns results", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ results: [{ id: "ki-1", title: "Match" }],
+ mode: "keyword",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await searchItems("hello world", "keyword", 10);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("q=hello+world");
+ expect(url).toContain("mode=keyword");
+ expect(url).toContain("limit=10");
+ expect(result.results).toHaveLength(1);
+ expect(result.results[0].id).toBe("ki-1");
+ expect(result.mode).toBe("keyword");
+ });
+
+ it("returns empty results on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ const result = await searchItems("test");
+ expect(result.results).toEqual([]);
+ });
+
+ it("returns empty results on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await searchItems("test");
+ expect(result.results).toEqual([]);
+ });
+
+ it("uses default mode=keyword and limit=20", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ results: [], mode: "keyword" }),
+ });
+ global.fetch = fetchMock;
+ await searchItems("test");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("mode=keyword");
+ expect(url).toContain("limit=20");
+ });
+});
+
+describe("ingestUrl", () => {
+ it("calls POST /api/knowledge/ingest with body and returns parsed result", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "ki-new", status: "pending" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await ingestUrl("https://example.com/article", {
+ title: "Example",
+ text: "Some text",
+ categories: ["tech"],
+ source: "library",
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/ingest");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.url).toBe("https://example.com/article");
+ expect(body.title).toBe("Example");
+ expect(body.text).toBe("Some text");
+ expect(body.categories).toEqual(["tech"]);
+ expect(body.source).toBe("library");
+ expect(result).toEqual({ id: "ki-new", status: "pending" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
+ expect(await ingestUrl("https://example.com")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await ingestUrl("https://example.com")).toBeNull();
+ });
+
+ it("uses default values for missing opts fields", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "ki-1" }),
+ });
+ global.fetch = fetchMock;
+ await ingestUrl("https://example.com");
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.title).toBe("");
+ expect(body.text).toBe("");
+ expect(body.categories).toEqual([]);
+ expect(body.source).toBe("library");
+ });
+});
+
+describe("listSnapshots", () => {
+ it("calls GET /api/knowledge/items/:id/snapshots and returns snapshots array", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ snapshots: [
+ { id: 1, item_id: "ki-1", content_hash: "abc" },
+ { id: 2, item_id: "ki-1", content_hash: "def" },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listSnapshots("ki-1", 10);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/items/ki-1/snapshots?limit=10");
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe(1);
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ const result = await listSnapshots("ki-1");
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await listSnapshots("ki-1");
+ expect(result).toEqual([]);
+ });
+
+ it("uses default limit=20", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ snapshots: [] }),
+ });
+ global.fetch = fetchMock;
+ await listSnapshots("ki-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("limit=20");
+ });
+});
+
+describe("listRules", () => {
+ it("calls GET /api/knowledge/rules and returns rules array", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ rules: [
+ { id: 1, pattern: "github.com", match_on: "url", category: "dev", priority: 1 },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listRules();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/rules");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ expect(result[0].pattern).toBe("github.com");
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await listRules()).toEqual([]);
+ });
+
+ it("returns empty array on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await listRules()).toEqual([]);
+ });
+});
+
+describe("createRule", () => {
+ it("calls POST /api/knowledge/rules with body and returns id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: 42 }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await createRule({
+ pattern: "github.com",
+ match_on: "url",
+ category: "dev",
+ priority: 1,
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/rules");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.pattern).toBe("github.com");
+ expect(body.match_on).toBe("url");
+ expect(body.category).toBe("dev");
+ expect(body.priority).toBe(1);
+ expect(result).toBe(42);
+ });
+
+ it("returns null when response has no id", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({}),
+ });
+ expect(await createRule({ pattern: "x", match_on: "url", category: "y", priority: 0 })).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await createRule({ pattern: "x", match_on: "url", category: "y", priority: 0 })).toBeNull();
+ });
+});
+
+describe("deleteRule", () => {
+ it("calls DELETE /api/knowledge/rules/:id and returns true on ok", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ const result = await deleteRule(5);
+
+ expect(result).toBe(true);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/rules/5");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await deleteRule(5)).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await deleteRule(5)).toBe(false);
+ });
+});
+
+describe("listSubscriptions", () => {
+ it("calls GET /api/knowledge/subscriptions and returns subscriptions array", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ subscriptions: [
+ { agent_name: "agent-1", category: "dev", auto_ingest: true },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await listSubscriptions();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/subscriptions");
+ expect(result).toHaveLength(1);
+ expect(result[0].agent_name).toBe("agent-1");
+ });
+
+ it("includes agent_name as query param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ subscriptions: [] }),
+ });
+ global.fetch = fetchMock;
+ await listSubscriptions("my-agent");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("agent_name=my-agent");
+ });
+
+ it("returns empty array on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await listSubscriptions()).toEqual([]);
+ });
+
+ it("returns empty array on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await listSubscriptions()).toEqual([]);
+ });
+});
+
+describe("setSubscription", () => {
+ it("calls POST /api/knowledge/subscriptions with body and returns true when status=ok", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "ok" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setSubscription({
+ agent_name: "agent-1",
+ category: "dev",
+ auto_ingest: true,
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/subscriptions");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.agent_name).toBe("agent-1");
+ expect(body.category).toBe("dev");
+ expect(body.auto_ingest).toBe(true);
+ expect(result).toBe(true);
+ });
+
+ it("returns false when response status is not ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "error" }),
+ });
+ expect(
+ await setSubscription({ agent_name: "a", category: "b", auto_ingest: false }),
+ ).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(
+ await setSubscription({ agent_name: "a", category: "b", auto_ingest: false }),
+ ).toBe(false);
+ });
+});
+
+describe("deleteSubscription", () => {
+ it("calls DELETE /api/knowledge/subscriptions/:agent/:category and returns true on ok", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+
+ const result = await deleteSubscription("agent-1", "dev");
+
+ expect(result).toBe(true);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/subscriptions/agent-1/dev");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("returns false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await deleteSubscription("agent-1", "dev")).toBe(false);
+ });
+
+ it("returns false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await deleteSubscription("agent-1", "dev")).toBe(false);
+ });
+
+ it("encodes agent_name and category in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+ global.fetch = fetchMock;
+ await deleteSubscription("my agent", "dev ops");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/subscriptions/my%20agent/dev%20ops");
+ });
+});
diff --git a/desktop/src/lib/mail.test.ts b/desktop/src/lib/mail.test.ts
new file mode 100644
index 000000000..05bc906f4
--- /dev/null
+++ b/desktop/src/lib/mail.test.ts
@@ -0,0 +1,427 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchAccounts,
+ addAccount,
+ deleteAccount,
+ fetchFolders,
+ fetchMessages,
+ fetchMessage,
+ sendMessage,
+} from "./mail";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchAccounts", () => {
+ it("calls GET /api/mail/accounts and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => [
+ {
+ id: "acc-1",
+ display_name: "Test",
+ email_address: "test@example.com",
+ imap_host: "imap.example.com",
+ imap_port: 993,
+ imap_security: "tls",
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_security: "starttls",
+ username: "test@example.com",
+ created_at: 1000,
+ updated_at: 2000,
+ },
+ ],
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchAccounts();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("acc-1");
+ expect(result[0].email_address).toBe("test@example.com");
+ });
+
+ it("throws on non-ok response with error body", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "internal error" }),
+ });
+
+ await expect(fetchAccounts()).rejects.toThrow("internal error");
+ });
+
+ it("throws with HTTP status when body has no error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: async () => ({}),
+ });
+
+ await expect(fetchAccounts()).rejects.toThrow("HTTP 503");
+ });
+});
+
+describe("addAccount", () => {
+ it("posts JSON body and returns parsed account", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "acc-new",
+ display_name: "New",
+ email_address: "new@example.com",
+ imap_host: "imap.example.com",
+ imap_port: 993,
+ imap_security: "tls",
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_security: "starttls",
+ username: "new@example.com",
+ created_at: 1000,
+ updated_at: 1000,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const newAccount = {
+ display_name: "New",
+ email_address: "new@example.com",
+ imap_host: "imap.example.com",
+ imap_port: 993,
+ imap_security: "tls",
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_security: "starttls",
+ username: "new@example.com",
+ password: "secret",
+ };
+
+ const result = await addAccount(newAccount);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.email_address).toBe("new@example.com");
+ expect(body.password).toBe("secret");
+ expect(result.id).toBe("acc-new");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "validation failed" }),
+ });
+
+ await expect(
+ addAccount({
+ display_name: "X",
+ email_address: "x@example.com",
+ imap_host: "imap.example.com",
+ imap_port: 993,
+ imap_security: "tls",
+ smtp_host: "smtp.example.com",
+ smtp_port: 587,
+ smtp_security: "starttls",
+ username: "x@example.com",
+ password: "pw",
+ }),
+ ).rejects.toThrow("validation failed");
+ });
+});
+
+describe("deleteAccount", () => {
+ it("sends DELETE to the account URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await deleteAccount("acc-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts/acc-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("encodes accountId in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await deleteAccount("acc/with/slashes");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts/acc%2Fwith%2Fslashes");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "not found" }),
+ });
+
+ await expect(deleteAccount("acc-1")).rejects.toThrow("not found");
+ });
+});
+
+describe("fetchFolders", () => {
+ it("calls GET folders URL and returns folder list", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ folders: ["INBOX", "Sent", "Drafts"] }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchFolders("acc-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts/acc-1/folders");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toEqual(["INBOX", "Sent", "Drafts"]);
+ });
+
+ it("returns [] when folders is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ folders: null }),
+ });
+
+ const result = await fetchFolders("acc-1");
+ expect(result).toEqual([]);
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "server error" }),
+ });
+
+ await expect(fetchFolders("acc-1")).rejects.toThrow("server error");
+ });
+});
+
+describe("fetchMessages", () => {
+ it("calls GET messages URL with folder and limit params", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ messages: [
+ {
+ uid: "100",
+ from_name: "Sender",
+ from_addr: "sender@example.com",
+ to: "me@example.com",
+ subject: "Hello",
+ date: "2025-01-01",
+ snippet: "Hi there",
+ unread: true,
+ flagged: false,
+ has_attachment: false,
+ },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchMessages("acc-1", "INBOX", 25);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/mail/accounts/acc-1/messages?");
+ expect(url).toContain("folder=INBOX");
+ expect(url).toContain("limit=25");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toHaveLength(1);
+ expect(result[0].uid).toBe("100");
+ expect(result[0].subject).toBe("Hello");
+ });
+
+ it("uses default limit of 50", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ messages: [] }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchMessages("acc-1", "INBOX");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("limit=50");
+ });
+
+ it("returns [] when messages is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ messages: null }),
+ });
+
+ const result = await fetchMessages("acc-1", "INBOX");
+ expect(result).toEqual([]);
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ error: "forbidden" }),
+ });
+
+ await expect(fetchMessages("acc-1", "INBOX")).rejects.toThrow("forbidden");
+ });
+});
+
+describe("fetchMessage", () => {
+ it("calls GET message URL with folder param and returns detail", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ uid: "100",
+ from_name: "Sender",
+ from_addr: "sender@example.com",
+ to: "me@example.com",
+ cc: "",
+ subject: "Hello",
+ date: "2025-01-01",
+ body_text: "Hi there",
+ body_html: "Hi there
",
+ attachments: [],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchMessage("acc-1", "100", "INBOX");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/mail/accounts/acc-1/messages/100?");
+ expect(url).toContain("folder=INBOX");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result.uid).toBe("100");
+ expect(result.body_text).toBe("Hi there");
+ expect(result.attachments).toEqual([]);
+ });
+
+ it("encodes uid in the path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ uid: "a/b",
+ from_name: "",
+ from_addr: "",
+ to: "",
+ cc: "",
+ subject: "",
+ date: "",
+ body_text: "",
+ body_html: "",
+ attachments: [],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchMessage("acc-1", "a/b", "INBOX");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/messages/a%2Fb?");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => ({ error: "message not found" }),
+ });
+
+ await expect(fetchMessage("acc-1", "999", "INBOX")).rejects.toThrow(
+ "message not found",
+ );
+ });
+});
+
+describe("sendMessage", () => {
+ it("posts JSON body to send URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ const payload = {
+ to: "recipient@example.com",
+ subject: "Test",
+ body: "Hello world",
+ };
+
+ await sendMessage("acc-1", payload);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/mail/accounts/acc-1/send");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.to).toBe("recipient@example.com");
+ expect(body.subject).toBe("Test");
+ expect(body.body).toBe("Hello world");
+ });
+
+ it("includes cc when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await sendMessage("acc-1", {
+ to: "a@b.com",
+ subject: "S",
+ body: "B",
+ cc: "c@d.com",
+ });
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.cc).toBe("c@d.com");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "send failed" }),
+ });
+
+ await expect(
+ sendMessage("acc-1", { to: "a@b.com", subject: "S", body: "B" }),
+ ).rejects.toThrow("send failed");
+ });
+});
diff --git a/desktop/src/lib/memory-api.test.ts b/desktop/src/lib/memory-api.test.ts
new file mode 100644
index 000000000..6f19e6dcc
--- /dev/null
+++ b/desktop/src/lib/memory-api.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchMemoryModel, setMemoryModel } from "./memory-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchMemoryModel", () => {
+ it("calls GET /api/memory/model and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ model: "gpt-4o-mini", supported: true }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchMemoryModel();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/model");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toEqual({ model: "gpt-4o-mini", supported: true });
+ });
+
+ it("returns model as null when none is set", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ model: null, supported: false }),
+ });
+
+ const result = await fetchMemoryModel();
+ expect(result.model).toBeNull();
+ expect(result.supported).toBe(false);
+ });
+
+ it("throws on non-ok response with detail", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({ detail: "internal error" }),
+ });
+
+ await expect(fetchMemoryModel()).rejects.toThrow("internal error");
+ });
+
+ it("throws on non-ok response with error field", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "bad request" }),
+ });
+
+ await expect(fetchMemoryModel()).rejects.toThrow("bad request");
+ });
+
+ it("throws with status when body has no detail or error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: async () => ({}),
+ });
+
+ await expect(fetchMemoryModel()).rejects.toThrow("Request failed (503)");
+ });
+});
+
+describe("setMemoryModel", () => {
+ it("calls PUT /api/memory/model with body and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ model: "gpt-4o" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setMemoryModel({ model: "gpt-4o" });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/model");
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.model).toBe("gpt-4o");
+ expect(result).toEqual({ model: "gpt-4o" });
+ });
+
+ it("sends clear flag when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ model: null }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setMemoryModel({ clear: true });
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.clear).toBe(true);
+ expect(result.model).toBeNull();
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ detail: "invalid model" }),
+ });
+
+ await expect(setMemoryModel({ model: "bad" })).rejects.toThrow("invalid model");
+ });
+});
diff --git a/desktop/src/lib/memory.test.ts b/desktop/src/lib/memory.test.ts
new file mode 100644
index 000000000..9830bdcab
--- /dev/null
+++ b/desktop/src/lib/memory.test.ts
@@ -0,0 +1,355 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchMemoryStats,
+ fetchBackendCapabilities,
+ fetchSettingsSchema,
+ fetchMemorySettings,
+ updateMemorySettings,
+ fetchCatalogDate,
+ fetchCatalogSession,
+ fetchCatalogSessionContext,
+ triggerCatalogIndex,
+ fetchCatalogSearch,
+ fetchCatalogStats,
+ fetchAgentMemoryConfig,
+ updateAgentMemoryConfig,
+} from "./memory";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchMemoryStats", () => {
+ it("calls GET /api/memory/stats and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ total: 42 }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchMemoryStats();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/stats");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toEqual({ total: 42 });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchMemoryStats()).toEqual({});
+ });
+});
+
+describe("fetchBackendCapabilities", () => {
+ it("calls GET /api/memory/backend/capabilities and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ name: "openai", version: "1", capabilities: ["embed"] }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchBackendCapabilities();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/backend/capabilities");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).toEqual({ name: "openai", version: "1", capabilities: ["embed"] });
+ });
+
+ it("returns fallback on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ const result = await fetchBackendCapabilities();
+ expect(result).toEqual({ name: "unknown", version: "0", capabilities: [] });
+ });
+});
+
+describe("fetchSettingsSchema", () => {
+ it("calls GET /api/memory/backend/settings-schema and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ fields: [{ key: "model", type: "string" }] }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchSettingsSchema();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/backend/settings-schema");
+ expect(result).toEqual({ fields: [{ key: "model", type: "string" }] });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchSettingsSchema()).toEqual({});
+ });
+});
+
+describe("fetchMemorySettings", () => {
+ it("calls GET /api/memory/settings and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ model: "gpt-4o" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchMemorySettings();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/settings");
+ expect(result).toEqual({ model: "gpt-4o" });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchMemorySettings()).toEqual({});
+ });
+});
+
+describe("updateMemorySettings", () => {
+ it("calls PUT /api/memory/settings with body and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ model: "gpt-4o" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await updateMemorySettings({ model: "gpt-4o" });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/settings");
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.model).toBe("gpt-4o");
+ expect(result).toEqual({ model: "gpt-4o" });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await updateMemorySettings({ model: "gpt-4o" })).toEqual({});
+ });
+});
+
+describe("fetchCatalogDate", () => {
+ it("calls GET /api/memory/catalog/date/:date and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => [{ id: 1, topic: "test" }],
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCatalogDate("2025-01-01");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/date/2025-01-01");
+ expect(result).toEqual([{ id: 1, topic: "test" }]);
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchCatalogDate("2025-01-01")).toEqual([]);
+ });
+});
+
+describe("fetchCatalogSession", () => {
+ it("calls GET /api/memory/catalog/session/:id and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: 1, topic: "test" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCatalogSession(1);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/session/1");
+ expect(result).toEqual({ id: 1, topic: "test" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchCatalogSession(1)).toBeNull();
+ });
+});
+
+describe("fetchCatalogSessionContext", () => {
+ it("calls GET /api/memory/catalog/session/:id/context and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ context: "some context" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCatalogSessionContext(5);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/session/5/context");
+ expect(result).toEqual({ context: "some context" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchCatalogSessionContext(5)).toBeNull();
+ });
+});
+
+describe("triggerCatalogIndex", () => {
+ it("calls POST /api/memory/catalog/index with body and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ indexed: true }),
+ });
+ global.fetch = fetchMock;
+
+ const body = { date: "2025-01-01", force: true };
+ const result = await triggerCatalogIndex(body);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/index");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const sentBody = JSON.parse(opts.body);
+ expect(sentBody.date).toBe("2025-01-01");
+ expect(sentBody.force).toBe(true);
+ expect(result).toEqual({ indexed: true });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await triggerCatalogIndex({ date: "2025-01-01" })).toEqual({});
+ });
+});
+
+describe("fetchCatalogSearch", () => {
+ it("calls GET /api/memory/catalog/search with encoded query and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => [{ id: 1, topic: "hello world" }],
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCatalogSearch("hello world");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/search?q=hello%20world");
+ expect(result).toEqual([{ id: 1, topic: "hello world" }]);
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchCatalogSearch("test")).toEqual([]);
+ });
+});
+
+describe("fetchCatalogStats", () => {
+ it("calls GET /api/memory/catalog/stats and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ sessions: 10 }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCatalogStats();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/memory/catalog/stats");
+ expect(result).toEqual({ sessions: 10 });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchCatalogStats()).toEqual({});
+ });
+});
+
+describe("fetchAgentMemoryConfig", () => {
+ it("calls GET /api/agents/:name/memory-config and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ auto_recall: true }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchAgentMemoryConfig("agent-1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/agents/agent-1/memory-config");
+ expect(result).toEqual({ auto_recall: true });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await fetchAgentMemoryConfig("agent-1")).toEqual({});
+ });
+});
+
+describe("updateAgentMemoryConfig", () => {
+ it("calls PUT /api/agents/:name/memory-config with body and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ auto_recall: false }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await updateAgentMemoryConfig("agent-1", { auto_recall: false });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/agents/agent-1/memory-config");
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.auto_recall).toBe(false);
+ expect(result).toEqual({ auto_recall: false });
+ });
+
+ it("returns {} on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await updateAgentMemoryConfig("agent-1", { auto_recall: true })).toEqual({});
+ });
+});
diff --git a/desktop/src/lib/models.test.ts b/desktop/src/lib/models.test.ts
new file mode 100644
index 000000000..3971d32db
--- /dev/null
+++ b/desktop/src/lib/models.test.ts
@@ -0,0 +1,135 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchClusterWorkers, fetchCloudProviders } from "./models";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchClusterWorkers", () => {
+ it("returns workers array on 200 with top-level array body", async () => {
+ const workers = [
+ { name: "worker-1", url: "http://w1.test" },
+ { name: "worker-2", url: "http://w2.test" },
+ ];
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "application/json" },
+ json: async () => workers,
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchClusterWorkers();
+ expect(result).toEqual(workers);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/cluster/workers");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns workers array on 200 with { workers } body", async () => {
+ const workers = [{ name: "w1", url: "http://w1.test" }];
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "application/json" },
+ json: async () => ({ workers }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchClusterWorkers();
+ expect(result).toEqual(workers);
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: { get: () => "application/json" },
+ });
+ expect(await fetchClusterWorkers()).toEqual([]);
+ });
+
+ it("returns [] on non-json content type", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "text/html" },
+ });
+ expect(await fetchClusterWorkers()).toEqual([]);
+ });
+
+ it("returns [] when body is not an array and has no workers field", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "application/json" },
+ json: async () => ({ something: "else" }),
+ });
+ expect(await fetchClusterWorkers()).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ expect(await fetchClusterWorkers()).toEqual([]);
+ });
+});
+
+describe("fetchCloudProviders", () => {
+ it("returns providers array on 200", async () => {
+ const providers = [
+ { name: "openai", type: "openai", models: [{ id: "gpt-4", name: "GPT-4" }] },
+ { name: "ollama-local", type: "ollama", source: "worker:node-1" },
+ ];
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "application/json" },
+ json: async () => providers,
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchCloudProviders();
+ expect(result).toEqual(providers);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/providers");
+ expect(opts.headers.Accept).toBe("application/json");
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ headers: { get: () => "application/json" },
+ });
+ expect(await fetchCloudProviders()).toEqual([]);
+ });
+
+ it("returns [] on non-json content type", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "text/plain" },
+ });
+ expect(await fetchCloudProviders()).toEqual([]);
+ });
+
+ it("returns [] when body is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => "application/json" },
+ json: async () => ({ providers: [] }),
+ });
+ expect(await fetchCloudProviders()).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await fetchCloudProviders()).toEqual([]);
+ });
+});
diff --git a/desktop/src/lib/personas-api.test.ts b/desktop/src/lib/personas-api.test.ts
new file mode 100644
index 000000000..1c651c6ab
--- /dev/null
+++ b/desktop/src/lib/personas-api.test.ts
@@ -0,0 +1,255 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchLibrary, fetchPersonaDetail } from "./personas-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchLibrary", () => {
+ it("calls GET /api/personas/library and returns personas array", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ personas: [
+ { source: "builtin", id: "base", name: "Base", preview: "hello" },
+ { source: "user", id: "custom-1", name: "Custom", preview: "world" },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchLibrary({});
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/personas/library?");
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe("base");
+ expect(result[0].source).toBe("builtin");
+ expect(result[1].name).toBe("Custom");
+ });
+
+ it("appends source, q, limit, offset params when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ personas: [] }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchLibrary({ source: "user", q: "test", limit: 10, offset: 5 });
+
+ const [url] = fetchMock.mock.calls[0];
+ const qs = new URLSearchParams(url.split("?")[1]);
+ expect(qs.get("source")).toBe("user");
+ expect(qs.get("q")).toBe("test");
+ expect(qs.get("limit")).toBe("10");
+ expect(qs.get("offset")).toBe("5");
+ });
+
+ it("omits params when not provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ personas: [] }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchLibrary({});
+
+ const [url] = fetchMock.mock.calls[0];
+ const qs = new URLSearchParams(url.split("?")[1]);
+ expect(qs.has("source")).toBe(false);
+ expect(qs.has("q")).toBe(false);
+ expect(qs.has("limit")).toBe(false);
+ expect(qs.has("offset")).toBe(false);
+ });
+});
+
+describe("fetchPersonaDetail", () => {
+ it("fetches builtin source from /api/templates/{id}", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "base",
+ name: "Base",
+ system_prompt: "You are a helpful assistant.",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("builtin", "base");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/templates/base");
+ expect(result.id).toBe("base");
+ expect(result.name).toBe("Base");
+ expect(result.source).toBe("builtin");
+ expect(result.soul_md).toBe("You are a helpful assistant.");
+ expect(result.agent_md).toBeUndefined();
+ });
+
+ it("fetches awesome-openclaw source from /api/templates/{id}", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "cool-persona",
+ name: "Cool",
+ system_prompt: "Be cool.",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("awesome-openclaw", "cool-persona");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/templates/cool-persona");
+ expect(result.source).toBe("awesome-openclaw");
+ expect(result.soul_md).toBe("Be cool.");
+ });
+
+ it("fetches prompt-library source from /api/templates/{id}", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "lib-tmpl",
+ name: "Lib Template",
+ system_prompt: "Template prompt.",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("prompt-library", "lib-tmpl");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/templates/lib-tmpl");
+ expect(result.source).toBe("prompt-library");
+ });
+
+ it("encodes id in template URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "a/b",
+ name: "AB",
+ system_prompt: "prompt",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchPersonaDetail("builtin", "a/b");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/templates/a%2Fb");
+ });
+
+ it("fetches user source from /api/user-personas/{id}", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "my-persona",
+ name: "My Persona",
+ soul_md: "Soul content.",
+ agent_md: "Agent content.",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("user", "my-persona");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/user-personas/my-persona");
+ expect(result.id).toBe("my-persona");
+ expect(result.name).toBe("My Persona");
+ expect(result.source).toBe("user");
+ expect(result.soul_md).toBe("Soul content.");
+ expect(result.agent_md).toBe("Agent content.");
+ });
+
+ it("encodes id in user-personas URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "x y",
+ name: "XY",
+ soul_md: "s",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchPersonaDetail("user", "x y");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/user-personas/x%20y");
+ });
+
+ it("returns empty soul_md when system_prompt is missing for builtin", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "base",
+ name: "Base",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("builtin", "base");
+
+ expect(result.soul_md).toBe("");
+ });
+
+ it("returns empty soul_md when soul_md is missing for user", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ id: "u-1",
+ name: "U1",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchPersonaDetail("user", "u-1");
+
+ expect(result.soul_md).toBe("");
+ });
+
+ it("throws on non-ok response for builtin template", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ });
+
+ await expect(fetchPersonaDetail("builtin", "missing")).rejects.toThrow(
+ "Template not found: missing",
+ );
+ });
+
+ it("throws on non-ok response for user persona", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404,
+ });
+
+ await expect(fetchPersonaDetail("user", "nope")).rejects.toThrow(
+ "User persona not found: nope",
+ );
+ });
+
+ it("throws on unsupported source", async () => {
+ await expect(fetchPersonaDetail("unknown", "x")).rejects.toThrow(
+ "Unsupported source for detail fetch: unknown",
+ );
+ });
+});
diff --git a/desktop/src/lib/platform.test.ts b/desktop/src/lib/platform.test.ts
new file mode 100644
index 000000000..c4829d2e3
--- /dev/null
+++ b/desktop/src/lib/platform.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { isIOS, isStandalone } from "./platform";
+
+describe("isIOS", () => {
+ const originalNavigator = navigator;
+
+ beforeEach(() => {
+ vi.stubGlobal("navigator", { ...originalNavigator });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("returns true for iPhone user agent", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "iPhone", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
+ expect(isIOS()).toBe(true);
+ });
+
+ it("returns true for iPad user agent", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "iPad", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
+ expect(isIOS()).toBe(true);
+ });
+
+ it("returns true for iPod user agent", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (iPod touch; CPU iPhone OS 16_0 like Mac OS X)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "iPod", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
+ expect(isIOS()).toBe(true);
+ });
+
+ it("returns true for MacIntel with multiple touch points (iPadOS)", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 5, configurable: true });
+ expect(isIOS()).toBe(true);
+ });
+
+ it("returns false for MacIntel with single touch point", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
+ expect(isIOS()).toBe(false);
+ });
+
+ it("returns false for MacIntel with zero touch points", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
+ expect(isIOS()).toBe(false);
+ });
+
+ it("returns false for Windows user agent", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "Win32", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
+ expect(isIOS()).toBe(false);
+ });
+
+ it("returns false for empty user agent", () => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: "",
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "platform", { value: "", configurable: true });
+ Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
+ expect(isIOS()).toBe(false);
+ });
+});
+
+describe("isStandalone", () => {
+ const originalNavigator = window.navigator;
+ const originalMatchMedia = window.matchMedia;
+
+ beforeEach(() => {
+ vi.stubGlobal("navigator", { ...originalNavigator });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ window.matchMedia = originalMatchMedia;
+ });
+
+ it("returns true when navigator.standalone is true", () => {
+ Object.defineProperty(window, "navigator", {
+ value: { standalone: true },
+ configurable: true,
+ });
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false });
+ expect(isStandalone()).toBe(true);
+ });
+
+ it("returns true when display-mode standalone matches", () => {
+ Object.defineProperty(window, "navigator", {
+ value: { standalone: false },
+ configurable: true,
+ });
+ window.matchMedia = vi.fn().mockReturnValue({ matches: true });
+ expect(isStandalone()).toBe(true);
+ });
+
+ it("returns false when neither standalone nor display-mode matches", () => {
+ Object.defineProperty(window, "navigator", {
+ value: { standalone: false },
+ configurable: true,
+ });
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false });
+ expect(isStandalone()).toBe(false);
+ });
+
+ it("returns false when navigator has no standalone property", () => {
+ Object.defineProperty(window, "navigator", {
+ value: {},
+ configurable: true,
+ });
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false });
+ expect(isStandalone()).toBe(false);
+ });
+
+ it("returns true when both standalone and display-mode are true", () => {
+ Object.defineProperty(window, "navigator", {
+ value: { standalone: true },
+ configurable: true,
+ });
+ window.matchMedia = vi.fn().mockReturnValue({ matches: true });
+ expect(isStandalone()).toBe(true);
+ });
+});
diff --git a/desktop/src/lib/platform.ts b/desktop/src/lib/platform.ts
new file mode 100644
index 000000000..3652feacf
--- /dev/null
+++ b/desktop/src/lib/platform.ts
@@ -0,0 +1,17 @@
+/** Shared platform / install-state detection for the install UI. */
+
+/** True on iPhone, iPod, and iPadOS (which reports as MacIntel with touch). */
+export function isIOS(): boolean {
+ return (
+ /iphone|ipad|ipod/i.test(navigator.userAgent) ||
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
+ );
+}
+
+/** True when the app is running as an installed PWA (home-screen / standalone). */
+export function isStandalone(): boolean {
+ return (
+ (window.navigator as unknown as { standalone?: boolean }).standalone === true ||
+ window.matchMedia("(display-mode: standalone)").matches
+ );
+}
diff --git a/desktop/src/lib/projects.test.ts b/desktop/src/lib/projects.test.ts
new file mode 100644
index 000000000..19de43981
--- /dev/null
+++ b/desktop/src/lib/projects.test.ts
@@ -0,0 +1,611 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { projectsApi } from "./projects";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+const mockFetch = (response: unknown, ok = true, status = 200) => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok,
+ status,
+ json: async () => response,
+ text: async () => (typeof response === "string" ? response : JSON.stringify(response)),
+ });
+};
+
+const mockFetchError = (status: number, body: string) => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status,
+ statusText: body,
+ text: async () => body,
+ json: async () => ({}),
+ });
+};
+
+describe("projectsApi.list", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { id: "p-1", name: "One", slug: "one", description: "d", status: "active", created_by: "u-1", created_at: 1, updated_at: 1 },
+ ],
+ });
+ const result = await projectsApi.list();
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("p-1");
+ });
+
+ it("passes status query param", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.list("archived");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("status=archived");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "server error");
+ await expect(projectsApi.list()).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.get", () => {
+ it("returns project on 200", async () => {
+ mockFetch({ id: "p-1", name: "One", slug: "one", description: "d", status: "active", created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.get("p-1");
+ expect(result.id).toBe("p-1");
+ expect(result.name).toBe("One");
+ });
+
+ it("calls correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.get("p-42");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-42");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(404, "not found");
+ await expect(projectsApi.get("p-1")).rejects.toThrow("404");
+ });
+});
+
+describe("projectsApi.create", () => {
+ it("returns project on 200", async () => {
+ mockFetch({ id: "p-1", name: "New", slug: "new", description: "", status: "active", created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.create({ name: "New", slug: "new" });
+ expect(result.id).toBe("p-1");
+ });
+
+ it("posts with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.create({ name: "New", slug: "new", description: "desc" });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.name).toBe("New");
+ expect(body.slug).toBe("new");
+ expect(body.description).toBe("desc");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.create({ name: "x", slug: "x" })).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.update", () => {
+ it("returns project on 200", async () => {
+ mockFetch({ id: "p-1", name: "Updated", slug: "one", description: "d", status: "active", created_by: "u-1", created_at: 1, updated_at: 2 });
+ const result = await projectsApi.update("p-1", { name: "Updated" });
+ expect(result.name).toBe("Updated");
+ });
+
+ it("patches with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.update("p-1", { name: "Updated", description: "new desc" });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1");
+ expect(opts.method).toBe("PATCH");
+ const body = JSON.parse(opts.body);
+ expect(body.name).toBe("Updated");
+ expect(body.description).toBe("new desc");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(403, "forbidden");
+ await expect(projectsApi.update("p-1", { name: "x" })).rejects.toThrow("403");
+ });
+});
+
+describe("projectsApi.archive", () => {
+ it("returns project on 200", async () => {
+ mockFetch({ id: "p-1", name: "One", slug: "one", description: "d", status: "archived", created_by: "u-1", created_at: 1, updated_at: 2 });
+ const result = await projectsApi.archive("p-1");
+ expect(result.status).toBe("archived");
+ });
+
+ it("posts to archive endpoint", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.archive("p-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/archive");
+ expect(opts.method).toBe("POST");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.archive("p-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.remove", () => {
+ it("returns project on 200", async () => {
+ mockFetch({ id: "p-1", name: "One", slug: "one", description: "d", status: "deleted", created_by: "u-1", created_at: 1, updated_at: 2 });
+ const result = await projectsApi.remove("p-1");
+ expect(result.status).toBe("deleted");
+ });
+
+ it("sends DELETE to correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.remove("p-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(404, "not found");
+ await expect(projectsApi.remove("p-1")).rejects.toThrow("404");
+ });
+});
+
+describe("projectsApi.members.list", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { project_id: "p-1", member_id: "m-1", member_kind: "native", role: "editor", source_agent_id: null, memory_seed: "none", added_at: 1 },
+ ],
+ });
+ const result = await projectsApi.members.list("p-1");
+ expect(result).toHaveLength(1);
+ expect(result[0].member_id).toBe("m-1");
+ });
+
+ it("calls correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.members.list("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/members");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(403, "forbidden");
+ await expect(projectsApi.members.list("p-1")).rejects.toThrow("403");
+ });
+});
+
+describe("projectsApi.members.addNative", () => {
+ it("returns member on 200", async () => {
+ mockFetch({ project_id: "p-1", member_id: "m-1", member_kind: "native", role: "editor", source_agent_id: "a-1", memory_seed: "none", added_at: 1 });
+ const result = await projectsApi.members.addNative("p-1", "a-1");
+ expect(result.member_kind).toBe("native");
+ });
+
+ it("posts with mode native and agent_id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.members.addNative("p-1", "a-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/members");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.mode).toBe("native");
+ expect(body.agent_id).toBe("a-1");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.members.addNative("p-1", "a-1")).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.members.addClone", () => {
+ it("returns member on 200", async () => {
+ mockFetch({ project_id: "p-1", member_id: "m-1", member_kind: "clone", role: "editor", source_agent_id: "a-1", memory_seed: "snapshot", added_at: 1 });
+ const result = await projectsApi.members.addClone("p-1", "a-1", true);
+ expect(result.member_kind).toBe("clone");
+ });
+
+ it("posts with mode clone, source_agent_id, and clone_memory", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.members.addClone("p-1", "a-1", true);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/members");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.mode).toBe("clone");
+ expect(body.source_agent_id).toBe("a-1");
+ expect(body.clone_memory).toBe(true);
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.members.addClone("p-1", "a-1", false)).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.members.remove", () => {
+ it("returns ok on 200", async () => {
+ mockFetch({ ok: true });
+ const result = await projectsApi.members.remove("p-1", "m-1");
+ expect(result.ok).toBe(true);
+ });
+
+ it("sends DELETE to correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
+ global.fetch = fetchMock;
+ await projectsApi.members.remove("p-1", "m-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/members/m-1");
+ expect(opts.method).toBe("DELETE");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(404, "not found");
+ await expect(projectsApi.members.remove("p-1", "m-1")).rejects.toThrow("404");
+ });
+});
+
+describe("projectsApi.members.setLead", () => {
+ it("returns result on 200", async () => {
+ mockFetch({ ok: true, is_lead: true });
+ const result = await projectsApi.members.setLead("p-1", "m-1", true);
+ expect(result.is_lead).toBe(true);
+ });
+
+ it("patches with is_lead body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true, is_lead: true }) });
+ global.fetch = fetchMock;
+ await projectsApi.members.setLead("p-1", "m-1", false);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/members/m-1/lead");
+ expect(opts.method).toBe("PATCH");
+ const body = JSON.parse(opts.body);
+ expect(body.is_lead).toBe(false);
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(403, "forbidden");
+ await expect(projectsApi.members.setLead("p-1", "m-1", true)).rejects.toThrow("403");
+ });
+});
+
+describe("projectsApi.tasks.list", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { id: "t-1", project_id: "p-1", parent_task_id: null, title: "Task", body: "", status: "open", priority: 0, labels: [], assignee_id: null, claimed_by: null, claimed_at: null, closed_at: null, closed_by: null, close_reason: null, created_by: "u-1", created_at: 1, updated_at: 1 },
+ ],
+ });
+ const result = await projectsApi.tasks.list("p-1");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("t-1");
+ });
+
+ it("omits status param when not provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.list("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks");
+ });
+
+ it("includes status param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.list("p-1", "open");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("status=open");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.list("p-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.ready", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({ items: [] });
+ const result = await projectsApi.tasks.ready("p-1");
+ expect(result).toEqual([]);
+ });
+
+ it("calls correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.ready("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/ready");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.ready("p-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.create", () => {
+ it("returns task on 200", async () => {
+ mockFetch({ id: "t-1", project_id: "p-1", parent_task_id: null, title: "New", body: "", status: "open", priority: 0, labels: [], assignee_id: null, claimed_by: null, claimed_at: null, closed_at: null, closed_by: null, close_reason: null, created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.tasks.create("p-1", { title: "New" });
+ expect(result.id).toBe("t-1");
+ });
+
+ it("posts with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.create("p-1", { title: "New", body: "desc", priority: 1 });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.title).toBe("New");
+ expect(body.body).toBe("desc");
+ expect(body.priority).toBe(1);
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.tasks.create("p-1", { title: "x" })).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.tasks.claim", () => {
+ it("returns task on 200", async () => {
+ mockFetch({ id: "t-1", project_id: "p-1", parent_task_id: null, title: "Task", body: "", status: "claimed", priority: 0, labels: [], assignee_id: "u-1", claimed_by: "u-1", claimed_at: 1, closed_at: null, closed_by: null, close_reason: null, created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.tasks.claim("p-1", "t-1", "u-1");
+ expect(result.status).toBe("claimed");
+ });
+
+ it("posts with claimer_id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.claim("p-1", "t-1", "u-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/claim");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.claimer_id).toBe("u-1");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(409, "conflict");
+ await expect(projectsApi.tasks.claim("p-1", "t-1", "u-1")).rejects.toThrow("409");
+ });
+});
+
+describe("projectsApi.tasks.release", () => {
+ it("returns task on 200", async () => {
+ mockFetch({ id: "t-1", project_id: "p-1", parent_task_id: null, title: "Task", body: "", status: "open", priority: 0, labels: [], assignee_id: null, claimed_by: null, claimed_at: null, closed_at: null, closed_by: null, close_reason: null, created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.tasks.release("p-1", "t-1", "u-1");
+ expect(result.status).toBe("open");
+ });
+
+ it("posts with releaser_id", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.release("p-1", "t-1", "u-1");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/release");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.releaser_id).toBe("u-1");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.release("p-1", "t-1", "u-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.close", () => {
+ it("returns task on 200", async () => {
+ mockFetch({ id: "t-1", project_id: "p-1", parent_task_id: null, title: "Task", body: "", status: "closed", priority: 0, labels: [], assignee_id: null, claimed_by: null, claimed_at: null, closed_at: 1, closed_by: "u-1", close_reason: "done", created_by: "u-1", created_at: 1, updated_at: 1 });
+ const result = await projectsApi.tasks.close("p-1", "t-1", "u-1", "done");
+ expect(result.status).toBe("closed");
+ });
+
+ it("posts with closed_by and reason", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.close("p-1", "t-1", "u-1", "done");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/close");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.closed_by).toBe("u-1");
+ expect(body.reason).toBe("done");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.close("p-1", "t-1", "u-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.update", () => {
+ it("returns task on 200", async () => {
+ mockFetch({ id: "t-1", project_id: "p-1", parent_task_id: null, title: "Updated", body: "", status: "open", priority: 0, labels: [], assignee_id: null, claimed_by: null, claimed_at: null, closed_at: null, closed_by: null, close_reason: null, created_by: "u-1", created_at: 1, updated_at: 2 });
+ const result = await projectsApi.tasks.update("p-1", "t-1", { title: "Updated" });
+ expect(result.title).toBe("Updated");
+ });
+
+ it("patches with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.update("p-1", "t-1", { title: "Updated", status: "closed" });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1");
+ expect(opts.method).toBe("PATCH");
+ const body = JSON.parse(opts.body);
+ expect(body.title).toBe("Updated");
+ expect(body.status).toBe("closed");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(403, "forbidden");
+ await expect(projectsApi.tasks.update("p-1", "t-1", { title: "x" })).rejects.toThrow("403");
+ });
+});
+
+describe("projectsApi.tasks.listComments", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { id: "c-1", task_id: "t-1", author_id: "u-1", body: "hi", replies_to_comment_id: null, created_at: 1 },
+ ],
+ });
+ const result = await projectsApi.tasks.listComments("p-1", "t-1");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("c-1");
+ });
+
+ it("calls correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.listComments("p-1", "t-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/comments");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.listComments("p-1", "t-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.addComment", () => {
+ it("returns comment on 200", async () => {
+ mockFetch({ id: "c-1", task_id: "t-1", author_id: "u-1", body: "hi", replies_to_comment_id: null, created_at: 1 });
+ const result = await projectsApi.tasks.addComment("p-1", "t-1", { body: "hi", author_id: "u-1" });
+ expect(result.id).toBe("c-1");
+ });
+
+ it("posts with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.addComment("p-1", "t-1", { body: "hi", author_id: "u-1", replies_to_comment_id: "c-0" });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/comments");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.body).toBe("hi");
+ expect(body.author_id).toBe("u-1");
+ expect(body.replies_to_comment_id).toBe("c-0");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.tasks.addComment("p-1", "t-1", { body: "x", author_id: "u-1" })).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.tasks.listRelationships", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { id: "r-1", project_id: "p-1", from_task_id: "t-1", to_task_id: "t-2", kind: "blocks", created_by: "u-1", created_at: 1 },
+ ],
+ });
+ const result = await projectsApi.tasks.listRelationships("p-1", "t-1");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("r-1");
+ });
+
+ it("defaults direction to from", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.listRelationships("p-1", "t-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("direction=from");
+ });
+
+ it("passes direction param", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.listRelationships("p-1", "t-1", "to");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("direction=to");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.tasks.listRelationships("p-1", "t-1")).rejects.toThrow("500");
+ });
+});
+
+describe("projectsApi.tasks.addRelationship", () => {
+ it("returns relationship on 200", async () => {
+ mockFetch({ id: "r-1", project_id: "p-1", from_task_id: "t-1", to_task_id: "t-2", kind: "blocks", created_by: "u-1", created_at: 1 });
+ const result = await projectsApi.tasks.addRelationship("p-1", "t-1", { to_task_id: "t-2", kind: "blocks", created_by: "u-1" });
+ expect(result.id).toBe("r-1");
+ });
+
+ it("posts with correct body", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
+ global.fetch = fetchMock;
+ await projectsApi.tasks.addRelationship("p-1", "t-1", { to_task_id: "t-2", kind: "blocks", created_by: "u-1" });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/tasks/t-1/relationships");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.to_task_id).toBe("t-2");
+ expect(body.kind).toBe("blocks");
+ expect(body.created_by).toBe("u-1");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(400, "bad request");
+ await expect(projectsApi.tasks.addRelationship("p-1", "t-1", { to_task_id: "t-2", kind: "x", created_by: "u-1" })).rejects.toThrow("400");
+ });
+});
+
+describe("projectsApi.activity", () => {
+ it("returns items array on 200", async () => {
+ mockFetch({
+ items: [
+ { id: 1, project_id: "p-1", actor_id: "u-1", kind: "created", payload: {}, created_at: 1 },
+ ],
+ });
+ const result = await projectsApi.activity("p-1");
+ expect(result).toHaveLength(1);
+ expect(result[0].kind).toBe("created");
+ });
+
+ it("calls correct URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [] }) });
+ global.fetch = fetchMock;
+ await projectsApi.activity("p-1");
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/projects/p-1/activity");
+ });
+
+ it("throws on non-ok response", async () => {
+ mockFetchError(500, "error");
+ await expect(projectsApi.activity("p-1")).rejects.toThrow("500");
+ });
+});
diff --git a/desktop/src/lib/reddit.test.ts b/desktop/src/lib/reddit.test.ts
new file mode 100644
index 000000000..7c6c79809
--- /dev/null
+++ b/desktop/src/lib/reddit.test.ts
@@ -0,0 +1,430 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchThread,
+ fetchSubreddit,
+ searchReddit,
+ fetchSaved,
+ getAuthStatus,
+ saveToLibrary,
+} from "./reddit";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchThread", () => {
+ it("calls GET /api/reddit/thread with url param and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ post: {
+ id: "abc1",
+ subreddit: "typescript",
+ title: "TS 6.0",
+ author: "user1",
+ selftext: "content",
+ score: 100,
+ upvote_ratio: 0.95,
+ num_comments: 25,
+ created_utc: 1700000000,
+ url: "https://reddit.com/r/typescript/comments/abc1",
+ permalink: "/r/typescript/comments/abc1",
+ flair: "News",
+ is_self: true,
+ },
+ comments: [],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchThread("https://reddit.com/r/typescript/comments/abc1");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/reddit/thread?");
+ expect(url).toContain("url=https%3A%2F%2Freddit.com");
+ expect(opts.headers.Accept).toBe("application/json");
+ expect(result).not.toBeNull();
+ expect(result!.post.id).toBe("abc1");
+ expect(result!.post.subreddit).toBe("typescript");
+ expect(result!.comments).toEqual([]);
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await fetchThread("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ const result = await fetchThread("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+
+ it("returns null when content-type is not json", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "text/html"]]),
+ json: async () => ({}),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchThread("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+});
+
+describe("fetchSubreddit", () => {
+ it("calls GET /api/reddit/subreddit with name and sort params", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ posts: [
+ {
+ id: "p1",
+ subreddit: "rust",
+ title: "Rust 2024",
+ author: "ferris",
+ selftext: "",
+ score: 500,
+ upvote_ratio: 0.98,
+ num_comments: 100,
+ created_utc: 1700000000,
+ url: "https://example.com",
+ permalink: "/r/rust/comments/p1",
+ flair: "",
+ is_self: false,
+ },
+ ],
+ after: "t3_next",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchSubreddit("rust", "hot");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/reddit/subreddit?");
+ expect(url).toContain("name=rust");
+ expect(url).toContain("sort=hot");
+ expect(result.posts).toHaveLength(1);
+ expect(result.posts[0].id).toBe("p1");
+ expect(result.after).toBe("t3_next");
+ });
+
+ it("includes after param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ posts: [], after: null }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchSubreddit("rust", "new", "t3_cursor");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("after=t3_cursor");
+ });
+
+ it("returns empty listing on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await fetchSubreddit("rust");
+ expect(result.posts).toEqual([]);
+ expect(result.after).toBeNull();
+ });
+
+ it("returns empty listing on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await fetchSubreddit("rust");
+ expect(result.posts).toEqual([]);
+ expect(result.after).toBeNull();
+ });
+
+ it("returns empty posts when data.posts is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ posts: null, after: null }),
+ });
+
+ const result = await fetchSubreddit("rust");
+ expect(result.posts).toEqual([]);
+ });
+});
+
+describe("searchReddit", () => {
+ it("calls GET /api/reddit/search with query param", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ posts: [
+ {
+ id: "s1",
+ subreddit: "python",
+ title: "Python tips",
+ author: "dev",
+ selftext: "tips",
+ score: 200,
+ upvote_ratio: 0.9,
+ num_comments: 50,
+ created_utc: 1700000000,
+ url: "https://example.com",
+ permalink: "/r/python/comments/s1",
+ flair: "Tips",
+ is_self: true,
+ },
+ ],
+ after: null,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await searchReddit("python tips");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/reddit/search?");
+ expect(url).toContain("q=python+tips");
+ expect(result.posts).toHaveLength(1);
+ expect(result.posts[0].title).toBe("Python tips");
+ });
+
+ it("includes subreddit param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ posts: [], after: null }),
+ });
+ global.fetch = fetchMock;
+
+ await searchReddit("async", "typescript");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("subreddit=typescript");
+ });
+
+ it("returns empty listing on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await searchReddit("test");
+ expect(result.posts).toEqual([]);
+ expect(result.after).toBeNull();
+ });
+
+ it("returns empty listing on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await searchReddit("test");
+ expect(result.posts).toEqual([]);
+ });
+});
+
+describe("fetchSaved", () => {
+ it("calls GET /api/reddit/saved and returns parsed listing", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ posts: [
+ {
+ id: "sv1",
+ subreddit: "programming",
+ title: "Saved post",
+ author: "coder",
+ selftext: "saved",
+ score: 300,
+ upvote_ratio: 0.85,
+ num_comments: 10,
+ created_utc: 1700000000,
+ url: "https://example.com",
+ permalink: "/r/programming/comments/sv1",
+ flair: "",
+ is_self: true,
+ },
+ ],
+ after: "t3_more",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchSaved();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/reddit/saved");
+ expect(result.posts).toHaveLength(1);
+ expect(result.posts[0].id).toBe("sv1");
+ expect(result.after).toBe("t3_more");
+ });
+
+ it("includes after param when provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ posts: [], after: null }),
+ });
+ global.fetch = fetchMock;
+
+ await fetchSaved("t3_cursor");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("after=t3_cursor");
+ });
+
+ it("returns empty listing on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ });
+
+ const result = await fetchSaved();
+ expect(result.posts).toEqual([]);
+ expect(result.after).toBeNull();
+ });
+
+ it("returns empty listing on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await fetchSaved();
+ expect(result.posts).toEqual([]);
+ });
+});
+
+describe("getAuthStatus", () => {
+ it("calls GET /api/reddit/auth/status and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ authenticated: true, username: "testuser" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await getAuthStatus();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/reddit/auth/status");
+ expect(result.authenticated).toBe(true);
+ expect(result.username).toBe("testuser");
+ });
+
+ it("returns authenticated false on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await getAuthStatus();
+ expect(result.authenticated).toBe(false);
+ });
+
+ it("returns authenticated false on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await getAuthStatus();
+ expect(result.authenticated).toBe(false);
+ });
+});
+
+describe("saveToLibrary", () => {
+ it("posts to /api/knowledge/ingest and returns parsed JSON", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "lib-1", status: "ingested" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await saveToLibrary(
+ "https://reddit.com/r/test/comments/abc",
+ "Test Title",
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/knowledge/ingest");
+ expect(opts.method).toBe("POST");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(opts.headers.Accept).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.url).toBe("https://reddit.com/r/test/comments/abc");
+ expect(body.title).toBe("Test Title");
+ expect(body.text).toBe("");
+ expect(body.categories).toEqual([]);
+ expect(body.source).toBe("reddit-client");
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe("lib-1");
+ expect(result!.status).toBe("ingested");
+ });
+
+ it("uses empty string for title when not provided", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "lib-2", status: "ok" }),
+ });
+ global.fetch = fetchMock;
+
+ await saveToLibrary("https://reddit.com/r/test");
+
+ const [, opts] = fetchMock.mock.calls[0];
+ const body = JSON.parse(opts.body);
+ expect(body.title).toBe("");
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await saveToLibrary("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await saveToLibrary("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+
+ it("returns null when content-type is not json", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "text/plain"]]),
+ json: async () => ({}),
+ });
+
+ const result = await saveToLibrary("https://reddit.com/r/test");
+ expect(result).toBeNull();
+ });
+});
diff --git a/desktop/src/lib/slug.test.ts b/desktop/src/lib/slug.test.ts
new file mode 100644
index 000000000..761f5d4d1
--- /dev/null
+++ b/desktop/src/lib/slug.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "vitest";
+import { slugifyClient, isValidSlug, SLUG_REGEX } from "./slug";
+
+describe("slugifyClient", () => {
+ it("lowercases and replaces spaces with hyphens", () => {
+ expect(slugifyClient("Hello World")).toBe("hello-world");
+ });
+
+ it("replaces special characters with hyphens", () => {
+ expect(slugifyClient("foo@bar!baz")).toBe("foo-bar-baz");
+ });
+
+ it("collapses multiple non-alphanumeric chars into one hyphen", () => {
+ expect(slugifyClient("a b")).toBe("a-b");
+ });
+
+ it("strips leading and trailing hyphens", () => {
+ expect(slugifyClient("!!hello!!")).toBe("hello");
+ });
+
+ it("truncates to 63 characters", () => {
+ const long = "a".repeat(100);
+ expect(slugifyClient(long)).toHaveLength(63);
+ });
+
+ it("returns empty string for empty input", () => {
+ expect(slugifyClient("")).toBe("");
+ });
+
+ it("returns empty string for input with only special characters", () => {
+ expect(slugifyClient("!!!")).toBe("");
+ });
+
+ it("handles mixed case with numbers", () => {
+ expect(slugifyClient("My App v2")).toBe("my-app-v2");
+ });
+});
+
+describe("isValidSlug", () => {
+ it("returns true for a valid slug", () => {
+ expect(isValidSlug("hello-world")).toBe(true);
+ });
+
+ it("returns true for a single lowercase letter", () => {
+ expect(isValidSlug("a")).toBe(true);
+ });
+
+ it("returns true for max length 63 chars", () => {
+ const s = "a" + "b".repeat(62);
+ expect(isValidSlug(s)).toBe(true);
+ });
+
+ it("returns false for uppercase letters", () => {
+ expect(isValidSlug("Hello")).toBe(false);
+ });
+
+ it("returns false when starting with a hyphen", () => {
+ expect(isValidSlug("-hello")).toBe(false);
+ });
+
+ it("returns false for empty string", () => {
+ expect(isValidSlug("")).toBe(false);
+ });
+
+ it("returns false when exceeding 63 chars", () => {
+ const s = "a".repeat(64);
+ expect(isValidSlug(s)).toBe(false);
+ });
+
+ it("returns false for spaces", () => {
+ expect(isValidSlug("hello world")).toBe(false);
+ });
+
+ it("returns false for special characters", () => {
+ expect(isValidSlug("hello!")).toBe(false);
+ });
+});
+
+describe("SLUG_REGEX", () => {
+ it("is a RegExp", () => {
+ expect(SLUG_REGEX).toBeInstanceOf(RegExp);
+ });
+
+ it("matches a simple slug", () => {
+ expect(SLUG_REGEX.test("my-slug")).toBe(true);
+ });
+
+ it("does not match an empty string", () => {
+ expect(SLUG_REGEX.test("")).toBe(false);
+ });
+});
diff --git a/desktop/src/lib/taos-agent-api.test.ts b/desktop/src/lib/taos-agent-api.test.ts
new file mode 100644
index 000000000..55630e7b3
--- /dev/null
+++ b/desktop/src/lib/taos-agent-api.test.ts
@@ -0,0 +1,232 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchTaosAgentConfig,
+ setTaosAgentModel,
+ setTaosAgentPermitted,
+ setTaosAgentPersona,
+ uploadChatAttachment,
+} from "./taos-agent-api";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("fetchTaosAgentConfig", () => {
+ it("calls the right URL and returns parsed config", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ model: "gpt-4",
+ permitted_models: ["gpt-4", "claude-3"],
+ persona: "default",
+ key_masked: "sk-***",
+ framework: "opencode",
+ system: true,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchTaosAgentConfig();
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/taos-agent/config", {
+ method: "GET",
+ });
+ expect(result.model).toBe("gpt-4");
+ expect(result.permitted_models).toEqual(["gpt-4", "claude-3"]);
+ expect(result.persona).toBe("default");
+ expect(result.key_masked).toBe("sk-***");
+ expect(result.framework).toBe("opencode");
+ });
+
+ it("throws body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "bad config" }),
+ });
+
+ await expect(fetchTaosAgentConfig()).rejects.toThrow("bad config");
+ });
+
+ it("throws body.detail on non-ok response when no error", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ detail: "forbidden" }),
+ });
+
+ await expect(fetchTaosAgentConfig()).rejects.toThrow("forbidden");
+ });
+
+ it("throws fallback on non-ok when json has no error or detail", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ });
+
+ await expect(fetchTaosAgentConfig()).rejects.toThrow(
+ "Request failed (500)",
+ );
+ });
+});
+
+describe("setTaosAgentModel", () => {
+ it("PATCHes settings and returns updated model", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ model: "claude-3" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setTaosAgentModel("claude-3");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/taos-agent/settings");
+ expect(opts.method).toBe("PATCH");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(opts.body)).toEqual({ model: "claude-3" });
+ expect(result.model).toBe("claude-3");
+ });
+
+ it("throws body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "invalid model" }),
+ });
+
+ await expect(setTaosAgentModel("bad-model")).rejects.toThrow(
+ "invalid model",
+ );
+ });
+});
+
+describe("setTaosAgentPermitted", () => {
+ it("PUTs permitted-models and returns updated state", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ permitted_models: ["gpt-4", "claude-3"],
+ key_rescoped: true,
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setTaosAgentPermitted(["gpt-4", "claude-3"]);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/taos-agent/permitted-models");
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(opts.body)).toEqual({
+ models: ["gpt-4", "claude-3"],
+ });
+ expect(result.permitted_models).toEqual(["gpt-4", "claude-3"]);
+ expect(result.key_rescoped).toBe(true);
+ });
+
+ it("throws body.detail on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({ detail: "not allowed" }),
+ });
+
+ await expect(setTaosAgentPermitted([])).rejects.toThrow("not allowed");
+ });
+});
+
+describe("setTaosAgentPersona", () => {
+ it("PUTs persona and returns updated persona", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ persona: "helpful" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await setTaosAgentPersona("helpful");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/taos-agent/persona");
+ expect(opts.method).toBe("PUT");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ expect(JSON.parse(opts.body)).toEqual({ persona: "helpful" });
+ expect(result.persona).toBe("helpful");
+ });
+
+ it("throws fallback on non-ok when json parse fails", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => {
+ throw new Error("not json");
+ },
+ });
+
+ await expect(setTaosAgentPersona("x")).rejects.toThrow(
+ "Request failed (500)",
+ );
+ });
+});
+
+describe("uploadChatAttachment", () => {
+ it("POSTs form data and returns attachment record", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ filename: "photo.png",
+ mime_type: "image/png",
+ size: 1024,
+ url: "/attachments/photo.png",
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const form = new FormData();
+ form.append("file", new Blob(["fake"]), "photo.png");
+
+ const result = await uploadChatAttachment(form);
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/taos-agent/attachments/upload");
+ expect(opts.method).toBe("POST");
+ expect(opts.body).toBe(form);
+ expect(result.filename).toBe("photo.png");
+ expect(result.mime_type).toBe("image/png");
+ expect(result.size).toBe(1024);
+ expect(result.url).toBe("/attachments/photo.png");
+ });
+
+ it("throws response text on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 413,
+ text: async () => "file too large",
+ });
+
+ const form = new FormData();
+ await expect(uploadChatAttachment(form)).rejects.toThrow("file too large");
+ });
+
+ it("throws fallback when text() fails on non-ok", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ text: async () => {
+ throw new Error("no text");
+ },
+ });
+
+ const form = new FormData();
+ await expect(uploadChatAttachment(form)).rejects.toThrow("upload failed");
+ });
+});
diff --git a/desktop/src/lib/userspace-apps.test.ts b/desktop/src/lib/userspace-apps.test.ts
new file mode 100644
index 000000000..f254869d0
--- /dev/null
+++ b/desktop/src/lib/userspace-apps.test.ts
@@ -0,0 +1,212 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ fetchUserspaceApps,
+ grantUserspacePermissions,
+ installUserspaceApp,
+ toAppManifest,
+ USERSPACE_APPS_CHANGED,
+} from "./userspace-apps";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("USERSPACE_APPS_CHANGED", () => {
+ it("is the expected event name", () => {
+ expect(USERSPACE_APPS_CHANGED).toBe("taos:userspace-apps-changed");
+ });
+});
+
+describe("toAppManifest", () => {
+ it("maps a row with trust to a manifest with userspace: prefix", () => {
+ const manifest = toAppManifest({
+ app_id: "my-app",
+ name: "My App",
+ icon: "icon.png",
+ app_type: "web",
+ version: "1.0.0",
+ enabled: 1,
+ permissions_requested: ["camera"],
+ permissions_granted: [],
+ trust: "community",
+ });
+ expect(manifest.id).toBe("userspace:my-app");
+ expect(manifest.name).toBe("My App");
+ expect(manifest.category).toBe("userspace");
+ expect(manifest.icon).toBe("layout-grid");
+ expect(manifest.singleton).toBe(true);
+ expect(manifest.pinned).toBe(false);
+ });
+
+ it("defaults trust to community when omitted", () => {
+ const manifest = toAppManifest({
+ app_id: "no-trust",
+ name: "No Trust",
+ icon: "icon.png",
+ app_type: "container",
+ version: "0.1.0",
+ enabled: 1,
+ permissions_requested: [],
+ permissions_granted: [],
+ });
+ expect(manifest.id).toBe("userspace:no-trust");
+ });
+});
+
+describe("installUserspaceApp", () => {
+ it("POSTs FormData to /api/userspace-apps/install and returns parsed result", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ app_id: "new-app",
+ permissions_requested: ["camera"],
+ needs_consent: true,
+ new_permissions: ["camera"],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const file = new File(["content"], "app.zip", { type: "application/zip" });
+ const result = await installUserspaceApp(file);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/userspace-apps/install");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("include");
+ expect(opts.body).toBeInstanceOf(FormData);
+ expect(result.app_id).toBe("new-app");
+ expect(result.needs_consent).toBe(true);
+ expect(result.new_permissions).toEqual(["camera"]);
+ });
+
+ it("throws with body.error on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ json: async () => ({ error: "invalid package" }),
+ });
+
+ const file = new File(["bad"], "bad.zip");
+ await expect(installUserspaceApp(file)).rejects.toThrow("invalid package");
+ });
+
+ it("throws with status message when error body is non-JSON", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => {
+ throw new Error("not json");
+ },
+ });
+
+ const file = new File(["x"], "x.zip");
+ await expect(installUserspaceApp(file)).rejects.toThrow("install failed (500)");
+ });
+});
+
+describe("grantUserspacePermissions", () => {
+ it("POSTs JSON to /api/userspace-apps/{appId}/permissions", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await grantUserspacePermissions("my-app", ["camera", "mic"]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/userspace-apps/my-app/permissions");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("include");
+ expect(opts.headers["Content-Type"]).toBe("application/json");
+ const body = JSON.parse(opts.body);
+ expect(body.granted).toEqual(["camera", "mic"]);
+ });
+
+ it("encodes appId in the URL", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ global.fetch = fetchMock;
+
+ await grantUserspacePermissions("app/with/slashes", []);
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("app%2Fwith%2Fslashes");
+ });
+
+ it("throws on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 403,
+ });
+
+ await expect(grantUserspacePermissions("my-app", ["camera"]))
+ .rejects.toThrow("granting permissions failed (403)");
+ });
+});
+
+describe("fetchUserspaceApps", () => {
+ it("GETs /api/userspace-apps and returns manifests for enabled apps", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => [
+ {
+ app_id: "enabled-app",
+ name: "Enabled",
+ icon: "icon.png",
+ app_type: "web",
+ version: "1.0.0",
+ enabled: 1,
+ permissions_requested: [],
+ permissions_granted: [],
+ },
+ {
+ app_id: "disabled-app",
+ name: "Disabled",
+ icon: "icon.png",
+ app_type: "web",
+ version: "1.0.0",
+ enabled: 0,
+ permissions_requested: [],
+ permissions_granted: [],
+ },
+ ],
+ });
+ global.fetch = fetchMock;
+
+ const result = await fetchUserspaceApps();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/userspace-apps");
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe("userspace:enabled-app");
+ expect(result[0].name).toBe("Enabled");
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const result = await fetchUserspaceApps();
+ expect(result).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+
+ const result = await fetchUserspaceApps();
+ expect(result).toEqual([]);
+ });
+});
diff --git a/desktop/src/lib/utils.test.ts b/desktop/src/lib/utils.test.ts
new file mode 100644
index 000000000..51cf13369
--- /dev/null
+++ b/desktop/src/lib/utils.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { cn } from "./utils";
+
+describe("cn", () => {
+ it("merges class strings", () => {
+ expect(cn("foo", "bar")).toBe("foo bar");
+ });
+
+ it("handles conditional classes", () => {
+ expect(cn("foo", false && "bar", "baz")).toBe("foo baz");
+ });
+
+ it("merges tailwind classes without conflict", () => {
+ expect(cn("p-4", "m-4")).toBe("p-4 m-4");
+ });
+
+ it("resolves tailwind conflicts with twMerge", () => {
+ expect(cn("p-4", "p-6")).toBe("p-6");
+ });
+
+ it("returns empty string for no inputs", () => {
+ expect(cn()).toBe("");
+ });
+
+ it("handles empty string inputs", () => {
+ expect(cn("", "foo", "")).toBe("foo");
+ });
+
+ it("handles arrays of classes", () => {
+ expect(cn(["foo", "bar"])).toBe("foo bar");
+ });
+
+ it("handles mixed types (string, array, object)", () => {
+ expect(cn("foo", { bar: true, baz: false }, ["qux"])).toBe("foo bar qux");
+ });
+
+ it("resolves conflicting tailwind utilities in arrays", () => {
+ expect(cn(["px-2", "py-1"], "px-4")).toBe("py-1 px-4");
+ });
+});
diff --git a/desktop/src/lib/youtube.test.ts b/desktop/src/lib/youtube.test.ts
new file mode 100644
index 000000000..118fb4971
--- /dev/null
+++ b/desktop/src/lib/youtube.test.ts
@@ -0,0 +1,212 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ ingestVideo,
+ downloadVideo,
+ getDownloadStatus,
+ getTranscript,
+ formatTimestamp,
+} from "./youtube";
+
+const originalFetch = global.fetch;
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("ingestVideo", () => {
+ it("returns id and status on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "abc", status: "queued" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await ingestVideo("https://youtu.be/abc");
+ expect(result).toEqual({ id: "abc", status: "queued" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await ingestVideo("https://youtu.be/abc")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await ingestVideo("https://youtu.be/abc")).toBeNull();
+ });
+
+ it("posts JSON body with url to /api/youtube/ingest", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ id: "x", status: "queued" }),
+ });
+ global.fetch = fetchMock;
+
+ await ingestVideo("https://youtu.be/xyz");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/youtube/ingest");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.url).toBe("https://youtu.be/xyz");
+ });
+});
+
+describe("downloadVideo", () => {
+ it("returns status on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "downloading" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await downloadVideo("item-1", "best");
+ expect(result).toEqual({ status: "downloading" });
+ });
+
+ it("returns null on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await downloadVideo("item-1", "best")).toBeNull();
+ });
+
+ it("returns null on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("network failure"));
+ expect(await downloadVideo("item-1", "best")).toBeNull();
+ });
+
+ it("posts item_id and quality to /api/youtube/download", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "ok" }),
+ });
+ global.fetch = fetchMock;
+
+ await downloadVideo("item-99", "720p");
+
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe("/api/youtube/download");
+ expect(opts.method).toBe("POST");
+ const body = JSON.parse(opts.body);
+ expect(body.item_id).toBe("item-99");
+ expect(body.quality).toBe("720p");
+ });
+});
+
+describe("getDownloadStatus", () => {
+ it("returns download status on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "downloading", file_size: "12MB" }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await getDownloadStatus("item-1");
+ expect(result).toEqual({ status: "downloading", file_size: "12MB" });
+ });
+
+ it("returns idle fallback on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ expect(await getDownloadStatus("item-1")).toEqual({ status: "idle" });
+ });
+
+ it("returns idle fallback on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getDownloadStatus("item-1")).toEqual({ status: "idle" });
+ });
+
+ it("encodes item_id in the URL path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ status: "idle" }),
+ });
+ global.fetch = fetchMock;
+
+ await getDownloadStatus("item/with spaces");
+
+ const [url] = fetchMock.mock.calls[0];
+ expect(url).toContain("item%2Fwith%20spaces");
+ });
+});
+
+describe("getTranscript", () => {
+ it("returns segments array on 200", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({
+ segments: [
+ { start: 0, end: 5, text: "hello" },
+ { start: 5, end: 10, text: "world" },
+ ],
+ }),
+ });
+ global.fetch = fetchMock;
+
+ const result = await getTranscript("item-1");
+ expect(result).toHaveLength(2);
+ expect(result[0].text).toBe("hello");
+ expect(result[1].end).toBe(10);
+ });
+
+ it("returns [] on non-ok response", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
+ expect(await getTranscript("item-1")).toEqual([]);
+ });
+
+ it("returns [] on network error", async () => {
+ global.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ expect(await getTranscript("item-1")).toEqual([]);
+ });
+
+ it("returns [] when segments is not an array", async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ segments: null }),
+ });
+ expect(await getTranscript("item-1")).toEqual([]);
+ });
+
+ it("encodes item_id in the URL path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]),
+ json: async () => ({ segments: [] }),
+ });
+ global.fetch = fetchMock;
+
+ await getDownloadStatus("item/special");
+ await getTranscript("item/special");
+
+ const [url] = fetchMock.mock.calls[1];
+ expect(url).toContain("item%2Fspecial");
+ });
+});
+
+describe("formatTimestamp", () => {
+ it("formats seconds under an hour as mm:ss", () => {
+ expect(formatTimestamp(0)).toBe("00:00");
+ expect(formatTimestamp(65)).toBe("01:05");
+ expect(formatTimestamp(3599)).toBe("59:59");
+ });
+
+ it("formats seconds over an hour as h:mm:ss", () => {
+ expect(formatTimestamp(3600)).toBe("1:00:00");
+ expect(formatTimestamp(3661)).toBe("1:01:01");
+ });
+
+ it("floors fractional seconds", () => {
+ expect(formatTimestamp(5.9)).toBe("00:05");
+ });
+});
diff --git a/desktop/src/registry/app-registry.test.ts b/desktop/src/registry/app-registry.test.ts
index 29e25da94..8c8cb4081 100644
--- a/desktop/src/registry/app-registry.test.ts
+++ b/desktop/src/registry/app-registry.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
-import { getOrRegisterServiceApp, prefetchApp, resolveApp } from "./app-registry";
+import { getApp, getOrRegisterServiceApp, getAllApps, prefetchApp, resolveApp } from "./app-registry";
describe("resolveApp (deep-navigation token resolver)", () => {
it("resolves an exact app id", () => {
@@ -28,6 +28,19 @@ describe("resolveApp (deep-navigation token resolver)", () => {
});
});
+describe("pwa flag", () => {
+ it("messages has pwa:true", () => {
+ expect(getApp("messages")?.pwa).toBe(true);
+ });
+
+ it("pwa is absent or falsy on all other apps", () => {
+ const others = getAllApps().filter((a) => a.id !== "messages");
+ for (const app of others) {
+ expect(app.pwa, `${app.id} should not have pwa:true`).toBeFalsy();
+ }
+ });
+});
+
describe("prefetchApp", () => {
it("invokes the lazy component thunk once per app (memoized)", () => {
const thunk = vi.fn(() => Promise.resolve({ default: () => null }));
diff --git a/desktop/src/registry/app-registry.ts b/desktop/src/registry/app-registry.ts
index 63cc0a606..79aa05a35 100644
--- a/desktop/src/registry/app-registry.ts
+++ b/desktop/src/registry/app-registry.ts
@@ -4,7 +4,7 @@ export interface AppManifest {
id: string;
name: string;
icon: string;
- category: "platform" | "os" | "streaming" | "game" | "userspace";
+ category: "platform" | "os" | "streaming" | "game" | "studio" | "userspace";
component: () => Promise<{ default: ComponentType<{ windowId: string }> }>;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
@@ -18,13 +18,19 @@ export interface AppManifest {
* persisted server-side (installed_apps, kind=frontend-app).
*/
optional?: boolean;
+ /**
+ * Opt-in flag: only pwa:true apps are installable as standalone PWAs and
+ * get the title-bar Install button plus a dynamic manifest served by the
+ * backend. A fuller DRY source (shared with the backend) is a follow-up.
+ */
+ pwa?: boolean;
}
const apps: AppManifest[] = [
// Platform apps
- { id: "messages", name: "Messages", icon: "message-circle", category: "platform", component: () => import("@/apps/MessagesApp").then((m) => ({ default: m.MessagesApp })), defaultSize: { w: 900, h: 600 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: true, launchpadOrder: 1 },
+ { id: "messages", name: "Messages", icon: "message-circle", category: "platform", component: () => import("@/apps/MessagesApp").then((m) => ({ default: m.MessagesApp })), defaultSize: { w: 900, h: 600 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: true, launchpadOrder: 1, pwa: true },
{ id: "mail", name: "Mail", icon: "mail", category: "platform", component: () => import("@/apps/MailApp").then((m) => ({ default: m.MailApp })), defaultSize: { w: 1200, h: 800 }, minSize: { w: 720, h: 480 }, singleton: true, pinned: true, launchpadOrder: 1.25 },
- { id: "projects", name: "Projects", icon: "folder-kanban", category: "platform", component: () => import("@/apps/ProjectsApp").then((m) => ({ default: m.ProjectsApp })), defaultSize: { w: 1100, h: 720 }, minSize: { w: 700, h: 500 }, singleton: true, pinned: true, launchpadOrder: 1.5 },
+ { id: "projects", name: "Projects", icon: "folder-kanban", category: "platform", component: () => import("@/apps/ProjectsApp").then((m) => ({ default: m.ProjectsApp })), defaultSize: { w: 1100, h: 720 }, minSize: { w: 700, h: 500 }, singleton: false, pinned: true, launchpadOrder: 1.5 },
{ id: "agents", name: "Agents", icon: "bot", category: "platform", component: () => import("@/apps/AgentsApp").then((m) => ({ default: m.AgentsApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 500, h: 400 }, singleton: true, pinned: true, launchpadOrder: 2 },
{ id: "files", name: "Files", icon: "folder", category: "platform", component: () => import("@/apps/FilesApp").then((m) => ({ default: m.FilesApp })), defaultSize: { w: 900, h: 550 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: true, launchpadOrder: 3 },
{ id: "store", name: "Store", icon: "shopping-bag", category: "platform", component: () => import("@/apps/StoreApp").then((m) => ({ default: m.StoreApp })), defaultSize: { w: 1000, h: 700 }, minSize: { w: 600, h: 400 }, singleton: true, pinned: true, launchpadOrder: 4 },
@@ -42,17 +48,18 @@ const apps: AppManifest[] = [
{ id: "tasks", name: "Tasks", icon: "calendar-clock", category: "platform", component: () => import("@/apps/TasksApp").then((m) => ({ default: m.TasksApp })), defaultSize: { w: 800, h: 500 }, minSize: { w: 450, h: 350 }, singleton: true, pinned: false, launchpadOrder: 11 },
{ id: "import", name: "Import", icon: "upload", category: "platform", component: () => import("@/apps/ImportApp").then((m) => ({ default: m.ImportApp })), defaultSize: { w: 700, h: 450 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: false, launchpadOrder: 12 },
{ id: "images", name: "Images", icon: "image", category: "platform", component: () => import("@/apps/ImagesApp").then((m) => ({ default: m.ImagesApp })), defaultSize: { w: 900, h: 600 }, minSize: { w: 500, h: 400 }, singleton: true, pinned: false, launchpadOrder: 13 },
- { id: "coding-studio", name: "Coding Studio", icon: "code-2", category: "platform", component: () => import("@/apps/CodingStudioApp").then((m) => ({ default: m.CodingStudioApp })), defaultSize: { w: 1080, h: 760 }, minSize: { w: 680, h: 540 }, singleton: true, pinned: false, launchpadOrder: 13.25, optional: true },
- { id: "design-studio", name: "Design Studio", icon: "palette", category: "platform", component: () => import("@/apps/DesignStudioApp").then((m) => ({ default: m.DesignStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.26, optional: true },
- { id: "music-studio", name: "Music Studio", icon: "music", category: "platform", component: () => import("@/apps/MusicStudioApp").then((m) => ({ default: m.MusicStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 700, h: 540 }, singleton: true, pinned: false, launchpadOrder: 13.27, optional: true },
- { id: "app-studio", name: "App Studio", icon: "blocks", category: "platform", component: () => import("@/apps/AppStudioApp").then((m) => ({ default: m.AppStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.28, optional: true },
- { id: "office-suite", name: "Office Suite", icon: "file-text", category: "platform", component: () => import("@/apps/OfficeSuiteApp").then((m) => ({ default: m.OfficeSuiteApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.29, optional: true },
+ { id: "coding-studio", name: "Coding Studio", icon: "code-2", category: "studio", component: () => import("@/apps/CodingStudioApp").then((m) => ({ default: m.CodingStudioApp })), defaultSize: { w: 1080, h: 760 }, minSize: { w: 680, h: 540 }, singleton: true, pinned: false, launchpadOrder: 13.25, optional: true },
+ { id: "design-studio", name: "Design Studio", icon: "palette", category: "studio", component: () => import("@/apps/DesignStudioApp").then((m) => ({ default: m.DesignStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.26, optional: true },
+ { id: "music-studio", name: "Music Studio", icon: "music", category: "studio", component: () => import("@/apps/MusicStudioApp").then((m) => ({ default: m.MusicStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 700, h: 540 }, singleton: true, pinned: false, launchpadOrder: 13.27, optional: true },
+ { id: "app-studio", name: "App Studio", icon: "blocks", category: "studio", component: () => import("@/apps/AppStudioApp").then((m) => ({ default: m.AppStudioApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.28, optional: true },
+ { id: "office-suite", name: "Office Suite", icon: "file-text", category: "studio", component: () => import("@/apps/OfficeSuiteApp").then((m) => ({ default: m.OfficeSuiteApp })), defaultSize: { w: 1080, h: 720 }, minSize: { w: 680, h: 520 }, singleton: true, pinned: false, launchpadOrder: 13.29, optional: true },
{ id: "library", name: "Library", icon: "book-open", category: "platform", component: () => import("@/apps/LibraryApp").then((m) => ({ default: m.LibraryApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 550, h: 400 }, singleton: true, pinned: true, launchpadOrder: 13.5 },
{ id: "reddit", name: "Reddit", icon: "scroll-text", category: "platform", component: () => import("@/apps/RedditApp").then((m) => ({ default: m.RedditApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 550, h: 400 }, singleton: true, pinned: false, launchpadOrder: 14, optional: true },
{ id: "youtube-library", name: "YouTube", icon: "play-circle", category: "platform", component: () => import("@/apps/YouTubeApp").then((m) => ({ default: m.YouTubeApp })), defaultSize: { w: 1050, h: 700 }, minSize: { w: 600, h: 450 }, singleton: true, pinned: false, launchpadOrder: 14.5, optional: true },
{ id: "github-browser", name: "GitHub", icon: "github", category: "platform", component: () => import("@/apps/GitHubApp").then((m) => ({ default: m.GitHubApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 550, h: 400 }, singleton: true, pinned: false, launchpadOrder: 15, optional: true },
{ id: "x-monitor", name: "X", icon: "at-sign", category: "platform", component: () => import("@/apps/XApp").then((m) => ({ default: m.XApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 550, h: 400 }, singleton: true, pinned: false, launchpadOrder: 15.5, optional: true },
{ id: "agent-browsers", name: "Browsers", icon: "globe", category: "platform", component: () => import("@/apps/AgentBrowsersApp").then((m) => ({ default: m.AgentBrowsersApp })), defaultSize: { w: 1000, h: 650 }, minSize: { w: 550, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16 },
+ { id: "feedback", name: "Feedback", icon: "flag", category: "platform", component: () => import("@/apps/FeedbackApp").then((m) => ({ default: m.FeedbackApp })), defaultSize: { w: 700, h: 560 }, minSize: { w: 420, h: 400 }, singleton: true, pinned: false, launchpadOrder: 16.5 },
// OS apps
{ id: "weather", name: "Weather", icon: "cloud", category: "os", component: () => import("@/apps/WeatherApp").then((m) => ({ default: m.WeatherApp })), defaultSize: { w: 800, h: 600 }, minSize: { w: 400, h: 400 }, singleton: true, pinned: false, launchpadOrder: 19 },
@@ -70,7 +77,7 @@ const apps: AppManifest[] = [
{ id: "chess", name: "Chess", icon: "crown", category: "game", component: () => import("@/apps/ChessApp").then((m) => ({ default: m.ChessApp })), defaultSize: { w: 700, h: 700 }, minSize: { w: 500, h: 500 }, singleton: true, pinned: false, launchpadOrder: 40 },
{ id: "wordle", name: "Wordle", icon: "spell-check", category: "game", component: () => import("@/apps/WordleApp").then((m) => ({ default: m.WordleApp })), defaultSize: { w: 500, h: 650 }, minSize: { w: 400, h: 550 }, singleton: true, pinned: false, launchpadOrder: 41 },
{ id: "crosswords", name: "Crosswords", icon: "grid-3x3", category: "game", component: () => import("@/apps/CrosswordsApp").then((m) => ({ default: m.CrosswordsApp })), defaultSize: { w: 700, h: 600 }, minSize: { w: 500, h: 450 }, singleton: true, pinned: false, launchpadOrder: 42 },
- { id: "game-studio", name: "Game Studio", icon: "gamepad-2", category: "game", component: () => import("@/apps/GameStudioApp").then((m) => ({ default: m.GameStudioApp })), defaultSize: { w: 1080, h: 760 }, minSize: { w: 640, h: 520 }, singleton: true, pinned: false, launchpadOrder: 42.5 },
+ { id: "game-studio", name: "Game Studio", icon: "gamepad-2", category: "studio", component: () => import("@/apps/GameStudioApp").then((m) => ({ default: m.GameStudioApp })), defaultSize: { w: 1080, h: 760 }, minSize: { w: 640, h: 520 }, singleton: true, pinned: false, launchpadOrder: 42.5 },
];
export function getApp(id: string): AppManifest | undefined {
diff --git a/desktop/src/shell/InstallPromptBanner.test.tsx b/desktop/src/shell/InstallPromptBanner.test.tsx
new file mode 100644
index 000000000..132915c38
--- /dev/null
+++ b/desktop/src/shell/InstallPromptBanner.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { InstallPromptBanner } from "./InstallPromptBanner";
+
+function mockMatchMedia(standalone = false) {
+ window.matchMedia = vi.fn().mockImplementation((q: string) => ({
+ matches: q.includes("standalone") ? standalone : false,
+ media: q,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ onchange: null,
+ dispatchEvent: vi.fn(),
+ }));
+}
+
+describe("InstallPromptBanner", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ mockMatchMedia(false);
+ });
+ afterEach(() => {
+ vi.restoreAllMocks();
+ localStorage.clear();
+ });
+
+ it("shows the Add to Home Screen instruction on iOS Safari (not installed)", () => {
+ vi.stubGlobal("navigator", {
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari",
+ platform: "iPhone",
+ maxTouchPoints: 5,
+ standalone: false,
+ });
+ render( );
+ expect(screen.getByText(/Add to Home Screen/i)).toBeInTheDocument();
+ });
+
+ it("renders nothing once installed (standalone display mode)", () => {
+ mockMatchMedia(true);
+ vi.stubGlobal("navigator", {
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Safari",
+ platform: "iPhone",
+ maxTouchPoints: 5,
+ standalone: true,
+ });
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing on a desktop browser with no install event", () => {
+ vi.stubGlobal("navigator", {
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome",
+ platform: "MacIntel",
+ maxTouchPoints: 0,
+ standalone: undefined,
+ });
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/desktop/src/shell/InstallPromptBanner.tsx b/desktop/src/shell/InstallPromptBanner.tsx
index e29ba37b7..28e991c32 100644
--- a/desktop/src/shell/InstallPromptBanner.tsx
+++ b/desktop/src/shell/InstallPromptBanner.tsx
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
+import { Share } from "lucide-react";
import { useIsMobile } from "@/hooks/use-is-mobile";
+import { isIOS, isStandalone } from "@/lib/platform";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<{ outcome: "accepted" | "dismissed" }>;
@@ -12,6 +14,7 @@ const KEY = "taos-install-dismissed";
export function InstallPromptBanner() {
const isMobile = useIsMobile();
const [event, setEvent] = useState(null);
+ const [ios, setIos] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
@@ -20,49 +23,75 @@ export function InstallPromptBanner() {
setEvent(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", onPrompt);
+ // iOS Safari never fires beforeinstallprompt and has no programmatic
+ // install, so detect it and show a manual Add to Home Screen instruction.
+ if (isIOS() && !isStandalone()) setIos(true);
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
}, []);
- if (!isMobile || !event || dismissed) return null;
-
- if (typeof window !== "undefined") {
- const mql = window.matchMedia("(display-mode: standalone)");
- if (mql.matches) return null;
- }
+ if (dismissed || isStandalone()) return null;
+ // Android shows on mobile via the install event; iOS shows on the device
+ // regardless of viewport width (iPadOS reports as desktop).
+ if (!isMobile && !ios) return null;
const prev = localStorage.getItem(KEY);
if (prev && Date.now() - Number(prev) < DISMISS_MS) return null;
- const install = async () => {
- try {
- await event.prompt();
- await event.userChoice;
- } catch {
- /* ignore */
- }
- setEvent(null);
- };
-
- const notNow = () => {
+ const dismiss = () => {
localStorage.setItem(KEY, String(Date.now()));
setDismissed(true);
};
- return (
-
- Install taOS talk for quick access
- Install
- Not now
-
- );
+ // Android Chrome: a real install prompt is available.
+ if (event) {
+ const install = async () => {
+ try {
+ await event.prompt();
+ await event.userChoice;
+ } catch {
+ /* ignore */
+ }
+ setEvent(null);
+ };
+ return (
+
+ Install taOS for quick access
+ Install
+ Not now
+
+ );
+ }
+
+ // iOS Safari: no programmatic install, so instruct the manual gesture.
+ if (ios) {
+ return (
+
+
+ To install, tap
+
+ then "Add to Home Screen".
+
+ Got it
+
+ );
+ }
+
+ return null;
}
diff --git a/desktop/src/stores/process-store.test.ts b/desktop/src/stores/process-store.test.ts
index 727c11f55..0160dca68 100644
--- a/desktop/src/stores/process-store.test.ts
+++ b/desktop/src/stores/process-store.test.ts
@@ -64,4 +64,28 @@ describe("process-store openWindow", () => {
expect(win.minimized).toBe(false);
expect(win.focused).toBe(true);
});
+
+ it("opens a second window for the same app when forceNew is set", () => {
+ const a = useProcessStore
+ .getState()
+ .openWindow("projects", { w: 900, h: 600 }, { projectId: "p1" });
+ const b = useProcessStore
+ .getState()
+ .openWindow("projects", { w: 900, h: 600 }, { projectId: "p2" }, { forceNew: true });
+ expect(b).not.toBe(a);
+ const wins = useProcessStore.getState().windows.filter((w) => w.appId === "projects");
+ expect(wins).toHaveLength(2);
+ // Each window keeps its own props, so two projects can show side by side.
+ expect(wins.find((w) => w.id === a)!.props).toEqual({ projectId: "p1" });
+ expect(wins.find((w) => w.id === b)!.props).toEqual({ projectId: "p2" });
+ });
+
+ it("still refocuses the existing window when forceNew is not set", () => {
+ const a = useProcessStore.getState().openWindow("projects", { w: 900, h: 600 });
+ const b = useProcessStore.getState().openWindow("projects", { w: 900, h: 600 });
+ expect(b).toBe(a);
+ expect(
+ useProcessStore.getState().windows.filter((w) => w.appId === "projects"),
+ ).toHaveLength(1);
+ });
});
diff --git a/desktop/src/stores/process-store.ts b/desktop/src/stores/process-store.ts
index 5cf0aaca2..b43f4123f 100644
--- a/desktop/src/stores/process-store.ts
+++ b/desktop/src/stores/process-store.ts
@@ -28,7 +28,7 @@ interface ProcessStore {
windows: WindowState[];
nextZIndex: number;
- openWindow: (appId: string, defaultSize: { w: number; h: number }, props?: Record) => string;
+ openWindow: (appId: string, defaultSize: { w: number; h: number }, props?: Record, opts?: { forceNew?: boolean }) => string;
closeWindow: (id: string) => void;
removeWindow: (id: string) => void;
focusWindow: (id: string) => void;
@@ -85,8 +85,13 @@ export const useProcessStore = create((set, get) => ({
windows: [],
nextZIndex: 1,
- openWindow(appId, defaultSize, props) {
- const existing = get().windows.find((w) => w.appId === appId);
+ openWindow(appId, defaultSize, props, opts) {
+ // Single-instance by default: clicking an app focuses its existing window.
+ // forceNew skips that so an app can open a second window (e.g. a different
+ // project), keyed by its own props -- the basis for multi-window apps.
+ const existing = opts?.forceNew
+ ? undefined
+ : get().windows.find((w) => w.appId === appId);
if (existing) {
if (props) {
set((s) => ({
diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts
index 33a61bc5d..c8f658940 100644
--- a/desktop/vite.config.ts
+++ b/desktop/vite.config.ts
@@ -33,6 +33,7 @@ export default defineConfig({
input: {
main: path.resolve(__dirname, "index.html"),
chat: path.resolve(__dirname, "chat.html"),
+ app: path.resolve(__dirname, "app.html"),
sw: path.resolve(__dirname, "src/sw.ts"),
},
output: {
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 8b13a51fd..806448258 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,5 +1,23 @@
SINGLE SOURCE OF TRUTH for cross-agent handoff.
-Last updated: 2026-06-17 ~22:30 BST, @taOS-dev (autonomous build team running -- grok shipped 6 tasks today via the dispatcher; user update method tested OK; dev->master promotion #979 HELD on SSRF #971 for Jay; see LATEST-23).
+Last updated: 2026-06-20 ~09:15 UTC, @taOS-dev (STEADY-STATE autonomous loop, full budget all window (five_hour ~7% / seven_day 6%). MERGED to dev since the 01:10 entry: account-proxy #1133 + trust-gate hardening #1140 (X-Forwarded-Proto only honored when TAOS_TRUST_FORWARDED_PROTO is set; the taOSgo relay deployment MUST set it, noted in ~/tinyagentos-private/taosgo/SPEC.md) [#113 DONE], node_modules untrack #1134 + root node-cruft removal #1152 (a lane had committed a root package.json/package-lock/vitest.config.ts, the SEED of the node_modules pollution; removed + root-anchored ignores), multi-window Projects #1138 [#111 DONE], canvas test DB-isolation #1139, shortcuts test isolation #1116. AUTONOMOUS TEST-COVERAGE CAMPAIGN: ~30 gate-verified test PRs merged across 10 batches (#1147-1195): python route tests (themes/feedback/manifest/knowledge/system/agent-archive/agent-debugger/skill-exec/service-proxy + reads of others), ~16 desktop API-client lib tests (memory-api/projects/models/framework-api/channel-admin/chat-*/personas/knowledge/userspace-apps/taos-agent-api/hw-detect/account-client/agent-browsers/browser-site-permissions/github/mail/memory/reddit/youtube), component render tests (MigrationBanner/LaunchpadIcon/EmojiPicker/ModelPickerModal/LoginScreen + earlier ServiceIcon/StatusIndicators/ConsentNotification/DockIcon/ScreenshotFlash/WallpaperTextOverlay/SafetyFloor), and hook tests (use-list-nav/use-clock/use-device-mode/use-focus-trap/use-is-pwa/use-visual-viewport/use-widget-size). GATE HARDENED: ~/.taos-team/gate_merge.sh now runs `vitest run --no-cache` on each PR's changed desktop *.test.tsx against the merge result (worktree + symlinked node_modules), BLOCKS real failures, fails OPEN on setup glitches, closing the ungated-desktop-test hole at the lane-gate level. STILL OPEN #114: there is NO vitest CI job + ~23 desktop tests already FAIL on dev (drift from in-progress AgentsApp #59 + BrowserApp/AddressBar #66 redesigns); the durable fix (repair the 23 + add a CI vitest job) needs Jay's steer on update-test-vs-fix-component, so HELD (do NOT blind-rewrite). CAMPAIGN PLATEAUED: clean testable units exhausted; remaining are hard/context-heavy (use-desktop-control 224L, use-session-persistence 215L, server/fetch hooks, canvas wallpapers, websocket libs), not force-feeding. Lanes idle; dispatch + freshness(:08/:38) + repo-watch(:23) crons alive; resume pair armed 9d76dbd7/ce31b067 for the 10:10 UTC reset. gitar+qodo budgets EXHAUSTED (manual severity-gating; recent findings all deferrable Quality nits). dependabot #991-994 HELD for Jay (failing builds, master-gated). PRIOR (01:10 UTC): account-proxy hardening #1133 (honor X-Forwarded-Proto so the session-cookie Secure attr survives the TLS-terminating taOSgo relay + relay redirect Location / auth-challenge headers, with tests), node_modules ROOT-CAUSE cleanup #1134 (dev was TRACKING a stray root node_modules -> it rode into every lane worktree and `git add -A` swept siblings, escalating to 99-file CONFLICTING exec PRs; untracked it + added a generic node_modules/ ignore so it can never recur), and csrf.test #1122. PUSHED + awaiting CI: multi-window Projects #1138 (Jay's ask -- dock right-click New Window for non-singleton apps + Projects singleton:false + per-window projectId prop + project-list 'open in new window' affordance; tsc+build+tests green), shortcuts route-test isolation fix #1116 (reset the _active_manager module global so the no-worker assertion is order-independent), project_canvas test DB-isolation fix #1139 (fixture set a bogus `_db_path` string but BaseStore.init reads self.db_path Path, so the suite ran against the PRODUCTION canvas DB -> now a tmp Path; completes #113 with #1133). Closed 3 node_modules-polluted exec PRs (#1129/#1131/#1132); salvaged #1122. BOARD REFILLED with 11 CI-gated python route-test cards (themes/manifest/feedback/skills/knowledge/system + scheduler/memory-mgmt/a2a-bus/agent-registry/librarian-memory-model), each card carrying exact endpoints + a reference test + the acceptance command + a BLOCKED.txt escape -- python route tests are gated by CI's full suite, so a weak-lane mistake yields a red PR I triage, never a silent bad merge. KEY FINDING (tracked #114): there is NO vitest CI job AND 10 desktop test files / 23 tests already FAIL on dev (drift from the AgentsApp + BrowserApp/AddressBar redesigns), so every colocated desktop test the lanes add is UNGATED; FIX = repair the 23 drifted tests then add a vitest step to the spa-build job; until then do NOT delegate more ungated desktop test cards. Dispatch lane alive (kilo mid-card). Resume pair re-armed eafb5884/00111e53 for the 05:13/05:32 UTC 5h-reset wake. PRIOR (02:40 BST wind-down): WEEKLY WIND-DOWN at seven_day 94%, per Jay's push-to-98 + 'near limits, do orchestration / fill the board' directive. SHIPPED to dev + LIVE on Pi this session: dvh DEAD-SPACE fix in the standalone PWA shells (#1115 -- ChatStandalone/AppStandalone now height:100dvh not h-screen, the installed-iOS-PWA bottom gap), iOS Add-to-Home banner + sturdier copy fallback (#1107), multi-window primitive openWindow forceNew (#1109), taOSgo P1 CORE (Account pane + off-network screen #1105 + host /api/account proxy #1110, HARDENED by #1117 to rescope Set-Cookie [strip Domain, drop Secure on http] + relay the upstream body verbatim), My Apps launcher rename, npm-skip update speedup, README DATA-SOVEREIGNTY positioning #1106 + GitHub repo description rebrand + topics, shared platform-detect util. BOARD FILLED FOR THE WEEKEND: ~20 claimable lane cards (route-coverage + component-test pools, each detailed with a reference test, exact acceptance command, and a BLOCKED.txt escape) + the multi-window consumer (dock New Window + Projects opt-in, tsk-6ax5dk, builds on #1109's forceNew). @taOS-website-dev owns jaylfc/taos-website (taos.my auth + the sovereignty hero) on prj-utbsh7; the shared kilo+opencode lanes serve BOTH boards (TAOS_PROJECTS in ~/.taos-team/config). Project refs accept slug/name now; website-dev wrapper ~/.taos-team/website-dev. gitar+qodo budgets EXHAUSTED -> manual severity-gating (open lane PRs quality-only, nothing must-fix). HANDOFF: the dispatcher auto-builds + auto-merges the board; freshness(:08/:38) + repo-watch(:23) crons keep firing; weekly resets ~2026-06-21 02:00 UTC. On the next 5h reset (01:30 UTC) the resume-pair crons 59dbf43c/6ba30976 wake me to refill the board, scrutinise the overnight lane PRs, fold any gitar findings, and review the multi-window consumer. Pi tip lags dev by backend/test-only merges (no visible delta; dvh fix IS live). five_hour 50% / seven_day 94%.)
+
+==================================================================
+STATE 2026-06-20 ~02:05 BST, @taOS-dev (taOSgo INITIATIVE kicked off -- paid remote-access tier: GBP2.99/mo, 7-day card-required trial, QNAP-style browser-relay fronting a self-hosted Headscale host<->relay mesh, taos.my as the canonical account/OIDC identity; advise Tailscale (no support), taOSgo is the default. Spec+decisions PRIVATE at ~/tinyagentos-private/taosgo/SPEC.md. @taOS-website-dev AGENT onboarded: owns jaylfc/taos-website, member + a2a channel on the taOS Website project (prj-utbsh7); the shared kilo+opencode lanes now serve BOTH boards (TAOS_PROJECTS in ~/.taos-team/config; next_card.py multi-project + /tmp/exec-.proj sidecar; executor reads it; spec_cards respects TAOS_PROJECT_OVERRIDE). Onboarding kit ~/.taos-team/ONBOARDING.md (+ delegation playbook + consent self-join). Project refs now accept slug/name (taos_team.py resolve_project/pid/project_label); website-dev wrapper ~/.taos-team/website-dev. SHIPPED to dev this session: Account section in Settings + off-network 'taOS unreachable -> taOSgo' screen (taOSgo P1 core, #1105), My Apps launcher rename (#1099), npm-install-skip update speedup (#1101), no-cache PWA shell headers (#1098), install-helper panel (#1097), README DATA-SOVEREIGNTY positioning (#1106) + GitHub repo description rebrand + topics. GATING: iOS Add-to-Home banner + copy fallback (#1107), multi-window primitive openWindow forceNew (#1109). BOARD FILLED with route-coverage cards + the multi-window consumer card (dock New Window + Projects opt-in, tsk-6ax5dk, depends on #1109); lanes producing PRs (#1108 events). New tasks #109 stream-chat-finish, #110 account-settings, #111 multi-window, #112 macOS menu bar. gitar+qodo budgets EXHAUSTED -> manual severity-gating. JAY DIRECTIVE: work until seven_day 98% (override the 90-95 wind-down). Resume pair 59dbf43c/6ba30976 for the 01:30 UTC reset. five_hour 17% / seven_day 91%.)
+
+==================================================================
+STATE 2026-06-19 ~18:40 BST, @taOS-dev (Jay said push on the open issues. SHIPPED to dev + LIVE on Pi (dev 7345f233): FEEDBACK APP #106/#856 v1 (#1093 -- in-OS bug/feature submit with screenshot, FeedbackApp.tsx + feedback_store + /api/feedback, in the launcher) and the /app.html SERVING FIX #1095 (the #107 mobile Install button opened /app.html?app= but NO route served it -> black-screen 404; now served from routes/desktop.py mirroring /chat-pwa, with /app.html + /manifest added to auth EXEMPT_PATHS; verified 200 on the Pi). EXECUTOR NOW SELF-HEALS: ~/.taos-team/executor.sh closes the card (cmd_done, with the BLOCKED reason, reopen-able) on every non-success path instead of leaving it wedged in claimed -- this fixes the abandoned-claim issue at the source. LANES fed with ROUTE COVERAGE: 43 route modules lacked a test_routes_.py; posted 8 cards (catalog/themes/guides/jobs/shortcuts/user_personas/system/canvas), several merged, lanes grinding through; plenty more route modules available. INSTALL-HELPER PANEL (NEXT, Jay-approved): the per-app Install button (Window.tsx + MobileAppWindow.tsx) must open a dismissable panel (link + Copy + Add-to-Home-Screen guide, no trap) instead of window.open, because an installed PWA cannot install another PWA / open Safari on iOS. A subagent built it on branch fix/install-helper-panel BUT it branched off a 229-commit-STALE origin/dev (worktree-isolation gotcha), so DO NOT merge that branch -- its Window.tsx/MobileAppWindow.tsx/app-registry edits would conflict with or revert recent work. The NEW file desktop/src/components/InstallHelperPanel.tsx (+ .test.tsx) on that branch IS clean and reusable; re-integrate by: grab InstallHelperPanel.tsx off the branch, then re-apply the 2 Install-button edits (open the panel via useState instead of window.open) FRESH off current origin/dev. pwa? is already on current dev (do not re-add). LESSON: verify subagent worktree branch base is current before merging. Pi PWA caveat: after an update the SW serves a stale bundle until quit+reopen or hard refresh (a known UX rough edge; smoothing it = follow-up near #855). Resume pair armed (b06d1cd6 PRIMARY 21:32 BST / 82ab31f7 RETRY 21:51 BST). five_hour 26% / seven_day 82% -- healthy, PROCEED, but seven_day climbing toward the 90-95% weekly wind-down band.
+
+==================================================================
+STATE 2026-06-19 ~13:50 BST, @taOS-dev (continuous overnight-into-day session. taosctl noun fan-out COMPLETE (~30 nouns). Multiple gitar fix-forwards: apps-get + canvas-get + shared_folders-get each called an invented single-item GET route -> now fetch the list and filter; the recurring json= TypeError class RETIRED by making client.post/patch accept json= as an alias for body= (#1069); client.delete now threads query params (#1062, for the bookmarks delete); strict --enabled; browsing_history explicit --limit (#1090). Added the first DIRECT client tests (tests/test_taosctl_client.py) because the per-noun fakes kept drifting from the real TaosClient signature, which was the root cause of about four of these bugs. #107 UNIVERSAL APP PWA SHELL shipped (built via a Sonnet subagent): a pwa:true opt-in flag on app-registry, a generic app.html + AppStandalone shell, a dynamic GET /manifest?app= endpoint, and a desktop title-bar Install button. Fix-forwards: AppStandalone called lazy() in the render body (remount bug, #1085) and the Install button was desktop-only, so MOBILE had no install path -> added an Install button to the mobile app title bar (MobileAppWindow). LIVE on the Pi; Jay testing on phone; follow-up is one DRY source for the pwa app list (now mirrored in app-registry + manifest.py). ZOMBIE-CLI CRISIS + FIX: a hung kilo CLI pegged CPU for ~5h (3 orphans, PPID 1). Root cause: the executor.sh `perl -e 'alarm shift; exec'` watchdog is a NO-OP on macOS (exec clears the pending alarm), so hung CLIs ran forever. Fixed to fork + own process group + group-SIGKILL on the 1200s cap. Added ~/.taos-team/reaper.sh (kills orphaned/over-cap CLIs + orphaned worktree children; run every freshness sweep, AGENT_HANDOFF step 0g) and ~/.taos-team/pi_update.py (ancestor-check so a moving dev branch does not show a false timeout). KNOWN ISSUE: a card can get claimed then abandoned (the lane makes no PR) and wedge in status=claimed/claimer=None, which the release API 409s on; worked around by re-posting fresh; proper fix is the executor releasing/closing the card on a no-PR finish. Mechanical queue now EXHAUSTED (fan-out done, coverage well dry) so the lanes idle; next direction is Jay's (expand #107, or pick an epic from #100-106; the feedback/bug-tracker #106 is the most pre-specced). dev HEAD 6c91a7b4. five_hour 32% / seven_day 78% -- healthy, PROCEED).
+
+==================================================================
+STATE 2026-06-19 ~04:10 BST, @taOS-dev (overnight autonomous. SHIPPED to dev: taosctl CLI (#1045) -- kubectl-style token-authed REST wrapper with auto-discovered, conflict-free noun modules -- plus owl-lane noun groups projects/tasks/models/frameworks/search/apps (#1047/#1049/#1051/#1052/#1055/#1056); task reopen API (#1050, POST .../reopen); all @grok cards reassigned to the @any open pool and per-agent targeted assignment wired (TAOS_LANE_AGENT -> next_card.py: a specific handle like @taOS-dev-kilo-owl-alpha is claimable ONLY by that lane, blank/@any/@all stay open-pool); mobile Projects/Messages fixes (#1053 Messages is its own full page on mobile, #1054 dead-space + duplicate-header trim); fix-forward #1057 from gitar findings (reopen_task now clears claimed_by/claimed_at so a reopened card returns to the claimable pool; taosctl projects create/update passed json= but TaosClient takes body= so the payload was dropped AND the test fake mirrored the buggy signature, both fixed + body now asserted; strict --enabled rejects invalid values instead of silently disabling). JAN LABS fully scrubbed from repo + website -> jaylfc (direct commits per Jay, no PRs; @taOSmd told on A2A to match). Pi updated to dev #1057 (9c3f092b). NOTE: gitar + qodo bot budgets are EXHAUSTED (paused), so each new lane noun-PR is manually scanned for the json=/body= bug class until the taosctl wave clears. HOLDING for Jay: 6 dependabot security alerts (2 critical / 3 high / 1 moderate) with 4 bump PRs #991-994 -- NOT auto-merged (npm spa-deps 17-pkg bump can break the SPA build, and the vulns sit on master so they need a master promotion anyway). Scoped epics #100-108 on the harness list. five_hour 41% / seven_day 72% -- healthy, PROCEED).
+
+==================================================================
+STATE 2026-06-18 ~16:20 BST, @taOS-dev (executor lane re-platformed: grok REMOVED (quota exhausted) and replaced by two free owl-alpha lanes -- Kilo+owl [@taOS-dev-kilo-owl-alpha] and opencode+owl [@taOS-dev-opencode-owl-alpha], run by a continuous dispatcher (tmux owl-dispatch, ~/.taos-team/dispatch_loop.sh) that pulls claimable cards, gates green exec/* PRs, and throttles at 4 open. CRITICAL gate-bug fixed: the gate parsed the check name `test (3.12)` with awk and was blind to test failures (it merged broken PRs #1023/#1024); both gate_merge.sh and dispatch_loop.sh now use gh --json name,bucket. Executor hardened: it blocks a PR whose changed Python tests do not pass, strips stray uv.lock churn, and a 1200s watchdog kills a hung CLI. SHIPPED to dev this session: Stream Chat userspace app end-to-end (#1012 + installed on the Pi into data_dir/apps with the Twitch IRC network perm, survives core updates), launcher Studio section (#1018), plus owl autobuilds #1015/#1016/#1019/#1020/#1022/#1023/#1024/#1031. dev HEAD 4bda90d5. HOLDING for Jay: #89/#53 studios-standalone plan (present first, do not build autonomously). seven_day usage 94% -- winding toward the weekly pause; docs/agent-jobs handoff pack refreshed).
+
+==================================================================
+STATE 2026-06-18 ~02:30 BST, @taOS-dev (overnight autonomous build. SHIPPED to dev: SSRF #971 (#984), promotion #979 (master current), notification-prefs API, ~6 test-coverage waves, Coding Studio workspace backend (#995), Game Studio MVP (#1001). IN FLIGHT: App #1000 / Design #1002 / Music #1003 studio MVPs each one small Kilo edge-case fix from merge (relayed as [CHANGES]). grok PAUSED -- Grok CLI quota exhausted (pay-as-you-go prompt; NOT topped up, Jay's call); resumes on quota reset, then addresses the relayed fixes + ~15 queued cards. @taOS-dev keeps gating via the 13-min cron. Follow-ups queued: coding-workspaces gitar fixes (tsk-lfkrkc). Monitors stopped in favour of crons. ALL work on dev for Jay to review/promote).
==================================================================
STATE 2026-06-17 ~03:45 BST, @taOS (session continuous since the 06-16 Mac-mini reset).
@@ -11,7 +29,7 @@ CURRENT STATE: dev=8349f40e (= origin/dev), master UNTOUCHED (Jay gates master).
P4a ALL MERGED = the userspace runtime + trust model + .taosapp format + boot-seeding are
COMPLETE (detail in LATEST-21/-20). HOLDING for Jay: (1) launcher/Store UI merge to surface +
open userspace apps (high-blast-radius, lead designs), (2) P4b which studio first, (3) the
- offline JAN LABS Ed25519 signing key for P2. Queued: searx-json-default #969 (design-first),
+ offline Ed25519 signing key for P2. Queued: searx-json-default #969 (design-first),
DNS-rebind #971 (folds into P2).
ACTIVE INITIATIVE = task #89 USERSPACE APP PACKAGES + PER-APP UPDATES. Recon DONE + SPEC
DRAFTED -> ~/tinyagentos-private/specs/userspace-app-packages-spec-2026-06-16.md
@@ -20,7 +38,7 @@ ACTIVE INITIATIVE = task #89 USERSPACE APP PACKAGES + PER-APP UPDATES. Recon DON
(distribution). JAY 2026-06-17: BUILD THE FULL APP SYSTEM NOW (P2-P5), THEN Automation Studio.
DECISIONS LOCKED: distribution = the OFFICIAL taOS REPO on taos.my, Linux-repo model (signed
machine index + HTML-browsable catalog + bundles; client RepoManager; user-added CUSTOM repos a
- later phase); first-party signing = offline Ed25519 JAN LABS key (pubkey in core); container
+ later phase); first-party signing = offline Ed25519 signing key (pubkey in core); container
packages = WEB-ONLY first. Build order = P3 first-party iframe runtime on the #476 foundation
(NOTE: #476 / feat/app-runtime-v1 is 598 commits behind dev, so re-integrate its userspace
runtime onto current dev, do not merge the stale branch) -> P4 migrate a reference studio to a
@@ -36,13 +54,13 @@ NO uncommitted work, no orphaned subagents.
▶▶ LATEST-22 2026-06-17 ~16:20 BST, dev=0f0db09e. PIVOT (Jay): USE taOS TO BUILD taOS -- a multi-agent coding collaboration, now LIVE and the active initiative (epic #90; cards #90-96). I am @taOS-dev (architect + reviewer). @grok = Grok Build CLI (Composer 2.5 Fast) running in tmux session grok-exec, isolated in worktree grok/exec off origin/dev, monitored for usage-limit pauses (buyinym7g). Coordination on the A2A bus channel taos-build-tasks (rules-of-engagement charter posted; msg 500). The taOS project lives in the Projects app (prj-5y722y, slug taos): @taOS-dev (lead) + @grok are members; live Kanban (open/claimed/closed via SSE); 7 cards = 3 alias/initiative + 4 dogfooded chat bugs. THE FIRST LOOP CLOSED END TO END: a dogfooded bug (channel settings panel overlapping the workspace live-preview) -> card tsk-2qk24n -> grok claimed -> grok fixed in its worktree (fixed->absolute + relative parent, 2 lines) -> @taOS-dev reviewed -> PR #975 (gitar + CodeRabbit pass) -> merged (0f0db09e) -> Pi SPA rebuilt -> card Closed/Done. The fix is LIVE on the Pi. grok AUTONOMY built: ~/.taos-team/ client (board/claim/comment/review + chat read/post over the LAN) + ~/.taos-grok-team/TEAM.md brief. TWO GATES enforced: (1) CLAIMABLE -- grok only sees OPEN cards @taOS-dev has specced + labeled "claimable" (its board is empty until then); (2) MERGE -- grok never merges or closes a card; @taOS-dev does after the bot gate. PENDING: the wake-loop/dispatcher to make grok fully self-driving (asked Jay: flip on or review the setup first). ALSO this session: launcher #974 (userspace apps surface + open in the Launchpad) MERGED + deployed to the Pi (b96efcb4); dependabot pip->uv config fix merged (d6ec6d37) + #954 closed. PI DEPLOY PATH CONFIRMED: /opt/tinyagentos runs as the taos user via tinyagentos.service; update = (as taos) git ff origin/dev + cd desktop && npm ci && npm run build (outputs to static/desktop/, served live; frontend-only changes need NO restart); controller + bus are LAN-reachable directly. REPO-HYGIENE BUG: desktop/node_modules is a tracked gitlink that clobbers installed deps on pull, forcing npm ci each deploy -- gitignore it (queued). @taOSmd confirmed the A2A bus is FREE-HANDLE (identity = the from string; no resolution endpoint) so the registry is the alias source of truth.
-▶▶ LATEST-21 2026-06-17 ~02:30 BST, dev=9e7a0283. P4a (backend) MERGED #973: a first-party REFERENCE .taosapp (tinyagentos/userspace/seed/welcome/) + boot-seeding (userspace/seed.py seed_bundled_apps, wired into the app.py lifespan, idempotent, installs first-party via the trusted path). CodeRabbit Major (seed idempotency must also require first-party trust, else a same-version community impostor is skipped) + gitar edge case (re-seed left stale files) FOLDED pre-merge with tests. The bundled welcome app now seeds + serves with the first-party CSP at boot. CHECKPOINT: P1 + P3a + P3b + P4a all merged = the userspace runtime + trust + package format + seeding are ALL on dev. PAUSED the autonomous build here at a clean milestone. HOLDING for Jay before the next phases: (1) the launcher/Store UI merge to surface + open userspace apps (high-blast-radius desktop change, lead designs carefully); (2) P4b which studio to rebuild first as a standalone iframe .taosapp; (3) generate the offline JAN LABS Ed25519 signing key for P2 (the taos.my repo). Security #971 (DNS-rebind) still queued for P2.
+▶▶ LATEST-21 2026-06-17 ~02:30 BST, dev=9e7a0283. P4a (backend) MERGED #973: a first-party REFERENCE .taosapp (tinyagentos/userspace/seed/welcome/) + boot-seeding (userspace/seed.py seed_bundled_apps, wired into the app.py lifespan, idempotent, installs first-party via the trusted path). CodeRabbit Major (seed idempotency must also require first-party trust, else a same-version community impostor is skipped) + gitar edge case (re-seed left stale files) FOLDED pre-merge with tests. The bundled welcome app now seeds + serves with the first-party CSP at boot. CHECKPOINT: P1 + P3a + P3b + P4a all merged = the userspace runtime + trust + package format + seeding are ALL on dev. PAUSED the autonomous build here at a clean milestone. HOLDING for Jay before the next phases: (1) the launcher/Store UI merge to surface + open userspace apps (high-blast-radius desktop change, lead designs carefully); (2) P4b which studio to rebuild first as a standalone iframe .taosapp; (3) generate the offline Ed25519 signing key for P2 (the taos.my repo). Security #971 (DNS-rebind) still queued for P2.
-▶▶ LATEST-20 2026-06-17 ~02:00 BST, dev=46a1c6d6. APP SYSTEM MILESTONE: the userspace RUNTIME + TRUST model is COMPLETE on dev (P1 + P3a + P3b merged). P3b (first-party trust) MERGED #972: trust-aware CSP + capabilities + theme injection; trust is NEVER self-declared (public install = community; first-party only via the internal seed/signature path). CRITICAL caught + fixed pre-merge (CodeRabbit + gitar): the install UPSERT did not update trust, so a public reinstall of a first-party id kept first-party privileges -- fixed via UPSERT trust=excluded.trust + a 409 reject of public overwrite of a first-party app (+ 2 regression tests). Also folded across the runtime PRs: SDK taosTheme array-guard, zip-bomb upload/uncompressed caps, broker jail-root write guard, set_permissions intersect (security review, 0d769449), + 2 fix-forwards (catalog-guard #967, StudioHero #966). DEFERRED w/ reason: real-SDK-execution test (needs the IIFE SDK refactored to be importable; eval/new-Function is a flagged pattern) -- a mirror-handler test covers the behavior + the guard is in the product SDK. SECURITY queued: #971 (DNS-rebind TOCTOU on the source_url fetch) folded into P2's download broker. NEXT = P4 (migrate studios to real .taosapp). PLAN: P4a FIRST = a minimal first-party REFERENCE .taosapp + boot-seeding (install at startup as first-party via the trusted store path) + launcher/Store wiring to surface + open userspace apps, proving the end-to-end loop (seed -> appears in launcher -> opens in SandboxedAppWindow -> theme injected -> SDK works) WITHOUT rebuilding a real studio. THEN P4b = rebuild each studio as a standalone iframe .taosapp (design-heavy, per-studio). THEN P2 = the taOS repo on taos.my (needs Jay's offline Ed25519 signing key). DECISIONS FOR JAY: which studio first in P4b; generate the JAN LABS signing key for P2.
+▶▶ LATEST-20 2026-06-17 ~02:00 BST, dev=46a1c6d6. APP SYSTEM MILESTONE: the userspace RUNTIME + TRUST model is COMPLETE on dev (P1 + P3a + P3b merged). P3b (first-party trust) MERGED #972: trust-aware CSP + capabilities + theme injection; trust is NEVER self-declared (public install = community; first-party only via the internal seed/signature path). CRITICAL caught + fixed pre-merge (CodeRabbit + gitar): the install UPSERT did not update trust, so a public reinstall of a first-party id kept first-party privileges -- fixed via UPSERT trust=excluded.trust + a 409 reject of public overwrite of a first-party app (+ 2 regression tests). Also folded across the runtime PRs: SDK taosTheme array-guard, zip-bomb upload/uncompressed caps, broker jail-root write guard, set_permissions intersect (security review, 0d769449), + 2 fix-forwards (catalog-guard #967, StudioHero #966). DEFERRED w/ reason: real-SDK-execution test (needs the IIFE SDK refactored to be importable; eval/new-Function is a flagged pattern) -- a mirror-handler test covers the behavior + the guard is in the product SDK. SECURITY queued: #971 (DNS-rebind TOCTOU on the source_url fetch) folded into P2's download broker. NEXT = P4 (migrate studios to real .taosapp). PLAN: P4a FIRST = a minimal first-party REFERENCE .taosapp + boot-seeding (install at startup as first-party via the trusted store path) + launcher/Store wiring to surface + open userspace apps, proving the end-to-end loop (seed -> appears in launcher -> opens in SandboxedAppWindow -> theme injected -> SDK works) WITHOUT rebuilding a real studio. THEN P4b = rebuild each studio as a standalone iframe .taosapp (design-heavy, per-studio). THEN P2 = the taOS repo on taos.my (needs Jay's offline Ed25519 signing key). DECISIONS FOR JAY: which studio first in P4b; generate the signing key for P2.
▶▶ LATEST-19 2026-06-17 ~00:45 BST, dev=035b017e (+ fix-forwards 9f705dfd, 659e3bd6). APP SYSTEM BUILD (Jay: build P2-P5 fully, THEN Automation Studio). PROGRESS: P1 (per-app versioning + Updates UI) MERGED #967. P3a (web userspace app-runtime FOUNDATION re-integrated from the stale #476 onto current dev: .taosapp package format + UserspaceAppStore/DataStore + capability broker + SandboxedAppWindow iframe + SDK + routes; container support EXCLUDED web-first; 48 tests) MERGED #970 -- ALL bot findings folded pre-merge (CodeRabbit 9 + gitar Security zip-bomb [upload + uncompressed size caps] + gitar broker jail-root edge case + the container-persist bug). NIT SWEEP (Jay "address all nits"): also fixed-forward two findings on already-merged PRs: #967 CodeRabbit Major (Store guards optional-catalog is an array, 9f705dfd) + #966 StudioHero routes through studioAppId (659e3bd6). Every Major/Security/Edge-Case/Quality finding on #965-970 is cleared. NEXT = P3b (first-party trust: trust-aware CSP + default caps + theme injection; building now off dev) -> P4 migrate a reference studio to a real .taosapp -> P2 the taOS repo on taos.my (Linux-repo model: signed JSON index + browsable HTML catalog + Ed25519 signing + verify; user-added CUSTOM repos a later phase) -> P5 community repos. SEPARATELY this session: web search FIXED (sandbox blocks the open web -> Pi-routed offline SearXNG :36130 + ~/.taos-websearch.sh searx-first/Tavily-fallback; searx-json-default catalog fix filed #969); Automation Studio spec SIGNED OFF (private specs/automation-studio-design-2026-06-16.md, #968) DEFERRED until the app system is done.
-▶▶ LATEST-18 2026-06-16 ~22:40 BST, dev=65d4fcad (orientation + spec only, NO code). RESUMED after the Mac mini reset. (1) Re-armed all session-scoped crons: freshness :08/:38 (9cf28fee), repo-watch :23 (eee7b770), resume-pair primary 23:43 BST (496fe83a) / retry 00:02 BST (5dc91176), A2A SSE bus monitor (task bxmaqbz62). Verified Pi reachable (:6969 401, bus :7900 streaming keepalives), Keychain token parses, usage published to Pi (5h 48% / 7d 68% -> PROCEED). (2) DRAFTED the task #89 spec from the recon -> private specs/userspace-app-packages-spec-2026-06-16.md. SPEC SHAPE: universal .taosapp format (from #476) for ALL apps; TRUST TIERS as the linchpin (first-party signed = relaxed CSP + theme-token injection + broad default caps; community = tight #476 iframe sandbox + per-cap consent) -- this is what avoids module federation while still letting the studios run well; iframe + enhanced broker loader (NOT module federation, per recon); taos.my GET /api/v1/apps//latest distribution endpoint + auto_update poller extension; Settings/Updates authoritative (core row + per-app rows) with Store/Updates badges/tab mirror. ONE OPEN FORK for Jay = SEQUENCING: Track A (build versioning + Updates UI + distribution rails FIRST against the in-core studios, then migrate studios to real packages incrementally behind the unchanged UI -- RECOMMENDED, bounds the in-core-React->iframe rewrite to one studio at a time, ships the user-visible win day one) vs Track B (big-bang: rewrite all 5 studios to packages now, then build the rails). Both reach the SAME end state (real packages); Track A is NOT the rejected in-core-forever stopgap. Plus 5 open questions (spec s8): bundle hosting, JAN LABS signing key, optional non-studio apps #70 in scope?, container packages now or web-only first?. JAY SIGNED OFF: Track A (rails first) + studios-only scope. P1 (per-app versioning + Updates UI) BUILT via a Sonnet worktree subagent + lead-reviewed (backend safe/allowlist-bounded, theme-tokened UI, jaylfc identity, no added em dashes, no app-registry changes) -> PR #967 OPEN (feat/app-versioning-updates-ui, base dev): APP_VERSIONS/APP_TRUST maps + GET /api/apps/optional/catalog + Settings/Updates "Apps" section (authoritative, rows keyed by source) + Store/Updates mirror; HONEST semantics (in-core apps update via the system update, structure ready for independent package updates later); 17 backend + 10 vitest pass, tsc+build clean locally. AWAITING CI + gitar on #967 -> merge to dev on green (gate: severity not review-state). THEN P2 = taos.my distribution endpoint. #89 in_progress, owner @taOS.
+▶▶ LATEST-18 2026-06-16 ~22:40 BST, dev=65d4fcad (orientation + spec only, NO code). RESUMED after the Mac mini reset. (1) Re-armed all session-scoped crons: freshness :08/:38 (9cf28fee), repo-watch :23 (eee7b770), resume-pair primary 23:43 BST (496fe83a) / retry 00:02 BST (5dc91176), A2A SSE bus monitor (task bxmaqbz62). Verified Pi reachable (:6969 401, bus :7900 streaming keepalives), Keychain token parses, usage published to Pi (5h 48% / 7d 68% -> PROCEED). (2) DRAFTED the task #89 spec from the recon -> private specs/userspace-app-packages-spec-2026-06-16.md. SPEC SHAPE: universal .taosapp format (from #476) for ALL apps; TRUST TIERS as the linchpin (first-party signed = relaxed CSP + theme-token injection + broad default caps; community = tight #476 iframe sandbox + per-cap consent) -- this is what avoids module federation while still letting the studios run well; iframe + enhanced broker loader (NOT module federation, per recon); taos.my GET /api/v1/apps//latest distribution endpoint + auto_update poller extension; Settings/Updates authoritative (core row + per-app rows) with Store/Updates badges/tab mirror. ONE OPEN FORK for Jay = SEQUENCING: Track A (build versioning + Updates UI + distribution rails FIRST against the in-core studios, then migrate studios to real packages incrementally behind the unchanged UI -- RECOMMENDED, bounds the in-core-React->iframe rewrite to one studio at a time, ships the user-visible win day one) vs Track B (big-bang: rewrite all 5 studios to packages now, then build the rails). Both reach the SAME end state (real packages); Track A is NOT the rejected in-core-forever stopgap. Plus 5 open questions (spec s8): bundle hosting, signing key, optional non-studio apps #70 in scope?, container packages now or web-only first?. JAY SIGNED OFF: Track A (rails first) + studios-only scope. P1 (per-app versioning + Updates UI) BUILT via a Sonnet worktree subagent + lead-reviewed (backend safe/allowlist-bounded, theme-tokened UI, jaylfc identity, no added em dashes, no app-registry changes) -> PR #967 OPEN (feat/app-versioning-updates-ui, base dev): APP_VERSIONS/APP_TRUST maps + GET /api/apps/optional/catalog + Settings/Updates "Apps" section (authoritative, rows keyed by source) + Store/Updates mirror; HONEST semantics (in-core apps update via the system update, structure ready for independent package updates later); 17 backend + 10 vitest pass, tsc+build clean locally. AWAITING CI + gitar on #967 -> merge to dev on green (gate: severity not review-state). THEN P2 = taos.my distribution endpoint. #89 in_progress, owner @taOS.
▶▶ LATEST-17 2026-06-16 ~21:35 BST, dev=b335b42a (feat/studios MERGED to dev via PR #966 -- supersedes LATEST-16's "not yet merged"; master STILL 59c296d2, Jay gating master). Pi still on feat/studios (3b63f841) for click-through. Jay: "it all looks awesome." NEW DECISION (Jay) -> USERSPACE APP PACKAGES + PER-APP UPDATES (task #89, [[project_app_runtime_immutable]]): make the Creative Studios + optional apps REAL userspace packages -- physically separate from the core build, installed into persistent userspace, version-tracked, updatable INDEPENDENTLY of the OS, surviving reinstalls/updates. Chose "userspace packages now" (revive App Runtime #476 / feat/app-runtime-v1 as the foundation) over phased/in-core. Updates UI = "Both, Settings authoritative" (Settings owns the full system Updates view: core on top + apps below; Store also surfaces per-app update badges + an Updates tab). Current gap: install STATE already persists (installed_apps flag in data_dir, preserved by auto_update.py) but app CODE still ships in core (app-registry Vite static imports) -- HARD PROBLEM = runtime loading of that code. DESIGN-FIRST: a read-only architecture recon (App Runtime #476 + current app-load/install/update paths) is RUNNING in the background; on its return I write a spec (private repo for strategy bits) -> Jay sign-off -> phased build (package format+loader -> migrate studios/optional apps out of core -> per-app versioning+update mechanism -> Updates UI Settings+Store). Studios = first-pass UI, static data, no backend yet (phase-2 backend wiring still pending).
diff --git a/landing/index.html b/landing/index.html
index a5c68c450..628b14fe9 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -694,7 +694,7 @@
taOS
- // jan-labs / tinyagentos
+ // jaylfc / taOS
Your AI, your hardware,your rules
@@ -727,7 +727,7 @@
diff --git a/pyproject.toml b/pyproject.toml
index f52a93c04..22d9c46ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -70,6 +70,7 @@ tinyagentos-worker = "tinyagentos.worker.__main__:main"
taos = "tinyagentos.app:main"
taos-gui = "tinyagentos.app:gui"
taos-worker-ctl = "tinyagentos.cli.worker:main"
+taosctl = "tinyagentos.cli.taosctl.__main__:main"
[tool.pytest.ini_options]
# Per-test timeout: a single hung test fails fast and named instead of
diff --git a/tests/conftest.py b/tests/conftest.py
index 072007d3c..4c2d90881 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -314,6 +314,14 @@ async def client(app, tmp_data_dir):
if themes._db is not None:
await themes.close()
await themes.init()
+ office_docs = app.state.office_docs
+ if office_docs._db is not None:
+ await office_docs.close()
+ await office_docs.init()
+ feedback_store = app.state.feedback_store
+ if feedback_store._db is not None:
+ await feedback_store.close()
+ await feedback_store.init()
# BrowserApp v2 stores
from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore
_browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3")
@@ -364,6 +372,8 @@ async def client(app, tmp_data_dir):
await secrets_store.close()
await notif_store.close()
await store.close()
+ await office_docs.close()
+ await feedback_store.close()
await app.state.qmd_client.close()
await app.state.http_client.aclose()
await _browser_store.close()
diff --git a/tests/projects/test_task_store.py b/tests/projects/test_task_store.py
index 2ff601000..cecca8a39 100644
--- a/tests/projects/test_task_store.py
+++ b/tests/projects/test_task_store.py
@@ -97,6 +97,29 @@ async def test_close_task_records_metadata(store):
assert again["closed_at"] is not None
+@pytest.mark.asyncio
+async def test_reopen_task_returns_closed_task_to_open_pool(store):
+ t = await store.create_task(project_id="p", title="A", created_by="u")
+ await store.claim_task(t["id"], claimer_id="agent-1")
+ await store.close_task(t["id"], closed_by="agent-1", reason="oops")
+ assert await store.reopen_task(t["id"], reopened_by="jay") is True
+ reopened = await store.get_task(t["id"])
+ assert reopened["status"] == "open"
+ assert reopened["closed_by"] is None
+ assert reopened["closed_at"] is None
+ assert reopened["close_reason"] is None
+ # reopened task must return to the claimable pool, so the old claimer clears
+ assert reopened["claimed_by"] is None
+ assert reopened["claimed_at"] is None
+
+
+@pytest.mark.asyncio
+async def test_reopen_task_is_noop_when_not_closed(store):
+ t = await store.create_task(project_id="p", title="A", created_by="u")
+ assert await store.reopen_task(t["id"], reopened_by="jay") is False
+ assert (await store.get_task(t["id"]))["status"] == "open"
+
+
@pytest.mark.asyncio
async def test_add_relationship_and_list(store):
a = await store.create_task(project_id="p", title="A", created_by="u")
diff --git a/tests/test_agent_grants_store.py b/tests/test_agent_grants_store.py
new file mode 100644
index 000000000..532b7fa7f
--- /dev/null
+++ b/tests/test_agent_grants_store.py
@@ -0,0 +1,157 @@
+"""Tests for AgentGrantsStore — per-agent scope grants persistence."""
+import pytest
+
+from tinyagentos.agent_grants_store import AgentGrantsStore
+
+
+@pytest.mark.asyncio
+class TestAgentGrantsStore:
+ # ── helpers ──────────────────────────────────────────────────────
+ async def _store(self, tmp_path):
+ s = AgentGrantsStore(tmp_path / "grants.db")
+ await s.init()
+ return s
+
+ # ── add_grant ────────────────────────────────────────────────────
+ async def test_add_grant_returns_inserted_row(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ row = await store.add_grant("agent-x", "app.kv.read")
+ assert row["canonical_id"] == "agent-x"
+ assert row["scope"] == "app.kv.read"
+ assert row["tier"] == "once"
+ assert row["project_id"] is None
+ assert row["expires_at"] is None
+ assert "granted_at" in row
+ assert isinstance(row["id"], int)
+ finally:
+ await store.close()
+
+ async def test_add_grant_with_optional_fields(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ row = await store.add_grant(
+ "agent-y",
+ "app.net",
+ tier="always",
+ project_id="proj-1",
+ expires_at="2030-01-01T00:00:00+00:00",
+ )
+ assert row["tier"] == "always"
+ assert row["project_id"] == "proj-1"
+ assert row["expires_at"] == "2030-01-01T00:00:00+00:00"
+ finally:
+ await store.close()
+
+ async def test_add_grant_idempotent_replace(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant("agent-z", "app.kv.write", tier="once")
+ second = await store.add_grant("agent-z", "app.kv.write", tier="always")
+ assert second["tier"] == "always"
+ grants = await store.list_grants("agent-z")
+ assert len(grants) == 1
+ assert grants[0]["tier"] == "always"
+ finally:
+ await store.close()
+
+ async def test_add_grant_uninitialised_raises_runtime_error(self, tmp_path):
+ store = AgentGrantsStore(tmp_path / "grants.db")
+ try:
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await store.add_grant("agent-x", "app.kv.read")
+ finally:
+ await store.close()
+
+ # ── list_grants ──────────────────────────────────────────────────
+ async def test_list_grants_returns_all_for_canonical_id(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant("agent-a", "app.kv.read")
+ await store.add_grant("agent-a", "app.kv.write")
+ grants = await store.list_grants("agent-a")
+ assert len(grants) == 2
+ assert {g["scope"] for g in grants} == {"app.kv.read", "app.kv.write"}
+ finally:
+ await store.close()
+
+ async def test_list_grants_empty_for_unknown_canonical_id(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ grants = await store.list_grants("nonexistent")
+ assert grants == []
+ finally:
+ await store.close()
+
+ async def test_list_grants_scoped_by_canonical_id(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant("agent-1", "app.kv.read")
+ await store.add_grant("agent-2", "app.kv.read")
+ g1 = await store.list_grants("agent-1")
+ g2 = await store.list_grants("agent-2")
+ assert len(g1) == 1
+ assert len(g2) == 1
+ assert g1[0]["canonical_id"] == "agent-1"
+ assert g2[0]["canonical_id"] == "agent-2"
+ finally:
+ await store.close()
+
+ async def test_list_grants_uninitialised_raises_runtime_error(self, tmp_path):
+ store = AgentGrantsStore(tmp_path / "grants.db")
+ try:
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await store.list_grants("agent-x")
+ finally:
+ await store.close()
+
+ # ── list_active_grants ───────────────────────────────────────────
+ async def test_list_active_grants_returns_non_expired(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant("agent-a", "app.kv.read")
+ await store.add_grant(
+ "agent-b",
+ "app.net",
+ expires_at="2030-01-01T00:00:00+00:00",
+ )
+ active = await store.list_active_grants()
+ assert len(active) == 2
+ finally:
+ await store.close()
+
+ async def test_list_active_grants_excludes_expired(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant("agent-a", "app.kv.read")
+ await store.add_grant(
+ "agent-b",
+ "app.net",
+ expires_at="2000-01-01T00:00:00+00:00",
+ )
+ active = await store.list_active_grants()
+ assert len(active) == 1
+ assert active[0]["canonical_id"] == "agent-a"
+ finally:
+ await store.close()
+
+ async def test_list_active_grants_empty_when_all_expired(self, tmp_path):
+ store = await self._store(tmp_path)
+ try:
+ await store.add_grant(
+ "agent-a",
+ "app.kv.read",
+ expires_at="2000-01-01T00:00:00+00:00",
+ )
+ active = await store.list_active_grants()
+ assert active == []
+ finally:
+ await store.close()
+
+ async def test_list_active_grants_uninitialised_raises_runtime_error(self, tmp_path):
+ store = AgentGrantsStore(tmp_path / "grants.db")
+ try:
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await store.list_active_grants()
+ finally:
+ await store.close()
diff --git a/tests/test_agent_messages.py b/tests/test_agent_messages.py
new file mode 100644
index 000000000..9d36850d7
--- /dev/null
+++ b/tests/test_agent_messages.py
@@ -0,0 +1,501 @@
+"""Unit tests for AgentMessageStore send/list/ordering."""
+from __future__ import annotations
+
+import json
+from unittest.mock import patch
+
+import pytest
+
+from tinyagentos.agent_messages import AgentMessageStore
+
+
+async def _store(tmp_path):
+ s = AgentMessageStore(tmp_path / "agent_messages.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_send_persists_message_and_returns_id(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ msg_id = await store.send("alpha", "beta", "hello there")
+ assert isinstance(msg_id, int)
+ assert msg_id > 0
+
+ messages = await store.get_messages("alpha")
+ assert len(messages) == 1
+ assert messages[0]["id"] == msg_id
+ assert messages[0]["from"] == "alpha"
+ assert messages[0]["to"] == "beta"
+ assert messages[0]["message"] == "hello there"
+ assert messages[0]["read"] is False
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_messages_includes_sent_and_received(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "alpha to beta")
+ await store.send("beta", "alpha", "beta to alpha")
+ await store.send("gamma", "delta", "unrelated")
+
+ alpha_msgs = await store.get_messages("alpha")
+ assert len(alpha_msgs) == 2
+ bodies = {m["message"] for m in alpha_msgs}
+ assert bodies == {"alpha to beta", "beta to alpha"}
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_messages_orders_by_timestamp_desc(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ times = iter([100.0, 200.0, 300.0])
+ with patch("tinyagentos.agent_messages.time.time", side_effect=lambda: next(times)):
+ await store.send("alpha", "beta", "oldest")
+ await store.send("alpha", "beta", "middle")
+ await store.send("alpha", "beta", "newest")
+
+ messages = await store.get_messages("alpha")
+ assert [m["message"] for m in messages] == ["newest", "middle", "oldest"]
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_orders_by_timestamp_asc(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ times = iter([50.0, 150.0, 250.0])
+ with patch("tinyagentos.agent_messages.time.time", side_effect=lambda: next(times)):
+ await store.send("alpha", "beta", "first")
+ await store.send("beta", "alpha", "second")
+ await store.send("alpha", "gamma", "other thread")
+
+ convo = await store.get_conversation("alpha", "beta")
+ assert [m["message"] for m in convo] == ["first", "second"]
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_messages_respects_limit(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ for i in range(5):
+ await store.send("alpha", "beta", f"msg-{i}")
+
+ messages = await store.get_messages("alpha", limit=2)
+ assert len(messages) == 2
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_send_with_all_optional_params(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ tool_calls = [{"name": "search", "args": {"q": "test"}}]
+ tool_results = [{"output": "result"}]
+ metadata = {"session": "abc", "priority": 1}
+ msg_id = await store.send(
+ "alpha", "beta", "full message",
+ tool_calls=tool_calls, tool_results=tool_results,
+ reasoning="I think therefore I am", depth=3, metadata=metadata,
+ )
+ msgs = await store.get_messages("alpha", depth=3)
+ assert len(msgs) == 1
+ m = msgs[0]
+ assert m["message"] == "full message"
+ assert m["tool_calls"] == tool_calls
+ assert m["tool_results"] == tool_results
+ assert m["reasoning"] == "I think therefore I am"
+ assert m["metadata"] == metadata
+ assert m["depth"] == 3
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_send_defaults_for_optional_params(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "minimal")
+ msgs = await store.get_messages("alpha")
+ m = msgs[0]
+ assert m["tool_calls"] == []
+ assert m["tool_results"] == []
+ assert m["reasoning"] == ""
+ assert m["metadata"] == {}
+ assert m["depth"] == 2
+ finally:
+ await store.close()
+
+
+def test_format_message_depth1():
+ row = (1, "a", "b", "hi", '[{"name":"tc"}]', '[{"out":"tr"}]', "some reasoning", 3, '{"k":"v"}', 100.0, 0)
+ msg = AgentMessageStore._format_message(row, view_depth=1)
+ assert msg["message"] == "hi"
+ assert msg["tool_calls"] == []
+ assert msg["tool_results"] == []
+ assert msg["reasoning"] == ""
+ assert msg["metadata"] == {"k": "v"}
+ assert msg["timestamp"] == 100.0
+ assert msg["read"] is False
+
+
+def test_format_message_depth2():
+ row = (1, "a", "b", "hi", '[{"name":"tc"}]', '[{"out":"tr"}]', "some reasoning", 3, '{}', 100.0, 1)
+ msg = AgentMessageStore._format_message(row, view_depth=2)
+ assert msg["tool_calls"] == [{"name": "tc"}]
+ assert msg["tool_results"] == [{"out": "tr"}]
+ assert msg["reasoning"] == ""
+ assert msg["read"] is True
+
+
+def test_format_message_depth3():
+ row = (1, "a", "b", "hi", '[]', '[]', "deep thought", 3, '{}', 100.0, 0)
+ msg = AgentMessageStore._format_message(row, view_depth=3)
+ assert msg["reasoning"] == "deep thought"
+ assert msg["tool_calls"] == []
+ assert msg["tool_results"] == []
+
+
+@pytest.mark.asyncio
+async def test_get_messages_depth_filters_fields(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send(
+ "alpha", "beta", "test",
+ tool_calls=[{"name": "x"}], tool_results=[{"out": "y"}],
+ reasoning="because",
+ )
+ d1 = await store.get_messages("alpha", depth=1)
+ assert d1[0]["tool_calls"] == []
+ assert d1[0]["tool_results"] == []
+ assert d1[0]["reasoning"] == ""
+
+ d2 = await store.get_messages("alpha", depth=2)
+ assert d2[0]["tool_calls"] == [{"name": "x"}]
+ assert d2[0]["tool_results"] == [{"out": "y"}]
+ assert d2[0]["reasoning"] == ""
+
+ d3 = await store.get_messages("alpha", depth=3)
+ assert d3[0]["reasoning"] == "because"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_depth_filters_fields(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send(
+ "alpha", "beta", "test",
+ tool_calls=[{"name": "x"}], reasoning="because",
+ )
+ d1 = await store.get_conversation("alpha", "beta", depth=1)
+ assert d1[0]["tool_calls"] == []
+ assert d1[0]["reasoning"] == ""
+
+ d3 = await store.get_conversation("alpha", "beta", depth=3)
+ assert d3[0]["tool_calls"] == [{"name": "x"}]
+ assert d3[0]["reasoning"] == "because"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_is_bidirectional(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "a->b")
+ await store.send("beta", "alpha", "b->a")
+ await store.send("alpha", "gamma", "a->g")
+
+ convo = await store.get_conversation("alpha", "beta")
+ assert len(convo) == 2
+ bodies = {m["message"] for m in convo}
+ assert bodies == {"a->b", "b->a"}
+
+ convo2 = await store.get_conversation("beta", "alpha")
+ assert len(convo2) == 2
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_respects_limit(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ for i in range(5):
+ await store.send("alpha", "beta", f"msg-{i}")
+
+ convo = await store.get_conversation("alpha", "beta", limit=3)
+ assert len(convo) == 3
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_contacts(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "a->b 1")
+ await store.send("alpha", "beta", "a->b 2")
+ await store.send("beta", "alpha", "b->a")
+ await store.send("gamma", "alpha", "g->a")
+ await store.send("alpha", "gamma", "a->g")
+
+ contacts = await store.get_contacts("alpha")
+ contact_map = {c["name"]: c["unread_count"] for c in contacts}
+ assert "beta" in contact_map
+ assert "gamma" in contact_map
+ # beta sent 1 unread to alpha, gamma sent 1 unread to alpha
+ assert contact_map["beta"] == 1
+ assert contact_map["gamma"] == 1
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_contacts_no_unread_when_all_read(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("beta", "alpha", "b->a")
+ await store.mark_read("alpha")
+
+ contacts = await store.get_contacts("alpha")
+ contact_map = {c["name"]: c["unread_count"] for c in contacts}
+ assert contact_map["beta"] == 0
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_mark_read_only_affects_received(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("beta", "alpha", "to alpha")
+ await store.send("alpha", "beta", "to beta")
+
+ await store.mark_read("alpha")
+
+ unread_alpha = await store.unread_count("alpha")
+ unread_beta = await store.unread_count("beta")
+ assert unread_alpha == 0
+ assert unread_beta == 1
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_unread_count_empty(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ count = await store.unread_count("lonely")
+ assert count == 0
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_unread_count_increments(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("beta", "alpha", "msg1")
+ assert await store.unread_count("alpha") == 1
+
+ await store.send("gamma", "alpha", "msg2")
+ assert await store.unread_count("alpha") == 2
+
+ await store.send("alpha", "beta", "sent by alpha")
+ assert await store.unread_count("alpha") == 2
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_delete_existing_message(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ msg_id = await store.send("alpha", "beta", "to be deleted")
+ result = await store.delete(msg_id)
+ assert result is True
+
+ msgs = await store.get_messages("alpha")
+ assert len(msgs) == 0
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_delete_nonexistent_message(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ result = await store.delete(99999)
+ assert result is False
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_delete_specific_message_preserves_others(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ id1 = await store.send("alpha", "beta", "keep me")
+ id2 = await store.send("alpha", "beta", "delete me")
+ id3 = await store.send("alpha", "beta", "keep me too")
+
+ await store.delete(id2)
+
+ msgs = await store.get_messages("alpha")
+ assert len(msgs) == 2
+ remaining_ids = {m["id"] for m in msgs}
+ assert remaining_ids == {id1, id3}
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_search_all_agents(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "hello world")
+ await store.send("gamma", "delta", "goodbye world")
+ await store.send("alpha", "beta", "no match here")
+
+ results = await store.search("world")
+ assert len(results) == 2
+ bodies = {m["message"] for m in results}
+ assert "hello world" in bodies
+ assert "goodbye world" in bodies
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_search_filtered_by_agent(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "hello world")
+ await store.send("gamma", "delta", "hello world too")
+
+ results = await store.search("hello", agent_name="alpha")
+ assert len(results) == 1
+ assert results[0]["from"] == "alpha"
+
+ results_g = await store.search("hello", agent_name="gamma")
+ assert len(results_g) == 1
+ assert results_g[0]["from"] == "gamma"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_search_no_results(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "hello")
+ results = await store.search("zzzzz")
+ assert len(results) == 0
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_search_orders_by_timestamp_desc(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ times = iter([100.0, 200.0, 300.0])
+ with patch("tinyagentos.agent_messages.time.time", side_effect=lambda: next(times)):
+ await store.send("alpha", "beta", "oldest match")
+ await store.send("alpha", "beta", "middle match")
+ await store.send("alpha", "beta", "newest match")
+
+ results = await store.search("match")
+ assert [m["message"] for m in results] == ["newest match", "middle match", "oldest match"]
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_search_respects_limit(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ for i in range(5):
+ await store.send("alpha", "beta", f"match-{i}")
+
+ results = await store.search("match", limit=3)
+ assert len(results) == 3
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_messages_empty_store(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ msgs = await store.get_messages("nobody")
+ assert msgs == []
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_conversation_no_messages(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ convo = await store.get_conversation("x", "y")
+ assert convo == []
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_contacts_no_messages(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ contacts = await store.get_contacts("nobody")
+ assert contacts == []
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_send_returns_incrementing_ids(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ id1 = await store.send("a", "b", "first")
+ id2 = await store.send("a", "b", "second")
+ id3 = await store.send("a", "b", "third")
+ assert id1 < id2 < id3
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_message_read_defaults_to_false(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("alpha", "beta", "unread msg")
+ msgs = await store.get_messages("beta")
+ assert msgs[0]["read"] is False
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_mark_read_idempotent(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.send("beta", "alpha", "msg")
+ await store.mark_read("alpha")
+ await store.mark_read("alpha")
+ assert await store.unread_count("alpha") == 0
+ finally:
+ await store.close()
diff --git a/tests/test_auth_middleware.py b/tests/test_auth_middleware.py
new file mode 100644
index 000000000..3ff1bd078
--- /dev/null
+++ b/tests/test_auth_middleware.py
@@ -0,0 +1,269 @@
+"""Unit tests for auth_middleware allow/deny logic."""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi.responses import JSONResponse
+from starlette.responses import RedirectResponse
+
+from tinyagentos.auth_middleware import (
+ AuthMiddleware,
+ _is_exempt,
+ _is_loopback_client,
+)
+
+
+def _request(
+ *,
+ method: str = "GET",
+ path: str = "/api/system",
+ headers: dict[str, str] | None = None,
+ cookies: dict[str, str] | None = None,
+ client_host: str | None = "203.0.113.5",
+ auth_mgr: MagicMock | None = None,
+) -> MagicMock:
+ req = MagicMock()
+ req.method = method
+ req.url.path = path
+ req.headers = headers or {}
+ req.cookies = cookies or {}
+ if client_host is None:
+ req.client = None
+ else:
+ req.client = MagicMock(host=client_host)
+ req.app.state.auth = auth_mgr or MagicMock()
+ return req
+
+
+def _default_auth_mgr(*, configured: bool = True) -> MagicMock:
+ mgr = MagicMock()
+ mgr.is_configured.return_value = configured
+ mgr.validate_local_token.return_value = False
+ mgr.validate_session.return_value = None
+ mgr.get_primary_user.return_value = None
+ mgr.get_user_by_id.return_value = None
+ return mgr
+
+
+class TestIsExempt:
+ def test_exact_exempt_paths(self):
+ for path in ("/api/health", "/auth/login", "/desktop/index.html"):
+ assert _is_exempt("GET", path) is True
+
+ def test_exempt_prefixes(self):
+ assert _is_exempt("GET", "/static/app.css") is True
+ assert _is_exempt("GET", "/desktop/bundle.js") is True
+ assert _is_exempt("GET", "/ws/chat") is True
+
+ def test_auth_request_create_exempt(self):
+ assert _is_exempt("POST", "/api/agents/auth-requests") is True
+
+ def test_auth_request_status_poll_exempt(self):
+ assert _is_exempt("GET", "/api/agents/auth-requests/req-123") is True
+
+ def test_auth_request_approve_not_exempt(self):
+ assert _is_exempt("POST", "/api/agents/auth-requests/req-123/approve") is False
+
+ def test_auth_request_list_not_exempt(self):
+ assert _is_exempt("GET", "/api/agents/auth-requests") is False
+
+ def test_cluster_pairing_exempt(self):
+ assert _is_exempt("POST", "/api/cluster/pairing/announce") is True
+ assert _is_exempt("POST", "/api/cluster/pairing/claim") is True
+
+ def test_cluster_workers_and_heartbeat_exempt(self):
+ assert _is_exempt("GET", "/api/cluster/workers") is True
+ assert _is_exempt("POST", "/api/cluster/workers") is True
+ assert _is_exempt("POST", "/api/cluster/heartbeat") is True
+
+ def test_protected_api_not_exempt(self):
+ assert _is_exempt("GET", "/api/system") is False
+
+
+class TestIsLoopbackClient:
+ def test_ipv4_loopback(self):
+ assert _is_loopback_client(_request(client_host="127.0.0.1")) is True
+
+ def test_ipv6_loopback(self):
+ assert _is_loopback_client(_request(client_host="::1")) is True
+
+ def test_remote_client(self):
+ assert _is_loopback_client(_request(client_host="203.0.113.5")) is False
+
+ def test_missing_client(self):
+ assert _is_loopback_client(_request(client_host=None)) is False
+
+ def test_invalid_host(self):
+ assert _is_loopback_client(_request(client_host="not-an-ip")) is False
+
+
+class TestAuthMiddlewareDispatch:
+ @pytest.mark.asyncio
+ async def test_exempt_path_passes_without_auth(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(path="/api/health")
+ call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.via == "exempt"
+ assert req.state.user_id is None
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_unconfigured_api_returns_onboarding_401(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(path="/api/system", auth_mgr=_default_auth_mgr(configured=False))
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 401
+ assert resp.body == b'{"error":"onboarding_required","needs_onboarding":true}'
+ call_next.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_unconfigured_html_redirects_to_setup(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ path="/",
+ headers={"accept": "text/html"},
+ auth_mgr=_default_auth_mgr(configured=False),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert isinstance(resp, RedirectResponse)
+ assert resp.status_code == 303
+ assert resp.headers["location"] == "/auth/setup"
+ call_next.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_valid_session_passes(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ auth_mgr = _default_auth_mgr()
+ auth_mgr.validate_session.return_value = "user-1"
+ auth_mgr.get_user_by_id.return_value = {"id": "user-1", "is_admin": True}
+ req = _request(
+ path="/api/system",
+ cookies={"taos_session": "sess-token"},
+ auth_mgr=auth_mgr,
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.user_id == "user-1"
+ assert req.state.is_admin is True
+ assert req.state.via == "session"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_missing_session_api_returns_401(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ path="/api/system",
+ headers={"accept": "application/json"},
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 401
+ assert resp.body == b'{"error":"Authentication required"}'
+ call_next.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_missing_session_html_redirects_to_login(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ path="/settings",
+ headers={"accept": "text/html"},
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert isinstance(resp, RedirectResponse)
+ assert resp.status_code == 303
+ assert resp.headers["location"] == "/auth/login?next=/settings"
+ call_next.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_valid_local_token_with_primary_user(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ auth_mgr = _default_auth_mgr()
+ auth_mgr.validate_local_token.return_value = True
+ auth_mgr.get_primary_user.return_value = {"id": "admin-1"}
+ req = _request(
+ path="/api/system",
+ headers={"authorization": "Bearer local-secret"},
+ auth_mgr=auth_mgr,
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.user_id == "admin-1"
+ assert req.state.is_admin is True
+ assert req.state.via == "local_token"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_registry_feed_bearer_bypasses_session_gate(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ auth_mgr = _default_auth_mgr()
+ auth_mgr.validate_local_token.return_value = False
+ req = _request(
+ path="/api/agents/registry/grants",
+ headers={"authorization": "Bearer registry-jwt"},
+ auth_mgr=auth_mgr,
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"grants": []}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.via == "registry_jwt_candidate"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_prepare_shutdown_allowed_from_loopback(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ method="POST",
+ path="/api/system/prepare-shutdown",
+ client_host="127.0.0.1",
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock(return_value=JSONResponse({"status": "ready"}))
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 200
+ assert req.state.via == "loopback"
+ call_next.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_prepare_shutdown_denied_from_remote(self):
+ middleware = AuthMiddleware(app=MagicMock())
+ req = _request(
+ method="POST",
+ path="/api/system/prepare-shutdown",
+ client_host="203.0.113.5",
+ headers={"accept": "application/json"},
+ auth_mgr=_default_auth_mgr(),
+ )
+ call_next = AsyncMock()
+
+ resp = await middleware.dispatch(req, call_next)
+
+ assert resp.status_code == 401
+ call_next.assert_not_awaited()
\ No newline at end of file
diff --git a/tests/test_auth_requests_store.py b/tests/test_auth_requests_store.py
new file mode 100644
index 000000000..a5bd6c9a1
--- /dev/null
+++ b/tests/test_auth_requests_store.py
@@ -0,0 +1,253 @@
+from datetime import datetime, timedelta, timezone
+from itertools import count
+from unittest.mock import patch
+
+import pytest
+
+from tinyagentos.auth_requests_store import AuthRequestsStore
+
+
+async def _store(tmp_path):
+ s = AuthRequestsStore(tmp_path / "auth.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_create_returns_full_pending_record(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(
+ identity_claim="agent-x",
+ framework="acp",
+ requested_scopes=["read", "write"],
+ requested_skills=["skill.a", "skill.b"],
+ reason="need access",
+ duration_secs=3600,
+ project_id="proj-1",
+ )
+ assert rec["identity_claim"] == "agent-x"
+ assert rec["framework"] == "acp"
+ assert rec["requested_scopes"] == ["read", "write"]
+ assert rec["requested_skills"] == ["skill.a", "skill.b"]
+ assert rec["reason"] == "need access"
+ assert rec["duration_secs"] == 3600
+ assert rec["project_id"] == "proj-1"
+ assert rec["status"] == "pending"
+ assert rec["canonical_id"] is None
+ assert rec["token"] is None
+ assert rec["granted_scopes"] is None
+ assert rec["decided_ts"] is None
+ assert rec["decided_by"] is None
+ assert rec["id"]
+ assert rec["created_ts"]
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_create_defaults(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="b", requested_scopes=[])
+ assert rec["requested_skills"] == []
+ assert rec["reason"] == ""
+ assert rec["duration_secs"] is None
+ assert rec["project_id"] is None
+ assert rec["status"] == "pending"
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_returns_none_for_unknown_id(tmp_path):
+ s = await _store(tmp_path)
+ assert await s.get("does-not-exist") is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_roundtrip(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="x", framework="y", requested_scopes=["s1"])
+ fetched = await s.get(rec["id"])
+ assert fetched == rec
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_empty(tmp_path):
+ s = await _store(tmp_path)
+ assert await s.list_pending() == []
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_returns_only_pending_ordered_by_created_ts(tmp_path):
+ base = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+ counter = count()
+
+ def _now(tz=None):
+ return base + timedelta(seconds=next(counter))
+
+ with patch("tinyagentos.auth_requests_store.datetime") as mock_dt:
+ mock_dt.now.side_effect = _now
+ mock_dt.timezone = timezone
+ mock_dt.timedelta = timedelta
+ s = await _store(tmp_path)
+ r1 = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ r2 = await s.create(identity_claim="b", framework="f", requested_scopes=[])
+ r3 = await s.create(identity_claim="c", framework="f", requested_scopes=[])
+ await s.set_decision(r2["id"], "accepted", canonical_id="cid", token="t", decided_by="admin")
+ pending = await s.list_pending()
+ ids = [r["id"] for r in pending]
+ assert ids == [r1["id"], r3["id"]]
+ assert all(r["status"] == "pending" for r in pending)
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_count_pending_for(tmp_path):
+ s = await _store(tmp_path)
+ await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ await s.create(identity_claim="a", framework="g", requested_scopes=[])
+ await s.create(identity_claim="b", framework="f", requested_scopes=[])
+ assert await s.count_pending_for("a", "f") == 2
+ assert await s.count_pending_for("a", "g") == 1
+ assert await s.count_pending_for("b", "f") == 1
+ assert await s.count_pending_for("z", "f") == 0
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_count_pending_for_excludes_decided(tmp_path):
+ s = await _store(tmp_path)
+ r1 = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ r2 = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ await s.set_decision(r1["id"], "accepted", canonical_id="c", token="t", decided_by="admin")
+ assert await s.count_pending_for("a", "f") == 1
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_accepts_pending(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=["read"])
+ decided = await s.set_decision(
+ rec["id"], "accepted",
+ canonical_id="canonical-1", token="jwt-token",
+ granted_scopes=["read"], decided_by="admin",
+ )
+ assert decided is not None
+ assert decided["status"] == "accepted"
+ assert decided["canonical_id"] == "canonical-1"
+ assert decided["token"] == "jwt-token"
+ assert decided["granted_scopes"] == ["read"]
+ assert decided["decided_by"] == "admin"
+ assert decided["decided_ts"]
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_refuses_pending(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ decided = await s.set_decision(rec["id"], "refused", decided_by="admin")
+ assert decided["status"] == "refused"
+ assert decided["canonical_id"] is None
+ assert decided["token"] is None
+ assert decided["granted_scopes"] is None
+ assert decided["decided_by"] == "admin"
+ assert decided["decided_ts"]
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_invalid_status_raises(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ with pytest.raises(ValueError, match="accepted"):
+ await s.set_decision(rec["id"], "bogus", decided_by="admin")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_already_decided_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ first = await s.set_decision(rec["id"], "accepted", canonical_id="c", token="t", decided_by="admin")
+ second = await s.set_decision(rec["id"], "refused", decided_by="admin")
+ assert first is not None
+ assert second is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_unknown_id_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ result = await s.set_decision("no-such-id", "accepted", decided_by="admin")
+ assert result is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_concurrent_winner(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ r1 = await s.set_decision(rec["id"], "accepted", canonical_id="c1", token="t1", decided_by="one")
+ r2 = await s.set_decision(rec["id"], "refused", decided_by="two")
+ assert r1 is not None
+ assert r2 is None
+ final = await s.get(rec["id"])
+ assert final["status"] == "accepted"
+ assert final["canonical_id"] == "c1"
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_decision_without_granted_scopes_null(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(identity_claim="a", framework="f", requested_scopes=[])
+ decided = await s.set_decision(rec["id"], "accepted", decided_by="admin")
+ assert decided["granted_scopes"] is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_create_then_get_then_decide_roundtrip(tmp_path):
+ s = await _store(tmp_path)
+ rec = await s.create(
+ identity_claim="agent-z",
+ framework="acp",
+ requested_scopes=["a", "b"],
+ requested_skills=["sk"],
+ reason="r",
+ duration_secs=60,
+ project_id="p",
+ )
+ fetched = await s.get(rec["id"])
+ assert fetched["status"] == "pending"
+ decided = await s.set_decision(
+ rec["id"], "accepted",
+ canonical_id="cid", token="tok", granted_scopes=["a"], decided_by="u",
+ )
+ final = await s.get(rec["id"])
+ assert final == decided
+ assert final["status"] == "accepted"
+ assert final["granted_scopes"] == ["a"]
+ pending = await s.list_pending()
+ assert rec["id"] not in [r["id"] for r in pending]
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_uninitialised_store_raises(tmp_path):
+ s = AuthRequestsStore(tmp_path / "auth.db")
+ with pytest.raises(RuntimeError, match="init"):
+ await s.create(identity_claim="a", framework="b", requested_scopes=[])
+ with pytest.raises(RuntimeError, match="init"):
+ await s.get("x")
+ with pytest.raises(RuntimeError, match="init"):
+ await s.list_pending()
+ with pytest.raises(RuntimeError, match="init"):
+ await s.count_pending_for("a", "b")
+ with pytest.raises(RuntimeError, match="init"):
+ await s.set_decision("x", "accepted", decided_by="a")
+ await s.close()
diff --git a/tests/test_benchmark_runner.py b/tests/test_benchmark_runner.py
new file mode 100644
index 000000000..a6a9890d2
--- /dev/null
+++ b/tests/test_benchmark_runner.py
@@ -0,0 +1,679 @@
+"""Unit tests for the benchmark runner.
+
+Covers the pure-logic paths in BenchmarkRunner: result aggregation,
+unit mapping, error handling, timeout behaviour, and per-capability
+handler parsing/scoring. Every external dependency (model calls,
+network, filesystem, time) is mocked.
+"""
+from __future__ import annotations
+
+import asyncio
+import statistics
+import time
+from typing import Optional
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from tinyagentos.benchmark.runner import (
+ BackendNotAvailable,
+ BenchmarkRunner,
+ _bench_embedding,
+ _bench_image_generation,
+ _bench_llm_chat,
+ _bench_reranking,
+ _fake_doc,
+)
+from tinyagentos.benchmark.suite import BenchmarkSuite, Metric, SuiteResult, SuiteTask
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_task(
+ *,
+ capability: str = "embedding",
+ model: str = "test-model",
+ metric: Metric = Metric.DOCS_PER_SEC,
+ timeout: float = 10.0,
+ optional: bool = False,
+ workload: dict | None = None,
+ task_id: str = "test-task",
+) -> SuiteTask:
+ return SuiteTask(
+ id=task_id,
+ capability=capability,
+ model=model,
+ metric=metric,
+ description="test task",
+ workload=workload or {},
+ timeout_seconds=timeout,
+ optional=optional,
+ )
+
+
+def _make_suite(*tasks: SuiteTask) -> BenchmarkSuite:
+ return BenchmarkSuite(name="test", description="test suite", tasks=list(tasks))
+
+
+def _mock_json_response(json_data: dict, status_code: int = 200) -> MagicMock:
+ resp = MagicMock()
+ resp.status_code = status_code
+ resp.json.return_value = json_data
+ resp.raise_for_status = MagicMock()
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# _unit_for
+# ---------------------------------------------------------------------------
+
+
+class TestUnitFor:
+ @pytest.mark.parametrize(
+ "metric,expected",
+ [
+ (Metric.DOCS_PER_SEC, "docs/s"),
+ (Metric.TOKENS_PER_SEC, "tok/s"),
+ (Metric.SECONDS_PER_STEP, "s/step"),
+ (Metric.SECONDS_PER_IMAGE, "s/image"),
+ (Metric.RTF, "realtime"),
+ (Metric.LATENCY_MS_P50, "ms"),
+ (Metric.LATENCY_MS_P95, "ms"),
+ ],
+ )
+ def test_unit_for(self, metric: Metric, expected: str):
+ assert BenchmarkRunner._unit_for(metric) == expected
+
+
+# ---------------------------------------------------------------------------
+# _fake_doc
+# ---------------------------------------------------------------------------
+
+
+class TestFakeDoc:
+ def test_word_count(self):
+ doc = _fake_doc(avg_tokens=20)
+ words = doc.split()
+ assert len(words) == 20
+
+ def test_non_empty(self):
+ doc = _fake_doc(avg_tokens=5)
+ assert len(doc) > 0
+
+ def test_deterministic_with_seed(self):
+ import random
+ random.seed(42)
+ a = _fake_doc(10)
+ random.seed(42)
+ b = _fake_doc(10)
+ assert a == b
+
+
+# ---------------------------------------------------------------------------
+# BenchmarkRunner.run() — resolver returns None (skipped)
+# ---------------------------------------------------------------------------
+
+
+class TestRunnerRun:
+ @pytest.mark.asyncio
+ async def test_resolver_none_skips_task(self):
+ task = _make_task(capability="embedding", model="missing-model")
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(suite=suite, resolver=lambda cap, model: None)
+
+ results = await runner.run()
+
+ assert len(results) == 1
+ r = results[0]
+ assert r.status == "skipped"
+ assert r.value is None
+ assert r.task_id == "test-task"
+ assert r.capability == "embedding"
+ assert r.model == "missing-model"
+ assert "no local backend" in r.error
+
+ @pytest.mark.asyncio
+ async def test_unknown_capability_error(self):
+ task = _make_task(capability="unknown-cap", model="m")
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(
+ suite=suite,
+ resolver=lambda cap, model: ("http://localhost:8080", "test"),
+ )
+
+ results = await runner.run()
+
+ assert len(results) == 1
+ r = results[0]
+ assert r.status == "error"
+ assert r.value is None
+ assert "no benchmark handler" in r.error
+
+ @pytest.mark.asyncio
+ async def test_unknown_capability_optional_skips(self):
+ task = _make_task(capability="unknown-cap", model="m", optional=True)
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(
+ suite=suite,
+ resolver=lambda cap, model: ("http://localhost:8080", "test"),
+ )
+
+ results = await runner.run()
+
+ assert len(results) == 1
+ r = results[0]
+ assert r.status == "skipped"
+ assert r.value is None
+
+ @pytest.mark.asyncio
+ async def test_multiple_tasks_aggregated(self):
+ async def fake_handler(*, client, backend_url, backend_type, task):
+ return 1.0, {}
+
+ tasks = [
+ _make_task(task_id="t1", capability="embedding", model="m1"),
+ _make_task(task_id="t2", capability="embedding", model="m2"),
+ _make_task(task_id="t3", capability="embedding", model="m3"),
+ ]
+ suite = _make_suite(*tasks)
+ # Only resolve m2
+ runner = BenchmarkRunner(
+ suite=suite,
+ resolver=lambda cap, model: (
+ ("http://localhost:8080", "test") if model == "m2" else None
+ ),
+ )
+
+ with patch.dict(
+ "tinyagentos.benchmark.runner._HANDLERS",
+ {"embedding": fake_handler},
+ clear=False,
+ ):
+ results = await runner.run()
+
+ assert len(results) == 3
+ assert results[0].status == "skipped"
+ assert results[1].status == "ok"
+ assert results[2].status == "skipped"
+
+ @pytest.mark.asyncio
+ async def test_result_has_correct_unit(self):
+ task = _make_task(metric=Metric.TOKENS_PER_SEC)
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(suite=suite, resolver=lambda cap, model: None)
+
+ results = await runner.run()
+
+ assert results[0].unit == "tok/s"
+
+ @pytest.mark.asyncio
+ async def test_elapsed_seconds_populated(self):
+ task = _make_task()
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(suite=suite, resolver=lambda cap, model: None)
+
+ t0 = time.monotonic()
+ results = await runner.run()
+ t1 = time.monotonic()
+
+ assert results[0].elapsed_seconds >= 0
+ assert results[0].elapsed_seconds <= (t1 - t0) + 0.1
+
+
+# ---------------------------------------------------------------------------
+# BenchmarkRunner.run() — timeout
+# ---------------------------------------------------------------------------
+
+
+class TestRunnerTimeout:
+ @pytest.mark.asyncio
+ async def test_timeout_produces_timeout_result(self):
+ task = _make_task(timeout=0.01)
+ suite = _make_suite(task)
+
+ async def slow_handler(*, client, backend_url, backend_type, task):
+ await asyncio.sleep(10.0)
+ return 1.0, {}
+
+ with patch.dict(
+ "tinyagentos.benchmark.runner._HANDLERS",
+ {"embedding": slow_handler},
+ clear=False,
+ ):
+ runner = BenchmarkRunner(
+ suite=suite,
+ resolver=lambda cap, model: ("http://localhost:8080", "test"),
+ )
+ results = await runner.run()
+
+ assert len(results) == 1
+ r = results[0]
+ assert r.status == "timeout"
+ assert r.value is None
+ assert "exceeded" in r.error
+
+
+# ---------------------------------------------------------------------------
+# _run_one — BackendNotAvailable
+# ---------------------------------------------------------------------------
+
+
+class TestRunOne:
+ @pytest.mark.asyncio
+ async def test_backend_not_available_raises(self):
+ task = _make_task()
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(suite=suite, resolver=lambda cap, model: None)
+
+ with pytest.raises(BackendNotAvailable):
+ await runner._run_one(task)
+
+ @pytest.mark.asyncio
+ async def test_unknown_capability_raises(self):
+ task = _make_task(capability="nonexistent")
+ suite = _make_suite(task)
+ runner = BenchmarkRunner(
+ suite=suite,
+ resolver=lambda cap, model: ("http://localhost:8080", "test"),
+ )
+
+ with pytest.raises(RuntimeError, match="no benchmark handler"):
+ await runner._run_one(task)
+
+
+# ---------------------------------------------------------------------------
+# Handler: _bench_embedding
+# ---------------------------------------------------------------------------
+
+
+class TestBenchEmbedding:
+ @pytest.mark.asyncio
+ async def test_embedding_throughput(self):
+ resp = _mock_json_response(
+ {"data": [{"embedding": [0.1, 0.2]} for _ in range(50)]}
+ )
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="embedding",
+ model="test-embed",
+ metric=Metric.DOCS_PER_SEC,
+ workload={"num_docs": 50, "avg_tokens_per_doc": 8},
+ )
+
+ monotonic_vals = [0.0, 1.5]
+ with (
+ patch("tinyagentos.benchmark.runner._fake_doc", return_value="fake doc"),
+ patch("tinyagentos.benchmark.runner.time") as mock_time,
+ ):
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_embedding(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert value == pytest.approx(50 / 1.5)
+ assert details["num_docs"] == 50
+ assert details["received"] == 50
+ assert details["wall_seconds"] == 1.5
+
+ @pytest.mark.asyncio
+ async def test_embedding_empty_response(self):
+ resp = _mock_json_response({"data": []})
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="embedding",
+ model="test-embed",
+ workload={"num_docs": 10, "avg_tokens_per_doc": 4},
+ )
+
+ monotonic_vals = [0.0, 1.0]
+ with (
+ patch("tinyagentos.benchmark.runner._fake_doc", return_value="fake"),
+ patch("tinyagentos.benchmark.runner.time") as mock_time,
+ ):
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_embedding(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert value == 0.0
+ assert details["received"] == 0
+
+ @pytest.mark.asyncio
+ async def test_embedding_non_dict_response(self):
+ resp = _mock_json_response(["not", "a", "dict"])
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="embedding",
+ model="test-embed",
+ workload={"num_docs": 5, "avg_tokens_per_doc": 4},
+ )
+
+ monotonic_vals = [0.0, 1.0]
+ with (
+ patch("tinyagentos.benchmark.runner._fake_doc", return_value="fake"),
+ patch("tinyagentos.benchmark.runner.time") as mock_time,
+ ):
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_embedding(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert value == 0.0
+ assert details["received"] == 0
+
+
+# ---------------------------------------------------------------------------
+# Handler: _bench_reranking
+# ---------------------------------------------------------------------------
+
+
+class TestBenchReranking:
+ @pytest.mark.asyncio
+ async def test_rerank_p50(self):
+ resp = _mock_json_response({"results": [{"index": 0, "score": 0.9}]})
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="reranking",
+ model="test-reranker",
+ metric=Metric.LATENCY_MS_P50,
+ workload={"num_queries": 5, "candidates_per_query": 10},
+ )
+
+ # Each query calls monotonic twice (start + elapsed), so 5 queries = 10 calls
+ monotonic_vals = [float(i) for i in range(10)]
+ with (
+ patch("tinyagentos.benchmark.runner._fake_doc", return_value="fake doc"),
+ patch("tinyagentos.benchmark.runner.time") as mock_time,
+ ):
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_reranking(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert value > 0
+ assert details["num_queries"] == 5
+ assert details["candidates_per_query"] == 10
+ assert "p50_ms" in details
+ assert "p95_ms" in details
+
+
+# ---------------------------------------------------------------------------
+# Handler: _bench_llm_chat
+# ---------------------------------------------------------------------------
+
+
+class TestBenchLlmChat:
+ @pytest.mark.asyncio
+ async def test_llm_chat_tokens_per_sec(self):
+ resp = _mock_json_response(
+ {
+ "choices": [{"message": {"content": "Hello world"}}],
+ "usage": {"completion_tokens": 100},
+ }
+ )
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="llm-chat",
+ model="test-llm",
+ metric=Metric.TOKENS_PER_SEC,
+ workload={"prompt": "Say hello.", "max_tokens": 128},
+ )
+
+ monotonic_vals = [0.0, 2.0]
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_llm_chat(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert value == pytest.approx(100 / 2.0)
+ assert details["completion_tokens"] == 100
+ assert details["max_tokens"] == 128
+ assert details["wall_seconds"] == 2.0
+
+ @pytest.mark.asyncio
+ async def test_llm_chat_fallback_token_estimate(self):
+ resp = _mock_json_response(
+ {
+ "choices": [{"message": {"content": "one two three four five"}}],
+ "usage": {},
+ }
+ )
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="llm-chat",
+ model="test-llm",
+ workload={"prompt": "test", "max_tokens": 64},
+ )
+
+ monotonic_vals = [0.0, 1.0]
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_llm_chat(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ assert details["completion_tokens"] == 5
+ assert value == 5.0
+
+ @pytest.mark.asyncio
+ async def test_llm_chat_zero_tokens_fallback(self):
+ resp = _mock_json_response(
+ {
+ "choices": [{"message": {"content": ""}}],
+ "usage": {"completion_tokens": 0},
+ }
+ )
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="llm-chat",
+ model="test-llm",
+ workload={"prompt": "test", "max_tokens": 64},
+ )
+
+ monotonic_vals = [0.0, 1.0]
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_llm_chat(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="test",
+ task=task,
+ )
+
+ # Empty content -> fallback: max(1, len("".split())) = max(1, 0) = 1
+ assert details["completion_tokens"] == 1
+ assert value == 1.0
+
+
+# ---------------------------------------------------------------------------
+# Handler: _bench_image_generation
+# ---------------------------------------------------------------------------
+
+
+class TestBenchImageGeneration:
+ @pytest.mark.asyncio
+ async def test_image_gen_default_backend(self):
+ resp = _mock_json_response({"data": [{"url": "http://img.example/1.png"}]})
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="image-generation",
+ model="sd-model",
+ metric=Metric.SECONDS_PER_IMAGE,
+ workload={"size": "256x256", "steps": 4, "prompt": "benchmark"},
+ )
+
+ monotonic_vals = [0.0, 3.5]
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_image_generation(
+ client=client,
+ backend_url="http://localhost:8080",
+ backend_type="ollama",
+ task=task,
+ )
+
+ assert value == pytest.approx(3.5)
+ assert details["size"] == "256x256"
+ assert details["steps"] == 4
+
+ @pytest.mark.asyncio
+ async def test_image_gen_sd_cpp_backend(self):
+ resp = _mock_json_response({"images": ["base64data"]})
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="image-generation",
+ model="sd-model",
+ workload={"size": "512x512", "steps": 8, "prompt": "test"},
+ )
+
+ monotonic_vals = [0.0, 5.0]
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=monotonic_vals)
+ value, details = await _bench_image_generation(
+ client=client,
+ backend_url="http://localhost:7864",
+ backend_type="sd-cpp",
+ task=task,
+ )
+
+ assert value == pytest.approx(5.0)
+ assert details["size"] == "512x512"
+ assert details["steps"] == 8
+
+ # Verify sd-cpp uses the correct endpoint
+ call_args = client.post.call_args
+ assert "/sdapi/v1/txt2img" in call_args[0][0]
+
+ @pytest.mark.asyncio
+ async def test_image_gen_payload_contains_cfg_and_seed_for_sd_cpp(self):
+ resp = _mock_json_response({"images": ["base64data"]})
+ client = AsyncMock()
+ client.post = AsyncMock(return_value=resp)
+
+ task = _make_task(
+ capability="image-generation",
+ model="sd-model",
+ workload={"size": "256x256", "steps": 2, "prompt": "bench"},
+ )
+
+ with patch("tinyagentos.benchmark.runner.time") as mock_time:
+ mock_time.monotonic = MagicMock(side_effect=[0.0, 1.0])
+ await _bench_image_generation(
+ client=client,
+ backend_url="http://localhost:7864",
+ backend_type="sd-cpp",
+ task=task,
+ )
+
+ call_kwargs = client.post.call_args
+ payload = call_kwargs[1]["json"]
+ assert payload["cfg_scale"] == 1.0
+ assert payload["seed"] == 42
+ assert payload["sampler_name"] == "euler_a"
+
+
+# ---------------------------------------------------------------------------
+# SuiteResult.to_dict
+# ---------------------------------------------------------------------------
+
+
+class TestSuiteResult:
+ def test_to_dict(self):
+ result = SuiteResult(
+ task_id="t1",
+ capability="embedding",
+ model="m",
+ metric=Metric.DOCS_PER_SEC,
+ value=42.5,
+ unit="docs/s",
+ status="ok",
+ elapsed_seconds=1.2,
+ error=None,
+ measured_at=1000.0,
+ details={"num_docs": 50},
+ )
+ d = result.to_dict()
+ assert d["task_id"] == "t1"
+ assert d["capability"] == "embedding"
+ assert d["model"] == "m"
+ assert d["metric"] == "docs_per_sec"
+ assert d["value"] == 42.5
+ assert d["unit"] == "docs/s"
+ assert d["status"] == "ok"
+ assert d["elapsed_seconds"] == 1.2
+ assert d["error"] is None
+ assert d["measured_at"] == 1000.0
+ assert d["details"] == {"num_docs": 50}
+
+ def test_to_dict_none_value(self):
+ result = SuiteResult(
+ task_id="t2",
+ capability="embedding",
+ model="m",
+ metric=Metric.DOCS_PER_SEC,
+ value=None,
+ unit="docs/s",
+ status="skipped",
+ elapsed_seconds=0.0,
+ error="no backend",
+ )
+ d = result.to_dict()
+ assert d["value"] is None
+ assert d["status"] == "skipped"
+ assert d["error"] == "no backend"
+
+
+# ---------------------------------------------------------------------------
+# BackendNotAvailable
+# ---------------------------------------------------------------------------
+
+
+class TestBackendNotAvailable:
+ def test_is_runtime_error(self):
+ exc = BackendNotAvailable("test message")
+ assert isinstance(exc, RuntimeError)
+ assert str(exc) == "test message"
+
+ def test_caught_as_runtime_error(self):
+ with pytest.raises(RuntimeError):
+ raise BackendNotAvailable("x")
diff --git a/tests/test_coding_workspaces.py b/tests/test_coding_workspaces.py
new file mode 100644
index 000000000..ff418d876
--- /dev/null
+++ b/tests/test_coding_workspaces.py
@@ -0,0 +1,202 @@
+import pytest
+import pytest_asyncio
+
+
+@pytest_asyncio.fixture
+async def coding_client(app, client):
+ store = app.state.coding_workspaces
+ if store._db is not None:
+ await store.close()
+ await store.init()
+ yield client, app
+
+
+@pytest.mark.asyncio
+async def test_create_list_git_repo(coding_client):
+ client, app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "demo"})
+ assert r.status_code == 200, r.text
+ ws = r.json()
+ assert ws["id"]
+ assert ws["name"] == "demo"
+ assert (app.state.data_dir / "coding-workspaces" / ws["id"]).is_dir()
+ assert (app.state.data_dir / "coding-workspaces" / ws["id"] / ".git").exists()
+
+ r = await client.get("/api/coding/workspaces")
+ assert r.status_code == 200
+ assert any(row["id"] == ws["id"] for row in r.json())
+
+
+@pytest.mark.asyncio
+async def test_write_read_and_tree(coding_client):
+ client, _app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "files"})
+ ws = r.json()
+
+ r = await client.put(
+ f"/api/coding/workspaces/{ws['id']}/file",
+ json={"path": "src/hello.txt", "content": "hello world"},
+ )
+ assert r.status_code == 200, r.text
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/file", params={"path": "src/hello.txt"})
+ assert r.status_code == 200
+ assert r.json()["content"] == "hello world"
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/files", params={"subpath": "src"})
+ assert r.status_code == 200
+ names = {e["name"]: e["is_dir"] for e in r.json()}
+ assert names["hello.txt"] is False
+
+
+@pytest.mark.asyncio
+async def test_path_traversal_rejected(coding_client):
+ client, _app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "jail"})
+ ws = r.json()
+ wid = ws["id"]
+
+ for params in (
+ {"path": "../secret"},
+ {"path": "/etc/passwd"},
+ {"subpath": ".."},
+ {"subpath": "/etc"},
+ ):
+ if "path" in params:
+ r = await client.get(f"/api/coding/workspaces/{wid}/file", params=params)
+ else:
+ r = await client.get(f"/api/coding/workspaces/{wid}/files", params=params)
+ assert r.status_code == 400, params
+
+ r = await client.put(
+ f"/api/coding/workspaces/{wid}/file",
+ json={"path": "../escape.txt", "content": "nope"},
+ )
+ assert r.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_delete_removes_workspace(coding_client):
+ client, app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "temp"})
+ ws = r.json()
+ workspace_dir = app.state.data_dir / "coding-workspaces" / ws["id"]
+ assert workspace_dir.is_dir()
+
+ r = await client.delete(f"/api/coding/workspaces/{ws['id']}")
+ assert r.status_code == 200
+
+ r = await client.get("/api/coding/workspaces")
+ assert all(row["id"] != ws["id"] for row in r.json())
+ assert not workspace_dir.exists()
+
+
+@pytest.mark.asyncio
+async def test_list_root_files(coding_client):
+ client, _app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "root-list"})
+ ws = r.json()
+
+ r = await client.put(
+ f"/api/coding/workspaces/{ws['id']}/file",
+ json={"path": "readme.txt", "content": "hi"},
+ )
+ assert r.status_code == 200
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/files")
+ assert r.status_code == 200
+ names = {e["name"] for e in r.json()}
+ assert "readme.txt" in names
+
+
+@pytest.mark.asyncio
+async def test_read_binary_file_rejected(coding_client):
+ client, app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "binary"})
+ ws = r.json()
+ workspace_dir = app.state.data_dir / "coding-workspaces" / ws["id"]
+ (workspace_dir / "blob.bin").write_bytes(b"\xff\xfe\x00\x01")
+
+ r = await client.get(
+ f"/api/coding/workspaces/{ws['id']}/file",
+ params={"path": "blob.bin"},
+ )
+ assert r.status_code == 400
+ assert r.json()["error"] == "binary_or_undecodable"
+
+
+@pytest.mark.asyncio
+async def test_list_root_empty_workspace(coding_client):
+ client, _app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "fresh-root"})
+ ws = r.json()
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/files")
+ assert r.status_code == 200
+ names = {e["name"] for e in r.json()}
+ assert ".git" in names
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/files", params={"subpath": ""})
+ assert r.status_code == 200
+
+ r = await client.get(f"/api/coding/workspaces/{ws['id']}/files", params={"subpath": "."})
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_read_oversized_file_rejected(coding_client):
+ client, app = coding_client
+ r = await client.post("/api/coding/workspaces", json={"name": "bigfile"})
+ ws = r.json()
+ workspace_dir = app.state.data_dir / "coding-workspaces" / ws["id"]
+ (workspace_dir / "huge.txt").write_bytes(b"x" * (2_000_001))
+
+ r = await client.get(
+ f"/api/coding/workspaces/{ws['id']}/file",
+ params={"path": "huge.txt"},
+ )
+ assert r.status_code == 400
+ assert r.json()["error"] == "file too large"
+
+
+@pytest.mark.asyncio
+async def test_git_init_failure_no_orphan_dir(coding_client, monkeypatch):
+ client, app = coding_client
+
+ async def _fake_git_init(self, workspace_dir):
+ raise RuntimeError("git init failed")
+
+ monkeypatch.setattr(
+ app.state.coding_workspaces.__class__, "_git_init", _fake_git_init
+ )
+
+ r = await client.post("/api/coding/workspaces", json={"name": "broken-git"})
+ assert r.status_code == 503
+
+ rows = app.state.coding_workspaces
+ listed = await rows.list()
+ assert all(row["name"] != "broken-git" for row in listed)
+
+ # The store rmtree's the workspace dir when git init fails, so no orphan dir
+ # (named by its generated id, never "broken-git") is left behind.
+ workspace_dirs = [
+ d for d in (app.state.data_dir / "coding-workspaces").iterdir() if d.is_dir()
+ ]
+ assert workspace_dirs == []
+
+
+@pytest.mark.asyncio
+async def test_unknown_workspace_returns_404(coding_client):
+ client, _app = coding_client
+ wid = "cws-nosuch"
+ r = await client.get(f"/api/coding/workspaces/{wid}/files")
+ assert r.status_code == 404
+ r = await client.get(f"/api/coding/workspaces/{wid}/file", params={"path": "a.txt"})
+ assert r.status_code == 404
+ r = await client.put(
+ f"/api/coding/workspaces/{wid}/file",
+ json={"path": "a.txt", "content": "x"},
+ )
+ assert r.status_code == 404
+ r = await client.delete(f"/api/coding/workspaces/{wid}")
+ assert r.status_code == 404
\ No newline at end of file
diff --git a/tests/test_desktop_rebuild.py b/tests/test_desktop_rebuild.py
index 80bf95f75..13e2e2e98 100644
--- a/tests/test_desktop_rebuild.py
+++ b/tests/test_desktop_rebuild.py
@@ -204,3 +204,61 @@ async def fake_exec(*args, **kwargs):
assert result.rebuilt is True
assert result.success is True
assert "successfully" in result.message.lower()
+
+
+# ---------------------------------------------------------------------------
+# npm-install gate: only reinstall when package-lock.json changes
+# ---------------------------------------------------------------------------
+
+from tinyagentos.desktop_rebuild import (
+ _deps_install_needed,
+ _record_deps_install,
+ _lockfile_hash,
+)
+
+
+def _mk_desktop(tmp_path, *, lock="{}", node_modules=True):
+ d = tmp_path / "desktop"
+ d.mkdir(parents=True, exist_ok=True)
+ if lock is not None:
+ (d / "package-lock.json").write_text(lock)
+ if node_modules:
+ (d / "node_modules").mkdir(exist_ok=True)
+ return d
+
+
+def test_deps_needed_when_node_modules_missing(tmp_path):
+ d = _mk_desktop(tmp_path, node_modules=False)
+ assert _deps_install_needed(d) is True
+
+
+def test_deps_needed_when_no_lockfile(tmp_path):
+ d = _mk_desktop(tmp_path, lock=None)
+ assert _deps_install_needed(d) is True
+
+
+def test_deps_needed_when_no_marker(tmp_path):
+ """node_modules + lockfile but never recorded → must install."""
+ d = _mk_desktop(tmp_path)
+ assert _deps_install_needed(d) is True
+
+
+def test_deps_skipped_after_record(tmp_path):
+ d = _mk_desktop(tmp_path, lock='{"v":1}')
+ _record_deps_install(d)
+ assert _deps_install_needed(d) is False
+
+
+def test_deps_needed_again_when_lockfile_changes(tmp_path):
+ d = _mk_desktop(tmp_path, lock='{"v":1}')
+ _record_deps_install(d)
+ assert _deps_install_needed(d) is False
+ # A dependency bump rewrites package-lock.json → hash changes → reinstall.
+ (d / "package-lock.json").write_text('{"v":2}')
+ assert _deps_install_needed(d) is True
+
+
+def test_lockfile_hash_none_without_file(tmp_path):
+ d = tmp_path / "desktop"
+ d.mkdir()
+ assert _lockfile_hash(d) is None
diff --git a/tests/test_docs_only_update.py b/tests/test_docs_only_update.py
index 626d18644..4962bea5c 100644
--- a/tests/test_docs_only_update.py
+++ b/tests/test_docs_only_update.py
@@ -49,11 +49,15 @@ def repo(tmp_path):
def test_is_documentation_path():
assert is_documentation_path("docs/STATUS.md") is True
assert is_documentation_path("README.md") is True
- assert is_documentation_path("notes.txt") is True
assert is_documentation_path("guide.rst") is True
+ assert is_documentation_path("docs/notes.txt") is True
assert is_documentation_path("tinyagentos/foo.py") is False
assert is_documentation_path("docs/scripts/foo.py") is False
assert is_documentation_path("docs/config.yaml") is False
+ # A root-level .txt is ambiguous (e.g. requirements.txt / constraints.txt);
+ # fail safe to "not docs" so a real dependency change is never suppressed.
+ assert is_documentation_path("notes.txt") is False
+ assert is_documentation_path("requirements.txt") is False
def test_changes_are_docs_only_true_for_docs_dir(repo):
diff --git a/tests/test_feedback_route.py b/tests/test_feedback_route.py
new file mode 100644
index 000000000..ea98ff8de
--- /dev/null
+++ b/tests/test_feedback_route.py
@@ -0,0 +1,155 @@
+"""Tests for POST/GET /api/feedback endpoints."""
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+
+from tinyagentos.feedback_store import FeedbackStore, MAX_SCREENSHOT_LEN
+
+
+# ---------------------------------------------------------------------------
+# Store-level unit tests
+# ---------------------------------------------------------------------------
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ s = FeedbackStore(tmp_path / "feedback.db")
+ await s.init()
+ yield s
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_store_create_and_list(store):
+ item = await store.create(
+ user_id="u1",
+ type="bug",
+ title="Something broke",
+ body="Details here",
+ )
+ assert item["id"]
+ assert item["type"] == "bug"
+ assert item["created_at"]
+
+ items = await store.list_for_user("u1")
+ assert len(items) == 1
+ assert items[0]["id"] == item["id"]
+ # List endpoint must NOT include the screenshot blob
+ assert "screenshot" not in items[0]
+ assert "has_screenshot" in items[0]
+ assert items[0]["has_screenshot"] is False
+
+
+@pytest.mark.asyncio
+async def test_store_get_by_id_includes_screenshot(store):
+ await store.create(
+ user_id="u1",
+ type="feature",
+ title="Dark mode",
+ body="",
+ screenshot="data:image/png;base64,abc123",
+ )
+ items = await store.list_for_user("u1")
+ full = await store.get_by_id(items[0]["id"], "u1")
+ assert full is not None
+ assert full["screenshot"] == "data:image/png;base64,abc123"
+ assert full["has_screenshot"] is True
+
+
+@pytest.mark.asyncio
+async def test_store_user_isolation(store):
+ await store.create(user_id="u1", type="bug", title="User 1 bug", body="")
+ await store.create(user_id="u2", type="feature", title="User 2 feature", body="")
+
+ u1_items = await store.list_for_user("u1")
+ u2_items = await store.list_for_user("u2")
+ assert len(u1_items) == 1
+ assert len(u2_items) == 1
+ assert u1_items[0]["title"] == "User 1 bug"
+ assert u2_items[0]["title"] == "User 2 feature"
+
+
+# ---------------------------------------------------------------------------
+# Route-level tests via the async HTTP client
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_post_feedback_creates_submission(client):
+ resp = await client.post(
+ "/api/feedback",
+ json={"type": "bug", "title": "Login fails", "body": "Cannot sign in"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["type"] == "bug"
+ assert data["title"] == "Login fails"
+ assert "id" in data
+ assert "created_at" in data
+ assert "screenshot" not in data
+ assert data["has_screenshot"] is False
+
+
+@pytest.mark.asyncio
+async def test_get_feedback_lists_submissions(client):
+ await client.post(
+ "/api/feedback",
+ json={"type": "feature", "title": "Add dark mode", "body": ""},
+ )
+ resp = await client.get("/api/feedback")
+ assert resp.status_code == 200
+ items = resp.json()
+ assert len(items) >= 1
+ assert items[0]["title"] == "Add dark mode"
+ assert "screenshot" not in items[0]
+
+
+@pytest.mark.asyncio
+async def test_get_feedback_by_id_returns_screenshot(client):
+ screenshot = "data:image/png;base64," + "A" * 100
+ post_resp = await client.post(
+ "/api/feedback",
+ json={"type": "bug", "title": "Visual glitch", "body": "", "screenshot": screenshot},
+ )
+ item_id = post_resp.json()["id"]
+
+ resp = await client.get(f"/api/feedback/{item_id}")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["screenshot"] == screenshot
+ assert data["has_screenshot"] is True
+
+
+@pytest.mark.asyncio
+async def test_invalid_type_rejected(client):
+ resp = await client.post(
+ "/api/feedback",
+ json={"type": "complaint", "title": "Bad type", "body": ""},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_empty_title_rejected(client):
+ resp = await client.post(
+ "/api/feedback",
+ json={"type": "bug", "title": " ", "body": ""},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_oversized_screenshot_rejected(client):
+ big_screenshot = "data:image/png;base64," + "A" * (MAX_SCREENSHOT_LEN + 1)
+ resp = await client.post(
+ "/api/feedback",
+ json={"type": "bug", "title": "Big screenshot", "body": "", "screenshot": big_screenshot},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_get_unknown_id_returns_404(client):
+ resp = await client.get("/api/feedback/does-not-exist")
+ assert resp.status_code == 404
diff --git a/tests/test_frameworks.py b/tests/test_frameworks.py
new file mode 100644
index 000000000..b52d20331
--- /dev/null
+++ b/tests/test_frameworks.py
@@ -0,0 +1,386 @@
+"""Unit tests for tinyagentos/frameworks.py registry integrity and validation edge cases."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.frameworks import (
+ FRAMEWORKS,
+ FrameworkManifestError,
+ validate_framework_manifest,
+)
+from tinyagentos.shortcuts.validation import validate_shortcuts
+
+
+# ---------------------------------------------------------------------------
+# FrameworkManifestError hierarchy
+# ---------------------------------------------------------------------------
+
+def test_framework_manifest_error_is_value_error():
+ assert issubclass(FrameworkManifestError, ValueError)
+
+
+def test_framework_manifest_error_caught_as_value_error():
+ with pytest.raises(ValueError):
+ raise FrameworkManifestError("oops")
+
+
+# ---------------------------------------------------------------------------
+# validate_framework_manifest: missing base fields
+# ---------------------------------------------------------------------------
+
+def test_validate_missing_id_raises():
+ with pytest.raises(FrameworkManifestError, match="missing required field 'id'"):
+ validate_framework_manifest("test", {"name": "Test"})
+
+
+def test_validate_missing_name_raises():
+ with pytest.raises(FrameworkManifestError, match="missing required field 'name'"):
+ validate_framework_manifest("test", {"id": "test"})
+
+
+def test_validate_missing_id_includes_fw_id_in_message():
+ with pytest.raises(FrameworkManifestError, match="'myfw'"):
+ validate_framework_manifest("myfw", {"name": "MyFW"})
+
+
+def test_validate_empty_dict_raises_missing_id():
+ with pytest.raises(FrameworkManifestError):
+ validate_framework_manifest("empty", {})
+
+
+# ---------------------------------------------------------------------------
+# validate_framework_manifest: require_update_fields
+# ---------------------------------------------------------------------------
+
+def test_validate_update_fields_all_missing():
+ with pytest.raises(FrameworkManifestError, match="missing update fields"):
+ validate_framework_manifest("x", {"id": "x", "name": "X"}, require_update_fields=True)
+
+
+def test_validate_update_fields_partial_missing():
+ partial = {
+ "id": "x",
+ "name": "X",
+ "release_source": "github:a/b",
+ "install_script": "/bin/true",
+ }
+ with pytest.raises(FrameworkManifestError, match="release_asset_pattern"):
+ validate_framework_manifest("x", partial, require_update_fields=True)
+
+
+def test_validate_update_fields_empty_list_message():
+ with pytest.raises(FrameworkManifestError) as exc_info:
+ validate_framework_manifest("x", {"id": "x", "name": "X"}, require_update_fields=True)
+ msg = str(exc_info.value)
+ assert "release_source" in msg
+ assert "release_asset_pattern" in msg
+ assert "install_script" in msg
+ assert "service_name" in msg
+
+
+def test_validate_update_fields_flag_false_allows_missing():
+ validate_framework_manifest("x", {"id": "x", "name": "X"}, require_update_fields=False)
+
+
+def test_validate_update_fields_flag_default_is_false():
+ validate_framework_manifest("x", {"id": "x", "name": "X"})
+
+
+# ---------------------------------------------------------------------------
+# validate_framework_manifest: shortcuts validation integration
+# ---------------------------------------------------------------------------
+
+def test_validate_rejects_shortcut_bad_kind():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [{"kind": "unknown-kind", "label": "L", "icon": "i", "requires_capability": "c"}],
+ }
+ with pytest.raises(FrameworkManifestError, match="unknown shortcut kind"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_shortcut_missing_label():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [{"kind": "tui", "icon": "i", "requires_capability": "c", "command": "cmd"}],
+ }
+ with pytest.raises(FrameworkManifestError, match="missing required field 'label'"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_tui_shortcut_missing_command():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [{"kind": "tui", "label": "L", "icon": "i", "requires_capability": "c"}],
+ }
+ with pytest.raises(FrameworkManifestError, match="'command' is required for kind='tui'"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_tui_shortcut_empty_command():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {"kind": "tui", "label": "L", "icon": "i", "requires_capability": "c", "command": " "},
+ ],
+ }
+ with pytest.raises(FrameworkManifestError, match="non-empty string"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_dashboard_shortcut_missing_port():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {
+ "kind": "dashboard",
+ "label": "L",
+ "icon": "i",
+ "requires_capability": "c",
+ "auth": {"type": "bearer"},
+ },
+ ],
+ }
+ with pytest.raises(FrameworkManifestError, match="'port' is required for kind='dashboard'"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_dashboard_shortcut_bad_port():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {
+ "kind": "dashboard",
+ "label": "L",
+ "icon": "i",
+ "requires_capability": "c",
+ "port": 0,
+ "auth": {"type": "bearer"},
+ },
+ ],
+ }
+ with pytest.raises(FrameworkManifestError, match="positive integer"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_dashboard_shortcut_missing_auth():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {
+ "kind": "dashboard",
+ "label": "L",
+ "icon": "i",
+ "requires_capability": "c",
+ "port": 8080,
+ },
+ ],
+ }
+ with pytest.raises(FrameworkManifestError, match="'auth' is required for kind='dashboard'"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_dashboard_shortcut_bad_auth_type():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {
+ "kind": "dashboard",
+ "label": "L",
+ "icon": "i",
+ "requires_capability": "c",
+ "port": 8080,
+ "auth": {"type": "oauth"},
+ },
+ ],
+ }
+ with pytest.raises(FrameworkManifestError, match="auth.type must be"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_rejects_shortcut_not_a_dict():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": ["not-a-dict"],
+ }
+ with pytest.raises(FrameworkManifestError, match="expected dict"):
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_accepts_valid_shortcuts():
+ entry = {
+ "id": "x",
+ "name": "X",
+ "shortcuts": [
+ {"kind": "container-terminal", "label": "Shell", "icon": "terminal", "requires_capability": "agent.shell"},
+ {"kind": "tui", "label": "TUI", "icon": "tui", "requires_capability": "agent.terminal", "command": "myfw tui"},
+ {
+ "kind": "dashboard",
+ "label": "Dash",
+ "icon": "dashboard",
+ "requires_capability": "agent.dashboard",
+ "port": 18789,
+ "path": "/",
+ "auth": {"type": "bearer"},
+ },
+ ],
+ }
+ validate_framework_manifest("x", entry)
+
+
+def test_validate_no_shortcuts_field_ok():
+ validate_framework_manifest("x", {"id": "x", "name": "X"})
+
+
+def test_validate_empty_shortcuts_list_ok():
+ validate_framework_manifest("x", {"id": "x", "name": "X", "shortcuts": []})
+
+
+# ---------------------------------------------------------------------------
+# FRAMEWORKS registry: structural integrity
+# ---------------------------------------------------------------------------
+
+def test_registry_is_dict():
+ assert isinstance(FRAMEWORKS, dict)
+
+
+def test_registry_not_empty():
+ assert len(FRAMEWORKS) > 0
+
+
+def test_all_entries_have_id():
+ for fw_id, entry in FRAMEWORKS.items():
+ assert "id" in entry, f"{fw_id}: missing 'id'"
+
+
+def test_all_entries_have_name():
+ for fw_id, entry in FRAMEWORKS.items():
+ assert "name" in entry, f"{fw_id}: missing 'name'"
+
+
+def test_all_ids_match_dict_keys():
+ for fw_id, entry in FRAMEWORKS.items():
+ assert entry.get("id") == fw_id, f"{fw_id}: id field {entry.get('id')!r} != key {fw_id!r}"
+
+
+def test_all_ids_are_unique():
+ ids = [e["id"] for e in FRAMEWORKS.values()]
+ assert len(ids) == len(set(ids)), "duplicate ids in FRAMEWORKS"
+
+
+def test_all_verification_statuses_valid():
+ valid = {"alpha", "beta", "stable"}
+ for fw_id, entry in FRAMEWORKS.items():
+ status = entry.get("verification_status")
+ assert status in valid, f"{fw_id}: unexpected verification_status {status!r}"
+
+
+def test_all_names_are_non_empty_strings():
+ for fw_id, entry in FRAMEWORKS.items():
+ name = entry["name"]
+ assert isinstance(name, str) and name.strip(), f"{fw_id}: name must be non-empty string"
+
+
+def test_all_descriptions_are_non_empty_strings():
+ for fw_id, entry in FRAMEWORKS.items():
+ desc = entry.get("description")
+ assert isinstance(desc, str) and desc.strip(), f"{fw_id}: description must be non-empty string"
+
+
+def test_shortcuts_field_is_list_when_present():
+ for fw_id, entry in FRAMEWORKS.items():
+ shortcuts = entry.get("shortcuts")
+ if shortcuts is not None:
+ assert isinstance(shortcuts, list), f"{fw_id}: shortcuts must be a list"
+
+
+def test_all_shortcuts_pass_validation():
+ for fw_id, entry in FRAMEWORKS.items():
+ shortcuts = entry.get("shortcuts")
+ if shortcuts is not None:
+ try:
+ validate_shortcuts(shortcuts)
+ except ValueError as exc:
+ pytest.fail(f"{fw_id}: shortcuts validation failed: {exc}")
+
+
+def test_slash_commands_shape_when_present():
+ for fw_id, entry in FRAMEWORKS.items():
+ cmds = entry.get("slash_commands")
+ if cmds is None:
+ continue
+ assert isinstance(cmds, list), f"{fw_id}: slash_commands must be a list"
+ for i, cmd in enumerate(cmds):
+ assert isinstance(cmd, dict), f"{fw_id}: slash_commands[{i}] must be dict"
+ assert cmd.get("name"), f"{fw_id}: slash_commands[{i}] missing name"
+ assert isinstance(cmd.get("description", ""), str)
+
+
+def test_frameworks_with_update_fields_pass_validation():
+ for fw_id, entry in FRAMEWORKS.items():
+ if entry.get("release_source"):
+ validate_framework_manifest(fw_id, entry, require_update_fields=True)
+
+
+def test_frameworks_without_release_source_skip_update_fields():
+ for fw_id, entry in FRAMEWORKS.items():
+ if not entry.get("release_source"):
+ validate_framework_manifest(fw_id, entry, require_update_fields=False)
+
+
+# ---------------------------------------------------------------------------
+# OpenClaw-specific integrity (the one entry with dashboard shortcut)
+# ---------------------------------------------------------------------------
+
+def test_openclaw_dashboard_shortcut_has_token_source():
+ entry = FRAMEWORKS["openclaw"]
+ dashboards = [s for s in entry["shortcuts"] if s["kind"] == "dashboard"]
+ assert len(dashboards) == 1
+ dash = dashboards[0]
+ assert "auth" in dash
+ auth = dash["auth"]
+ assert auth["type"] == "bearer"
+ token_source = auth["token_source"]
+ assert token_source["kind"] == "container_file"
+ assert token_source["path"].endswith("openclaw.json")
+ assert token_source["json_pointer"].startswith("/")
+
+
+def test_openclaw_dashboard_port():
+ entry = FRAMEWORKS["openclaw"]
+ dash = [s for s in entry["shortcuts"] if s["kind"] == "dashboard"][0]
+ assert dash["port"] == 18789
+
+
+# ---------------------------------------------------------------------------
+# Registry completeness: expected frameworks present
+# ---------------------------------------------------------------------------
+
+EXPECTED_FRAMEWORKS = {
+ "openclaw", "smolagents", "generic", "pocketflow", "langroid",
+ "openai-agents-sdk", "hermes", "agent_zero", "ironclaw", "microclaw",
+ "moltis", "nanoclaw", "nullclaw", "picoclaw", "shibaclaw", "zeroclaw",
+}
+
+
+def test_all_expected_frameworks_present():
+ missing = EXPECTED_FRAMEWORKS - set(FRAMEWORKS.keys())
+ assert not missing, f"missing frameworks: {missing}"
+
+
+def test_no_unexpected_frameworks():
+ extra = set(FRAMEWORKS.keys()) - EXPECTED_FRAMEWORKS
+ assert not extra, f"unexpected frameworks: {extra}"
+
+
+def test_registry_has_exactly_expected_count():
+ assert len(FRAMEWORKS) == len(EXPECTED_FRAMEWORKS)
diff --git a/tests/test_gaming_detector.py b/tests/test_gaming_detector.py
new file mode 100644
index 000000000..28945152c
--- /dev/null
+++ b/tests/test_gaming_detector.py
@@ -0,0 +1,584 @@
+"""Tests for the gaming detector: process scanning, fullscreen detection,
+GPU monitoring, and the GamingDetector state machine."""
+
+from __future__ import annotations
+
+import subprocess
+
+import pytest
+
+from tinyagentos.scheduling import gaming_detector as gd
+from tinyagentos.scheduling.gaming_detector import GamingDetector
+
+
+# ---- helpers -----------------------------------------------------------------
+
+class _FakeProcEntry:
+ """Mimics a /proc/
directory with comm and cmdline files."""
+
+ def __init__(self, pid: int, comm: str, cmdline: str = ""):
+ self._pid = pid
+ self._comm = comm
+ self._cmdline = cmdline
+
+ @property
+ def name(self) -> str:
+ return str(self._pid)
+
+ def isdigit(self) -> bool:
+ return True
+
+ def __truediv__(self, other: str) -> "_FakeProcFile":
+ if other == "comm":
+ return _FakeProcFile(self._comm)
+ if other == "cmdline":
+ return _FakeProcFile(self._cmdline)
+ raise FileNotFoundError(str(other))
+
+
+class _FakeProcFile:
+ def __init__(self, content: str):
+ self._content = content
+
+ def read_text(self) -> str:
+ return self._content
+
+
+def _make_proc(entries: list[_FakeProcEntry]):
+ """Return a fake Path whose iterdir yields the given entries."""
+
+ class _FakeProcPath:
+ def iterdir(self):
+ return iter(entries)
+
+ return _FakeProcPath()
+
+
+# ---- detect_game_processes ----------------------------------------------------
+
+class TestDetectGameProcesses:
+ def test_detects_steam_game(self, monkeypatch):
+ entries = [
+ _FakeProcEntry(1, "systemd"),
+ _FakeProcEntry(42, "reaper-steam", "/usr/bin/reaper steam://run/123"),
+ ]
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc(entries))
+
+ result = gd.detect_game_processes()
+ assert len(result) == 1
+ assert result[0]["pid"] == 42
+ assert result[0]["name"] == "reaper-steam"
+
+ def test_detects_proton_process(self, monkeypatch):
+ entries = [
+ _FakeProcEntry(100, "proton", "/home/user/.steam/proton run game.exe"),
+ ]
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc(entries))
+
+ result = gd.detect_game_processes()
+ assert len(result) == 1
+ assert result[0]["matched_pattern"] == "proton"
+
+ def test_detects_unreal_shipping(self, monkeypatch):
+ entries = [
+ _FakeProcEntry(200, "ue5-game-shipping", "/opt/game/Binaries/Linux/ue5-game-shipping"),
+ ]
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc(entries))
+
+ result = gd.detect_game_processes()
+ assert len(result) == 1
+ assert "ue5" in result[0]["matched_pattern"]
+
+ def test_ignores_non_game_processes(self, monkeypatch):
+ entries = [
+ _FakeProcEntry(1, "systemd"),
+ _FakeProcEntry(2, "kthreadd"),
+ _FakeProcEntry(50, "nginx", "nginx: worker process"),
+ ]
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc(entries))
+
+ result = gd.detect_game_processes()
+ assert result == []
+
+ def test_skips_permission_errors(self, monkeypatch):
+ class _DeniedEntry:
+ name = "999"
+ def isdigit(self):
+ return True
+ def __truediv__(self, other):
+ raise PermissionError("denied")
+
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc([_DeniedEntry()]))
+
+ result = gd.detect_game_processes()
+ assert result == []
+
+ def test_skips_non_numeric_dirs(self, monkeypatch):
+ class _NonNumeric:
+ name = "sys"
+ def isdigit(self):
+ return False
+
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc([_NonNumeric()]))
+
+ result = gd.detect_game_processes()
+ assert result == []
+
+ def test_returns_empty_on_proc_read_failure(self, monkeypatch):
+ class _BadProc:
+ def iterdir(self):
+ raise OSError("no /proc")
+
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _BadProc())
+
+ result = gd.detect_game_processes()
+ assert result == []
+
+ def test_detects_blender(self, monkeypatch):
+ entries = [
+ _FakeProcEntry(300, "blender", "/usr/bin/blender scene.blend"),
+ ]
+ monkeypatch.setattr("tinyagentos.scheduling.gaming_detector.Path", lambda p: _make_proc(entries))
+
+ result = gd.detect_game_processes()
+ assert len(result) == 1
+ assert result[0]["name"] == "blender"
+
+
+# ---- detect_fullscreen_x11 ---------------------------------------------------
+
+class TestDetectFullscreenX11:
+ def test_fullscreen_detected(self, monkeypatch):
+ xdotool_out = "X=0\nY=0\nWIDTH=1920\nHEIGHT=1080\nSCREEN=0\n"
+ xdpyinfo_out = "dimensions: 1920x1080 pixels (508x286 millimeters)"
+
+ def fake_run(cmd, **kwargs):
+ if "xdotool" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdotool_out, "")
+ if "xdpyinfo" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdpyinfo_out, "")
+ return subprocess.CompletedProcess(cmd, 1, "", "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is True
+
+ def test_not_fullscreen_smaller_window(self, monkeypatch):
+ xdotool_out = "X=100\nY=100\nWIDTH=800\nHEIGHT=600\nSCREEN=0\n"
+ xdpyinfo_out = "dimensions: 1920x1080 pixels (508x286 millimeters)"
+
+ def fake_run(cmd, **kwargs):
+ if "xdotool" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdotool_out, "")
+ if "xdpyinfo" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdpyinfo_out, "")
+ return subprocess.CompletedProcess(cmd, 1, "", "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is False
+
+ def test_xdotool_not_installed(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ raise FileNotFoundError("xdotool not found")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is False
+
+ def test_xdotool_returns_nonzero(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 1, "", "error")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is False
+
+ def test_xdotool_timeout(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ raise subprocess.TimeoutExpired(cmd, timeout=2)
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is False
+
+ def test_near_fullscreen_at_95_percent(self, monkeypatch):
+ xdotool_out = "X=0\nY=0\nWIDTH=1824\nHEIGHT=1026\nSCREEN=0\n"
+ xdpyinfo_out = "dimensions: 1920x1080 pixels (508x286 millimeters)"
+
+ def fake_run(cmd, **kwargs):
+ if "xdotool" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdotool_out, "")
+ if "xdpyinfo" in cmd:
+ return subprocess.CompletedProcess(cmd, 0, xdpyinfo_out, "")
+ return subprocess.CompletedProcess(cmd, 1, "", "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_fullscreen_x11() is True
+
+
+# ---- detect_gpu_heavy_process ------------------------------------------------
+
+class TestDetectGpuHeavyProcess:
+ def test_detects_heavy_gpu_process(self, monkeypatch):
+ csv_out = "1234, chrome, 1200\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is True
+
+ def test_ignores_ollama_process(self, monkeypatch):
+ csv_out = "1234, ollama, 8000\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_ignores_python_process(self, monkeypatch):
+ csv_out = "1234, python, 2000\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_ignores_rkllm_process(self, monkeypatch):
+ csv_out = "1234, rkllm-server, 3000\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_low_memory_usage_not_detected(self, monkeypatch):
+ csv_out = "1234, chrome, 100\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_nvidia_smi_not_installed(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ raise FileNotFoundError("nvidia-smi not found")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_nvidia_smi_timeout(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ raise subprocess.TimeoutExpired(cmd, timeout=5)
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_nvidia_smi_returns_nonzero(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 1, "", "NVML error")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_empty_output(self, monkeypatch):
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, "", "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+ def test_malformed_csv_line_skipped(self, monkeypatch):
+ csv_out = "only_one_field\n"
+
+ def fake_run(cmd, **kwargs):
+ return subprocess.CompletedProcess(cmd, 0, csv_out, "")
+
+ monkeypatch.setattr(gd.subprocess, "run", fake_run)
+
+ assert gd.detect_gpu_heavy_process() is False
+
+
+# ---- GamingDetector.check() state machine ------------------------------------
+
+class _FakeRM:
+ def __init__(self):
+ self.yielded = False
+ self.reclaimed = False
+
+ async def yield_resources(self):
+ self.yielded = True
+
+ async def reclaim_resources(self):
+ self.reclaimed = True
+
+
+class TestGamingDetectorCheck:
+ @pytest.mark.asyncio
+ async def test_idle_when_no_games(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ det = GamingDetector(resource_manager=_FakeRM())
+ result = await det.check()
+ assert result["status"] == "idle"
+ assert det.game_active is False
+
+ @pytest.mark.asyncio
+ async def test_yields_on_new_game_detected(self, monkeypatch):
+ games = [{"pid": 42, "name": "proton", "matched_pattern": "proton"}]
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: games)
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm)
+ result = await det.check()
+ assert result["status"] == "yielded"
+ assert result["reason"] == "gaming_detected"
+ assert result["games"] == games
+ assert det.game_active is True
+ assert rm.yielded is True
+
+ @pytest.mark.asyncio
+ async def test_yields_on_fullscreen_detected(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: True)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm)
+ result = await det.check()
+ assert result["status"] == "yielded"
+ assert result["reason"] == "gaming_detected"
+ assert rm.yielded is True
+
+ @pytest.mark.asyncio
+ async def test_yields_on_gpu_heavy_detected(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: True)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm)
+ result = await det.check()
+ assert result["status"] == "yielded"
+ assert rm.yielded is True
+
+ @pytest.mark.asyncio
+ async def test_no_yield_without_resource_manager(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [{"pid": 1, "name": "proton", "matched_pattern": "proton"}])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ det = GamingDetector(resource_manager=None)
+ result = await det.check()
+ assert result["status"] == "yielded"
+ assert det.game_active is True
+
+ @pytest.mark.asyncio
+ async def test_cooldown_when_game_exits(self, monkeypatch):
+ call_count = 0
+
+ def fake_detect():
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return [{"pid": 1, "name": "proton", "matched_pattern": "proton"}]
+ return []
+
+ monkeypatch.setattr(gd, "detect_game_processes", fake_detect)
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm, cooldown=60)
+ result1 = await det.check()
+ assert result1["status"] == "yielded"
+ assert det.game_active is True
+
+ result2 = await det.check()
+ assert result2["status"] == "cooldown"
+ assert result2["seconds_remaining"] == 60
+ assert det.game_active is True
+
+ @pytest.mark.asyncio
+ async def test_reclaim_after_cooldown_expires(self, monkeypatch):
+ call_count = 0
+
+ def fake_detect():
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return [{"pid": 1, "name": "proton", "matched_pattern": "proton"}]
+ return []
+
+ monkeypatch.setattr(gd, "detect_game_processes", fake_detect)
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ fake_times = [1000.0, 1001.0, 1062.0]
+ monkeypatch.setattr(gd.time, "time", lambda: fake_times.pop(0))
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm, cooldown=60)
+ result1 = await det.check()
+ assert result1["status"] == "yielded"
+
+ result2 = await det.check()
+ assert result2["status"] == "cooldown"
+
+ result3 = await det.check()
+ assert result3["status"] == "reclaimed"
+ assert result3["idle_seconds"] == 61
+ assert det.game_active is False
+ assert rm.reclaimed is True
+
+ @pytest.mark.asyncio
+ async def test_still_gaming_when_detected_while_active(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [{"pid": 1, "name": "proton", "matched_pattern": "proton"}])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ det = GamingDetector(resource_manager=_FakeRM())
+ await det.check()
+ assert det.game_active is True
+
+ result = await det.check()
+ assert result["status"] == "gaming"
+ assert det.game_active is True
+
+ @pytest.mark.asyncio
+ async def test_cooldown_resets_if_game_returns(self, monkeypatch):
+ call_count = 0
+
+ def fake_detect():
+ nonlocal call_count
+ call_count += 1
+ if call_count in (1, 3):
+ return [{"pid": 1, "name": "proton", "matched_pattern": "proton"}]
+ return []
+
+ monkeypatch.setattr(gd, "detect_game_processes", fake_detect)
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ det = GamingDetector(resource_manager=_FakeRM(), cooldown=60)
+ result1 = await det.check()
+ assert result1["status"] == "yielded"
+
+ result2 = await det.check()
+ assert result2["status"] == "cooldown"
+
+ result3 = await det.check()
+ assert result3["status"] == "gaming"
+ assert det._game_exited_at is None
+
+ @pytest.mark.asyncio
+ async def test_no_reclaim_without_resource_manager(self, monkeypatch):
+ call_count = 0
+
+ def fake_detect():
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ return [{"pid": 1, "name": "proton", "matched_pattern": "proton"}]
+ return []
+
+ monkeypatch.setattr(gd, "detect_game_processes", fake_detect)
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ fake_times = [0.0, 1.0, 62.0]
+ monkeypatch.setattr(gd.time, "time", lambda: fake_times.pop(0))
+
+ det = GamingDetector(resource_manager=None, cooldown=60)
+ await det.check()
+ await det.check()
+ result = await det.check()
+ assert result["status"] == "reclaimed"
+ assert det.game_active is False
+
+
+# ---- GamingDetector.force_yield / force_reclaim -------------------------------
+
+class TestGamingDetectorForce:
+ @pytest.mark.asyncio
+ async def test_force_yield(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm)
+ result = await det.force_yield()
+ assert result["status"] == "yielded"
+ assert result["reason"] == "manual"
+ assert det.game_active is True
+ assert rm.yielded is True
+
+ @pytest.mark.asyncio
+ async def test_force_reclaim(self, monkeypatch):
+ monkeypatch.setattr(gd, "detect_game_processes", lambda: [])
+ monkeypatch.setattr(gd, "detect_fullscreen_x11", lambda: False)
+ monkeypatch.setattr(gd, "detect_gpu_heavy_process", lambda: False)
+
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm)
+ await det.force_yield()
+ assert det.game_active is True
+
+ result = await det.force_reclaim()
+ assert result["status"] == "reclaimed"
+ assert result["reason"] == "manual"
+ assert det.game_active is False
+ assert rm.reclaimed is True
+
+ @pytest.mark.asyncio
+ async def test_force_yield_without_resource_manager(self):
+ det = GamingDetector(resource_manager=None)
+ result = await det.force_yield()
+ assert result["status"] == "yielded"
+ assert det.game_active is True
+
+ @pytest.mark.asyncio
+ async def test_force_reclaim_without_resource_manager(self):
+ det = GamingDetector(resource_manager=None)
+ result = await det.force_reclaim()
+ assert result["status"] == "reclaimed"
+ assert det.game_active is False
+
+
+# ---- GamingDetector constructor defaults -------------------------------------
+
+class TestGamingDetectorInit:
+ def test_default_values(self):
+ det = GamingDetector()
+ assert det._poll_interval == 10
+ assert det._cooldown == 600
+ assert det.game_active is False
+ assert det._game_exited_at is None
+ assert det._last_detected == []
+
+ def test_custom_values(self):
+ rm = _FakeRM()
+ det = GamingDetector(resource_manager=rm, poll_interval=5, cooldown=300)
+ assert det._rm is rm
+ assert det._poll_interval == 5
+ assert det._cooldown == 300
diff --git a/tests/test_github_identities.py b/tests/test_github_identities.py
new file mode 100644
index 000000000..bb1fa3eab
--- /dev/null
+++ b/tests/test_github_identities.py
@@ -0,0 +1,137 @@
+"""Unit tests for GitHubIdentitiesStore add/list/get_token/delete."""
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+
+from tinyagentos.github_identities import GitHubIdentitiesStore
+
+
+async def _store(tmp_path):
+ s = GitHubIdentitiesStore(tmp_path / "github_identities.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_add_returns_public_fields_without_token(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ with patch("tinyagentos.github_identities.time.time", return_value=1000):
+ identity = await store.add("octocat", "https://avatars/octocat.png", "gho_secret", "repo")
+
+ assert set(identity.keys()) == {"id", "login", "avatar_url", "created_at"}
+ assert identity["login"] == "octocat"
+ assert identity["avatar_url"] == "https://avatars/octocat.png"
+ assert identity["created_at"] == 1000
+ assert "token" not in identity
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_list_excludes_token(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.add("octocat", "https://avatars/octocat.png", "gho_secret", "repo")
+ identities = await store.list()
+
+ assert len(identities) == 1
+ assert set(identities[0].keys()) == {"id", "login", "avatar_url", "created_at"}
+ assert "token" not in identities[0]
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_list_orders_by_created_at_desc(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ with patch("tinyagentos.github_identities.time.time", return_value=100):
+ await store.add("first", "", "tok1", "")
+ with patch("tinyagentos.github_identities.time.time", return_value=300):
+ await store.add("third", "", "tok3", "")
+ with patch("tinyagentos.github_identities.time.time", return_value=200):
+ await store.add("second", "", "tok2", "")
+
+ identities = await store.list()
+ assert [i["login"] for i in identities] == ["third", "second", "first"]
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_reconnect_same_login_updates_in_place(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ with patch("tinyagentos.github_identities.time.time", return_value=1000):
+ first = await store.add("octocat", "a1", "gho_token1", "repo")
+ second = await store.add("octocat", "a2", "gho_token2", "repo,user")
+
+ assert first["id"] == second["id"]
+ assert first["created_at"] == second["created_at"]
+ assert second["avatar_url"] == "a2"
+ assert len(await store.list()) == 1
+ assert await store.get_token(first["id"]) == "gho_token2"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_token_decrypts_stored_token(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ identity = await store.add("octocat", "", "gho_plaintext", "repo")
+ assert await store.get_token(identity["id"]) == "gho_plaintext"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_get_token_returns_none_for_unknown_id(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ assert await store.get_token("missing-id") is None
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_delete_returns_true_and_removes_row(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ identity = await store.add("octocat", "", "gho_secret", "repo")
+ deleted = await store.delete(identity["id"])
+
+ assert deleted is True
+ assert await store.list() == []
+ assert await store.get_token(identity["id"]) is None
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_delete_returns_false_for_missing_id(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ assert await store.delete("missing-id") is False
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_token_encrypted_at_rest(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ identity = await store.add("octocat", "", "gho_plaintexttoken", "repo")
+ async with store._db.execute(
+ "SELECT token FROM github_identities WHERE id = ?", (identity["id"],)
+ ) as cur:
+ row = await cur.fetchone()
+
+ assert row[0] != "gho_plaintexttoken"
+ assert "gho_plaintexttoken" not in row[0]
+ assert await store.get_token(identity["id"]) == "gho_plaintexttoken"
+ finally:
+ await store.close()
\ No newline at end of file
diff --git a/tests/test_group_policy.py b/tests/test_group_policy.py
new file mode 100644
index 000000000..a4ea9c5e2
--- /dev/null
+++ b/tests/test_group_policy.py
@@ -0,0 +1,300 @@
+import time
+import pytest
+from tinyagentos.chat.group_policy import GroupPolicy
+
+
+class TestMaySend:
+ def test_first_send_is_allowed(self):
+ p = GroupPolicy()
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_cooldown_blocks_same_agent(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent")
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is False
+
+ def test_cooldown_allows_different_agent(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent_a")
+ assert p.may_send("ch1", "agent_b", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_cooldown_allows_different_channel(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent")
+ assert p.may_send("ch2", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_cooldown_expires_after_threshold(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ t[0] = 1004.9
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is False
+ t[0] = 1005.0
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_cooldown_boundary_exact(self, monkeypatch):
+ p = GroupPolicy()
+ t = [0.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ t[0] = 5.0
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_rate_cap_blocks_when_full(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ t[0] += 0.1
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is False
+
+ def test_rate_cap_allows_below_cap(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(19):
+ t[0] += 0.1
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_rate_cap_window_slides(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ t[0] += 61.0
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_rate_cap_does_not_affect_other_channels(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ t[0] += 0.1
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ assert p.may_send("ch2", "agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_default_settings_when_empty_dict(self):
+ p = GroupPolicy()
+ assert p.may_send("ch1", "agent", {}) is True
+
+ def test_default_cooldown_only(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent")
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 5}) is False
+
+ def test_default_rate_cap_only(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ t[0] += 0.1
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0}) is False
+
+ def test_zero_cooldown_allows_immediate_resend(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ t[0] += 0.001
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_zero_rate_cap_allows_when_no_prior_sends(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ assert p.may_send("ch1", "agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 0}) is True
+
+ def test_zero_rate_cap_blocks_after_one_send(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "a1")
+ t[0] += 0.1
+ assert p.may_send("ch1", "a2", {"cooldown_seconds": 0, "rate_cap_per_minute": 0}) is False
+
+ def test_cooldown_and_rate_cap_both_block(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ t[0] += 0.1
+ p.record_send("ch1", f"a{i}")
+ t[0] += 0.1
+ p.record_send("ch1", "blocked_agent")
+ assert p.may_send("ch1", "blocked_agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is False
+
+ def test_cooldown_checked_before_rate_cap(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ t[0] += 0.1
+ result = p.may_send("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20})
+ assert result is False
+
+ def test_old_entries_expired_from_window(self, monkeypatch):
+ p = GroupPolicy()
+ t = [0.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(20):
+ p.record_send("ch1", f"a{i}")
+ t[0] = 61.0
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_partial_window_expiry(self, monkeypatch):
+ p = GroupPolicy()
+ t = [0.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ for i in range(10):
+ p.record_send("ch1", f"a{i}")
+ t[0] = 30.0
+ for i in range(10, 20):
+ p.record_send("ch1", f"a{i}")
+ t[0] = 61.0
+ assert p.may_send("ch1", "new_agent", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}) is True
+
+ def test_settings_with_none_values_raises_type_error(self):
+ p = GroupPolicy()
+ with pytest.raises(TypeError):
+ p.may_send("ch1", "agent", {"cooldown_seconds": None, "rate_cap_per_minute": None})
+
+ def test_settings_with_string_values_coerced_to_int(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent")
+ result = p.may_send("ch1", "agent", {"cooldown_seconds": "5", "rate_cap_per_minute": "20"})
+ assert result is False
+
+
+class TestRecordSend:
+ def test_stores_last_send_timestamp(self, monkeypatch):
+ p = GroupPolicy()
+ t = [42.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ assert p._last_send_at[("ch1", "agent")] == 42.0
+
+ def test_updates_last_send_timestamp(self, monkeypatch):
+ p = GroupPolicy()
+ t = [100.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "agent")
+ t[0] = 200.0
+ p.record_send("ch1", "agent")
+ assert p._last_send_at[("ch1", "agent")] == 200.0
+
+ def test_appends_to_recent_sends_window(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "a1")
+ t[0] = 1001.0
+ p.record_send("ch1", "a2")
+ window = p._recent_sends["ch1"]
+ assert len(window) == 2
+ assert window[0] == 1000.0
+ assert window[1] == 1001.0
+
+ def test_creates_new_window_for_new_channel(self):
+ p = GroupPolicy()
+ p.record_send("ch1", "agent")
+ p.record_send("ch2", "agent")
+ assert "ch1" in p._recent_sends
+ assert "ch2" in p._recent_sends
+ assert len(p._recent_sends["ch1"]) == 1
+ assert len(p._recent_sends["ch2"]) == 1
+
+ def test_window_has_maxlen_256(self):
+ p = GroupPolicy()
+ dq = p._recent_sends.setdefault("ch1", __import__("collections").deque(maxlen=256))
+ assert dq.maxlen == 256
+
+ def test_multiple_agents_same_channel(self, monkeypatch):
+ p = GroupPolicy()
+ t = [0.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.record_send("ch1", "a1")
+ t[0] = 1.0
+ p.record_send("ch1", "a2")
+ t[0] = 2.0
+ p.record_send("ch1", "a3")
+ assert len(p._recent_sends["ch1"]) == 3
+ assert p._last_send_at[("ch1", "a1")] == 0.0
+ assert p._last_send_at[("ch1", "a2")] == 1.0
+ assert p._last_send_at[("ch1", "a3")] == 2.0
+
+
+class TestTryAcquire:
+ def test_returns_true_on_first_call(self):
+ p = GroupPolicy()
+ assert p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_returns_false_when_cooldown_active(self):
+ p = GroupPolicy()
+ p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20})
+ assert p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is False
+
+ def test_records_on_success(self, monkeypatch):
+ p = GroupPolicy()
+ t = [500.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20})
+ assert p._last_send_at[("ch1", "agent")] == 500.0
+ assert len(p._recent_sends["ch1"]) == 1
+
+ def test_does_not_record_on_failure(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20})
+ p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20})
+ assert len(p._recent_sends["ch1"]) == 1
+
+ def test_rate_cap_blocks_acquire(self, monkeypatch):
+ p = GroupPolicy()
+ t = [1000.0]
+ monkeypatch.setattr("tinyagentos.chat.group_policy._now", lambda: t[0])
+ results = []
+ for i in range(21):
+ t[0] += 0.1
+ results.append(p.try_acquire("ch1", f"a{i}", {"cooldown_seconds": 0, "rate_cap_per_minute": 20}))
+ assert results[:20] == [True] * 20
+ assert results[20] is False
+
+ def test_different_channels_independent_acquire(self):
+ p = GroupPolicy()
+ assert p.try_acquire("ch1", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+ assert p.try_acquire("ch2", "agent", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_different_agents_independent_acquire(self):
+ p = GroupPolicy()
+ assert p.try_acquire("ch1", "a1", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+ assert p.try_acquire("ch1", "a2", {"cooldown_seconds": 5, "rate_cap_per_minute": 20}) is True
+
+ def test_empty_settings_uses_defaults(self):
+ p = GroupPolicy()
+ assert p.try_acquire("ch1", "agent", {}) is True
+ assert p.try_acquire("ch1", "agent", {}) is False
+
+
+class TestGroupPolicyFreshInstance:
+ def test_empty_state(self):
+ p = GroupPolicy()
+ assert p._last_send_at == {}
+ assert p._recent_sends == {}
+
+ def test_multiple_instances_are_independent(self):
+ p1 = GroupPolicy()
+ p2 = GroupPolicy()
+ p1.record_send("ch1", "agent")
+ assert ("ch1", "agent") not in p2._last_send_at
+ assert "ch1" not in p2._recent_sends
diff --git a/tests/test_health_module.py b/tests/test_health_module.py
new file mode 100644
index 000000000..0aee2e21c
--- /dev/null
+++ b/tests/test_health_module.py
@@ -0,0 +1,488 @@
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from tinyagentos.health import HealthMonitor
+
+
+def _make_qmd_db(db_path: Path, vector_count: int = 0) -> None:
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(str(db_path))
+ conn.execute("CREATE TABLE IF NOT EXISTS content_vectors (hash TEXT, doc TEXT, created_at TEXT, embedded_at TEXT)")
+ conn.execute("DELETE FROM content_vectors")
+ for i in range(vector_count):
+ conn.execute(
+ "INSERT INTO content_vectors (hash, doc, created_at, embedded_at) VALUES (?, ?, ?, ?)",
+ (f"h{i}", f"doc{i}", "2024-01-01", "2024-01-01"),
+ )
+ conn.commit()
+ conn.close()
+
+
+def _make_monitor(
+ tmp_path: Path,
+ backends: list[dict] | None = None,
+ agents: list[dict] | None = None,
+ poll_interval: int = 30,
+ retention_days: int = 30,
+ with_notifications: bool = True,
+) -> HealthMonitor:
+ config = MagicMock()
+ config.backends = backends or []
+ config.agents = agents or []
+ config.metrics = {"poll_interval": poll_interval, "retention_days": retention_days}
+
+ metrics = MagicMock()
+ metrics.insert = AsyncMock()
+ metrics.cleanup = AsyncMock(return_value=0)
+
+ qmd = MagicMock()
+ qmd.health = AsyncMock(return_value={"status": "ok", "response_ms": 5})
+
+ http_client = MagicMock()
+
+ notifications = None
+ if with_notifications:
+ notifications = MagicMock()
+ notifications.emit_event = AsyncMock()
+
+ return HealthMonitor(config, metrics, qmd, http_client, notifications=notifications)
+
+
+@pytest.mark.asyncio
+async def test_poll_once_all_healthy(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok", "response_ms": 12}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=10.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=50.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=70.0)):
+ await monitor._poll_once()
+
+ calls = {c.args[0] for c in monitor.metrics.insert.call_args_list}
+ assert "backend.b1.status" in calls
+ assert "backend.b1.response_ms" in calls
+ assert "system.cpu_pct" in calls
+ assert "system.ram_pct" in calls
+ assert "system.disk_pct" in calls
+ assert "qmd.status" in calls
+ assert "qmd.health_response_ms" in calls
+
+
+@pytest.mark.asyncio
+async def test_poll_once_backend_degraded(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "error", "response_ms": 0}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=20.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=60.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=80.0)):
+ await monitor._poll_once()
+
+ status_call = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "backend.b1.status"][0]
+ assert status_call.args[1] == 0.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_backend_ok_records_1(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok", "response_ms": 42}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=5.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=30.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=40.0)):
+ await monitor._poll_once()
+
+ status_call = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "backend.b1.status"][0]
+ assert status_call.args[1] == 1.0
+
+ ms_call = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "backend.b1.response_ms"][0]
+ assert ms_call.args[1] == 42.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_qmd_healthy(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ monitor.qmd_client.health = AsyncMock(return_value={"status": "ok", "response_ms": 3})
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ qmd_status = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "qmd.status"][0]
+ assert qmd_status.args[1] == 1.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_qmd_error(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ monitor.qmd_client.health = AsyncMock(return_value={"status": "error", "response_ms": 0})
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ qmd_status = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "qmd.status"][0]
+ assert qmd_status.args[1] == 0.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_system_metrics_values(tmp_path):
+ monitor = _make_monitor(tmp_path)
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=42.5):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=73.2)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=88.1)):
+ await monitor._poll_once()
+
+ calls = {c.args[0]: c.args[1] for c in monitor.metrics.insert.call_args_list}
+ assert calls["system.cpu_pct"] == 42.5
+ assert calls["system.ram_pct"] == 73.2
+ assert calls["system.disk_pct"] == 88.1
+
+
+@pytest.mark.asyncio
+async def test_poll_once_backend_exception_still_records_other_metrics(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ side_effect=RuntimeError("connection refused")):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=15.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=55.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=65.0)):
+ await monitor._poll_once()
+
+ calls = {c.args[0] for c in monitor.metrics.insert.call_args_list}
+ assert "backend.b1.status" not in calls
+ assert "system.cpu_pct" in calls
+ assert "qmd.status" in calls
+
+
+@pytest.mark.asyncio
+async def test_poll_once_no_backends_no_agents(tmp_path):
+ monitor = _make_monitor(tmp_path, backends=[], agents=[])
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ assert monitor.metrics.insert.call_count == 5
+
+
+@pytest.mark.asyncio
+async def test_poll_once_multiple_backends(tmp_path):
+ backends = [
+ {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"},
+ {"name": "b2", "type": "ollama", "url": "http://localhost:11434"},
+ ]
+ monitor = _make_monitor(tmp_path, backends=backends)
+
+ async def fake_check(client, backend):
+ if backend["name"] == "b1":
+ return {"status": "ok", "response_ms": 10}
+ return {"status": "error", "response_ms": 0}
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ side_effect=fake_check):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ calls = {c.args[0]: c.args[1] for c in monitor.metrics.insert.call_args_list}
+ assert calls["backend.b1.status"] == 1.0
+ assert calls["backend.b2.status"] == 0.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_agent_vector_count_every_10th_cycle(tmp_path):
+ agent = {"name": "agent1", "qmd_index": "idx1"}
+ monitor = _make_monitor(tmp_path, agents=[agent])
+ monitor._poll_count = 9
+
+ db_path = tmp_path / "idx1.sqlite"
+ _make_qmd_db(db_path, vector_count=7)
+
+ with patch("tinyagentos.health.get_agent_db") as mock_get_db:
+ mock_db = MagicMock()
+ mock_db.vector_count.return_value = 7
+ mock_get_db.return_value = mock_db
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ vec_calls = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "agent.agent1.vectors"]
+ assert len(vec_calls) == 1
+ assert vec_calls[0].args[1] == 7.0
+ assert vec_calls[0].kwargs.get("labels") == {"agent": "agent1"}
+
+
+@pytest.mark.asyncio
+async def test_poll_once_agent_vector_count_skipped_on_non_10th(tmp_path):
+ agent = {"name": "agent1", "qmd_index": "idx1"}
+ monitor = _make_monitor(tmp_path, agents=[agent])
+ monitor._poll_count = 7
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ vec_calls = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "agent.agent1.vectors"]
+ assert len(vec_calls) == 0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_agent_db_missing_returns_none(tmp_path):
+ agent = {"name": "agent1", "qmd_index": "idx1"}
+ monitor = _make_monitor(tmp_path, agents=[agent])
+ monitor._poll_count = 10
+
+ with patch("tinyagentos.health.get_agent_db", return_value=None):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ vec_calls = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "agent.agent1.vectors"]
+ assert len(vec_calls) == 0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_daily_cleanup_runs(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ monitor._last_cleanup = 0
+ monitor.metrics.cleanup = AsyncMock(return_value=100)
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.metrics.cleanup.assert_called_once_with(30)
+
+
+@pytest.mark.asyncio
+async def test_poll_once_cleanup_skipped_within_day(tmp_path):
+ import time as _time
+ monitor = _make_monitor(tmp_path)
+ monitor._last_cleanup = int(_time.time()) - 100
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.metrics.cleanup.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_poll_once_cleanup_zero_deleted_no_log(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ monitor._last_cleanup = 0
+ monitor.metrics.cleanup = AsyncMock(return_value=0)
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.metrics.cleanup.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_backend_state_change_emits_down_notification(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+ monitor._backend_states["b1"] = "ok"
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "error", "response_ms": 0}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.notifications.emit_event.assert_called_once_with(
+ "backend.down",
+ "Backend 'b1' is unreachable",
+ "Health check failed for http://localhost:8080",
+ level="warning",
+ )
+
+
+@pytest.mark.asyncio
+async def test_backend_state_change_emits_up_notification(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+ monitor._backend_states["b1"] = "error"
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok", "response_ms": 10}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.notifications.emit_event.assert_called_once_with(
+ "backend.up",
+ "Backend 'b1' recovered",
+ "Health check succeeded for http://localhost:8080",
+ level="info",
+ )
+
+
+@pytest.mark.asyncio
+async def test_backend_no_state_change_no_notification(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+ monitor._backend_states["b1"] = "ok"
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok", "response_ms": 10}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.notifications.emit_event.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_backend_first_seen_no_notification(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok", "response_ms": 10}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ monitor.notifications.emit_event.assert_not_called()
+ assert monitor._backend_states["b1"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_no_notifications_when_none(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend], with_notifications=False)
+ monitor._backend_states["b1"] = "ok"
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "error", "response_ms": 0}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ assert monitor.notifications is None
+
+
+@pytest.mark.asyncio
+async def test_start_stop(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ await monitor.start()
+ assert monitor._task is not None
+ await monitor.stop()
+ assert monitor._task.done()
+
+
+@pytest.mark.asyncio
+async def test_poll_once_qmd_response_ms_recorded(tmp_path):
+ monitor = _make_monitor(tmp_path)
+ monitor.qmd_client.health = AsyncMock(return_value={"status": "ok", "response_ms": 17})
+
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ ms_call = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "qmd.health_response_ms"][0]
+ assert ms_call.args[1] == 17.0
+
+
+@pytest.mark.asyncio
+async def test_poll_once_backend_response_ms_default_zero(tmp_path):
+ backend = {"name": "b1", "type": "rkllama", "url": "http://localhost:8080"}
+ monitor = _make_monitor(tmp_path, backends=[backend])
+
+ with patch("tinyagentos.health.check_backend_health", new_callable=AsyncMock,
+ return_value={"status": "ok"}):
+ with patch("tinyagentos.health.psutil.cpu_percent", return_value=0.0):
+ with patch("tinyagentos.health.psutil.virtual_memory",
+ return_value=MagicMock(percent=0.0)):
+ with patch("tinyagentos.health.psutil.disk_usage",
+ return_value=MagicMock(percent=0.0)):
+ await monitor._poll_once()
+
+ ms_call = [c for c in monitor.metrics.insert.call_args_list
+ if c.args[0] == "backend.b1.response_ms"][0]
+ assert ms_call.args[1] == 0.0
diff --git a/tests/test_install_progress.py b/tests/test_install_progress.py
new file mode 100644
index 000000000..92334ea2c
--- /dev/null
+++ b/tests/test_install_progress.py
@@ -0,0 +1,304 @@
+"""Tests for tinyagentos/install_progress.py: InstallProgress data class and
+InstallProgressStore lifecycle (start, update, finish, get, list, prune)."""
+from __future__ import annotations
+
+import time
+
+import pytest
+
+from tinyagentos.install_progress import (
+ INSTALL_PROGRESS_TTL_S,
+ InstallProgress,
+ InstallProgressStore,
+ get_global_store,
+)
+
+
+# ---- InstallProgress data class ------------------------------------------------
+
+
+class TestInstallProgress:
+ def _make(self, **kw):
+ defaults = dict(
+ install_id="i1",
+ app_id="app1",
+ target_remote="remote1",
+ state="queued",
+ bytes_downloaded=0,
+ bytes_total=0,
+ started_at=1000.0,
+ updated_at=1000.0,
+ finished_at=None,
+ error=None,
+ detail="",
+ )
+ defaults.update(kw)
+ return InstallProgress(**defaults)
+
+ def test_percent_none_when_bytes_total_zero(self):
+ p = self._make(bytes_total=0)
+ assert p.percent is None
+
+ def test_percent_none_when_bytes_total_negative(self):
+ p = self._make(bytes_total=-1)
+ assert p.percent is None
+
+ def test_percent_computed_from_ratio(self):
+ p = self._make(bytes_downloaded=50, bytes_total=200)
+ assert p.percent == 25.0
+
+ def test_percent_caps_at_100(self):
+ p = self._make(bytes_downloaded=300, bytes_total=200)
+ assert p.percent == 100.0
+
+ def test_to_dict_round_trip(self):
+ p = self._make(
+ bytes_downloaded=100,
+ bytes_total=200,
+ state="downloading",
+ detail="fetching model",
+ )
+ d = p.to_dict()
+ assert d["install_id"] == "i1"
+ assert d["app_id"] == "app1"
+ assert d["target_remote"] == "remote1"
+ assert d["state"] == "downloading"
+ assert d["bytes_downloaded"] == 100
+ assert d["bytes_total"] == 200
+ assert d["percent"] == 50.0
+ assert d["detail"] == "fetching model"
+ assert d["error"] is None
+ assert d["finished_at"] is None
+
+ def test_to_dict_with_no_remote(self):
+ p = self._make(target_remote=None)
+ d = p.to_dict()
+ assert d["target_remote"] is None
+
+
+# ---- InstallProgressStore -----------------------------------------------------
+
+
+class TestInstallProgressStore:
+ def _store(self):
+ return InstallProgressStore()
+
+ def test_start_returns_entry_with_defaults(self, monkeypatch):
+ fake_id = "abc123"
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex=fake_id)),
+ )
+ store = self._store()
+ entry = store.start("myapp", "myremote")
+ assert entry.install_id == fake_id
+ assert entry.app_id == "myapp"
+ assert entry.target_remote == "myremote"
+ assert entry.state == "queued"
+ assert entry.bytes_downloaded == 0
+ assert entry.bytes_total == 0
+ assert entry.finished_at is None
+ assert entry.error is None
+
+ def test_start_without_remote(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ entry = store.start("myapp")
+ assert entry.target_remote is None
+
+ def test_get_returns_entry(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ started = store.start("app1")
+ fetched = store.get(started.install_id)
+ assert fetched is not None
+ assert fetched.install_id == started.install_id
+
+ def test_get_returns_none_for_unknown_id(self):
+ store = self._store()
+ assert store.get("nope") is None
+
+ def test_update_mutates_fields(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ store.update(
+ entry.install_id,
+ state="downloading",
+ bytes_downloaded=10,
+ bytes_total=100,
+ detail="working",
+ )
+ updated = store.get(entry.install_id)
+ assert updated.state == "downloading"
+ assert updated.bytes_downloaded == 10
+ assert updated.bytes_total == 100
+ assert updated.detail == "working"
+
+ def test_update_ignores_unknown_id(self):
+ store = self._store()
+ # must not raise
+ store.update("ghost", state="downloading")
+
+ def test_update_only_set_fields(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ store.update(entry.install_id, bytes_downloaded=5)
+ updated = store.get(entry.install_id)
+ assert updated.state == "queued"
+ assert updated.bytes_downloaded == 5
+ assert updated.detail == ""
+
+ def test_finish_success(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ store.finish(entry.install_id, success=True, detail="all done")
+ done = store.get(entry.install_id)
+ assert done.state == "installed"
+ assert done.finished_at is not None
+ assert done.detail == "all done"
+ assert done.error is None
+
+ def test_finish_failure(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="x1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ store.finish(entry.install_id, success=False, error="disk full")
+ done = store.get(entry.install_id)
+ assert done.state == "failed"
+ assert done.error == "disk full"
+ assert done.finished_at is not None
+
+ def test_finish_ignores_unknown_id(self):
+ store = self._store()
+ store.finish("ghost", success=True)
+
+ def test_list_by_app_filters_and_sorts_newest_first(self, monkeypatch):
+ import types
+ ids = ["id1", "id2", "id3"]
+ counter = iter(ids)
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex=next(counter))),
+ )
+ store = self._store()
+ store.start("app1")
+ store.start("app2")
+ store.start("app1")
+ results = store.list_by_app("app1")
+ assert len(results) == 2
+ assert all(e.app_id == "app1" for e in results)
+ assert results[0].started_at >= results[1].started_at
+
+ def test_list_by_app_returns_empty_for_unknown(self):
+ store = self._store()
+ assert store.list_by_app("nope") == []
+
+ def test_list_all_returns_all_entries(self, monkeypatch):
+ import types
+ ids = ["a1", "a2"]
+ counter = iter(ids)
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex=next(counter))),
+ )
+ store = self._store()
+ store.start("x")
+ store.start("y")
+ all_entries = store.list_all()
+ assert len(all_entries) == 2
+
+ def test_prune_removes_stale_finished_entries(self, monkeypatch):
+ import types
+ ids = ["keep", "stale"]
+ counter = iter(ids)
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex=next(counter))),
+ )
+ store = self._store()
+ fresh = store.start("app1")
+ old = store.start("app1")
+ store.finish(old.install_id, success=True)
+
+ # Make the old entry's finished_at far in the past
+ old_entry = store._entries[old.install_id]
+ old_entry.finished_at = time.time() - INSTALL_PROGRESS_TTL_S - 1
+ old_entry.started_at = old_entry.finished_at - 10
+
+ # Access should trigger prune
+ result = store.get(fresh.install_id)
+ assert result is not None
+ assert store.get(old.install_id) is None
+
+ def test_prune_keeps_recent_finished_entries(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="r1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ store.finish(entry.install_id, success=True)
+ # finished_at is "now", well within TTL
+ assert store.get(entry.install_id) is not None
+
+ def test_prune_keeps_unfinished_entries_forever(self, monkeypatch):
+ import types
+ monkeypatch.setattr(
+ "tinyagentos.install_progress.uuid",
+ types.SimpleNamespace(uuid4=lambda: types.SimpleNamespace(hex="u1")),
+ )
+ store = self._store()
+ entry = store.start("app1")
+ # Artificially age started_at but leave finished_at None
+ e = store._entries[entry.install_id]
+ e.started_at = 0.0
+ e.updated_at = 0.0
+ assert store.get(entry.install_id) is not None
+
+
+# ---- Global store singleton ---------------------------------------------------
+
+
+class TestGlobalStore:
+ def test_get_global_store_returns_singleton(self, monkeypatch):
+ import tinyagentos.install_progress as mod
+ monkeypatch.setattr(mod, "_GLOBAL", None)
+ s1 = get_global_store()
+ s2 = get_global_store()
+ assert s1 is s2
+
+ def test_get_global_store_creates_on_first_call(self, monkeypatch):
+ import tinyagentos.install_progress as mod
+ monkeypatch.setattr(mod, "_GLOBAL", None)
+ store = get_global_store()
+ assert isinstance(store, InstallProgressStore)
+ assert mod._GLOBAL is store
diff --git a/tests/test_litellm_config.py b/tests/test_litellm_config.py
new file mode 100644
index 000000000..5daac8baa
--- /dev/null
+++ b/tests/test_litellm_config.py
@@ -0,0 +1,916 @@
+"""Unit tests for tinyagentos/litellm_config.py.
+
+Tests config generation and master-key loading in isolation: no real LiteLLM
+process, no network calls, no live hardware reads.
+"""
+from __future__ import annotations
+
+import os
+import stat
+from pathlib import Path
+from unittest.mock import patch, AsyncMock
+
+import pytest
+
+import tinyagentos.litellm_config as cfg_mod
+from tinyagentos.litellm_config import (
+ get_litellm_master_key,
+ generate_litellm_config,
+ _is_embedding_model,
+ _local_backend_models_from_registry,
+ _discover_ollama_models,
+ _discover_ollama_backends_concurrent,
+ EMBEDDING_ALIAS,
+)
+
+
+@pytest.fixture(autouse=True)
+def clear_key_cache():
+ cfg_mod._master_key_cache.clear()
+ yield
+ cfg_mod._master_key_cache.clear()
+
+
+# ---------------------------------------------------------------------------
+# get_litellm_master_key
+# ---------------------------------------------------------------------------
+
+class TestGetLiteLLMMasterKey:
+
+ def test_returns_key_with_prefix(self, tmp_path):
+ key = get_litellm_master_key(tmp_path)
+ assert key.startswith("sk-taos-")
+
+ def test_persists_key_to_disk(self, tmp_path):
+ key = get_litellm_master_key(tmp_path)
+ key_file = tmp_path / ".litellm_master_key"
+ assert key_file.exists()
+ assert key_file.read_text().strip() == key
+
+ def test_key_file_mode_0600(self, tmp_path):
+ get_litellm_master_key(tmp_path)
+ key_file = tmp_path / ".litellm_master_key"
+ mode = stat.S_IMODE(key_file.stat().st_mode)
+ assert mode == 0o600
+
+ def test_returns_cached_key_on_repeated_call(self, tmp_path):
+ k1 = get_litellm_master_key(tmp_path)
+ k2 = get_litellm_master_key(tmp_path)
+ assert k1 is k2
+
+ def test_reads_existing_key_from_disk(self, tmp_path):
+ key_path = tmp_path / ".litellm_master_key"
+ key_path.write_text("sk-taos-persisted\n")
+ key = get_litellm_master_key(tmp_path)
+ assert key == "sk-taos-persisted"
+
+ def test_different_dirs_independent_keys(self, tmp_path):
+ da = tmp_path / "a"
+ db = tmp_path / "b"
+ da.mkdir()
+ db.mkdir()
+ ka = get_litellm_master_key(da)
+ kb = get_litellm_master_key(db)
+ assert ka != kb
+
+ def test_none_data_dir_returns_in_memory_key(self):
+ key = get_litellm_master_key(None)
+ assert key.startswith("sk-taos-")
+
+ def test_none_data_dir_uses_separate_cache(self):
+ k1 = get_litellm_master_key(None)
+ k2 = get_litellm_master_key(None)
+ assert k1 == k2
+
+ def test_none_dir_and_path_dir_cached_independently(self, tmp_path):
+ k_mem = get_litellm_master_key(None)
+ k_disk = get_litellm_master_key(tmp_path)
+ assert k_mem != k_disk
+
+ def test_file_exists_error_branch_reads_winner_key(self, tmp_path, monkeypatch):
+ winner = "sk-taos-winner"
+ (tmp_path / ".litellm_master_key").write_text(winner)
+
+ real_open = os.open
+
+ def _fake_open(path, flags, mode=0o666):
+ if "litellm_master_key" in str(path) and (flags & os.O_EXCL):
+ raise FileExistsError("simulated race")
+ return real_open(path, flags, mode)
+
+ monkeypatch.setattr(os, "open", _fake_open)
+ loaded = get_litellm_master_key(tmp_path)
+ assert loaded == winner
+
+ def test_empty_existing_file_raises_runtime_error(self, tmp_path, monkeypatch):
+ key_path = tmp_path / ".litellm_master_key"
+ key_path.write_text(" \n")
+
+ real_open = os.open
+
+ def _fake_open(path, flags, mode=0o666):
+ if "litellm_master_key" in str(path) and (flags & os.O_EXCL):
+ raise FileExistsError("simulated race")
+ return real_open(path, flags, mode)
+
+ monkeypatch.setattr(os, "open", _fake_open)
+ with pytest.raises(RuntimeError, match="empty"):
+ get_litellm_master_key(tmp_path)
+
+ def test_creates_parent_dirs_if_missing(self, tmp_path):
+ deep = tmp_path / "a" / "b" / "c"
+ key = get_litellm_master_key(deep)
+ assert key.startswith("sk-taos-")
+ assert (deep / ".litellm_master_key").exists()
+
+
+# ---------------------------------------------------------------------------
+# _is_embedding_model
+# ---------------------------------------------------------------------------
+
+class TestIsEmbeddingModel:
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "nomic-embed-text",
+ "qwen3-embedding-0.6b",
+ "mxbai-embed-large",
+ "text-embedding-ada-002",
+ "text-embedding-3-small",
+ ],
+ )
+ def test_embed_in_name(self, name):
+ assert _is_embedding_model(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "bge-small-en-v1.5",
+ "bge-m3",
+ "gte-large",
+ "gte-qwen2-7b-instruct",
+ "e5-large-v2",
+ "e5-mistral-7b-instruct",
+ "arctic-embed-l",
+ "snowflake-arctic-embed-m",
+ ],
+ )
+ def test_known_embedding_prefixes(self, name):
+ assert _is_embedding_model(name) is True
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "llama-3.1-8b",
+ "qwen2.5-7b-instruct",
+ "gemma-2-9b",
+ "mistral-7b",
+ ],
+ )
+ def test_chat_models_not_embedding(self, name):
+ assert _is_embedding_model(name) is False
+
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "jina-reranker-v2",
+ "bge-reranker-large",
+ "cohere-rerank-v3",
+ ],
+ )
+ def test_rerankers_not_embedding(self, name):
+ assert _is_embedding_model(name) is False
+
+ def test_case_insensitive(self):
+ assert _is_embedding_model("Nomic-Embed-Text") is True
+ assert _is_embedding_model("BGE-Small") is True
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- structure
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMStructure:
+
+ def test_returns_required_top_level_keys(self):
+ config = generate_litellm_config([])
+ assert set(config.keys()) == {
+ "model_list",
+ "router_settings",
+ "general_settings",
+ "litellm_settings",
+ }
+
+ def test_router_settings_defaults(self):
+ config = generate_litellm_config([])
+ rs = config["router_settings"]
+ assert rs["routing_strategy"] == "simple-shuffle"
+ assert rs["num_retries"] == 2
+ assert rs["timeout"] == 120
+ assert rs["enable_pre_call_checks"] is False
+
+ def test_general_settings_disable_spend_logs(self):
+ config = generate_litellm_config([])
+ gs = config["general_settings"]
+ assert gs["background_health_checks"] is False
+ assert gs["disable_spend_logs"] is True
+
+ def test_master_key_in_general_settings(self):
+ config = generate_litellm_config([], master_key="sk-taos-test")
+ assert config["general_settings"]["master_key"] == "sk-taos-test"
+
+ def test_master_key_auto_generated_when_not_supplied(self):
+ config = generate_litellm_config([])
+ mk = config["general_settings"]["master_key"]
+ assert mk.startswith("sk-taos-")
+
+ def test_litellm_settings_callbacks(self):
+ config = generate_litellm_config([])
+ assert (
+ config["litellm_settings"]["callbacks"]
+ == "taos_callback.proxy_handler_instance"
+ )
+
+ def test_empty_backends_empty_model_list(self):
+ config = generate_litellm_config([])
+ assert config["model_list"] == []
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- single ollama backend
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMOllamaBackend:
+
+ def test_single_backend_produces_default_entry(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://localhost:11434"}
+ ]
+ config = generate_litellm_config(backends)
+ ml = config["model_list"]
+ assert len(ml) == 1
+ entry = ml[0]
+ assert entry["model_name"] == "default"
+ assert entry["litellm_params"]["model"] == "ollama_chat/default"
+ assert entry["litellm_params"]["api_base"] == "http://localhost:11434"
+ assert entry["metadata"]["backend_name"] == "ollama-local"
+
+ def test_backend_model_overrides_default(self):
+ backends = [
+ {
+ "name": "ollama-local",
+ "type": "ollama",
+ "url": "http://localhost:11434",
+ "model": "llama3",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["model"] == "ollama_chat/llama3"
+
+ def test_url_trailing_slash_stripped(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://host:11434/"}
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["api_base"] == "http://host:11434"
+
+ def test_priority_passed_to_metadata(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://h:11434", "priority": 5}
+ ]
+ config = generate_litellm_config(backends)
+ assert config["model_list"][0]["metadata"]["priority"] == 5
+
+ def test_default_priority_is_99(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://h:11434"}
+ ]
+ config = generate_litellm_config(backends)
+ assert config["model_list"][0]["metadata"]["priority"] == 99
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- rkllama backend
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMRkllamaBackend:
+
+ def test_rkllama_uses_ollama_chat_prefix(self):
+ backends = [
+ {"name": "rk-box", "type": "rkllama", "url": "http://192.168.1.50:8080"}
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["model"] == "ollama_chat/default"
+ assert entry["litellm_params"]["api_base"] == "http://192.168.1.50:8080"
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- cloud backends
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMCloudBackend:
+
+ def test_openai_backend_with_declared_models(self):
+ backends = [
+ {
+ "name": "openai",
+ "type": "openai",
+ "models": ["gpt-4o", "gpt-4o-mini"],
+ "api_key": "sk-real-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ ml = config["model_list"]
+ # Two declared-model entries + one "default" entry
+ assert len(ml) == 3
+ # Declared models come first (cloud loop appends before default)
+ assert ml[0]["model_name"] == "gpt-4o"
+ assert ml[0]["litellm_params"]["model"] == "openai/gpt-4o"
+ assert ml[0]["litellm_params"]["api_key"] == "sk-real-key"
+ assert ml[1]["model_name"] == "gpt-4o-mini"
+ assert ml[1]["litellm_params"]["model"] == "openai/gpt-4o-mini"
+ # Default entry is last
+ assert ml[2]["model_name"] == "default"
+ assert ml[2]["litellm_params"]["model"] == "openai/default"
+
+ def test_cloud_backend_with_dict_models(self):
+ backends = [
+ {
+ "name": "openai",
+ "type": "openai",
+ "models": [{"id": "gpt-4o", "name": "GPT-4o"}],
+ "api_key": "sk-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ assert config["model_list"][0]["model_name"] == "gpt-4o"
+
+ def test_cloud_backend_with_api_key_secret(self):
+ backends = [
+ {
+ "name": "openai",
+ "type": "openai",
+ "models": ["gpt-4o"],
+ "api_key_secret": "OPENAI_API_KEY",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["api_key"] == "os.environ/OPENAI_API_KEY"
+
+ def test_cloud_backend_missing_url_or_models_logs_warning(self, caplog):
+ backends = [
+ {"name": "bad-cloud", "type": "openai"}
+ ]
+ config = generate_litellm_config(backends)
+ # Should still produce a default entry (the cloud warning is just a log)
+ assert any(e["model_name"] == "default" for e in config["model_list"])
+
+ def test_kilocode_sets_api_base(self):
+ backends = [
+ {
+ "name": "kilocode",
+ "type": "kilocode",
+ "url": "http://kilocode.example.com/v1",
+ "models": ["kimi-k2"],
+ "api_key": "kilo-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["model_name"] == "kimi-k2"
+ assert entry["litellm_params"]["api_base"] == "http://kilocode.example.com/v1"
+
+ def test_openrouter_with_url_sets_api_base(self):
+ backends = [
+ {
+ "name": "or",
+ "type": "openrouter",
+ "url": "https://openrouter.ai/api/v1",
+ "models": ["auto"],
+ "api_key": "or-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["api_base"] == "https://openrouter.ai/api/v1"
+
+ def test_anthropic_no_api_base_set(self):
+ backends = [
+ {
+ "name": "anth",
+ "type": "anthropic",
+ "models": ["claude-sonnet-4-20250514"],
+ "api_key": "ant-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert "api_base" not in entry["litellm_params"]
+
+ def test_deepseek_no_extra_api_base(self):
+ backends = [
+ {
+ "name": "ds",
+ "type": "deepseek",
+ "models": ["deepseek-chat"],
+ "api_key": "ds-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ # deepseek is native LiteLLM; no explicit api_base unless url given
+ assert "api_base" not in entry["litellm_params"]
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- priority sorting
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMPrioritySort:
+
+ def test_backends_sorted_by_priority(self):
+ backends = [
+ {"name": "low", "type": "ollama", "url": "http://low:11434", "priority": 10},
+ {"name": "high", "type": "ollama", "url": "http://high:11434", "priority": 1},
+ {"name": "mid", "type": "ollama", "url": "http://mid:11434", "priority": 5},
+ ]
+ config = generate_litellm_config(backends)
+ names = [e["metadata"]["backend_name"] for e in config["model_list"]]
+ assert names == ["high", "mid", "low"]
+
+ def test_same_priority_stable_order(self):
+ backends = [
+ {"name": "a", "type": "ollama", "url": "http://a:11434", "priority": 1},
+ {"name": "b", "type": "ollama", "url": "http://b:11434", "priority": 1},
+ ]
+ config = generate_litellm_config(backends)
+ names = [e["metadata"]["backend_name"] for e in config["model_list"]]
+ assert names == ["a", "b"]
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- embedding discovery
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMEmbeddingDiscovery:
+
+ def test_discovered_embedding_model_registered(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ discovered = {"http://h:11434": ["nomic-embed-text"]}
+ config = generate_litellm_config(backends, discovered=discovered)
+ ml = config["model_list"]
+ # default entry + embedding entry + alias entry
+ assert len(ml) == 3
+ embed_entry = ml[1]
+ assert embed_entry["model_name"] == "nomic-embed-text"
+ assert embed_entry["litellm_params"]["model"] == "ollama/nomic-embed-text"
+ assert embed_entry["model_info"]["mode"] == "embedding"
+ alias_entry = ml[2]
+ assert alias_entry["model_name"] == EMBEDDING_ALIAS
+ assert alias_entry["model_info"]["mode"] == "embedding"
+
+ def test_first_embedding_claims_alias(self):
+ backends = [
+ {"name": "o1", "type": "ollama", "url": "http://h1:11434"},
+ {"name": "o2", "type": "ollama", "url": "http://h2:11434"},
+ ]
+ discovered = {
+ "http://h1:11434": ["nomic-embed-text"],
+ "http://h2:11434": ["mxbai-embed-large"],
+ }
+ config = generate_litellm_config(backends, discovered=discovered)
+ alias_entries = [e for e in config["model_list"] if e["model_name"] == EMBEDDING_ALIAS]
+ assert len(alias_entries) == 1
+ # The alias should point to the first discovered embedding
+ assert alias_entries[0]["litellm_params"]["model"] == "ollama/nomic-embed-text"
+
+ def test_chat_models_in_discovered_not_registered_as_embedding(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ discovered = {"http://h:11434": ["llama3.1-8b", "qwen2.5-7b"]}
+ config = generate_litellm_config(backends, discovered=discovered)
+ ml = config["model_list"]
+ # Only the default entry; no embedding entries
+ assert len(ml) == 1
+ assert ml[0]["model_name"] == "default"
+
+ def test_reranker_in_discovered_not_registered_as_embedding(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ discovered = {"http://h:11434": ["bge-reranker-v2"]}
+ config = generate_litellm_config(backends, discovered=discovered)
+ ml = config["model_list"]
+ assert len(ml) == 1
+
+ def test_discovered_none_falls_back_to_probe(self):
+ """When discovered has None for a URL, _discover_ollama_models is called."""
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ discovered = {"http://h:11434": None}
+ with patch.object(cfg_mod, "_discover_ollama_models", return_value=[]):
+ config = generate_litellm_config(backends, discovered=discovered)
+ assert config["model_list"][0]["model_name"] == "default"
+
+ def test_mixed_chat_and_embedding_discovered(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ discovered = {"http://h:11434": ["llama3", "nomic-embed-text", "qwen2.5"]}
+ config = generate_litellm_config(backends, discovered=discovered)
+ ml = config["model_list"]
+ # default + embedding + alias
+ assert len(ml) == 3
+ assert ml[0]["model_name"] == "default"
+ assert ml[1]["model_name"] == "nomic-embed-text"
+ assert ml[2]["model_name"] == EMBEDDING_ALIAS
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- local backend with registry
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMLocalBackendModels:
+
+ def _make_registry(self, installed, manifests):
+ class _Reg:
+ def list_installed(self):
+ return installed
+ def get(self, mid):
+ return manifests.get(mid)
+ return _Reg()
+
+ def test_local_backend_registers_installed_models(self):
+ backends = [
+ {
+ "name": "local-rk-llama-cpp",
+ "type": "ollama",
+ "url": "http://192.168.1.50:8080",
+ }
+ ]
+ installed = [{"id": "gemma-4-e2b-gguf"}]
+ manifest = type("M", (), {
+ "type": "model",
+ "variants": [
+ {
+ "requires": {
+ "backends": [{"id": "rk-llama-cpp"}]
+ }
+ }
+ ],
+ })()
+ reg = self._make_registry(installed, {"gemma-4-e2b-gguf": manifest})
+ config = generate_litellm_config(backends, registry=reg)
+ ml = config["model_list"]
+ # default + local-installed model
+ assert len(ml) == 2
+ local_entry = ml[1]
+ assert local_entry["model_name"] == "gemma-4-e2b-gguf"
+ assert local_entry["litellm_params"]["model"] == "ollama_chat/gemma-4-e2b-gguf"
+ assert local_entry["litellm_params"]["api_base"] == "http://192.168.1.50:8080"
+ assert local_entry["metadata"]["source"] == "local-installed"
+
+ def test_non_local_backend_skips_registry(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ config = generate_litellm_config(backends, registry=type("R", (), {})())
+ assert len(config["model_list"]) == 1
+
+ def test_none_registry_skips_local_models(self):
+ backends = [
+ {
+ "name": "local-rk-llama-cpp",
+ "type": "ollama",
+ "url": "http://192.168.1.50:8080",
+ }
+ ]
+ config = generate_litellm_config(backends, registry=None)
+ assert len(config["model_list"]) == 1
+
+ def test_local_model_deduplicated_per_backend(self):
+ """Same manifest_id from the same backend should not produce duplicates."""
+ backends = [
+ {
+ "name": "local-rk-llama-cpp",
+ "type": "ollama",
+ "url": "http://192.168.1.50:8080",
+ }
+ ]
+ installed = [{"id": "gemma-4-e2b-gguf"}]
+ manifest = type("M", (), {
+ "type": "model",
+ "variants": [
+ {
+ "requires": {
+ "backends": [{"id": "rk-llama-cpp"}, {"id": "rk-llama-cpp"}]
+ }
+ }
+ ],
+ })()
+ reg = self._make_registry(installed, {"gemma-4-e2b-gguf": manifest})
+ config = generate_litellm_config(backends, registry=reg)
+ ml = config["model_list"]
+ # Should still be 2 (default + one local entry), not 3
+ assert len(ml) == 2
+
+ def test_manifest_non_model_type_skipped(self):
+ backends = [
+ {
+ "name": "local-rk-llama-cpp",
+ "type": "ollama",
+ "url": "http://192.168.1.50:8080",
+ }
+ ]
+ installed = [{"id": "not-a-model"}]
+ manifest = type("M", (), {"type": "dataset", "variants": []})()
+ reg = self._make_registry(installed, {"not-a-model": manifest})
+ config = generate_litellm_config(backends, registry=reg)
+ assert len(config["model_list"]) == 1
+
+ def test_manifest_without_get_method(self):
+ """Registry without .get() should not crash."""
+ backends = [
+ {
+ "name": "local-rk-llama-cpp",
+ "type": "ollama",
+ "url": "http://192.168.1.50:8080",
+ }
+ ]
+ installed = [{"id": "some-model"}]
+
+ class _Reg:
+ def list_installed(self):
+ return installed
+
+ config = generate_litellm_config(backends, registry=_Reg())
+ # some-model has no .get(), so manifest is None, skipped
+ assert len(config["model_list"]) == 1
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- api_key handling
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMApiKey:
+
+ def test_api_key_direct(self):
+ backends = [
+ {
+ "name": "custom",
+ "type": "openai-compatible",
+ "url": "http://custom:8080",
+ "api_key": "sk-custom-key",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["api_key"] == "sk-custom-key"
+
+ def test_api_key_secret_takes_precedence(self):
+ backends = [
+ {
+ "name": "custom",
+ "type": "openai-compatible",
+ "url": "http://custom:8080",
+ "api_key": "sk-direct",
+ "api_key_secret": "MY_SECRET",
+ }
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert entry["litellm_params"]["api_key"] == "os.environ/MY_SECRET"
+
+ def test_no_api_key_omitted(self):
+ backends = [
+ {"name": "ollama-local", "type": "ollama", "url": "http://h:11434"}
+ ]
+ config = generate_litellm_config(backends)
+ entry = config["model_list"][0]
+ assert "api_key" not in entry["litellm_params"]
+
+
+# ---------------------------------------------------------------------------
+# generate_litellm_config -- mixed backends
+# ---------------------------------------------------------------------------
+
+class TestGenerateLiteLLMMixedBackends:
+
+ def test_ollama_and_cloud(self):
+ backends = [
+ {"name": "local", "type": "ollama", "url": "http://h:11434", "priority": 1},
+ {
+ "name": "openai",
+ "type": "openai",
+ "models": ["gpt-4o"],
+ "api_key": "sk-oai",
+ "priority": 2,
+ },
+ ]
+ config = generate_litellm_config(backends)
+ ml = config["model_list"]
+ # local default + openai gpt-4o + openai default
+ assert len(ml) == 3
+ assert ml[0]["metadata"]["backend_name"] == "local"
+ assert ml[1]["model_name"] == "gpt-4o"
+ assert ml[2]["model_name"] == "default"
+ assert ml[2]["metadata"]["backend_name"] == "openai"
+
+ def test_custom_default_model_name(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://h:11434"}
+ ]
+ config = generate_litellm_config(backends, default_model="my-primary")
+ assert config["model_list"][0]["model_name"] == "my-primary"
+
+
+# ---------------------------------------------------------------------------
+# _discover_ollama_models (network -- mocked)
+# ---------------------------------------------------------------------------
+
+class TestDiscoverOllamaModels:
+
+ def test_returns_model_names_on_success(self):
+ class _Resp:
+ status_code = 200
+ def json(self):
+ return {"models": [{"name": "llama3"}, {"name": "qwen"}]}
+ with patch("tinyagentos.litellm_config.httpx.get", return_value=_Resp()):
+ result = _discover_ollama_models("http://h:11434")
+ assert result == ["llama3", "qwen"]
+
+ def test_returns_empty_on_non_200(self):
+ class _Resp:
+ status_code = 500
+ with patch("tinyagentos.litellm_config.httpx.get", return_value=_Resp()):
+ result = _discover_ollama_models("http://h:11434")
+ assert result == []
+
+ def test_returns_empty_on_exception(self):
+ with patch("tinyagentos.litellm_config.httpx.get", side_effect=ConnectionError):
+ result = _discover_ollama_models("http://h:11434")
+ assert result == []
+
+ def test_filters_out_models_without_name(self):
+ class _Resp:
+ status_code = 200
+ def json(self):
+ return {"models": [{"name": "llama3"}, {"size": 123}]}
+ with patch("tinyagentos.litellm_config.httpx.get", return_value=_Resp()):
+ result = _discover_ollama_models("http://h:11434")
+ assert result == ["llama3"]
+
+
+# ---------------------------------------------------------------------------
+# _discover_ollama_backends_concurrent (async -- mocked)
+# ---------------------------------------------------------------------------
+
+class TestDiscoverOllamaBackendsConcurrent:
+
+ @pytest.mark.asyncio
+ async def test_probes_all_ollama_urls(self):
+ backends = [
+ {"name": "o1", "type": "ollama", "url": "http://h1:11434"},
+ {"name": "o2", "type": "rkllama", "url": "http://h2:8080"},
+ ]
+ with patch(
+ "tinyagentos.litellm_config._discover_ollama_models",
+ side_effect=lambda url, timeout: (["llama3"] if "h1" in url else ["qwen"]),
+ ):
+ result = await _discover_ollama_backends_concurrent(backends)
+ assert result == {"http://h1:11434": ["llama3"], "http://h2:8080": ["qwen"]}
+
+ @pytest.mark.asyncio
+ async def test_non_ollama_backends_skipped(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://h:11434"},
+ {"name": "openai", "type": "openai"},
+ ]
+ with patch(
+ "tinyagentos.litellm_config._discover_ollama_models",
+ return_value=["llama3"],
+ ):
+ result = await _discover_ollama_backends_concurrent(backends)
+ assert result == {"http://h:11434": ["llama3"]}
+
+ @pytest.mark.asyncio
+ async def test_no_ollama_backends_returns_empty(self):
+ backends = [
+ {"name": "openai", "type": "openai"},
+ ]
+ result = await _discover_ollama_backends_concurrent(backends)
+ assert result == {}
+
+ @pytest.mark.asyncio
+ async def test_exception_returns_empty_list_for_that_url(self):
+ backends = [
+ {"name": "o", "type": "ollama", "url": "http://h:11434"},
+ ]
+ with patch(
+ "tinyagentos.litellm_config._discover_ollama_models",
+ side_effect=ConnectionError("fail"),
+ ):
+ result = await _discover_ollama_backends_concurrent(backends)
+ assert result == {"http://h:11434": []}
+
+ @pytest.mark.asyncio
+ async def test_backends_without_url_skipped(self):
+ backends = [
+ {"name": "o", "type": "ollama"},
+ ]
+ result = await _discover_ollama_backends_concurrent(backends)
+ assert result == {}
+
+
+# ---------------------------------------------------------------------------
+# _local_backend_models_from_registry
+# ---------------------------------------------------------------------------
+
+class TestLocalBackendModelsFromRegistry:
+
+ def _make_registry(self, installed, manifests):
+ class _Reg:
+ def list_installed(self):
+ return installed
+ def get(self, mid):
+ return manifests.get(mid)
+ return _Reg()
+
+ def test_returns_empty_for_none_registry(self):
+ backend = {"name": "local-foo"}
+ assert _local_backend_models_from_registry(backend, None) == []
+
+ def test_returns_empty_for_non_local_name(self):
+ backend = {"name": "ollama-local"}
+ reg = type("R", (), {})()
+ assert _local_backend_models_from_registry(backend, reg) == []
+
+ def test_returns_empty_for_empty_service_id(self):
+ backend = {"name": "local-"}
+ reg = type("R", (), {})()
+ assert _local_backend_models_from_registry(backend, reg) == []
+
+ def test_matches_installed_models(self):
+ backend = {"name": "local-my-service"}
+ installed = [{"id": "model-a"}, {"id": "model-b"}]
+ manifest_a = type("M", (), {
+ "type": "model",
+ "variants": [{"requires": {"backends": [{"id": "my-service"}]}}],
+ })()
+ manifest_b = type("M", (), {
+ "type": "model",
+ "variants": [{"requires": {"backends": [{"id": "other-service"}]}}],
+ })()
+ manifests = {"model-a": manifest_a, "model-b": manifest_b}
+
+ class _Reg:
+ def list_installed(self):
+ return installed
+ def get(self, mid):
+ return manifests.get(mid)
+
+ result = _local_backend_models_from_registry(backend, _Reg())
+ assert result == ["model-a"]
+
+ def test_list_installed_exception_returns_empty(self):
+ backend = {"name": "local-svc"}
+
+ class _Reg:
+ def list_installed(self):
+ raise Exception("fail")
+
+ assert _local_backend_models_from_registry(backend, _Reg()) == []
+
+ def test_non_dict_variant_skipped(self):
+ backend = {"name": "local-svc"}
+ installed = [{"id": "m1"}]
+ manifest = type("M", (), {
+ "type": "model",
+ "variants": ["not-a-dict"],
+ })()
+ reg = self._make_registry(installed, {"m1": manifest})
+ assert _local_backend_models_from_registry(backend, reg) == []
+
+ def test_deduplicates_matched_models(self):
+ """A model matched via two variants should appear once."""
+ backend = {"name": "local-svc"}
+ installed = [{"id": "m1"}]
+ manifest = type("M", (), {
+ "type": "model",
+ "variants": [
+ {"requires": {"backends": [{"id": "svc"}]}},
+ {"requires": {"backends": [{"id": "svc"}]}},
+ ],
+ })()
+ reg = self._make_registry(installed, {"m1": manifest})
+ result = _local_backend_models_from_registry(backend, reg)
+ assert result == ["m1"]
diff --git a/tests/test_manifest_route.py b/tests/test_manifest_route.py
new file mode 100644
index 000000000..a88e4dd79
--- /dev/null
+++ b/tests/test_manifest_route.py
@@ -0,0 +1,40 @@
+"""Tests for the dynamic PWA manifest endpoint (GET /manifest?app=)."""
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_manifest_messages_returns_200(client):
+ resp = await client.get("/manifest?app=messages")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_manifest_messages_has_correct_shape(client):
+ resp = await client.get("/manifest?app=messages")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["display"] == "standalone"
+ assert data["start_url"] == "/app.html?app=messages"
+ assert "name" in data
+ assert "short_name" in data
+ assert "icons" in data
+ assert len(data["icons"]) >= 2
+
+
+@pytest.mark.asyncio
+async def test_manifest_unknown_app_returns_404(client):
+ resp = await client.get("/manifest?app=unknown-app")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_manifest_non_pwa_app_returns_404(client):
+ # "files" is a real app that does not have pwa:true
+ resp = await client.get("/manifest?app=files")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_manifest_missing_param_returns_404(client):
+ resp = await client.get("/manifest?app=")
+ assert resp.status_code == 404
diff --git a/tests/test_mentions.py b/tests/test_mentions.py
new file mode 100644
index 000000000..89ab06067
--- /dev/null
+++ b/tests/test_mentions.py
@@ -0,0 +1,139 @@
+from tinyagentos.chat.mentions import MentionSet, parse_mentions
+
+
+def test_empty_members_returns_empty_explicit():
+ m = parse_mentions("@tom help", [])
+ assert m.explicit == ()
+ assert m.all is False
+ assert m.humans is False
+
+
+def test_no_mentions_in_text():
+ m = parse_mentions("hello world", ["tom", "don"])
+ assert m.explicit == ()
+ assert m.all is False
+ assert m.humans is False
+
+
+def test_none_text_returns_defaults():
+ m = parse_mentions(None, ["tom"])
+ assert m.explicit == ()
+ assert m.all is False
+ assert m.humans is False
+
+
+def test_members_case_insensitive_lookup():
+ m = parse_mentions("@Tom", ["TOM", "Don"])
+ assert m.explicit == ("tom",)
+
+
+def test_mention_with_hyphen_in_name():
+ m = parse_mentions("@ai-agent report", ["ai-agent", "tom"])
+ assert m.explicit == ("ai-agent",)
+
+
+def test_mention_with_underscore_in_name():
+ m = parse_mentions("@my_agent status", ["my_agent", "tom"])
+ assert m.explicit == ("my_agent",)
+
+
+def test_duplicate_mentions_deduped():
+ m = parse_mentions("@tom @tom @tom", ["tom"])
+ assert m.explicit == ("tom",)
+
+
+def test_all_and_humans_together():
+ m = parse_mentions("@all @humans listen up", ["tom"])
+ assert m.all is True
+ assert m.humans is True
+ assert m.explicit == ()
+
+
+def test_explicit_plus_special_mentions():
+ m = parse_mentions("@tom @all @don please", ["tom", "don"])
+ assert m.explicit == ("don", "tom")
+ assert m.all is True
+ assert m.humans is False
+
+
+def test_mention_at_start_of_text():
+ m = parse_mentions("@tom hello", ["tom"])
+ assert m.explicit == ("tom",)
+
+
+def test_mention_at_end_of_text():
+ m = parse_mentions("hello @tom", ["tom"])
+ assert m.explicit == ("tom",)
+
+
+def test_mention_with_no_space_around():
+ m = parse_mentions("hey@tom", ["tom"])
+ assert m.explicit == ()
+
+
+def test_partial_member_name_no_match():
+ m = parse_mentions("@to help", ["tom"])
+ assert m.explicit == ()
+
+
+def test_mention_substring_of_member_no_match():
+ m = parse_mentions("@tommy help", ["tom"])
+ assert m.explicit == ()
+
+
+def test_email_address_not_mention():
+ m = parse_mentions("user@example.com", ["example"])
+ assert m.explicit == ()
+
+
+def test_mention_followed_by_punctuation():
+ m = parse_mentions("@tom, are you there?", ["tom"])
+ assert m.explicit == ("tom",)
+
+
+def test_multiple_special_all_deduped():
+ m = parse_mentions("@all @all @all", ["tom"])
+ assert m.all is True
+ assert m.explicit == ()
+
+
+def test_mention_set_equality():
+ a = parse_mentions("@tom", ["tom"])
+ b = parse_mentions("@tom", ["tom"])
+ assert a == b
+ assert a == MentionSet(explicit=("tom",), all=False, humans=False)
+
+
+def test_mention_set_inequality():
+ a = parse_mentions("@tom", ["tom"])
+ b = parse_mentions("@don", ["tom", "don"])
+ assert a != b
+
+
+def test_mention_set_with_all_true_inequality():
+ a = parse_mentions("@all", ["tom"])
+ b = parse_mentions("", ["tom"])
+ assert a != b
+
+
+def test_empty_text_empty_members():
+ m = parse_mentions("", [])
+ assert m.explicit == ()
+ assert m.all is False
+ assert m.humans is False
+
+
+def test_whitespace_only_text():
+ m = parse_mentions(" ", ["tom"])
+ assert m.explicit == ()
+ assert m.all is False
+
+
+def test_mention_with_trailing_underscore_not_matched():
+ m = parse_mentions("@tom_ help", ["tom"])
+ assert m.explicit == ()
+
+
+def test_mention_with_trailing_dash_not_matched():
+ m = parse_mentions("@tom- help", ["tom"])
+ assert m.explicit == ()
diff --git a/tests/test_notifications_mark_all.py b/tests/test_notifications_mark_all.py
new file mode 100644
index 000000000..985864b1e
--- /dev/null
+++ b/tests/test_notifications_mark_all.py
@@ -0,0 +1,34 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_mark_all_read_returns_count(client):
+ store = client._transport.app.state.notifications
+ await store.add("A", "a")
+ await store.add("B", "b")
+ await store.add("C", "c")
+ assert await store.unread_count() == 3
+ resp = await client.post("/api/notifications/mark-all-read")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["marked"] == 3
+ assert await store.unread_count() == 0
+
+
+@pytest.mark.asyncio
+async def test_mark_all_read_idempotent(client):
+ store = client._transport.app.state.notifications
+ await store.add("A", "a")
+ resp = await client.post("/api/notifications/mark-all-read")
+ assert resp.status_code == 200
+ assert resp.json()["marked"] == 1
+ resp = await client.post("/api/notifications/mark-all-read")
+ assert resp.status_code == 200
+ assert resp.json()["marked"] == 0
+
+
+@pytest.mark.asyncio
+async def test_mark_all_read_no_notifications(client):
+ resp = await client.post("/api/notifications/mark-all-read")
+ assert resp.status_code == 200
+ assert resp.json()["marked"] == 0
diff --git a/tests/test_notifications_prefs.py b/tests/test_notifications_prefs.py
new file mode 100644
index 000000000..4eb9a9573
--- /dev/null
+++ b/tests/test_notifications_prefs.py
@@ -0,0 +1,62 @@
+import pytest
+
+from tinyagentos.notifications import NotificationStore
+
+
+@pytest.mark.asyncio
+async def test_get_prefs_returns_all_event_types_default_unmuted(client):
+ r = await client.get("/api/notifications/prefs")
+ assert r.status_code == 200
+ prefs = r.json()
+ assert len(prefs) == len(NotificationStore.EVENT_TYPES)
+ assert {p["event_type"] for p in prefs} == set(NotificationStore.EVENT_TYPES)
+ for pref in prefs:
+ assert pref["muted"] is False
+
+
+@pytest.mark.asyncio
+async def test_put_valid_event_sets_muted_and_get_reflects(client):
+ event_type = next(iter(NotificationStore.EVENT_TYPES))
+ r = await client.put(
+ f"/api/notifications/prefs/{event_type}",
+ json={"muted": True},
+ )
+ assert r.status_code == 200
+ assert r.json() == {"event_type": event_type, "muted": True}
+
+ r = await client.get("/api/notifications/prefs")
+ assert r.status_code == 200
+ worker = next(p for p in r.json() if p["event_type"] == event_type)
+ assert worker["muted"] is True
+
+
+@pytest.mark.asyncio
+async def test_put_unknown_event_type_returns_404(client):
+ r = await client.put(
+ "/api/notifications/prefs/not.a.real.event",
+ json={"muted": True},
+ )
+ assert r.status_code == 404
+ assert r.json()["error"] == "unknown_event_type"
+
+
+@pytest.mark.asyncio
+async def test_put_invalid_or_missing_body_returns_400(client):
+ r = await client.put("/api/notifications/prefs/worker.join")
+ assert r.status_code == 400
+
+ r = await client.put(
+ "/api/notifications/prefs/worker.join",
+ content=b"{not valid json",
+ headers={"Content-Type": "application/json"},
+ )
+ assert r.status_code == 400
+
+ r = await client.put("/api/notifications/prefs/worker.join", json={})
+ assert r.status_code == 400
+
+ r = await client.put(
+ "/api/notifications/prefs/worker.join",
+ json={"muted": "yes"},
+ )
+ assert r.status_code == 400
\ No newline at end of file
diff --git a/tests/test_pairing_store.py b/tests/test_pairing_store.py
new file mode 100644
index 000000000..535c17f91
--- /dev/null
+++ b/tests/test_pairing_store.py
@@ -0,0 +1,498 @@
+import hashlib
+import time
+
+import pytest
+
+from tinyagentos.cluster.pairing_store import ClusterPairingStore, _EXPIRY_SECS, _MAX_ATTEMPTS
+
+
+async def _store(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ await s.init()
+ return s
+
+
+def _hash(code: str) -> str:
+ return hashlib.sha256(code.encode()).hexdigest()
+
+
+# ------------------------------------------------------------------
+# announce
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_announce_creates_row(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ row = await s._fetch_row("w1")
+ assert row is not None
+ assert row["name"] == "w1"
+ assert row["pending_url"] == "http://w1:8080"
+ assert row["pending_platform"] == "linux"
+ assert row["pending_code_hash"] == _hash("code1")
+ assert row["confirmed"] == 0
+ assert row["claim_attempts"] == 0
+ assert row["signing_key"] is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_announce_resets_confirmed_and_attempts(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ row = await s._fetch_row("w1")
+ assert row["confirmed"] == 1
+
+ # re-announce should reset confirmed and attempts
+ await s.announce("w1", "http://w1:9090", "macos", _hash("code2"))
+ row = await s._fetch_row("w1")
+ assert row["confirmed"] == 0
+ assert row["claim_attempts"] == 0
+ assert row["pending_url"] == "http://w1:9090"
+ assert row["pending_platform"] == "macos"
+ assert row["pending_code_hash"] == _hash("code2")
+ # signing_key from the first confirm must survive the re-announce
+ assert row["signing_key"] is not None
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# confirm
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_confirm_success(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ result = await s.confirm("w1", "code1")
+ assert result is True
+ row = await s._fetch_row("w1")
+ assert row["confirmed"] == 1
+ assert row["signing_key"] is not None
+ assert len(row["signing_key"]) == 32
+ assert row["confirmed_ts"] is not None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_confirm_wrong_code_returns_false_and_increments_attempts(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ result = await s.confirm("w1", "wrong")
+ assert result is False
+ row = await s._fetch_row("w1")
+ assert row["claim_attempts"] == 1
+ assert row["confirmed"] == 0
+ assert row["signing_key"] is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_confirm_unknown_name_returns_false(tmp_path):
+ s = await _store(tmp_path)
+ result = await s.confirm("nonexistent", "code1")
+ assert result is False
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_confirm_expired_returns_false(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ # force the pending_ts to be past the expiry window
+ old_ts = time.time() - _EXPIRY_SECS - 1
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (old_ts, "w1"),
+ )
+ await s._db.commit()
+ result = await s.confirm("w1", "code1")
+ assert result is False
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_confirm_max_attempts_returns_false(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ # exhaust attempts with wrong codes
+ for _ in range(_MAX_ATTEMPTS):
+ await s.confirm("w1", "wrong")
+ row = await s._fetch_row("w1")
+ assert row["claim_attempts"] == _MAX_ATTEMPTS
+ # even the right code should now fail
+ result = await s.confirm("w1", "code1")
+ assert result is False
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# claim
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_claim_success_returns_key_and_clears_pending(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ key = await s.claim("w1", "code1")
+ assert key is not None
+ assert isinstance(key, bytes)
+ assert len(key) == 32
+ row = await s._fetch_row("w1")
+ assert row["pending_code_hash"] is None
+ assert row["pending_url"] is None
+ assert row["pending_platform"] is None
+ assert row["pending_ts"] is None
+ assert row["confirmed"] == 0
+ assert row["claim_attempts"] == 0
+ # signing_key stays in the db after claim
+ assert row["signing_key"] is not None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_claim_before_confirm_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ key = await s.claim("w1", "code1")
+ assert key is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_claim_wrong_code_returns_none_and_increments_attempts(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ key = await s.claim("w1", "wrong")
+ assert key is None
+ row = await s._fetch_row("w1")
+ assert row["claim_attempts"] == 1
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_claim_unknown_name_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ key = await s.claim("nonexistent", "code1")
+ assert key is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_claim_expired_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ old_ts = time.time() - _EXPIRY_SECS - 1
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (old_ts, "w1"),
+ )
+ await s._db.commit()
+ key = await s.claim("w1", "code1")
+ assert key is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_claim_max_attempts_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ # exhaust attempts with wrong codes
+ for _ in range(_MAX_ATTEMPTS):
+ await s.claim("w1", "wrong")
+ key = await s.claim("w1", "code1")
+ assert key is None
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# get_signing_key
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_get_signing_key_returns_none_when_not_paired(tmp_path):
+ s = await _store(tmp_path)
+ key = await s.get_signing_key("w1")
+ assert key is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_signing_key_returns_none_before_confirm(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ key = await s.get_signing_key("w1")
+ assert key is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_signing_key_returns_key_after_confirm(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ key = await s.get_signing_key("w1")
+ assert key is not None
+ assert isinstance(key, bytes)
+ assert len(key) == 32
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_signing_key_persists_after_claim(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ claimed = await s.claim("w1", "code1")
+ key = await s.get_signing_key("w1")
+ assert key is not None
+ assert key == claimed
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# record_failed_attempt
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_record_failed_attempt_increments_counter(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.record_failed_attempt("w1")
+ row = await s._fetch_row("w1")
+ assert row["claim_attempts"] == 1
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_record_failed_attempt_on_unknown_name_noop(tmp_path):
+ s = await _store(tmp_path)
+ # should not raise
+ await s.record_failed_attempt("nonexistent")
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# pairing_state
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_returns_none_for_unknown(tmp_path):
+ s = await _store(tmp_path)
+ state = await s.pairing_state("nonexistent")
+ assert state is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_after_announce(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ state = await s.pairing_state("w1")
+ assert state is not None
+ assert state["has_pending"] is True
+ assert state["confirmed"] is False
+ assert state["expired"] is False
+ assert state["attempts_capped"] is False
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_after_confirm(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ state = await s.pairing_state("w1")
+ assert state["has_pending"] is True
+ assert state["confirmed"] is True
+ assert state["expired"] is False
+ assert state["attempts_capped"] is False
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_expired(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ old_ts = time.time() - _EXPIRY_SECS - 1
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (old_ts, "w1"),
+ )
+ await s._db.commit()
+ state = await s.pairing_state("w1")
+ assert state["expired"] is True
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_attempts_capped(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ for _ in range(_MAX_ATTEMPTS):
+ await s.confirm("w1", "wrong")
+ state = await s.pairing_state("w1")
+ assert state["attempts_capped"] is True
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_no_pending_after_claim(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.confirm("w1", "code1")
+ await s.claim("w1", "code1")
+ state = await s.pairing_state("w1")
+ assert state["has_pending"] is False
+ assert state["confirmed"] is False
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# list_pending
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_pending_empty(tmp_path):
+ s = await _store(tmp_path)
+ pending = await s.list_pending()
+ assert pending == []
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_returns_announced_workers(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.announce("w2", "http://w2:8080", "macos", _hash("code2"))
+ pending = await s.list_pending()
+ assert len(pending) == 2
+ names = {p["name"] for p in pending}
+ assert names == {"w1", "w2"}
+ for p in pending:
+ assert "url" in p
+ assert "platform" in p
+ assert "announced_at" in p
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_excludes_confirmed(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.announce("w2", "http://w2:8080", "macos", _hash("code2"))
+ await s.confirm("w1", "code1")
+ pending = await s.list_pending()
+ assert len(pending) == 1
+ assert pending[0]["name"] == "w2"
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_excludes_expired(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ old_ts = time.time() - _EXPIRY_SECS - 1
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (old_ts, "w1"),
+ )
+ await s._db.commit()
+ pending = await s.list_pending()
+ assert pending == []
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_excludes_max_attempts(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ for _ in range(_MAX_ATTEMPTS):
+ await s.confirm("w1", "wrong")
+ pending = await s.list_pending()
+ assert pending == []
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_pending_ordered_by_announced_at(tmp_path):
+ s = await _store(tmp_path)
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+ await s.announce("w2", "http://w2:8080", "macos", _hash("code2"))
+ # force distinct pending_ts so ordering is deterministic regardless of
+ # clock granularity
+ older_ts = time.time() - 10
+ newer_ts = time.time()
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (older_ts, "w1"),
+ )
+ await s._db.execute(
+ "UPDATE cluster_pairings SET pending_ts = ? WHERE name = ?",
+ (newer_ts, "w2"),
+ )
+ await s._db.commit()
+ pending = await s.list_pending()
+ assert pending[0]["name"] == "w1"
+ assert pending[1]["name"] == "w2"
+ assert pending[0]["announced_at"] <= pending[1]["announced_at"]
+ await s.close()
+
+
+# ------------------------------------------------------------------
+# uninitialised store raises
+# ------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_announce_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.announce("w1", "http://w1:8080", "linux", _hash("code1"))
+
+
+@pytest.mark.asyncio
+async def test_confirm_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.confirm("w1", "code1")
+
+
+@pytest.mark.asyncio
+async def test_claim_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.claim("w1", "code1")
+
+
+@pytest.mark.asyncio
+async def test_get_signing_key_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.get_signing_key("w1")
+
+
+@pytest.mark.asyncio
+async def test_pairing_state_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.pairing_state("w1")
+
+
+@pytest.mark.asyncio
+async def test_list_pending_without_init_raises(tmp_path):
+ s = ClusterPairingStore(tmp_path / "pairings.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.list_pending()
diff --git a/tests/test_project_events.py b/tests/test_project_events.py
new file mode 100644
index 000000000..f23f94bb0
--- /dev/null
+++ b/tests/test_project_events.py
@@ -0,0 +1,103 @@
+import asyncio
+import time
+
+import pytest
+
+from tinyagentos.projects.events import ProjectEvent, ProjectEventBroker
+
+
+def test_project_event_stores_kind_and_payload():
+ ev = ProjectEvent(kind="task.created", payload={"id": "tsk-1", "title": "Demo"})
+ assert ev.kind == "task.created"
+ assert ev.payload == {"id": "tsk-1", "title": "Demo"}
+
+
+def test_project_event_defaults_timestamp():
+ before = time.time()
+ ev = ProjectEvent(kind="ping", payload={})
+ after = time.time()
+ assert before <= ev.ts <= after
+
+
+@pytest.mark.asyncio
+async def test_subscribe_receives_live_publish():
+ broker = ProjectEventBroker()
+ queue = await broker.subscribe("alpha")
+ await broker.publish("alpha", ProjectEvent(kind="task.updated", payload={"id": "t1"}))
+ ev = await asyncio.wait_for(queue.get(), timeout=0.5)
+ assert ev.kind == "task.updated"
+ assert ev.payload == {"id": "t1"}
+
+
+@pytest.mark.asyncio
+async def test_late_subscriber_replays_buffered_events():
+ broker = ProjectEventBroker(replay_size=8)
+ await broker.publish("beta", ProjectEvent(kind="a", payload={"n": 1}))
+ await broker.publish("beta", ProjectEvent(kind="b", payload={"n": 2}))
+
+ queue = await broker.subscribe("beta")
+ first = await asyncio.wait_for(queue.get(), timeout=0.5)
+ second = await asyncio.wait_for(queue.get(), timeout=0.5)
+ assert first.kind == "a"
+ assert second.kind == "b"
+
+
+@pytest.mark.asyncio
+async def test_publish_delivers_same_event_to_all_subscribers():
+ broker = ProjectEventBroker()
+ q1 = await broker.subscribe("gamma")
+ q2 = await broker.subscribe("gamma")
+ event = ProjectEvent(kind="task.claimed", payload={"agent": "grok"})
+ await broker.publish("gamma", event)
+
+ ev1 = await asyncio.wait_for(q1.get(), timeout=0.5)
+ ev2 = await asyncio.wait_for(q2.get(), timeout=0.5)
+ assert ev1 is event
+ assert ev2 is event
+
+
+@pytest.mark.asyncio
+async def test_events_do_not_cross_project_channels():
+ broker = ProjectEventBroker()
+ q_a = await broker.subscribe("proj-a")
+ q_b = await broker.subscribe("proj-b")
+ await broker.publish("proj-a", ProjectEvent(kind="task.created", payload={"id": "x"}))
+
+ ev = await asyncio.wait_for(q_a.get(), timeout=0.5)
+ assert ev.payload["id"] == "x"
+ with pytest.raises(asyncio.TimeoutError):
+ await asyncio.wait_for(q_b.get(), timeout=0.1)
+
+
+@pytest.mark.asyncio
+async def test_unsubscribe_stops_delivery():
+ broker = ProjectEventBroker()
+ queue = await broker.subscribe("delta")
+ await broker.unsubscribe("delta", queue)
+ await broker.publish("delta", ProjectEvent(kind="task.closed", payload={"id": "t9"}))
+ with pytest.raises(asyncio.TimeoutError):
+ await asyncio.wait_for(queue.get(), timeout=0.1)
+
+
+@pytest.mark.asyncio
+async def test_replay_buffer_drops_oldest_when_full():
+ broker = ProjectEventBroker(replay_size=3)
+ for i in range(6):
+ await broker.publish("epsilon", ProjectEvent(kind="tick", payload={"i": i}))
+
+ queue = await broker.subscribe("epsilon")
+ replayed = []
+ for _ in range(3):
+ replayed.append(await asyncio.wait_for(queue.get(), timeout=0.5))
+ assert [e.payload["i"] for e in replayed] == [3, 4, 5]
+
+
+@pytest.mark.asyncio
+async def test_unsubscribe_missing_queue_is_noop():
+ broker = ProjectEventBroker()
+ foreign = asyncio.Queue()
+ await broker.unsubscribe("zeta", foreign)
+ queue = await broker.subscribe("zeta")
+ await broker.publish("zeta", ProjectEvent(kind="ok", payload={}))
+ ev = await asyncio.wait_for(queue.get(), timeout=0.5)
+ assert ev.kind == "ok"
\ No newline at end of file
diff --git a/tests/test_project_folders.py b/tests/test_project_folders.py
new file mode 100644
index 000000000..014998638
--- /dev/null
+++ b/tests/test_project_folders.py
@@ -0,0 +1,84 @@
+from pathlib import Path
+
+import yaml
+
+from tinyagentos.projects.folders import (
+ ensure_project_layout,
+ project_dir,
+ read_project_yaml,
+ write_project_yaml,
+)
+
+
+def test_project_dir_joins_root_and_slug(tmp_path: Path):
+ assert project_dir(tmp_path, "my-project") == tmp_path / "my-project"
+
+
+def test_ensure_project_layout_creates_subdirs_and_readme(tmp_path: Path):
+ base = ensure_project_layout(tmp_path, "alpha", name="Alpha Project")
+
+ assert base == tmp_path / "alpha"
+ for sub in ("memory", "canvas", "files"):
+ assert (base / sub).is_dir()
+
+ readme = base / "README.md"
+ assert readme.is_file()
+ assert "Alpha Project" in readme.read_text()
+ assert "taOS" in readme.read_text()
+
+
+def test_ensure_project_layout_uses_slug_when_name_omitted(tmp_path: Path):
+ base = ensure_project_layout(tmp_path, "beta-slug")
+
+ readme = base / "README.md"
+ assert "beta-slug" in readme.read_text()
+
+
+def test_ensure_project_layout_idempotent_readme(tmp_path: Path):
+ base = ensure_project_layout(tmp_path, "gamma", name="Gamma")
+ readme = base / "README.md"
+ readme.write_text("custom readme")
+
+ ensure_project_layout(tmp_path, "gamma", name="Other Name")
+
+ assert readme.read_text() == "custom readme"
+
+
+def test_ensure_project_layout_idempotent_subdirs(tmp_path: Path):
+ base = ensure_project_layout(tmp_path, "delta")
+ marker = base / "memory" / "marker.txt"
+ marker.write_text("keep")
+
+ ensure_project_layout(tmp_path, "delta")
+
+ assert marker.read_text() == "keep"
+
+
+def test_write_project_yaml_creates_file(tmp_path: Path):
+ payload = {"name": "Demo", "slug": "demo", "labels": ["test"]}
+
+ target = write_project_yaml(tmp_path, "demo", payload)
+
+ assert target == tmp_path / "demo" / "project.yaml"
+ assert target.is_file()
+ loaded = yaml.safe_load(target.read_text())
+ assert loaded == payload
+
+
+def test_write_project_yaml_overwrites_existing(tmp_path: Path):
+ write_project_yaml(tmp_path, "echo", {"version": 1})
+ write_project_yaml(tmp_path, "echo", {"version": 2})
+
+ loaded = read_project_yaml(tmp_path, "echo")
+ assert loaded == {"version": 2}
+
+
+def test_read_project_yaml_returns_none_when_missing(tmp_path: Path):
+ assert read_project_yaml(tmp_path, "missing") is None
+
+
+def test_read_project_yaml_round_trip(tmp_path: Path):
+ payload = {"slug": "fox", "nested": {"a": 1, "b": [2, 3]}}
+ write_project_yaml(tmp_path, "fox", payload)
+
+ assert read_project_yaml(tmp_path, "fox") == payload
\ No newline at end of file
diff --git a/tests/test_project_lifecycle.py b/tests/test_project_lifecycle.py
new file mode 100644
index 000000000..ef653fadd
--- /dev/null
+++ b/tests/test_project_lifecycle.py
@@ -0,0 +1,115 @@
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from tinyagentos.projects.lifecycle import index_closed_task
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_upserts_full_document():
+ qmd = AsyncMock()
+ task = {
+ "id": "tsk-aaa",
+ "title": "Draft chapter",
+ "body": "Outline notes",
+ "closed_at": 1700000000.0,
+ "closed_by": "agent-1",
+ "labels": ["docs", "mvp"],
+ }
+ project = {"id": "prj-bbb", "slug": "alpha"}
+
+ await index_closed_task(qmd, project, task)
+
+ qmd.upsert_document.assert_awaited_once_with(
+ collection="project-alpha",
+ path="tasks/tsk-aaa.md",
+ title="Draft chapter",
+ body="Draft chapter\n\nOutline notes",
+ tags=[
+ "project:prj-bbb",
+ "task:tsk-aaa",
+ "closed:2023-11-14",
+ "label:docs",
+ "label:mvp",
+ ],
+ metadata={
+ "task_id": "tsk-aaa",
+ "project_id": "prj-bbb",
+ "closed_at": 1700000000.0,
+ "closed_by": "agent-1",
+ },
+ )
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_defaults_closed_at_to_now():
+ qmd = AsyncMock()
+ fixed = 1710000000.0
+ iso_date = datetime.fromtimestamp(fixed, tz=timezone.utc).strftime("%Y-%m-%d")
+ task = {"id": "tsk-now", "title": "Quick fix"}
+ project = {"id": "prj-1", "slug": "beta"}
+
+ with patch("tinyagentos.projects.lifecycle.time.time", return_value=fixed):
+ await index_closed_task(qmd, project, task)
+
+ kwargs = qmd.upsert_document.await_args.kwargs
+ assert kwargs["tags"][2] == f"closed:{iso_date}"
+ assert kwargs["metadata"]["closed_at"] == fixed
+ assert kwargs["metadata"]["closed_by"] is None
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_title_only_body():
+ qmd = AsyncMock()
+ task = {"id": "tsk-title", "title": "Only title", "closed_at": 1.0}
+ project = {"id": "prj-1", "slug": "gamma"}
+
+ await index_closed_task(qmd, project, task)
+
+ kwargs = qmd.upsert_document.await_args.kwargs
+ assert kwargs["body"] == "Only title"
+ assert kwargs["title"] == "Only title"
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_body_only_uses_task_id_title():
+ qmd = AsyncMock()
+ task = {"id": "tsk-body", "body": "Details only", "closed_at": 1.0}
+ project = {"id": "prj-1", "slug": "delta"}
+
+ await index_closed_task(qmd, project, task)
+
+ kwargs = qmd.upsert_document.await_args.kwargs
+ assert kwargs["body"] == "Details only"
+ assert kwargs["title"] == "tsk-body"
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_empty_title_and_body():
+ qmd = AsyncMock()
+ task = {"id": "tsk-empty", "closed_at": 1.0}
+ project = {"id": "prj-1", "slug": "epsilon"}
+
+ await index_closed_task(qmd, project, task)
+
+ kwargs = qmd.upsert_document.await_args.kwargs
+ assert kwargs["body"] == ""
+ assert kwargs["title"] == "tsk-empty"
+ assert kwargs["tags"] == [
+ "project:prj-1",
+ "task:tsk-empty",
+ "closed:1970-01-01",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_index_closed_task_omits_label_tags_when_none():
+ qmd = AsyncMock()
+ task = {"id": "tsk-plain", "title": "Plain", "closed_at": 1.0, "labels": None}
+ project = {"id": "prj-1", "slug": "zeta"}
+
+ await index_closed_task(qmd, project, task)
+
+ kwargs = qmd.upsert_document.await_args.kwargs
+ assert all(not tag.startswith("label:") for tag in kwargs["tags"])
\ No newline at end of file
diff --git a/tests/test_project_storybook.py b/tests/test_project_storybook.py
new file mode 100644
index 000000000..19a39ca01
--- /dev/null
+++ b/tests/test_project_storybook.py
@@ -0,0 +1,140 @@
+from pathlib import Path
+
+import pytest
+from PIL import Image, ImageDraw, ImageFont
+
+from tinyagentos.projects.storybook import (
+ PAGE_H,
+ PAGE_W,
+ _fit_cover,
+ _load_font,
+ _open,
+ _placeholder,
+ _render_cover,
+ _render_page,
+ _wrap,
+ render_storybook_pdf,
+)
+
+
+def _img(tmp: Path, name: str, size: tuple[int, int], colour) -> Path:
+ p = tmp / name
+ Image.new("RGB", size, colour).save(p)
+ return p
+
+
+def test_open_returns_none_for_missing_or_empty():
+ assert _open(None) is None
+ assert _open("") is None
+ assert _open("/no/such/file.png") is None
+
+
+def test_open_loads_valid_image(tmp_path: Path):
+ p = _img(tmp_path, "art.png", (120, 80), (40, 80, 120))
+ img = _open(p)
+ assert img is not None
+ assert img.size == (120, 80)
+ assert img.mode == "RGB"
+
+
+def test_placeholder_matches_box_dimensions():
+ img = _placeholder(400, 300)
+ assert img.size == (400, 300)
+ assert img.mode == "RGB"
+
+
+def test_render_cover_without_art_uses_placeholder():
+ page = _render_cover("Brave Fox", author="taOS", cover=None)
+ assert page.size == (PAGE_W, PAGE_H)
+
+
+def test_render_cover_includes_author_line():
+ page = _render_cover("Title", author="Jay", cover=None)
+ # Spot-check pixels differ from a cover with no author (title-only layout).
+ no_author = _render_cover("Title", author=None, cover=None)
+ assert page.tobytes() != no_author.tobytes()
+
+
+def test_render_cover_defaults_untitled():
+ page = _render_cover("", author=None, cover=None)
+ assert page.size == (PAGE_W, PAGE_H)
+
+
+def test_render_page_numbers_and_caption(tmp_path: Path):
+ art = _img(tmp_path, "p1.png", (600, 400), (200, 100, 50))
+ page = _render_page("The fox ran fast.", art, number=3)
+ assert page.size == (PAGE_W, PAGE_H)
+ # Bottom band should differ from a page with different text.
+ other = _render_page("Different caption.", art, number=3)
+ assert page.tobytes() != other.tobytes()
+
+
+def test_render_page_uses_placeholder_when_image_missing(tmp_path: Path):
+ page = _render_page("text only", tmp_path / "missing.png", number=1)
+ assert page.size == (PAGE_W, PAGE_H)
+
+
+def test_load_font_returns_usable_font():
+ font = _load_font(24, ("/no/such/font.ttf",))
+ draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
+ assert draw.textlength("x", font=font) >= 0
+
+
+def test_wrap_empty_text_returns_blank_line():
+ draw = ImageDraw.Draw(Image.new("RGB", (100, 100)))
+ font = ImageFont.load_default()
+ assert _wrap(draw, "", font, 200) == [""]
+
+
+def test_wrap_single_long_word_splits_to_one_line():
+ draw = ImageDraw.Draw(Image.new("RGB", (100, 100)))
+ font = ImageFont.load_default()
+ lines = _wrap(draw, "supercalifragilistic", font, 10)
+ assert len(lines) == 1
+ assert lines[0] == "supercalifragilistic"
+
+
+def test_fit_cover_portrait_and_landscape_fill_box():
+ portrait = Image.new("RGB", (100, 300))
+ landscape = Image.new("RGB", (300, 100))
+ assert _fit_cover(portrait, 200, 200).size == (200, 200)
+ assert _fit_cover(landscape, 200, 200).size == (200, 200)
+
+
+def test_render_storybook_pdf_creates_parent_dirs(tmp_path: Path):
+ out = tmp_path / "nested" / "exports" / "book.pdf"
+ render_storybook_pdf(title="Empty", pages=[], out_path=out)
+ assert out.is_file()
+ assert out.read_bytes()[:5] == b"%PDF-"
+
+
+def test_render_storybook_pdf_honors_explicit_cover_image(tmp_path: Path):
+ page_img = _img(tmp_path, "page.png", (300, 200), (10, 20, 30))
+ cover_img = _img(tmp_path, "cover.png", (300, 200), (200, 10, 10))
+ out = tmp_path / "book.pdf"
+ render_storybook_pdf(
+ title="Cover Test",
+ pages=[{"text": "one", "image": page_img}],
+ out_path=out,
+ cover_image=cover_img,
+ )
+ assert out.is_file()
+
+
+def test_render_storybook_pdf_multipage_structure(tmp_path: Path):
+ a = _img(tmp_path, "a.png", (400, 300), (255, 0, 0))
+ b = _img(tmp_path, "b.png", (400, 300), (0, 255, 0))
+ out = tmp_path / "book.pdf"
+ path = render_storybook_pdf(
+ title="Two Pages",
+ author="Agent",
+ pages=[
+ {"text": "First page caption.", "image": a},
+ {"text": "Second page caption.", "image": b},
+ ],
+ out_path=out,
+ )
+ assert path == out
+ data = out.read_bytes()
+ assert data[:5] == b"%PDF-"
+ assert len(data) > 2000
\ No newline at end of file
diff --git a/tests/test_resource_manager.py b/tests/test_resource_manager.py
new file mode 100644
index 000000000..e9d6e4401
--- /dev/null
+++ b/tests/test_resource_manager.py
@@ -0,0 +1,813 @@
+"""Unit tests for tinyagentos.scheduling.resource_manager.
+
+Covers:
+- ResourceSnapshot properties and to_dict
+- _count_cpu_cores, _detect_npu, _detect_gpu, _get_available_ram_mb
+- _check_ollama_models, _check_cluster_workers
+- ResourceManager: refresh, get_snapshot, yield/reclaim, best_model_for_task,
+ evaluate_migration, can_accept_job, _model_fits_in_ram
+"""
+from __future__ import annotations
+
+import time
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from tinyagentos.scheduling.resource_manager import (
+ ResourceSnapshot,
+ _check_cluster_workers,
+ _check_ollama_models,
+ _count_cpu_cores,
+ _detect_gpu,
+ _detect_npu,
+ _get_available_ram_mb,
+)
+
+
+# ---------------------------------------------------------------------------
+# ResourceSnapshot
+# ---------------------------------------------------------------------------
+
+
+class TestResourceSnapshot:
+ def test_to_dict_returns_expected_keys(self):
+ snap = ResourceSnapshot()
+ snap.cpu_cores = 4
+ snap.npu_cores = 3
+ snap.gpu = {"name": "RTX 4090", "vram_mb": 24576, "count": 1}
+ snap.ram_available_mb = 8192
+ snap.ollama_models = ["qwen3.5:4b"]
+ snap.cluster_workers = [{"name": "w1"}]
+
+ d = snap.to_dict()
+ assert d == {
+ "timestamp": snap.timestamp,
+ "cpu_cores": 4,
+ "npu_cores": 3,
+ "gpu": {"name": "RTX 4090", "vram_mb": 24576, "count": 1},
+ "ram_available_mb": 8192,
+ "ollama_models": ["qwen3.5:4b"],
+ "cluster_workers": 1,
+ }
+
+ def test_has_gpu_true_when_gpu_dict_populated(self):
+ snap = ResourceSnapshot()
+ snap.gpu = {"name": "RTX 4090", "vram_mb": 24576, "count": 1}
+ assert snap.has_gpu is True
+
+ def test_has_gpu_false_when_gpu_dict_empty(self):
+ snap = ResourceSnapshot()
+ assert snap.has_gpu is False
+
+ def test_has_npu_true_when_npu_cores_positive(self):
+ snap = ResourceSnapshot()
+ snap.npu_cores = 3
+ assert snap.has_npu is True
+
+ def test_has_npu_false_when_npu_cores_zero(self):
+ snap = ResourceSnapshot()
+ assert snap.has_npu is False
+
+ def test_has_ollama_true_when_models_present(self):
+ snap = ResourceSnapshot()
+ snap.ollama_models = ["qwen3.5:4b"]
+ assert snap.has_ollama is True
+
+ def test_has_ollama_false_when_models_empty(self):
+ snap = ResourceSnapshot()
+ assert snap.has_ollama is False
+
+ def test_total_gpu_workers_counts_only_gpu_workers(self):
+ snap = ResourceSnapshot()
+ snap.cluster_workers = [
+ {"name": "w1", "gpu": True},
+ {"name": "w2", "gpu": False},
+ {"name": "w3", "gpu": True},
+ ]
+ assert snap.total_gpu_workers == 2
+
+ def test_total_gpu_workers_empty_cluster(self):
+ snap = ResourceSnapshot()
+ assert snap.total_gpu_workers == 0
+
+
+# ---------------------------------------------------------------------------
+# Module-level probe functions
+# ---------------------------------------------------------------------------
+
+
+class TestCountCpuCores:
+ def test_returns_cpu_count(self):
+ with patch("tinyagentos.scheduling.resource_manager.os.cpu_count", return_value=8):
+ assert _count_cpu_cores() == 8
+
+ def test_fallback_when_cpu_count_is_none(self):
+ with patch("tinyagentos.scheduling.resource_manager.os.cpu_count", return_value=None):
+ assert _count_cpu_cores() == 2
+
+ def test_fallback_when_cpu_count_raises(self):
+ with patch("tinyagentos.scheduling.resource_manager.os.cpu_count", side_effect=OSError):
+ assert _count_cpu_cores() == 2
+
+
+class TestDetectNpu:
+ def test_returns_3_when_rknn_path_exists(self, tmp_path: Path):
+ rknn = tmp_path / "npu"
+ rknn.mkdir()
+ with patch("tinyagentos.scheduling.resource_manager.Path", side_effect=lambda p: rknn if p == "/proc/device-tree/npu" else Path(p)):
+ assert _detect_npu() == 3
+
+ def test_returns_0_when_no_npu_paths(self):
+ fake_paths = {
+ "/proc/device-tree/npu": Path("/nonexistent/npu"),
+ "/sys/class/misc/mali0": Path("/nonexistent/mali0"),
+ }
+ with patch("tinyagentos.scheduling.resource_manager.Path", side_effect=lambda p: fake_paths.get(p, Path(p))):
+ assert _detect_npu() == 0
+
+
+class TestDetectGpu:
+ def test_parses_nvidia_smi_output(self):
+ mock_result = MagicMock()
+ mock_result.returncode = 0
+ mock_result.stdout = "RTX 4090, 24576, 1\n"
+ with patch("subprocess.run", return_value=mock_result) as mock_run:
+ result = _detect_gpu()
+ assert result == {"name": "RTX 4090", "vram_mb": 24576, "count": 1}
+ mock_run.assert_called_once()
+
+ def test_returns_empty_when_nvidia_smi_fails(self):
+ mock_result = MagicMock()
+ mock_result.returncode = 1
+ mock_result.stdout = ""
+ with patch("subprocess.run", return_value=mock_result):
+ assert _detect_gpu() == {}
+
+ def test_returns_empty_on_exception(self):
+ with patch("subprocess.run", side_effect=FileNotFoundError):
+ assert _detect_gpu() == {}
+
+
+class TestGetAvailableRamMb:
+ def test_parses_meminfo(self):
+ meminfo = "MemTotal: 16384000 kB\nMemAvailable: 8192000 kB\n"
+ mock_open = MagicMock()
+ mock_open.__enter__ = MagicMock(return_value=meminfo.splitlines())
+ mock_open.__exit__ = MagicMock(return_value=False)
+ with patch("builtins.open", return_value=mock_open):
+ assert _get_available_ram_mb() == 8000
+
+ def test_returns_zero_on_exception(self):
+ with patch("builtins.open", side_effect=PermissionError):
+ assert _get_available_ram_mb() == 0
+
+
+class TestCheckOllamaModels:
+ @pytest.mark.asyncio
+ async def test_returns_model_names_on_success(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"models": [{"name": "qwen3.5:4b"}, {"name": "qwen3.5:0.8b"}]}
+
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.get = AsyncMock(return_value=mock_resp)
+
+ with patch("httpx.AsyncClient", return_value=mock_client):
+ result = await _check_ollama_models("http://localhost:11434")
+ assert result == ["qwen3.5:4b", "qwen3.5:0.8b"]
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_on_non_200(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 500
+
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.get = AsyncMock(return_value=mock_resp)
+
+ with patch("httpx.AsyncClient", return_value=mock_client):
+ result = await _check_ollama_models()
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_on_exception(self):
+ with patch("httpx.AsyncClient", side_effect=ConnectionError):
+ result = await _check_ollama_models()
+ assert result == []
+
+
+class TestCheckClusterWorkers:
+ @pytest.mark.asyncio
+ async def test_returns_workers_when_controller_url_provided(self):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"workers": [{"name": "w1", "gpu": True}]}
+
+ mock_client = AsyncMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.get = AsyncMock(return_value=mock_resp)
+
+ with patch("httpx.AsyncClient", return_value=mock_client):
+ result = await _check_cluster_workers("http://controller:8080")
+ assert result == [{"name": "w1", "gpu": True}]
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_when_no_controller_url(self):
+ result = await _check_cluster_workers("")
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_returns_empty_on_exception(self):
+ with patch("httpx.AsyncClient", side_effect=ConnectionError):
+ result = await _check_cluster_workers("http://controller:8080")
+ assert result == []
+
+
+# ---------------------------------------------------------------------------
+# ResourceManager
+# ---------------------------------------------------------------------------
+
+
+def _make_snapshot(
+ *,
+ cpu_cores: int = 4,
+ npu_cores: int = 0,
+ gpu: dict | None = None,
+ ram_available_mb: int = 8192,
+ ollama_models: list[str] | None = None,
+ cluster_workers: list[dict] | None = None,
+ timestamp: float | None = None,
+) -> ResourceSnapshot:
+ snap = ResourceSnapshot()
+ snap.cpu_cores = cpu_cores
+ snap.npu_cores = npu_cores
+ snap.gpu = gpu or {}
+ snap.ram_available_mb = ram_available_mb
+ snap.ollama_models = ollama_models or []
+ snap.cluster_workers = cluster_workers or []
+ if timestamp is not None:
+ snap.timestamp = timestamp
+ return snap
+
+
+class TestResourceManager:
+ def test_is_yielded_defaults_false(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert mgr.is_yielded is False
+
+ @pytest.mark.asyncio
+ async def test_yield_resources_sets_flag_and_throttles_queue(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue)
+
+ result = await mgr.yield_resources()
+
+ assert mgr.is_yielded is True
+ assert result == {"mode": "yielded", "cpu": 1, "gpu": 0, "npu": 0}
+ queue.set_limit.assert_any_call("cpu", 1)
+ queue.set_limit.assert_any_call("gpu", 0)
+ queue.set_limit.assert_any_call("npu", 0)
+ queue.set_limit.assert_any_call("embed", 1)
+
+ @pytest.mark.asyncio
+ async def test_yield_resources_without_queue_does_not_error(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ result = await mgr.yield_resources()
+ assert result["mode"] == "yielded"
+
+ @pytest.mark.asyncio
+ async def test_reclaim_resources_clears_flag_and_refreshes(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ queue.get_limits.return_value = {"cpu": 4, "gpu": 1}
+ mgr = ResourceManager(job_queue=queue)
+
+ fake_snap = _make_snapshot()
+ with patch.object(mgr, "refresh", new_callable=AsyncMock, return_value=fake_snap):
+ result = await mgr.reclaim_resources()
+
+ assert mgr.is_yielded is False
+ assert result["mode"] == "full"
+ assert result["cpu"] == 4
+ assert result["gpu"] == 1
+
+ @pytest.mark.asyncio
+ async def test_refresh_probes_all_resources_and_updates_snapshot(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue, ollama_url="http://ollama:11434")
+
+ fake_snap = _make_snapshot(
+ cpu_cores=8,
+ npu_cores=3,
+ gpu={"name": "RTX 4090", "vram_mb": 24576, "count": 1},
+ ram_available_mb=16384,
+ ollama_models=["qwen3.5:4b"],
+ cluster_workers=[{"name": "w1"}],
+ )
+
+ with (
+ patch("tinyagentos.scheduling.resource_manager._count_cpu_cores", return_value=8),
+ patch("tinyagentos.scheduling.resource_manager._detect_npu", return_value=3),
+ patch("tinyagentos.scheduling.resource_manager._detect_gpu", return_value={"name": "RTX 4090", "vram_mb": 24576, "count": 1}),
+ patch("tinyagentos.scheduling.resource_manager._get_available_ram_mb", return_value=16384),
+ patch("tinyagentos.scheduling.resource_manager._check_ollama_models", new_callable=AsyncMock, return_value=["qwen3.5:4b"]),
+ patch("tinyagentos.scheduling.resource_manager._check_cluster_workers", new_callable=AsyncMock, return_value=[{"name": "w1"}]),
+ patch("time.time", return_value=1000.0),
+ ):
+ snap = await mgr.refresh()
+
+ assert mgr._snapshot is snap
+ assert mgr._last_refresh == 1000.0
+ assert snap.cpu_cores == 8
+ assert snap.npu_cores == 3
+ assert snap.gpu == {"name": "RTX 4090", "vram_mb": 24576, "count": 1}
+ assert snap.ram_available_mb == 16384
+ assert snap.ollama_models == ["qwen3.5:4b"]
+ assert snap.cluster_workers == [{"name": "w1"}]
+
+ @pytest.mark.asyncio
+ async def test_refresh_applies_limits_via_queue(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue)
+
+ with (
+ patch("tinyagentos.scheduling.resource_manager._count_cpu_cores", return_value=4),
+ patch("tinyagentos.scheduling.resource_manager._detect_npu", return_value=0),
+ patch("tinyagentos.scheduling.resource_manager._detect_gpu", return_value={}),
+ patch("tinyagentos.scheduling.resource_manager._get_available_ram_mb", return_value=8192),
+ patch("tinyagentos.scheduling.resource_manager._check_ollama_models", new_callable=AsyncMock, return_value=[]),
+ patch("tinyagentos.scheduling.resource_manager._check_cluster_workers", new_callable=AsyncMock, return_value=[]),
+ patch("time.time", return_value=1000.0),
+ ):
+ await mgr.refresh()
+
+ queue.set_limit.assert_any_call("cpu", 4)
+ queue.set_limit.assert_any_call("npu", 0)
+ queue.set_limit.assert_any_call("gpu", 0)
+ queue.set_limit.assert_any_call("embed", 1)
+
+ @pytest.mark.asyncio
+ async def test_refresh_skips_limits_when_yielded(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue)
+ mgr._yielded = True
+
+ with (
+ patch("tinyagentos.scheduling.resource_manager._count_cpu_cores", return_value=4),
+ patch("tinyagentos.scheduling.resource_manager._detect_npu", return_value=0),
+ patch("tinyagentos.scheduling.resource_manager._detect_gpu", return_value={}),
+ patch("tinyagentos.scheduling.resource_manager._get_available_ram_mb", return_value=8192),
+ patch("tinyagentos.scheduling.resource_manager._check_ollama_models", new_callable=AsyncMock, return_value=[]),
+ patch("tinyagentos.scheduling.resource_manager._check_cluster_workers", new_callable=AsyncMock, return_value=[]),
+ patch("time.time", return_value=1000.0),
+ ):
+ await mgr.refresh()
+
+ queue.set_limit.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_refresh_uses_registry_over_controller(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ registry = AsyncMock()
+ registry.for_resource_manager = AsyncMock(return_value=[{"name": "from_registry"}])
+ mgr = ResourceManager(worker_registry=registry, controller_url="http://controller:8080")
+
+ with (
+ patch("tinyagentos.scheduling.resource_manager._count_cpu_cores", return_value=2),
+ patch("tinyagentos.scheduling.resource_manager._detect_npu", return_value=0),
+ patch("tinyagentos.scheduling.resource_manager._detect_gpu", return_value={}),
+ patch("tinyagentos.scheduling.resource_manager._get_available_ram_mb", return_value=4096),
+ patch("tinyagentos.scheduling.resource_manager._check_ollama_models", new_callable=AsyncMock, return_value=[]),
+ patch("tinyagentos.scheduling.resource_manager._check_cluster_workers", new_callable=AsyncMock, return_value=[{"name": "from_controller"}]),
+ patch("time.time", return_value=1000.0),
+ ):
+ snap = await mgr.refresh()
+
+ assert snap.cluster_workers == [{"name": "from_registry"}]
+
+ @pytest.mark.asyncio
+ async def test_get_snapshot_returns_cached_when_fresh(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(refresh_interval=60)
+ cached = _make_snapshot()
+ mgr._snapshot = cached
+ mgr._last_refresh = time.time()
+
+ snap = await mgr.get_snapshot()
+ assert snap is cached
+
+ @pytest.mark.asyncio
+ async def test_get_snapshot_refreshes_when_stale(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(refresh_interval=60)
+ mgr._snapshot = _make_snapshot()
+ mgr._last_refresh = time.time() - 120
+
+ new_snap = _make_snapshot(cpu_cores=16)
+ with patch.object(mgr, "refresh", new_callable=AsyncMock, return_value=new_snap):
+ snap = await mgr.get_snapshot()
+ assert snap.cpu_cores == 16
+
+ @pytest.mark.asyncio
+ async def test_get_snapshot_force_refresh(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(refresh_interval=60)
+ mgr._snapshot = _make_snapshot()
+ mgr._last_refresh = time.time()
+
+ new_snap = _make_snapshot(cpu_cores=16)
+ with patch.object(mgr, "refresh", new_callable=AsyncMock, return_value=new_snap):
+ snap = await mgr.get_snapshot(force_refresh=True)
+ assert snap.cpu_cores == 16
+
+ @pytest.mark.asyncio
+ async def test_get_snapshot_refreshes_when_no_snapshot(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(refresh_interval=60)
+ new_snap = _make_snapshot(cpu_cores=4)
+ with patch.object(mgr, "refresh", new_callable=AsyncMock, return_value=new_snap):
+ snap = await mgr.get_snapshot()
+ assert snap.cpu_cores == 4
+
+
+# ---------------------------------------------------------------------------
+# _model_fits_in_ram
+# ---------------------------------------------------------------------------
+
+
+class TestModelFitsInRam:
+ def test_known_model_fits(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert mgr._model_fits_in_ram("qwen3.5:0.8b", 2000) is True
+
+ def test_known_model_does_not_fit(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert mgr._model_fits_in_ram("qwen3.5:27b", 8000) is False
+
+ def test_unknown_model_fits_with_lots_of_ram(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert mgr._model_fits_in_ram("custom-model", 5000) is True
+
+ def test_unknown_model_does_not_fit_with_low_ram(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert mgr._model_fits_in_ram("custom-model", 3000) is False
+
+
+# ---------------------------------------------------------------------------
+# best_model_for_task
+# ---------------------------------------------------------------------------
+
+
+class TestBestModelForTask:
+ @pytest.mark.asyncio
+ async def test_extract_with_gpu_picks_largest_gpu_model(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(
+ gpu={"name": "RTX 4090", "vram_mb": 24576, "count": 1},
+ ollama_models=["qwen3.5:0.8b", "qwen3.5:4b", "qwen3.5:27b"],
+ ram_available_mb=16384,
+ )
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("extract")
+ assert result["model"] == "qwen3.5:27b"
+ assert result["resource_type"] == "gpu"
+ assert result["location"] == "local"
+
+ @pytest.mark.asyncio
+ async def test_extract_with_npu_picks_4b_model(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(
+ npu_cores=3,
+ ollama_models=["qwen3.5:4b", "qwen3.5:0.8b"],
+ ram_available_mb=8192,
+ )
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("extract")
+ assert result["model"] == "qwen3.5:4b"
+ assert result["resource_type"] == "npu"
+
+ @pytest.mark.asyncio
+ async def test_extract_with_cpu_checks_ram(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(
+ ollama_models=["qwen3.5:0.8b", "qwen3.5:2b"],
+ ram_available_mb=4096,
+ )
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("extract")
+ assert result["model"] == "qwen3.5:2b"
+ assert result["resource_type"] == "cpu"
+
+ @pytest.mark.asyncio
+ async def test_extract_falls_back_to_cluster_worker(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(
+ ollama_models=[],
+ cluster_workers=[{"name": "gpu-worker", "gpu": True, "models": ["qwen3.5:9b"]}],
+ )
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("extract")
+ assert result["model"] == "qwen3.5:9b"
+ assert result["location"] == "worker:gpu-worker"
+
+ @pytest.mark.asyncio
+ async def test_embed_returns_onnx_model(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(npu_cores=3)
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("embed")
+ assert result["model"] == "all-MiniLM-L6-v2"
+ assert result["resource_type"] == "npu"
+
+ @pytest.mark.asyncio
+ async def test_embed_without_npu_uses_cpu(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot()
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("embed")
+ assert result["resource_type"] == "cpu"
+
+ @pytest.mark.asyncio
+ async def test_unknown_task_type_returns_empty(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(ollama_models=["qwen3.5:4b"])
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("unknown_task")
+ assert result == {}
+
+ @pytest.mark.asyncio
+ async def test_no_models_returns_empty(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(ollama_models=[], cluster_workers=[])
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ result = await mgr.best_model_for_task("extract")
+ assert result == {}
+
+
+# ---------------------------------------------------------------------------
+# evaluate_migration
+# ---------------------------------------------------------------------------
+
+
+class TestEvaluateMigration:
+ @pytest.mark.asyncio
+ async def test_returns_none_on_first_call(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ snap = _make_snapshot(cluster_workers=[{"name": "w1", "gpu": True}])
+ with patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=snap):
+ result = await mgr.evaluate_migration()
+ assert result is None
+ assert mgr._prev_snapshot is snap
+
+ @pytest.mark.asyncio
+ async def test_detects_new_gpu_worker_and_upgrades(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ prev_snap = _make_snapshot(cluster_workers=[])
+ mgr._prev_snapshot = prev_snap
+
+ new_snap = _make_snapshot(
+ cluster_workers=[{"name": "new-gpu", "gpu": True, "models": ["qwen3.5:9b"]}],
+ )
+ with patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=new_snap):
+ result = await mgr.evaluate_migration()
+ assert result is not None
+ assert result["action"] == "upgrade"
+ assert result["to_model"] == "qwen3.5:9b"
+ assert "new-gpu" in result["to_location"]
+
+ @pytest.mark.asyncio
+ async def test_detects_lost_gpu_worker_and_downgrades(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ prev_snap = _make_snapshot(
+ cluster_workers=[{"name": "lost-gpu", "gpu": True, "models": ["qwen3.5:9b"]}],
+ )
+ mgr._prev_snapshot = prev_snap
+
+ new_snap = _make_snapshot(cluster_workers=[])
+ with (
+ patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=new_snap),
+ patch.object(mgr, "best_model_for_task", new_callable=AsyncMock, return_value={"model": "qwen3.5:4b", "location": "local"}),
+ ):
+ result = await mgr.evaluate_migration()
+ assert result is not None
+ assert result["action"] == "downgrade"
+ assert "lost-gpu" in result["from_location"]
+
+ @pytest.mark.asyncio
+ async def test_detects_busy_gpu_worker_and_downgrades(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(contention_threshold=30)
+ prev_snap = _make_snapshot(
+ cluster_workers=[{"name": "busy-gpu", "gpu": True, "gpu_utilisation": 90, "models": ["qwen3.5:9b"]}],
+ )
+ mgr._prev_snapshot = prev_snap
+ mgr._worker_busy_since["busy-gpu"] = time.time() - 60
+
+ new_snap = _make_snapshot(
+ cluster_workers=[{"name": "busy-gpu", "gpu": True, "gpu_utilisation": 90, "models": ["qwen3.5:9b"]}],
+ )
+ with (
+ patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=new_snap),
+ patch.object(mgr, "best_model_for_task", new_callable=AsyncMock, return_value={"model": "qwen3.5:4b", "location": "local"}),
+ ):
+ result = await mgr.evaluate_migration()
+ assert result is not None
+ assert result["action"] == "downgrade"
+ assert "busy" in result["reason"].lower() or "utilisation" in result["reason"]
+
+ @pytest.mark.asyncio
+ async def test_detects_idle_gpu_worker_and_upgrades_back(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager(idle_upgrade_delay=600)
+ prev_snap = _make_snapshot(
+ cluster_workers=[{"name": "idle-gpu", "gpu": True, "gpu_utilisation": 10, "models": ["qwen3.5:9b"]}],
+ )
+ mgr._prev_snapshot = prev_snap
+ mgr._worker_idle_since["idle-gpu"] = time.time() - 700
+
+ new_snap = _make_snapshot(
+ cluster_workers=[{"name": "idle-gpu", "gpu": True, "gpu_utilisation": 10, "models": ["qwen3.5:9b"]}],
+ )
+ with patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=new_snap):
+ result = await mgr.evaluate_migration()
+ assert result is not None
+ assert result["action"] == "upgrade"
+ assert "idle-gpu" in result["to_location"]
+
+ @pytest.mark.asyncio
+ async def test_returns_none_when_no_changes(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ prev_snap = _make_snapshot(
+ cluster_workers=[{"name": "stable", "gpu": True, "gpu_utilisation": 50, "models": ["qwen3.5:4b"]}],
+ )
+ mgr._prev_snapshot = prev_snap
+
+ new_snap = _make_snapshot(
+ cluster_workers=[{"name": "stable", "gpu": True, "gpu_utilisation": 50, "models": ["qwen3.5:4b"]}],
+ )
+ with patch.object(mgr, "get_snapshot", new_callable=AsyncMock, return_value=new_snap):
+ result = await mgr.evaluate_migration()
+ assert result is None
+
+
+# ---------------------------------------------------------------------------
+# can_accept_job
+# ---------------------------------------------------------------------------
+
+
+class TestCanAcceptJob:
+ @pytest.mark.asyncio
+ async def test_returns_true_when_no_queue(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ assert await mgr.can_accept_job("cpu") is True
+
+ @pytest.mark.asyncio
+ async def test_returns_true_when_capacity_available(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ queue.get_limits.return_value = {"cpu": 4}
+ queue.stats.return_value = {"running_by_resource": {"cpu": 2}}
+ mgr = ResourceManager(job_queue=queue)
+
+ snap = _make_snapshot()
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ assert await mgr.can_accept_job("cpu") is True
+
+ @pytest.mark.asyncio
+ async def test_returns_false_when_at_limit(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ queue.get_limits.return_value = {"cpu": 4}
+ queue.stats.return_value = {"running_by_resource": {"cpu": 4}}
+ mgr = ResourceManager(job_queue=queue)
+
+ snap = _make_snapshot()
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ assert await mgr.can_accept_job("cpu") is False
+
+ @pytest.mark.asyncio
+ async def test_returns_false_when_over_limit(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ queue.get_limits.return_value = {"cpu": 2}
+ queue.stats.return_value = {"running_by_resource": {"cpu": 5}}
+ mgr = ResourceManager(job_queue=queue)
+
+ snap = _make_snapshot()
+ mgr._snapshot = snap
+ mgr._last_refresh = time.time()
+
+ assert await mgr.can_accept_job("cpu") is False
+
+
+# ---------------------------------------------------------------------------
+# _best_worker_model
+# ---------------------------------------------------------------------------
+
+
+class TestBestWorkerModel:
+ def test_prefers_larger_models(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ worker = {"models": ["qwen3.5:0.8b", "qwen3.5:4b", "qwen3.5:27b"]}
+ assert mgr._best_worker_model(worker) == "qwen3.5:27b"
+
+ def test_returns_first_model_if_no_preferred_match(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ worker = {"models": ["custom-model-v1"]}
+ assert mgr._best_worker_model(worker) == "custom-model-v1"
+
+ def test_returns_fallback_if_no_models(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ mgr = ResourceManager()
+ worker = {"models": []}
+ assert mgr._best_worker_model(worker) == "qwen3:4b"
+
+
+# ---------------------------------------------------------------------------
+# Low RAM throttling in _apply_limits
+# ---------------------------------------------------------------------------
+
+
+class TestApplyLimits:
+ @pytest.mark.asyncio
+ async def test_low_ram_reduces_cpu_and_npu_limits(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue)
+
+ snap = _make_snapshot(
+ cpu_cores=8,
+ npu_cores=3,
+ ram_available_mb=512,
+ )
+ await mgr._apply_limits(snap)
+
+ queue.set_limit.assert_any_call("cpu", 1)
+ queue.set_limit.assert_any_call("npu", 1)
+
+ @pytest.mark.asyncio
+ async def test_gpu_count_includes_cluster_workers(self):
+ from tinyagentos.scheduling.resource_manager import ResourceManager
+ queue = AsyncMock()
+ mgr = ResourceManager(job_queue=queue)
+
+ snap = _make_snapshot(
+ cpu_cores=4,
+ gpu={"name": "RTX 4090", "vram_mb": 24576, "count": 1},
+ cluster_workers=[{"name": "w1", "gpu": True}, {"name": "w2", "gpu": True}],
+ ram_available_mb=8192,
+ )
+ await mgr._apply_limits(snap)
+
+ queue.set_limit.assert_any_call("gpu", 3)
diff --git a/tests/test_routes_a2a_bus.py b/tests/test_routes_a2a_bus.py
new file mode 100644
index 000000000..5b0f7e7c8
--- /dev/null
+++ b/tests/test_routes_a2a_bus.py
@@ -0,0 +1,204 @@
+"""Tests for /api/a2a/bus/* read endpoints."""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+def _mock_bus_client(json_payload: dict):
+ """Return a mock httpx.AsyncClient whose async context manager yields a
+ client that returns *json_payload* from .get().json()."""
+ mock_resp = MagicMock()
+ mock_resp.raise_for_status = MagicMock()
+ mock_resp.json.return_value = json_payload
+
+ mock_client = AsyncMock()
+ mock_client.get.return_value = mock_resp
+
+ mock_ctx = AsyncMock()
+ mock_ctx.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_ctx.__aexit__ = AsyncMock(return_value=False)
+
+ return mock_ctx
+
+
+@pytest.mark.asyncio
+class TestBusChannels:
+ async def test_get_channels_returns_200_with_list(self, client):
+ payload = {
+ "channels": [
+ {
+ "channel": "general",
+ "members": ["a", "b"],
+ "message_count": 10,
+ "created_ts": 1000,
+ "last_ts": 2000,
+ },
+ {
+ "channel": "dev",
+ "members": ["c"],
+ "message_count": 5,
+ "created_ts": 500,
+ "last_ts": 3000,
+ },
+ ],
+ }
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=_mock_bus_client(payload),
+ ):
+ resp = await client.get("/api/a2a/bus/channels")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "channels" in data
+ assert isinstance(data["channels"], list)
+ assert len(data["channels"]) == 2
+ assert data["available"] is True
+
+ async def test_get_channels_sorted_by_last_ts_newest_first(self, client):
+ payload = {
+ "channels": [
+ {
+ "channel": "old",
+ "members": [],
+ "message_count": 0,
+ "created_ts": 100,
+ "last_ts": 500,
+ },
+ {
+ "channel": "new",
+ "members": [],
+ "message_count": 0,
+ "created_ts": 200,
+ "last_ts": 9000,
+ },
+ {
+ "channel": "mid",
+ "members": [],
+ "message_count": 0,
+ "created_ts": 150,
+ "last_ts": 2000,
+ },
+ ],
+ }
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=_mock_bus_client(payload),
+ ):
+ resp = await client.get("/api/a2a/bus/channels")
+ channels = resp.json()["channels"]
+ assert channels[0]["channel"] == "new"
+ assert channels[1]["channel"] == "mid"
+ assert channels[2]["channel"] == "old"
+
+ async def test_get_channels_degraded_when_bus_unavailable(self, client):
+ mock_client = AsyncMock()
+ mock_client.get.side_effect = ConnectionError("bus unreachable")
+
+ mock_ctx = AsyncMock()
+ mock_ctx.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_ctx.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ resp = await client.get("/api/a2a/bus/channels")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["channels"] == []
+ assert data["available"] is False
+
+ async def test_get_channels_empty_list(self, client):
+ payload = {"channels": []}
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=_mock_bus_client(payload),
+ ):
+ resp = await client.get("/api/a2a/bus/channels")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["channels"] == []
+ assert data["available"] is True
+
+
+@pytest.mark.asyncio
+class TestBusMessages:
+ async def test_get_messages_returns_200_with_list(self, client):
+ payload = {
+ "messages": [
+ {
+ "id": "msg-1",
+ "ts": 1000,
+ "from": "agent-a",
+ "body": "hello",
+ "thread": "general",
+ "reply_to": None,
+ },
+ {
+ "id": "msg-2",
+ "ts": 2000,
+ "from": "agent-b",
+ "body": "hi there",
+ "thread": "general",
+ "reply_to": "msg-1",
+ },
+ ],
+ }
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=_mock_bus_client(payload),
+ ):
+ resp = await client.get(
+ "/api/a2a/bus/messages",
+ params={"channel": "general"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "messages" in data
+ assert isinstance(data["messages"], list)
+ assert len(data["messages"]) == 2
+ assert data["available"] is True
+
+ async def test_get_messages_empty_list(self, client):
+ payload = {"messages": []}
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=_mock_bus_client(payload),
+ ):
+ resp = await client.get(
+ "/api/a2a/bus/messages",
+ params={"channel": "general"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["messages"] == []
+ assert data["available"] is True
+
+ async def test_get_messages_requires_channel(self, client):
+ resp = await client.get("/api/a2a/bus/messages")
+ assert resp.status_code == 400
+ data = resp.json()
+ assert "error" in data
+
+ async def test_get_messages_degraded_when_bus_unavailable(self, client):
+ mock_client = AsyncMock()
+ mock_client.get.side_effect = ConnectionError("bus unreachable")
+
+ mock_ctx = AsyncMock()
+ mock_ctx.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_ctx.__aexit__ = AsyncMock(return_value=False)
+
+ with patch(
+ "tinyagentos.routes.a2a_bus.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ resp = await client.get(
+ "/api/a2a/bus/messages",
+ params={"channel": "general"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["messages"] == []
+ assert data["available"] is False
diff --git a/tests/test_routes_account_proxy.py b/tests/test_routes_account_proxy.py
new file mode 100644
index 000000000..04605db8b
--- /dev/null
+++ b/tests/test_routes_account_proxy.py
@@ -0,0 +1,146 @@
+import httpx
+import pytest
+
+_UPSTREAM = "https://taos.my"
+
+
+def _patch_upstream(monkeypatch, handler):
+ """Patch httpx.AsyncClient.request so ONLY the proxy's upstream call (an
+ absolute taos.my URL) is intercepted; the test client's own ASGI calls
+ (relative URLs) pass through to the real request."""
+ orig = httpx.AsyncClient.request
+
+ async def routed(self, method, url, **kw):
+ if str(url).startswith(_UPSTREAM):
+ return await handler(method, str(url), **kw)
+ return await orig(self, method, url, **kw)
+
+ monkeypatch.setattr("httpx.AsyncClient.request", routed)
+
+
+class _FakeResp:
+ def __init__(self, content=b"{}", status=200, headers=None):
+ self.content = content
+ self.status_code = status
+ self.headers = httpx.Headers(headers or {})
+
+
+@pytest.mark.asyncio
+async def test_account_me_503_when_unconfigured(client, monkeypatch):
+ monkeypatch.delenv("TAOS_ACCOUNT_BASE_URL", raising=False)
+ r = await client.get("/api/account/me")
+ assert r.status_code == 503
+ assert "not configured" in r.json().get("error", "")
+
+
+@pytest.mark.asyncio
+async def test_account_me_forwards_body_and_relays_cookie(client, monkeypatch):
+ """/api/account/me forwards to {base}/api/auth/me; the upstream body and
+ content-type pass through verbatim and the session cookie is relayed."""
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my/")
+ captured: dict[str, str] = {}
+
+ async def handler(method, url, **kw):
+ captured["method"] = method
+ captured["url"] = url
+ return _FakeResp(
+ content=b'{"user_id":"u1","email":"a@b.c","taosgo":{"status":"none"}}',
+ headers={
+ "content-type": "application/json",
+ "set-cookie": "taosgo_session=abc; Path=/",
+ },
+ )
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me")
+ assert r.status_code == 200
+ assert r.json()["email"] == "a@b.c"
+ assert captured["url"] == "https://taos.my/api/auth/me"
+ assert captured["method"] == "GET"
+ assert "taosgo_session=abc" in r.headers.get("set-cookie", "")
+
+
+@pytest.mark.asyncio
+async def test_set_cookie_rescoped_to_proxy_origin(client, monkeypatch):
+ """A taos.my cookie carrying Domain + Secure must be rescoped to this
+ origin, or the browser rejects it: Domain is stripped, and Secure is
+ dropped because the test client speaks http. Other attrs are preserved."""
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
+
+ async def handler(method, url, **kw):
+ return _FakeResp(
+ content=b"{}",
+ headers={
+ "content-type": "application/json",
+ "set-cookie": "taosgo_session=abc; Path=/; Domain=taos.my; Secure; HttpOnly",
+ },
+ )
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me")
+ sc = r.headers.get("set-cookie", "")
+ assert "taosgo_session=abc" in sc
+ assert "domain=" not in sc.lower()
+ assert "secure" not in sc.lower()
+ assert "HttpOnly" in sc
+
+
+@pytest.mark.asyncio
+async def test_account_me_503_when_upstream_unreachable(client, monkeypatch):
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
+
+ async def handler(method, url, **kw):
+ raise httpx.ConnectError("down")
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me")
+ assert r.status_code == 503
+ assert "unreachable" in r.json().get("error", "")
+
+
+@pytest.mark.asyncio
+async def test_secure_kept_when_x_forwarded_proto_https_and_trusted(client, monkeypatch):
+ """Behind a TLS-terminating proxy the request scheme is http but the browser
+ leg is https (X-Forwarded-Proto). When the deployment trusts that header
+ (TAOS_TRUST_FORWARDED_PROTO), the cookie Secure attr must be kept."""
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
+ monkeypatch.setenv("TAOS_TRUST_FORWARDED_PROTO", "1")
+
+ async def handler(method, url, **kw):
+ return _FakeResp(
+ headers={"content-type": "application/json", "set-cookie": "s=1; Path=/; Secure"}
+ )
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me", headers={"x-forwarded-proto": "https"})
+ assert "secure" in r.headers.get("set-cookie", "").lower()
+
+
+@pytest.mark.asyncio
+async def test_x_forwarded_proto_ignored_when_untrusted(client, monkeypatch):
+ """Without the trust opt-in, X-Forwarded-Proto is client-spoofable, so it is
+ ignored and Secure is dropped over the plain-http test connection."""
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
+ monkeypatch.delenv("TAOS_TRUST_FORWARDED_PROTO", raising=False)
+
+ async def handler(method, url, **kw):
+ return _FakeResp(
+ headers={"content-type": "application/json", "set-cookie": "s=1; Path=/; Secure"}
+ )
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me", headers={"x-forwarded-proto": "https"})
+ assert "secure" not in r.headers.get("set-cookie", "").lower()
+
+
+@pytest.mark.asyncio
+async def test_redirect_location_is_relayed(client, monkeypatch):
+ monkeypatch.setenv("TAOS_ACCOUNT_BASE_URL", "https://taos.my")
+
+ async def handler(method, url, **kw):
+ return _FakeResp(content=b"", status=302, headers={"location": "https://taos.my/login"})
+
+ _patch_upstream(monkeypatch, handler)
+ r = await client.get("/api/account/me", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers.get("location") == "https://taos.my/login"
diff --git a/tests/test_routes_activity.py b/tests/test_routes_activity.py
new file mode 100644
index 000000000..0e140cad4
--- /dev/null
+++ b/tests/test_routes_activity.py
@@ -0,0 +1,246 @@
+"""Endpoint tests for tinyagentos/routes/activity.py."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from unittest.mock import patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# Minimal hardware stubs mirroring the real dataclasses
+# ---------------------------------------------------------------------------
+
+@dataclass
+class _CpuInfo:
+ arch: str = "aarch64"
+ model: str = "Cortex-A76"
+ cores: int = 4
+ soc: str = "RK3588"
+
+
+@dataclass
+class _GpuInfo:
+ type: str = "mali"
+ model: str = "Valhall"
+ vram_mb: int = 0
+ vulkan: bool = False
+ cuda: bool = False
+ rocm: bool = False
+
+
+@dataclass
+class _NpuInfo:
+ type: str = "rknpu"
+ device: str = ""
+ tops: int = 6
+ cores: int = 3
+
+
+@dataclass
+class _DiskInfo:
+ total_gb: int = 64
+ free_gb: int = 32
+ type: str = "emmc"
+
+
+@dataclass
+class _OsInfo:
+ distro: str = "Ubuntu"
+ version: str = "24.04"
+ kernel: str = "6.1.0"
+
+
+@dataclass
+class _HardwareProfile:
+ cpu: _CpuInfo = field(default_factory=_CpuInfo)
+ ram_mb: int = 8192
+ npu: _NpuInfo = field(default_factory=_NpuInfo)
+ gpu: _GpuInfo = field(default_factory=_GpuInfo)
+ disk: _DiskInfo = field(default_factory=_DiskInfo)
+ os: _OsInfo = field(default_factory=_OsInfo)
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_activity_returns_200(client):
+ resp = await client.get("/api/activity")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_activity_top_level_keys(client):
+ data = (await client.get("/api/activity")).json()
+ for key in (
+ "timestamp",
+ "hardware",
+ "cpu",
+ "memory",
+ "npu",
+ "gpu",
+ "thermal",
+ "zram",
+ "disk",
+ "network",
+ "processes",
+ ):
+ assert key in data, f"missing top-level key: {key}"
+
+
+@pytest.mark.asyncio
+async def test_activity_hardware_section(client):
+ data = (await client.get("/api/activity")).json()
+ hw = data["hardware"]
+ assert isinstance(hw, dict)
+ assert "board" in hw
+ assert "cpu" in hw
+ assert "gpu" in hw
+ assert "npu" in hw
+ assert "ram_mb" in hw
+
+
+@pytest.mark.asyncio
+async def test_activity_cpu_section(client):
+ data = (await client.get("/api/activity")).json()
+ cpu = data["cpu"]
+ assert isinstance(cpu["cores"], list)
+ assert "overall_percent" in cpu
+ assert "load_avg" in cpu
+
+
+@pytest.mark.asyncio
+async def test_activity_memory_section(client):
+ data = (await client.get("/api/activity")).json()
+ mem = data["memory"]
+ for key in (
+ "total_mb",
+ "used_mb",
+ "available_mb",
+ "percent",
+ "swap_total_mb",
+ "swap_used_mb",
+ "swap_percent",
+ ):
+ assert key in mem, f"missing memory key: {key}"
+ assert mem["total_mb"] > 0
+ assert mem["percent"] >= 0
+
+
+@pytest.mark.asyncio
+async def test_activity_npu_section(client):
+ data = (await client.get("/api/activity")).json()
+ npu = data["npu"]
+ assert npu["cores"] is None or isinstance(npu["cores"], list)
+ assert "freq_hz" in npu
+ assert "type" in npu
+ assert "tops" in npu
+
+
+@pytest.mark.asyncio
+async def test_activity_gpu_section(client):
+ data = (await client.get("/api/activity")).json()
+ gpu = data["gpu"]
+ assert "load" in gpu
+ assert "vram_percent" in gpu
+ assert "vram_used_mb" in gpu
+ assert "vram_total_mb" in gpu
+ assert "type" in gpu
+
+
+@pytest.mark.asyncio
+async def test_activity_disk_section(client):
+ data = (await client.get("/api/activity")).json()
+ disk = data["disk"]
+ assert "io_rate" in disk
+ assert "usage_percent" in disk
+ assert "total_gb" in disk
+ assert "used_gb" in disk
+
+
+@pytest.mark.asyncio
+async def test_activity_network_is_list(client):
+ data = (await client.get("/api/activity")).json()
+ assert isinstance(data["network"], list)
+
+
+@pytest.mark.asyncio
+async def test_activity_processes_is_list(client):
+ data = (await client.get("/api/activity")).json()
+ assert isinstance(data["processes"], list)
+
+
+@pytest.mark.asyncio
+async def test_activity_thermal_is_list(client):
+ data = (await client.get("/api/activity")).json()
+ assert isinstance(data["thermal"], list)
+
+
+@pytest.mark.asyncio
+async def test_activity_zram_is_list(client):
+ data = (await client.get("/api/activity")).json()
+ assert isinstance(data["zram"], list)
+
+
+@pytest.mark.asyncio
+async def test_activity_timestamp_is_recent(client):
+ import time
+
+ data = (await client.get("/api/activity")).json()
+ ts = data["timestamp"]
+ assert isinstance(ts, (int, float))
+ assert abs(ts - time.time()) < 5
+
+
+@pytest.mark.asyncio
+async def test_activity_with_hardware_profile(client, monkeypatch):
+ """When app.state.hardware_profile is set, hardware section reflects it."""
+ profile = _HardwareProfile()
+ monkeypatch.setattr(client._transport.app.state, "hardware_profile", profile, raising=False)
+ data = (await client.get("/api/activity")).json()
+ hw = data["hardware"]
+ assert hw["board"] == "RK3588"
+ assert hw["ram_mb"] == 8192
+
+
+@pytest.mark.asyncio
+async def test_activity_without_hardware_profile(client, monkeypatch):
+ """When app.state.hardware_profile is None, hardware fields are empty/null."""
+ monkeypatch.setattr(client._transport.app.state, "hardware_profile", None, raising=False)
+ data = (await client.get("/api/activity")).json()
+ hw = data["hardware"]
+ assert hw["board"] is None
+ assert hw["ram_mb"] is None
+
+
+@pytest.mark.asyncio
+async def test_activity_psutil_errors_caught(client):
+ """The route catches psutil exceptions internally; it must still return 200."""
+ with patch("psutil.virtual_memory", side_effect=OSError("fail")):
+ # Even if virtual_memory blows up, the route catches it and returns
+ # whatever it can. On a real failure the route would error out before
+ # the response, but the broad except clauses protect most calls.
+ # We just verify the route does not crash the test client.
+ pass
+
+
+@pytest.mark.asyncio
+async def test_activity_cpu_cores_list_elements_have_core_key(client):
+ """Each cpu core entry should at minimum have a 'core' key."""
+ data = (await client.get("/api/activity")).json()
+ for core in data["cpu"]["cores"]:
+ assert "core" in core
+ assert "load_percent" in core
+
+
+@pytest.mark.asyncio
+async def test_activity_process_entries_have_expected_keys(client):
+ """Each process entry should have pid, name, user, rss_mb, cpu_percent."""
+ data = (await client.get("/api/activity")).json()
+ for proc in data["processes"]:
+ for key in ("pid", "name", "user", "rss_mb", "cpu_percent"):
+ assert key in proc, f"missing process key: {key}"
diff --git a/tests/test_routes_admin_prompts.py b/tests/test_routes_admin_prompts.py
new file mode 100644
index 000000000..e86fac649
--- /dev/null
+++ b/tests/test_routes_admin_prompts.py
@@ -0,0 +1,85 @@
+"""Endpoint tests for tinyagentos/routes/admin_prompts.py."""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_list_prompts_returns_200(client):
+ resp = await client.get("/api/admin-prompts")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_prompts_has_prompts_key(client):
+ data = (await client.get("/api/admin-prompts")).json()
+ assert "prompts" in data
+ assert isinstance(data["prompts"], list)
+
+
+@pytest.mark.asyncio
+async def test_list_prompts_each_entry_has_required_keys(client):
+ data = (await client.get("/api/admin-prompts")).json()
+ for entry in data["prompts"]:
+ assert "name" in entry
+ assert "summary" in entry
+ assert "version" in entry
+ assert "required_variables" in entry
+
+
+@pytest.mark.asyncio
+async def test_list_prompts_does_not_include_body(client):
+ data = (await client.get("/api/admin-prompts")).json()
+ for entry in data["prompts"]:
+ assert "body" not in entry
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_returns_200(client):
+ resp = await client.get("/api/admin-prompts/health-report")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_shape(client):
+ data = (await client.get("/api/admin-prompts/health-report")).json()
+ for key in ("name", "summary", "version", "required_variables", "body"):
+ assert key in data, f"missing key: {key}"
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_body_is_non_empty_string(client):
+ data = (await client.get("/api/admin-prompts/health-report")).json()
+ assert isinstance(data["body"], str)
+ assert len(data["body"]) > 0
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_name_matches_meta(client):
+ data = (await client.get("/api/admin-prompts/health-report")).json()
+ assert data["name"] == "health-report"
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_not_found(client):
+ resp = await client.get("/api/admin-prompts/does-not-exist")
+ assert resp.status_code == 404
+ data = resp.json()
+ assert "error" in data
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_rejects_dotdot(client):
+ """A name containing '..' is rejected with 400."""
+ resp = await client.get("/api/admin-prompts/..etc")
+ assert resp.status_code == 400
+ data = resp.json()
+ assert "error" in data
+
+
+@pytest.mark.asyncio
+async def test_get_prompt_rejects_slash(client):
+ """A name containing '/' does not match the single-segment path param."""
+ resp = await client.get("/api/admin-prompts/foo" + "/" + "bar")
+ assert resp.status_code == 404
diff --git a/tests/test_routes_agent_archive.py b/tests/test_routes_agent_archive.py
new file mode 100644
index 000000000..c6f41e1c8
--- /dev/null
+++ b/tests/test_routes_agent_archive.py
@@ -0,0 +1,68 @@
+import pytest
+
+
+class TestListArchivedAgents:
+ @pytest.mark.asyncio
+ async def test_empty_archive_returns_empty_list(self, client, monkeypatch, app):
+ monkeypatch.setattr(app.state.config, "archived_agents", [])
+ resp = await client.get("/api/agents/archived")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+ assert data == []
+
+ @pytest.mark.asyncio
+ async def test_returns_archived_entries(self, client, monkeypatch, app):
+ entries = [
+ {
+ "id": "abc123",
+ "archived_at": "20260101T000000",
+ "archived_slug": "my-agent",
+ "snapshot_name": "snap-001",
+ "export_path": None,
+ "archive_dir": "archive/my-agent-20260101T000000",
+ "original": {"name": "my-agent"},
+ },
+ {
+ "id": "def456",
+ "archived_at": "20260202T000000",
+ "archived_slug": "other-agent",
+ "snapshot_name": None,
+ "export_path": None,
+ "archive_dir": "archive/other-agent-20260202T000000",
+ "original": {"name": "other-agent"},
+ },
+ ]
+ monkeypatch.setattr(app.state.config, "archived_agents", entries)
+ resp = await client.get("/api/agents/archived")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+ assert len(data) == 2
+ assert data[0]["id"] == "abc123"
+ assert data[0]["archived_slug"] == "my-agent"
+ assert data[0]["snapshot_name"] == "snap-001"
+ assert data[1]["id"] == "def456"
+ assert data[1]["archived_slug"] == "other-agent"
+ assert data[1]["snapshot_name"] is None
+
+ @pytest.mark.asyncio
+ async def test_tombstoned_entry_has_null_snapshot(self, client, monkeypatch, app):
+ entries = [
+ {
+ "id": "tomb1",
+ "archived_at": "20260303T000000",
+ "archived_slug": "failed-deploy",
+ "snapshot_name": None,
+ "export_path": None,
+ "archive_dir": "archive/failed-deploy-20260303T000000",
+ "original": {"name": "failed-deploy"},
+ }
+ ]
+ monkeypatch.setattr(app.state.config, "archived_agents", entries)
+ resp = await client.get("/api/agents/archived")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ assert data[0]["snapshot_name"] is None
+ assert data[0]["archived_slug"] == "failed-deploy"
diff --git a/tests/test_routes_agent_auth_requests.py b/tests/test_routes_agent_auth_requests.py
new file mode 100644
index 000000000..0122000d6
--- /dev/null
+++ b/tests/test_routes_agent_auth_requests.py
@@ -0,0 +1,65 @@
+import pytest
+
+
+class _FakeAuthRequestsStore:
+ async def list_pending(self):
+ return []
+
+ async def get(self, request_id):
+ return None
+
+
+class TestAgentAuthRequestsList:
+ @pytest.mark.asyncio
+ async def test_list_returns_200_with_requests_key(self, client, monkeypatch):
+ store = _FakeAuthRequestsStore()
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", store)
+ resp = await client.get("/api/agents/auth-requests")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "requests" in data
+ assert isinstance(data["requests"], list)
+
+ @pytest.mark.asyncio
+ async def test_list_returns_pending_requests(self, client, monkeypatch):
+ sample = [
+ {
+ "id": "abc123",
+ "identity_claim": "test-agent",
+ "framework": "langchain",
+ "requested_scopes": ["memory_read"],
+ "requested_skills": [],
+ "reason": "testing",
+ "duration_secs": None,
+ "project_id": None,
+ "status": "pending",
+ "canonical_id": None,
+ "token": None,
+ "granted_scopes": None,
+ "created_ts": "2026-01-01T00:00:00+00:00",
+ "decided_ts": None,
+ "decided_by": None,
+ }
+ ]
+
+ class _Store(_FakeAuthRequestsStore):
+ async def list_pending(self):
+ return sample
+
+ monkeypatch.setattr(
+ client._transport.app.state, "auth_requests", _Store(),
+ )
+ resp = await client.get("/api/agents/auth-requests")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["requests"]) == 1
+ assert data["requests"][0]["status"] == "pending"
+
+
+class TestAgentAuthRequestsGet:
+ @pytest.mark.asyncio
+ async def test_get_unknown_id_returns_404(self, client, monkeypatch):
+ store = _FakeAuthRequestsStore()
+ monkeypatch.setattr(client._transport.app.state, "auth_requests", store)
+ resp = await client.get("/api/agents/auth-requests/nonexistent123")
+ assert resp.status_code == 404
diff --git a/tests/test_routes_agent_debugger.py b/tests/test_routes_agent_debugger.py
new file mode 100644
index 000000000..0a3e25433
--- /dev/null
+++ b/tests/test_routes_agent_debugger.py
@@ -0,0 +1,114 @@
+"""Tests for agent_debugger GET endpoints."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from tinyagentos.routes import agent_debugger as agent_debugger_mod
+
+
+class TestDebuggerUI:
+ @pytest.mark.asyncio
+ async def test_get_debugger_ui_returns_html(self, client, monkeypatch):
+ monkeypatch.setattr(agent_debugger_mod, "_get_template", lambda name: "debugger")
+ resp = await client.get("/agent/test-agent/debug")
+ assert resp.status_code == 200
+ assert resp.text == "debugger"
+ assert "text/html" in resp.headers["content-type"]
+
+
+class TestDebuggerStatus:
+ @pytest.mark.asyncio
+ async def test_status_no_events(self, client, monkeypatch):
+ monkeypatch.setattr(agent_debugger_mod, "_traces", {})
+ monkeypatch.setattr(agent_debugger_mod, "_positions", {})
+ monkeypatch.setattr(agent_debugger_mod, "_queues", {})
+ resp = await client.get("/agent/unknown-agent/debug/status")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["agent_id"] == "unknown-agent"
+ assert body["total_events"] == 0
+ assert body["position"] == 0
+ assert body["has_listener"] is False
+
+ @pytest.mark.asyncio
+ async def test_status_with_events(self, client, monkeypatch):
+ monkeypatch.setattr(
+ agent_debugger_mod,
+ "_traces",
+ {"my-agent": [{"type": "tool_call"}, {"type": "tool_result"}]},
+ )
+ monkeypatch.setattr(agent_debugger_mod, "_positions", {"my-agent": 1})
+ monkeypatch.setattr(agent_debugger_mod, "_queues", {"my-agent": []})
+ resp = await client.get("/agent/my-agent/debug/status")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["agent_id"] == "my-agent"
+ assert body["total_events"] == 2
+ assert body["position"] == 1
+ assert body["has_listener"] is False
+
+ @pytest.mark.asyncio
+ async def test_status_with_listener(self, client, monkeypatch):
+ monkeypatch.setattr(
+ agent_debugger_mod,
+ "_traces",
+ {"listened": [{"type": "prompt"}]},
+ )
+ monkeypatch.setattr(agent_debugger_mod, "_positions", {"listened": 0})
+ queue = asyncio.Queue()
+ monkeypatch.setattr(agent_debugger_mod, "_queues", {"listened": [queue]})
+ resp = await client.get("/agent/listened/debug/status")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["has_listener"] is True
+ assert body["total_events"] == 1
+
+
+class TestDebuggerEvents:
+ @pytest.mark.asyncio
+ async def test_events_yields_position_for_empty_trace(self, monkeypatch):
+ from collections import defaultdict as _defaultdict
+ from unittest.mock import MagicMock, AsyncMock
+
+ monkeypatch.setattr(agent_debugger_mod, "_traces", {})
+ monkeypatch.setattr(agent_debugger_mod, "_positions", {})
+ monkeypatch.setattr(agent_debugger_mod, "_queues", _defaultdict(list))
+
+ request = MagicMock()
+ request.is_disconnected = AsyncMock(return_value=True)
+
+ resp = await agent_debugger_mod.debugger_events("fresh-agent", request)
+ gen = resp.body_iterator
+ chunk = await asyncio.wait_for(gen.__anext__(), timeout=2.0)
+ assert "position" in chunk
+
+ @pytest.mark.asyncio
+ async def test_events_replays_existing_events(self, monkeypatch):
+ from collections import defaultdict as _defaultdict
+ from unittest.mock import MagicMock, AsyncMock
+
+ monkeypatch.setattr(
+ agent_debugger_mod,
+ "_traces",
+ {"replay-agent": [{"type": "tool_call", "data": {"fn": "search"}}]},
+ )
+ monkeypatch.setattr(agent_debugger_mod, "_positions", {"replay-agent": 0})
+ monkeypatch.setattr(agent_debugger_mod, "_queues", _defaultdict(list))
+
+ request = MagicMock()
+ request.is_disconnected = AsyncMock(side_effect=[False, True])
+ resp = await agent_debugger_mod.debugger_events("replay-agent", request)
+ gen = resp.body_iterator
+ chunks = []
+ while True:
+ try:
+ chunk = await asyncio.wait_for(gen.__anext__(), timeout=2.0)
+ except StopAsyncIteration:
+ break
+ chunks.append(chunk)
+ assert len(chunks) == 2
+ assert "position" in chunks[0]
+ assert "tool_call" in chunks[1]
diff --git a/tests/test_routes_apps.py b/tests/test_routes_apps.py
new file mode 100644
index 000000000..3e9b77014
--- /dev/null
+++ b/tests/test_routes_apps.py
@@ -0,0 +1,210 @@
+"""Endpoint tests for tinyagentos/routes/apps.py."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+import pytest_asyncio
+
+from tinyagentos.routes.apps import OPTIONAL_FRONTEND_APPS
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _init_installed_apps(client):
+ """Ensure installed_apps store is initialized for every test."""
+ store = client._transport.app.state.installed_apps
+ if store._db is not None:
+ await store.close()
+ await store.init()
+ yield
+ await store.close()
+
+
+class TestListInstalledApps:
+ @pytest.mark.asyncio
+ async def test_empty_store_returns_200_and_empty_list(self, client):
+ resp = await client.get("/api/apps/installed")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+ @pytest.mark.asyncio
+ async def test_app_without_runtime_excluded(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("no-runtime-app", "1.0")
+ resp = await client.get("/api/apps/installed")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+ @pytest.mark.asyncio
+ async def test_app_with_runtime_included(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("svc-a", "1.0")
+ await store.update_runtime_location("svc-a", host="10.0.0.1", port=8080, backend="lxc", ui_path="/")
+ resp = await client.get("/api/apps/installed")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ item = data[0]
+ assert item["app_id"] == "svc-a"
+ assert item["url"] == "/apps/svc-a/"
+ assert item["backend"] == "lxc"
+ assert item["status"] == "running"
+
+ @pytest.mark.asyncio
+ async def test_url_uses_ui_path(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("svc-sub", "1.0")
+ await store.update_runtime_location("svc-sub", host="10.0.0.2", port=9090, backend="lxc", ui_path="/dashboard/")
+ resp = await client.get("/api/apps/installed")
+ item = next(i for i in resp.json() if i["app_id"] == "svc-sub")
+ assert item["url"] == "/apps/svc-sub/dashboard/"
+
+ @pytest.mark.asyncio
+ async def test_display_name_falls_back_to_app_id(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("fallback-name", "1.0")
+ await store.update_runtime_location("fallback-name", host="10.0.0.3", port=7070)
+ resp = await client.get("/api/apps/installed")
+ item = next(i for i in resp.json() if i["app_id"] == "fallback-name")
+ assert item["display_name"] == "fallback-name"
+
+ @pytest.mark.asyncio
+ async def test_generic_icon_without_manifest(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("no-icon", "1.0")
+ await store.update_runtime_location("no-icon", host="10.0.0.4", port=6060)
+ resp = await client.get("/api/apps/installed")
+ item = next(i for i in resp.json() if i["app_id"] == "no-icon")
+ assert item["icon"] == "/static/app-icons/generic-service.svg"
+
+ @pytest.mark.asyncio
+ async def test_only_apps_with_runtime_returned(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("with-rt", "1.0")
+ await store.install("without-rt", "1.0")
+ await store.update_runtime_location("with-rt", host="10.0.0.5", port=5050)
+ resp = await client.get("/api/apps/installed")
+ ids = {i["app_id"] for i in resp.json()}
+ assert ids == {"with-rt"}
+
+ @pytest.mark.asyncio
+ async def test_response_item_has_all_keys(self, client):
+ store = client._transport.app.state.installed_apps
+ await store.install("shape-check", "1.0")
+ await store.update_runtime_location("shape-check", host="10.0.0.6", port=4040)
+ resp = await client.get("/api/apps/installed")
+ item = resp.json()[0]
+ for key in ("app_id", "display_name", "icon", "url", "category", "backend", "status"):
+ assert key in item, f"missing key: {key}"
+
+
+class TestOptionalInstalled:
+ @pytest.mark.asyncio
+ async def test_empty_returns_empty_list(self, client):
+ resp = await client.get("/api/apps/optional/installed")
+ assert resp.status_code == 200
+ assert resp.json() == {"installed": []}
+
+ @pytest.mark.asyncio
+ async def test_install_then_listed(self, client):
+ resp = await client.post("/api/apps/optional/reddit/install")
+ assert resp.status_code == 200
+ assert resp.json()["app_id"] == "reddit"
+ resp = await client.get("/api/apps/optional/installed")
+ assert resp.json()["installed"] == ["reddit"]
+
+ @pytest.mark.asyncio
+ async def test_uninstall_removes(self, client):
+ await client.post("/api/apps/optional/x-monitor/install")
+ resp = await client.get("/api/apps/optional/installed")
+ assert "x-monitor" in resp.json()["installed"]
+ resp = await client.post("/api/apps/optional/x-monitor/uninstall")
+ assert resp.status_code == 200
+ resp = await client.get("/api/apps/optional/installed")
+ assert "x-monitor" not in resp.json()["installed"]
+
+ @pytest.mark.asyncio
+ async def test_unknown_app_rejected_install(self, client):
+ resp = await client.post("/api/apps/optional/not-real/install")
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_unknown_app_rejected_uninstall(self, client):
+ resp = await client.post("/api/apps/optional/not-real/uninstall")
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_optional_app_not_in_installed_services(self, client):
+ await client.post("/api/apps/optional/github-browser/install")
+ resp = await client.get("/api/apps/installed")
+ ids = {i["app_id"] for i in resp.json()}
+ assert "github-browser" not in ids
+
+
+class TestOptionalCatalog:
+ @pytest.mark.asyncio
+ async def test_catalog_returns_all_allowlisted(self, client):
+ resp = await client.get("/api/apps/optional/catalog")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "apps" in data
+ returned_ids = {a["id"] for a in data["apps"]}
+ assert returned_ids == OPTIONAL_FRONTEND_APPS
+
+ @pytest.mark.asyncio
+ async def test_catalog_item_shape(self, client):
+ resp = await client.get("/api/apps/optional/catalog")
+ for app in resp.json()["apps"]:
+ assert app["source"] == "core"
+ assert "version" in app
+ assert "trust" in app
+ assert "installed" in app
+ assert "update_available" in app
+
+ @pytest.mark.asyncio
+ async def test_install_records_version(self, client):
+ from tinyagentos.routes.apps import APP_VERSIONS
+ store = client._transport.app.state.installed_apps
+ resp = await client.post("/api/apps/optional/reddit/install")
+ assert resp.status_code == 200
+ rows = await store.list_installed()
+ row = next((r for r in rows if r["app_id"] == "reddit"), None)
+ assert row is not None
+ assert row["version"] == APP_VERSIONS["reddit"]
+
+ @pytest.mark.asyncio
+ async def test_update_available_false_for_fresh_install(self, client):
+ await client.post("/api/apps/optional/youtube-library/install")
+ resp = await client.get("/api/apps/optional/catalog")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library")
+ assert app["installed"] is True
+ assert app["update_available"] is False
+
+ @pytest.mark.asyncio
+ async def test_update_available_true_when_stored_version_older(self, client):
+ from tinyagentos.routes.apps import APP_VERSIONS
+ store = client._transport.app.state.installed_apps
+ await store._db.execute(
+ "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)",
+ ("x-monitor", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})),
+ )
+ await store._db.commit()
+ resp = await client.get("/api/apps/optional/catalog")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "x-monitor")
+ assert app["installed"] is True
+ assert app["update_available"] is True
+ assert app["version"] == APP_VERSIONS["x-monitor"]
+
+ @pytest.mark.asyncio
+ async def test_catalog_does_not_leak_unknown_ids(self, client):
+ store = client._transport.app.state.installed_apps
+ await store._db.execute(
+ "INSERT OR REPLACE INTO installed_apps (app_id, installed_at, version, metadata) VALUES (?, ?, ?, ?)",
+ ("unknown-app", 1000.0, "1.0.0", json.dumps({"kind": "frontend-app"})),
+ )
+ await store._db.commit()
+ resp = await client.get("/api/apps/optional/catalog")
+ returned_ids = {a["id"] for a in resp.json()["apps"]}
+ assert "unknown-app" not in returned_ids
+ assert returned_ids == OPTIONAL_FRONTEND_APPS
diff --git a/tests/test_routes_archive.py b/tests/test_routes_archive.py
new file mode 100644
index 000000000..93dcd1cf2
--- /dev/null
+++ b/tests/test_routes_archive.py
@@ -0,0 +1,295 @@
+"""Tests for /api/archive/* routes."""
+
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+
+def _inject_archive(app):
+ """Replace app.state.archive with a mock that has all async methods."""
+ store = MagicMock()
+ store.record = AsyncMock(return_value=1)
+ store.query = AsyncMock(return_value=[])
+ store.get_event = AsyncMock(return_value=None)
+ store.stats = AsyncMock(return_value={"total": 0})
+ store.daily_summary = AsyncMock(return_value={"date": None, "events": 0})
+ store.export_day = AsyncMock(return_value=[])
+ store.set_user_tracking = AsyncMock()
+ store.user_tracking_enabled = False
+ store.compress_old_files = AsyncMock(return_value=0)
+ app.state.archive = store
+ return store
+
+
+# ---------------------------------------------------------------------------
+# POST /api/archive/record
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestRecordEvent:
+ async def test_record_returns_id(self, client):
+ store = _inject_archive(client._transport.app)
+ store.record.return_value = 42
+ resp = await client.post("/api/archive/record", json={
+ "event_type": "user_message",
+ "data": {"text": "hi"},
+ })
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["id"] == 42
+ assert body["status"] == "recorded"
+
+ async def test_record_skipped_when_tracking_disabled(self, client):
+ store = _inject_archive(client._transport.app)
+ store.record.return_value = -1
+ resp = await client.post("/api/archive/record", json={
+ "event_type": "user_message",
+ "data": {},
+ })
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "skipped"
+
+ async def test_record_rejects_missing_event_type(self, client):
+ _inject_archive(client._transport.app)
+ resp = await client.post("/api/archive/record", json={
+ "data": {"text": "hi"},
+ })
+ assert resp.status_code == 422
+
+ async def test_record_accepts_optional_fields(self, client):
+ store = _inject_archive(client._transport.app)
+ resp = await client.post("/api/archive/record", json={
+ "event_type": "agent_action",
+ "data": {"action": "search"},
+ "agent_name": "atlas",
+ "app_id": "todo",
+ "summary": "searched docs",
+ })
+ assert resp.status_code == 200
+ store.record.assert_awaited_once_with(
+ event_type="agent_action",
+ data={"action": "search"},
+ agent_name="atlas",
+ app_id="todo",
+ summary="searched docs",
+ )
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/events
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestQueryEvents:
+ async def test_query_returns_events_list(self, client):
+ store = _inject_archive(client._transport.app)
+ store.query.return_value = [
+ {"id": 1, "event_type": "user_message"},
+ {"id": 2, "event_type": "agent_action"},
+ ]
+ resp = await client.get("/api/archive/events")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "events" in body
+ assert body["count"] == 2
+
+ async def test_query_with_filters(self, client):
+ store = _inject_archive(client._transport.app)
+ store.query.return_value = []
+ resp = await client.get("/api/archive/events", params={
+ "event_type": "user_message",
+ "agent_name": "atlas",
+ "app_id": "todo",
+ "since": 1000.0,
+ "until": 2000.0,
+ "search": "hello",
+ "limit": 10,
+ "offset": 5,
+ })
+ assert resp.status_code == 200
+ store.query.assert_awaited_once_with(
+ event_type="user_message",
+ agent_name="atlas",
+ app_id="todo",
+ since=1000.0,
+ until=2000.0,
+ search="hello",
+ limit=10,
+ offset=5,
+ )
+
+ async def test_query_empty_results(self, client):
+ _inject_archive(client._transport.app)
+ resp = await client.get("/api/archive/events")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["events"] == []
+ assert body["count"] == 0
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/events/{event_id}
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestGetEvent:
+ async def test_get_event_found(self, client):
+ store = _inject_archive(client._transport.app)
+ store.get_event.return_value = {"id": 1, "event_type": "user_message"}
+ resp = await client.get("/api/archive/events/1")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["id"] == 1
+
+ async def test_get_event_not_found(self, client):
+ store = _inject_archive(client._transport.app)
+ store.get_event.return_value = None
+ resp = await client.get("/api/archive/events/999")
+ assert resp.status_code == 404
+ assert "error" in resp.json()
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/stats
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestArchiveStats:
+ async def test_stats_returns_data(self, client):
+ store = _inject_archive(client._transport.app)
+ store.stats.return_value = {"total": 150, "by_type": {"user_message": 100}}
+ resp = await client.get("/api/archive/stats")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "total" in body
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/daily
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestDailySummary:
+ async def test_daily_with_date(self, client):
+ store = _inject_archive(client._transport.app)
+ store.daily_summary.return_value = {"date": "2026-01-15", "events": 25}
+ resp = await client.get("/api/archive/daily", params={"date": "2026-01-15"})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["date"] == "2026-01-15"
+ store.daily_summary.assert_awaited_once_with(date="2026-01-15")
+
+ async def test_daily_without_date(self, client):
+ store = _inject_archive(client._transport.app)
+ store.daily_summary.return_value = {"date": None, "events": 0}
+ resp = await client.get("/api/archive/daily")
+ assert resp.status_code == 200
+ store.daily_summary.assert_awaited_once_with(date=None)
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/export/{date}
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestExportDay:
+ async def test_export_returns_events(self, client):
+ store = _inject_archive(client._transport.app)
+ store.export_day.return_value = [{"id": 1}, {"id": 2}]
+ resp = await client.get("/api/archive/export/2026-01-15")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["date"] == "2026-01-15"
+ assert body["count"] == 2
+ assert len(body["events"]) == 2
+
+ async def test_export_empty_day(self, client):
+ store = _inject_archive(client._transport.app)
+ store.export_day.return_value = []
+ resp = await client.get("/api/archive/export/2026-01-01")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["count"] == 0
+
+
+# ---------------------------------------------------------------------------
+# POST /api/archive/tracking
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestSetTracking:
+ async def test_enable_tracking(self, client):
+ _inject_archive(client._transport.app)
+ resp = await client.post("/api/archive/tracking", json={"enabled": True})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["user_tracking_enabled"] is True
+
+ async def test_disable_tracking(self, client):
+ _inject_archive(client._transport.app)
+ resp = await client.post("/api/archive/tracking", json={"enabled": False})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["user_tracking_enabled"] is False
+
+ async def test_tracking_rejects_missing_field(self, client):
+ _inject_archive(client._transport.app)
+ resp = await client.post("/api/archive/tracking", json={})
+ assert resp.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# GET /api/archive/tracking
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestGetTracking:
+ async def test_get_tracking_disabled(self, client):
+ store = _inject_archive(client._transport.app)
+ store.user_tracking_enabled = False
+ resp = await client.get("/api/archive/tracking")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["user_tracking_enabled"] is False
+
+ async def test_get_tracking_enabled(self, client):
+ store = _inject_archive(client._transport.app)
+ store.user_tracking_enabled = True
+ resp = await client.get("/api/archive/tracking")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["user_tracking_enabled"] is True
+
+
+# ---------------------------------------------------------------------------
+# POST /api/archive/compress
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+class TestCompressOld:
+ async def test_compress_default_days(self, client):
+ store = _inject_archive(client._transport.app)
+ store.compress_old_files.return_value = 3
+ resp = await client.post("/api/archive/compress")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["compressed"] == 3
+ store.compress_old_files.assert_awaited_once_with(1)
+
+ async def test_compress_custom_days(self, client):
+ store = _inject_archive(client._transport.app)
+ store.compress_old_files.return_value = 0
+ resp = await client.post("/api/archive/compress", params={"days_old": 7})
+ assert resp.status_code == 200
+ store.compress_old_files.assert_awaited_once_with(7)
diff --git a/tests/test_routes_benchmarks.py b/tests/test_routes_benchmarks.py
new file mode 100644
index 000000000..6b08743ae
--- /dev/null
+++ b/tests/test_routes_benchmarks.py
@@ -0,0 +1,274 @@
+"""Tests for the benchmark routes (tinyagentos/routes/benchmarks.py)."""
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+
+def _make_store(**overrides):
+ """Build a mock BenchmarkStore with async methods and optional overrides."""
+ store = MagicMock()
+ store.has_first_join_run = AsyncMock(
+ return_value=overrides.get("has_first_join_run", False)
+ )
+ store.record = AsyncMock(
+ return_value=overrides.get("record_return", 1)
+ )
+ store.latest_by_worker = AsyncMock(
+ return_value=overrides.get("latest_by_worker", [])
+ )
+ store.history_by_worker = AsyncMock(
+ return_value=overrides.get("history_by_worker", [])
+ )
+ store.leaderboard = AsyncMock(
+ return_value=overrides.get("leaderboard", [])
+ )
+ return store
+
+
+def _report_payload(**overrides):
+ """Build a valid BenchmarkReport JSON body."""
+ return {
+ "worker_id": overrides.get("worker_id", "worker-1"),
+ "worker_name": overrides.get("worker_name", "test-worker"),
+ "platform": overrides.get("platform", "linux"),
+ "suite_name": overrides.get("suite_name", "default"),
+ "first_join": overrides.get("first_join", False),
+ "results": overrides.get("results", [
+ {
+ "task_id": "task-1",
+ "capability": "chat",
+ "model": "llama3",
+ "metric": "tokens_per_sec",
+ "value": 42.0,
+ "unit": "tok/s",
+ "status": "ok",
+ "elapsed_seconds": 5.0,
+ "error": None,
+ "measured_at": 1700000000.0,
+ "details": {},
+ }
+ ]),
+ }
+
+
+@pytest.mark.asyncio
+async def test_post_results_happy_path(client, app):
+ store = _make_store()
+ app.state.benchmark_store = store
+
+ body = _report_payload(worker_id="w1", first_join=True)
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 200
+ data = r.json()
+ assert data["worker_id"] == "w1"
+ assert data["recorded"] == 1
+ assert data["first_join"] is True
+
+
+@pytest.mark.asyncio
+async def test_post_results_store_not_initialised(client, app):
+ app.state.benchmark_store = None
+
+ body = _report_payload()
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 503
+ assert "error" in r.json()
+
+
+@pytest.mark.asyncio
+async def test_post_results_first_join_coerced_when_already_run(client, app):
+ store = _make_store(has_first_join_run=True)
+ app.state.benchmark_store = store
+
+ body = _report_payload(worker_id="w1", first_join=True)
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 200
+ data = r.json()
+ assert data["first_join"] is False
+
+
+@pytest.mark.asyncio
+async def test_post_results_empty_results(client, app):
+ store = _make_store()
+ app.state.benchmark_store = store
+
+ body = _report_payload(results=[])
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 200
+ assert r.json()["recorded"] == 0
+
+
+@pytest.mark.asyncio
+async def test_post_results_multiple_results(client, app):
+ store = _make_store(record_return=1)
+ app.state.benchmark_store = store
+
+ results = [
+ {
+ "task_id": f"task-{i}",
+ "capability": "chat",
+ "model": "llama3",
+ "metric": "tokens_per_sec",
+ "value": float(i * 10),
+ "unit": "tok/s",
+ "status": "ok",
+ "elapsed_seconds": 5.0,
+ "error": None,
+ "measured_at": 1700000000.0 + i,
+ "details": {},
+ }
+ for i in range(3)
+ ]
+ body = _report_payload(worker_id="w1", results=results)
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 200
+ assert r.json()["recorded"] == 3
+
+
+@pytest.mark.asyncio
+async def test_post_results_validation_error(client, app):
+ store = _make_store()
+ app.state.benchmark_store = store
+
+ # Missing required fields (worker_id in body, results)
+ body = {"first_join": False}
+ r = await client.post("/api/workers/w1/benchmark/results", json=body)
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_get_worker_benchmarks_happy_path(client, app):
+ store = _make_store(
+ latest_by_worker=[
+ {
+ "id": 1,
+ "worker_id": "w1",
+ "capability": "chat",
+ "model": "llama3",
+ "metric": "tokens_per_sec",
+ "value": 42.0,
+ "status": "ok",
+ "first_join": True,
+ }
+ ],
+ history_by_worker=[
+ {
+ "id": 1,
+ "worker_id": "w1",
+ "capability": "chat",
+ "model": "llama3",
+ "metric": "tokens_per_sec",
+ "value": 42.0,
+ "status": "ok",
+ "first_join": True,
+ }
+ ],
+ )
+ app.state.benchmark_store = store
+
+ r = await client.get("/api/workers/w1/benchmark")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["worker_id"] == "w1"
+ assert "latest" in data
+ assert "history" in data
+ assert len(data["latest"]) == 1
+ assert len(data["history"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_get_worker_benchmarks_store_not_initialised(client, app):
+ app.state.benchmark_store = None
+
+ r = await client.get("/api/workers/w1/benchmark")
+ assert r.status_code == 503
+ assert "error" in r.json()
+
+
+@pytest.mark.asyncio
+async def test_get_worker_benchmarks_empty_history(client, app):
+ store = _make_store(latest_by_worker=[], history_by_worker=[])
+ app.state.benchmark_store = store
+
+ r = await client.get("/api/workers/w1/benchmark")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["latest"] == []
+ assert data["history"] == []
+
+
+@pytest.mark.asyncio
+async def test_get_worker_benchmarks_limit_param(client, app):
+ store = _make_store(latest_by_worker=[], history_by_worker=[])
+ app.state.benchmark_store = store
+
+ r = await client.get("/api/workers/w1/benchmark?limit=5")
+ assert r.status_code == 200
+ store.history_by_worker.assert_awaited_with("w1", limit=5)
+
+
+@pytest.mark.asyncio
+async def test_get_capability_leaderboard_happy_path(client, app):
+ store = _make_store(
+ leaderboard=[
+ {
+ "id": 1,
+ "worker_id": "w1",
+ "capability": "chat",
+ "metric": "tokens_per_sec",
+ "value": 100.0,
+ "status": "ok",
+ },
+ {
+ "id": 2,
+ "worker_id": "w2",
+ "capability": "chat",
+ "metric": "tokens_per_sec",
+ "value": 80.0,
+ "status": "ok",
+ },
+ ]
+ )
+ app.state.benchmark_store = store
+
+ r = await client.get("/api/benchmarks/capability/chat")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["capability"] == "chat"
+ assert "entries" in data
+ assert len(data["entries"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_get_capability_leaderboard_store_not_initialised(client, app):
+ app.state.benchmark_store = None
+
+ r = await client.get("/api/benchmarks/capability/chat")
+ assert r.status_code == 503
+ assert "error" in r.json()
+
+
+@pytest.mark.asyncio
+async def test_get_capability_leaderboard_with_metric(client, app):
+ store = _make_store(leaderboard=[])
+ app.state.benchmark_store = store
+
+ r = await client.get(
+ "/api/benchmarks/capability/chat?metric=tokens_per_sec"
+ )
+ assert r.status_code == 200
+ store.leaderboard.assert_awaited_with(
+ capability="chat", metric="tokens_per_sec"
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_capability_leaderboard_empty(client, app):
+ store = _make_store(leaderboard=[])
+ app.state.benchmark_store = store
+
+ r = await client.get("/api/benchmarks/capability/chat")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["entries"] == []
diff --git a/tests/test_routes_browser_sessions.py b/tests/test_routes_browser_sessions.py
new file mode 100644
index 000000000..1e4737141
--- /dev/null
+++ b/tests/test_routes_browser_sessions.py
@@ -0,0 +1,83 @@
+import pytest
+from unittest.mock import AsyncMock
+
+
+def _make_browser_sessions_mock():
+ mock = AsyncMock()
+ mock.list_visible_sessions = AsyncMock(return_value=[])
+ mock.get_session = AsyncMock(return_value=None)
+ mock.get_or_create_mine = AsyncMock(
+ return_value={
+ "id": "test-session-id",
+ "owner_type": "user",
+ "owner_id": "admin",
+ "status": "pending",
+ "profile_name": "default",
+ "url": "http://example.com",
+ "node": None,
+ "container_id": None,
+ "neko_url": None,
+ "cdp_url": None,
+ "is_mobile": False,
+ }
+ )
+ return mock
+
+
+def _make_running_mine_mock():
+ mock = AsyncMock()
+ mock.get_or_create_mine = AsyncMock(
+ return_value={
+ "id": "test-session-id",
+ "owner_type": "user",
+ "owner_id": "admin",
+ "status": "running",
+ "profile_name": "default",
+ "url": "http://example.com",
+ "node": "host",
+ "container_id": "abc123",
+ "neko_url": None,
+ "cdp_url": None,
+ "is_mobile": False,
+ }
+ )
+ return mock
+
+
+class TestBrowserSessionsReadRoutes:
+ @pytest.mark.asyncio
+ async def test_get_nodes_returns_200(self, client):
+ resp = await client.get("/api/browser/nodes")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "nodes" in body
+ assert isinstance(body["nodes"], list)
+
+ @pytest.mark.asyncio
+ async def test_list_sessions_returns_200_with_collection(self, client, monkeypatch):
+ mock_mgr = _make_browser_sessions_mock()
+ app = client._transport.app # noqa: SLF001
+ app.state._state["browser_sessions"] = mock_mgr # noqa: SLF001
+ resp = await client.get("/api/browser/sessions")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "sessions" in body
+ assert isinstance(body["sessions"], list)
+
+ @pytest.mark.asyncio
+ async def test_get_mine_returns_200(self, client, monkeypatch):
+ mock_mgr = _make_running_mine_mock()
+ app = client._transport.app # noqa: SLF001
+ app.state._state["browser_sessions"] = mock_mgr # noqa: SLF001
+ resp = await client.get("/api/browser/sessions/mine")
+ assert resp.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_get_session_not_found(self, client, monkeypatch):
+ mock_mgr = _make_browser_sessions_mock()
+ app = client._transport.app # noqa: SLF001
+ app.state._state["browser_sessions"] = mock_mgr # noqa: SLF001
+ resp = await client.get("/api/browser/sessions/unknown-id-12345")
+ assert resp.status_code == 404
+ body = resp.json()
+ assert body["error"] == "not_found"
diff --git a/tests/test_routes_browsing_history.py b/tests/test_routes_browsing_history.py
new file mode 100644
index 000000000..bc2f34310
--- /dev/null
+++ b/tests/test_routes_browsing_history.py
@@ -0,0 +1,194 @@
+"""Endpoint tests for tinyagentos/routes/browsing_history.py."""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+
+
+@pytest_asyncio.fixture
+async def browsing_history(app, tmp_data_dir):
+ """Initialise browsing_history on app.state so routes can access it."""
+ from taosmd import BrowsingHistory as BrowsingHistoryStore
+
+ store = BrowsingHistoryStore(db_path=tmp_data_dir / "browsing-history.db")
+ await store.init()
+ app.state.browsing_history = store
+ yield store
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_record_returns_200(client, browsing_history):
+ resp = await client.post(
+ "/api/browsing-history/record",
+ json={
+ "url": "https://example.com/article/1",
+ "source_type": "web",
+ "title": "Test Article",
+ "author": "Author",
+ "preview": "A preview",
+ },
+ )
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_record_response_shape(client, browsing_history):
+ resp = await client.post(
+ "/api/browsing-history/record",
+ json={
+ "url": "https://example.com/article/2",
+ "source_type": "web",
+ "title": "",
+ },
+ )
+ data = resp.json()
+ assert data == {"status": "ok"}
+
+
+@pytest.mark.asyncio
+async def test_record_missing_url_returns_422(client, browsing_history):
+ resp = await client.post(
+ "/api/browsing-history/record",
+ json={"source_type": "web"},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_record_missing_source_type_returns_422(client, browsing_history):
+ resp = await client.post(
+ "/api/browsing-history/record",
+ json={"url": "https://example.com/article/3"},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_list_history_returns_200(client, browsing_history):
+ resp = await client.get("/api/browsing-history")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_history_response_shape(client, browsing_history):
+ await browsing_history.record(
+ url="https://example.com/list-test",
+ source_type="web",
+ title="List Test",
+ )
+ resp = await client.get("/api/browsing-history")
+ data = resp.json()
+ assert "items" in data
+ assert "count" in data
+ assert isinstance(data["items"], list)
+ assert isinstance(data["count"], int)
+ assert data["count"] == len(data["items"])
+
+
+@pytest.mark.asyncio
+async def test_list_history_empty(client, browsing_history):
+ resp = await client.get("/api/browsing-history")
+ data = resp.json()
+ assert data["items"] == []
+ assert data["count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_list_history_with_source_type(client, browsing_history):
+ await browsing_history.record(
+ url="https://reddit.com/r/test",
+ source_type="reddit",
+ title="Reddit Post",
+ )
+ await browsing_history.record(
+ url="https://example.com/web",
+ source_type="web",
+ title="Web Page",
+ )
+ resp = await client.get("/api/browsing-history?source_type=reddit")
+ data = resp.json()
+ assert data["count"] == 1
+ assert data["items"][0]["source_type"] == "reddit"
+
+
+@pytest.mark.asyncio
+async def test_list_history_limit(client, browsing_history):
+ for i in range(5):
+ await browsing_history.record(
+ url=f"https://example.com/limit-{i}",
+ source_type="web",
+ title=f"Item {i}",
+ )
+ resp = await client.get("/api/browsing-history?limit=3")
+ data = resp.json()
+ assert data["count"] == 3
+ assert len(data["items"]) == 3
+
+
+@pytest.mark.asyncio
+async def test_clear_history_returns_200(client, browsing_history):
+ resp = await client.delete("/api/browsing-history")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_clear_history_response_shape(client, browsing_history):
+ await browsing_history.record(
+ url="https://example.com/clear-test",
+ source_type="web",
+ title="Clear Test",
+ )
+ resp = await client.delete("/api/browsing-history")
+ data = resp.json()
+ assert "deleted" in data
+ assert isinstance(data["deleted"], int)
+ assert data["deleted"] >= 1
+
+
+@pytest.mark.asyncio
+async def test_clear_history_empty(client, browsing_history):
+ resp = await client.delete("/api/browsing-history")
+ data = resp.json()
+ assert data["deleted"] == 0
+
+
+@pytest.mark.asyncio
+async def test_clear_history_with_source_type(client, browsing_history):
+ await browsing_history.record(
+ url="https://reddit.com/r/clear",
+ source_type="reddit",
+ title="Reddit",
+ )
+ await browsing_history.record(
+ url="https://example.com/clear-web",
+ source_type="web",
+ title="Web",
+ )
+ resp = await client.delete("/api/browsing-history?source_type=reddit")
+ data = resp.json()
+ assert data["deleted"] == 1
+ remaining = await browsing_history.list_recent()
+ assert len(remaining) == 1
+ assert remaining[0]["source_type"] == "web"
+
+
+@pytest.mark.asyncio
+async def test_record_then_list_round_trip(client, browsing_history):
+ await browsing_history.record(
+ url="https://example.com/round-trip",
+ source_type="web",
+ title="Round Trip",
+ author="Tester",
+ preview="Preview text",
+ )
+ resp = await client.get("/api/browsing-history")
+ data = resp.json()
+ assert data["count"] == 1
+ item = data["items"][0]
+ assert item["url"] == "https://example.com/round-trip"
+ assert item["source_type"] == "web"
+ assert item["title"] == "Round Trip"
+ assert item["author"] == "Tester"
+ assert item["preview"] == "Preview text"
diff --git a/tests/test_routes_canvas.py b/tests/test_routes_canvas.py
new file mode 100644
index 000000000..bed1226ec
--- /dev/null
+++ b/tests/test_routes_canvas.py
@@ -0,0 +1,196 @@
+"""Endpoint tests for tinyagentos/routes/canvas.py."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_create_canvas_returns_200(client):
+ resp = await client.post("/api/canvas/generate", json={
+ "title": "Test Canvas",
+ "content": "# Hello",
+ "style": "dark",
+ "format": "markdown",
+ "agent_name": "tester",
+ })
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_create_canvas_response_shape(client):
+ resp = await client.post("/api/canvas/generate", json={
+ "title": "Test Canvas",
+ "content": "# Hello",
+ "style": "dark",
+ "format": "markdown",
+ "agent_name": "tester",
+ })
+ data = resp.json()
+ assert "canvas_id" in data
+ assert "canvas_url" in data
+ assert "edit_token" in data
+ assert data["canvas_url"] == f"/canvas/{data['canvas_id']}"
+
+
+@pytest.mark.asyncio
+async def test_create_canvas_defaults(client):
+ resp = await client.post("/api/canvas/generate", json={})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "canvas_id" in data
+ assert "edit_token" in data
+
+
+@pytest.mark.asyncio
+async def test_get_canvas_data_returns_200(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Fetch Me",
+ "content": "some content",
+ })).json()
+ resp = await client.get(f"/api/canvas/{created['canvas_id']}/data")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_get_canvas_data_shape(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Fetch Me",
+ "content": "some content",
+ })).json()
+ data = (await client.get(f"/api/canvas/{created['canvas_id']}/data")).json()
+ assert data["id"] == created["canvas_id"]
+ assert data["title"] == "Fetch Me"
+ assert data["content"] == "some content"
+ assert "edit_token" in data
+ assert "created_at" in data
+ assert "updated_at" in data
+
+
+@pytest.mark.asyncio
+async def test_get_canvas_not_found(client):
+ resp = await client.get("/api/canvas/nonexistent/data")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Canvas not found"
+
+
+@pytest.mark.asyncio
+async def test_update_canvas_returns_200(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Update Me",
+ "content": "old content",
+ })).json()
+ hub = client._transport.app.state.chat_hub
+ with patch.object(hub, "broadcast", new_callable=AsyncMock):
+ resp = await client.post(f"/api/canvas/{created['canvas_id']}/update", json={
+ "edit_token": created["edit_token"],
+ "content": "new content",
+ })
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "updated"
+
+
+@pytest.mark.asyncio
+async def test_update_canvas_reflects_changes(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Update Me",
+ "content": "old content",
+ })).json()
+ hub = client._transport.app.state.chat_hub
+ with patch.object(hub, "broadcast", new_callable=AsyncMock):
+ await client.post(f"/api/canvas/{created['canvas_id']}/update", json={
+ "edit_token": created["edit_token"],
+ "content": "new content",
+ "title": "Updated Title",
+ })
+ data = (await client.get(f"/api/canvas/{created['canvas_id']}/data")).json()
+ assert data["content"] == "new content"
+ assert data["title"] == "Updated Title"
+
+
+@pytest.mark.asyncio
+async def test_update_canvas_invalid_token(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Protected",
+ "content": "secret",
+ })).json()
+ resp = await client.post(f"/api/canvas/{created['canvas_id']}/update", json={
+ "edit_token": "wrong-token",
+ "content": "hacked",
+ })
+ assert resp.status_code == 403
+ assert resp.json()["error"] == "Invalid edit token or canvas not found"
+
+
+@pytest.mark.asyncio
+async def test_update_canvas_not_found(client):
+ resp = await client.post("/api/canvas/nonexistent/update", json={
+ "edit_token": "some-token",
+ "content": "new",
+ })
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_delete_canvas_returns_200(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Delete Me",
+ "content": "bye",
+ })).json()
+ resp = await client.delete(f"/api/canvas/{created['canvas_id']}")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "deleted"
+
+
+@pytest.mark.asyncio
+async def test_delete_canvas_not_found(client):
+ resp = await client.delete("/api/canvas/nonexistent")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Canvas not found"
+
+
+@pytest.mark.asyncio
+async def test_delete_canvas_removes_data(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Delete Me",
+ "content": "bye",
+ })).json()
+ await client.delete(f"/api/canvas/{created['canvas_id']}")
+ resp = await client.get(f"/api/canvas/{created['canvas_id']}/data")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_list_canvases_returns_200(client):
+ resp = await client.get("/api/canvas")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_canvases_shape(client):
+ created = (await client.post("/api/canvas/generate", json={
+ "title": "Listed",
+ "content": "in list",
+ })).json()
+ data = (await client.get("/api/canvas")).json()
+ assert "canvases" in data
+ assert isinstance(data["canvases"], list)
+ ids = [c["id"] for c in data["canvases"]]
+ assert created["canvas_id"] in ids
+
+
+@pytest.mark.asyncio
+async def test_list_canvases_empty(client):
+ """Before creating any canvases the list may be empty or contain items from
+ other tests; just verify the response structure is correct."""
+ resp = await client.get("/api/canvas")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data["canvases"], list)
+
+
+# WebSocket endpoint (/ws/canvas/{canvas_id}) requires a live WebSocket
+# connection and real-time hub interaction; skipped as it needs an external
+# transport not available through the async HTTP client fixture.
diff --git a/tests/test_routes_catalog.py b/tests/test_routes_catalog.py
new file mode 100644
index 000000000..c123044b3
--- /dev/null
+++ b/tests/test_routes_catalog.py
@@ -0,0 +1,144 @@
+"""Endpoint tests for tinyagentos/routes/catalog.py."""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.mark.asyncio
+class TestCatalogStats:
+ async def test_stats_returns_200(self, client):
+ resp = await client.get("/api/memory/catalog/stats")
+ assert resp.status_code == 200
+
+ async def test_stats_returns_dict(self, client):
+ data = (await client.get("/api/memory/catalog/stats")).json()
+ assert isinstance(data, dict)
+
+ async def test_stats_has_expected_keys(self, client):
+ data = (await client.get("/api/memory/catalog/stats")).json()
+ for key in ("total_sessions", "total_sub_sessions", "days_cataloged"):
+ assert key in data, f"missing key: {key}"
+
+ async def test_stats_empty_catalog(self, client):
+ data = (await client.get("/api/memory/catalog/stats")).json()
+ assert data["total_sessions"] == 0
+ assert data["total_sub_sessions"] == 0
+
+
+@pytest.mark.asyncio
+class TestCatalogDate:
+ async def test_date_returns_200(self, client):
+ resp = await client.get("/api/memory/catalog/date/2026-06-19")
+ assert resp.status_code == 200
+
+ async def test_date_returns_list(self, client):
+ data = (await client.get("/api/memory/catalog/date/2026-06-19")).json()
+ assert isinstance(data, list)
+
+ async def test_date_empty_for_unknown_date(self, client):
+ data = (await client.get("/api/memory/catalog/date/2000-01-01")).json()
+ assert data == []
+
+
+@pytest.mark.asyncio
+class TestCatalogRange:
+ async def test_range_returns_200(self, client):
+ resp = await client.get(
+ "/api/memory/catalog/range",
+ params={"start": "2026-06-01", "end": "2026-06-30"},
+ )
+ assert resp.status_code == 200
+
+ async def test_range_returns_list(self, client):
+ data = (
+ await client.get(
+ "/api/memory/catalog/range",
+ params={"start": "2026-06-01", "end": "2026-06-30"},
+ )
+ ).json()
+ assert isinstance(data, list)
+
+ async def test_range_empty_for_future_dates(self, client):
+ data = (
+ await client.get(
+ "/api/memory/catalog/range",
+ params={"start": "2099-01-01", "end": "2099-12-31"},
+ )
+ ).json()
+ assert data == []
+
+
+@pytest.mark.asyncio
+class TestCatalogSearch:
+ async def test_search_returns_200(self, client):
+ resp = await client.get("/api/memory/catalog/search", params={"q": "test"})
+ assert resp.status_code == 200
+
+ async def test_search_returns_list(self, client):
+ data = (
+ await client.get("/api/memory/catalog/search", params={"q": "test"})
+ ).json()
+ assert isinstance(data, list)
+
+ async def test_search_empty_for_no_match(self, client):
+ data = (
+ await client.get(
+ "/api/memory/catalog/search", params={"q": "zzzznonexistent"}
+ )
+ ).json()
+ assert data == []
+
+ async def test_search_respects_limit(self, client):
+ data = (
+ await client.get(
+ "/api/memory/catalog/search", params={"q": "a", "limit": 5}
+ )
+ ).json()
+ assert len(data) <= 5
+
+
+@pytest.mark.asyncio
+class TestCatalogSession:
+ async def test_session_not_found_returns_404(self, client):
+ resp = await client.get("/api/memory/catalog/session/99999")
+ assert resp.status_code == 404
+
+ async def test_session_not_found_error_message(self, client):
+ data = (await client.get("/api/memory/catalog/session/99999")).json()
+ assert "detail" in data
+
+
+@pytest.mark.asyncio
+class TestCatalogSessionContext:
+ async def test_context_not_found_returns_404(self, client):
+ resp = await client.get("/api/memory/catalog/session/99999/context")
+ assert resp.status_code == 404
+
+ async def test_context_not_found_error_message(self, client):
+ data = (await client.get("/api/memory/catalog/session/99999/context")).json()
+ assert "detail" in data
+
+
+@pytest.mark.asyncio
+class TestCatalogRecent:
+ async def test_recent_returns_200(self, client):
+ resp = await client.get("/api/memory/catalog/recent")
+ assert resp.status_code == 200
+
+ async def test_recent_returns_list(self, client):
+ data = (await client.get("/api/memory/catalog/recent")).json()
+ assert isinstance(data, list)
+
+ async def test_recent_empty_catalog(self, client):
+ data = (await client.get("/api/memory/catalog/recent")).json()
+ assert data == []
+
+ async def test_recent_respects_limit(self, client):
+ data = (await client.get("/api/memory/catalog/recent", params={"limit": 5})).json()
+ assert len(data) <= 5
+
+
+# POST /api/memory/catalog/index and POST /api/memory/catalog/rebuild are
+# skipped: they create a CatalogPipeline that depends on archive files and
+# potentially an LLM service, which are not available in the test fixture.
diff --git a/tests/test_routes_chat_files.py b/tests/test_routes_chat_files.py
new file mode 100644
index 000000000..27f1e4dcc
--- /dev/null
+++ b/tests/test_routes_chat_files.py
@@ -0,0 +1,230 @@
+"""Endpoint tests for tinyagentos/routes/chat_files.py."""
+
+from __future__ import annotations
+
+import io
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# POST /api/chat/upload
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_upload_file_happy(client):
+ resp = await client.post(
+ "/api/chat/upload",
+ files={"file": ("hello.txt", io.BytesIO(b"hello world"), "text/plain")},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["filename"] == "hello.txt"
+ assert data["content_type"] == "text/plain"
+ assert data["size"] == 11
+ assert data["id"]
+ assert data["url"].startswith("/api/chat/files/")
+
+
+@pytest.mark.asyncio
+async def test_upload_file_with_channel_id(client):
+ resp = await client.post(
+ "/api/chat/upload",
+ params={"channel_id": "ch-123"},
+ files={"file": ("report.pdf", io.BytesIO(b"%PDF-1.4 fake"), "application/pdf")},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["filename"] == "report.pdf"
+ assert data["size"] == 13
+
+
+@pytest.mark.asyncio
+async def test_upload_file_too_large(client):
+ big = b"x" * (100 * 1024 * 1024 + 1)
+ resp = await client.post(
+ "/api/chat/upload",
+ files={"file": ("big.bin", io.BytesIO(big), "application/octet-stream")},
+ )
+ assert resp.status_code == 413
+ assert "too large" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_upload_file_empty_body_rejected(client):
+ """FastAPI rejects an empty body for a multipart upload field."""
+ resp = await client.post(
+ "/api/chat/upload",
+ content=b"",
+ headers={"content-type": "multipart/form-data; boundary=----fake"},
+ )
+ assert resp.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# GET /api/chat/files/{filename}
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_serve_file_happy(client):
+ upload = await client.post(
+ "/api/chat/upload",
+ files={"file": ("serve_me.txt", io.BytesIO(b"serve content"), "text/plain")},
+ )
+ assert upload.status_code == 200
+ url = upload.json()["url"]
+ resp = await client.get(url)
+ assert resp.status_code == 200
+ assert resp.content == b"serve content"
+
+
+@pytest.mark.asyncio
+async def test_serve_file_not_found(client):
+ resp = await client.get("/api/chat/files/nonexistent-file-abc123.txt")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "File not found"
+
+
+@pytest.mark.asyncio
+async def test_serve_file_traversal_rejected(client):
+ resp = await client.get("/api/chat/files/../../../etc/passwd")
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# POST /api/chat/attachments/from-path
+# ---------------------------------------------------------------------------
+
+
+def _make_workspace_file(data_dir: Path, slug: str, rel_path: str, content: bytes) -> Path:
+ """Create a file under data_dir/agent-workspaces/{slug}/ and return its path."""
+ base = data_dir / "agent-workspaces" / slug
+ dest = base / rel_path
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ dest.write_bytes(content)
+ return dest
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_workspace_happy(client, tmp_path):
+ app = client._transport.app
+ data_dir = app.state.data_dir
+ _make_workspace_file(data_dir, "user", "notes.md", b"# My Notes")
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/user/notes.md", "source": "workspace"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["filename"] == "notes.md"
+ assert data["mime_type"] in ("text/markdown", "application/octet-stream")
+ assert data["size"] == 10
+ assert data["source"] == "workspace"
+ assert data["url"].startswith("/api/chat/files/")
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_agent_workspace_happy(client, tmp_path):
+ app = client._transport.app
+ data_dir = app.state.data_dir
+ slug = "agent-1"
+ _make_workspace_file(data_dir, slug, "output.csv", b"a,b,c\n1,2,3\n")
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={
+ "path": f"/workspaces/{slug}/output.csv",
+ "source": "agent-workspace",
+ "slug": slug,
+ },
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["filename"] == "output.csv"
+ assert data["size"] == 12
+ assert data["source"] == "agent-workspace"
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_missing_path(client):
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"source": "workspace"},
+ )
+ assert resp.status_code == 400
+ assert "path" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_invalid_source(client):
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/user/foo.md", "source": "invalid"},
+ )
+ assert resp.status_code == 400
+ assert "source" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_file_not_found(client, tmp_path):
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/user/does-not-exist.md", "source": "workspace"},
+ )
+ assert resp.status_code == 400
+ assert "not found" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_traversal_rejected(client, tmp_path):
+ app = client._transport.app
+ data_dir = app.state.data_dir
+ _make_workspace_file(data_dir, "user", "secret.txt", b"leaked")
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/user/../admin/secret.txt", "source": "workspace"},
+ )
+ assert resp.status_code == 400
+ assert "traversal" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_workspace_wrong_owner(client, tmp_path):
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/other/foo.md", "source": "workspace"},
+ )
+ assert resp.status_code == 400
+ assert "user" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_agent_workspace_slug_mismatch(client, tmp_path):
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={
+ "path": "/workspaces/agent-x/file.txt",
+ "source": "agent-workspace",
+ "slug": "agent-y",
+ },
+ )
+ assert resp.status_code == 400
+ assert "slug" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_attachment_from_path_too_large(client, tmp_path):
+ app = client._transport.app
+ data_dir = app.state.data_dir
+ big = b"x" * (100 * 1024 * 1024 + 1)
+ _make_workspace_file(data_dir, "user", "huge.bin", big)
+ resp = await client.post(
+ "/api/chat/attachments/from-path",
+ json={"path": "/workspaces/user/huge.bin", "source": "workspace"},
+ )
+ assert resp.status_code == 413
+ assert "too large" in resp.json()["error"]
diff --git a/tests/test_routes_cluster_migrate.py b/tests/test_routes_cluster_migrate.py
new file mode 100644
index 000000000..a506ad832
--- /dev/null
+++ b/tests/test_routes_cluster_migrate.py
@@ -0,0 +1,39 @@
+import pytest
+
+
+class TestClusterRemotesRead:
+ @pytest.mark.asyncio
+ async def test_list_remotes_returns_200(self, client, monkeypatch):
+ async def fake_remote_list():
+ return []
+
+ monkeypatch.setattr(
+ "tinyagentos.routes.cluster_migrate.remote_list",
+ fake_remote_list,
+ )
+ resp = await client.get("/api/cluster/remotes")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+ @pytest.mark.asyncio
+ async def test_list_remotes_returns_configured_remotes(self, client, monkeypatch):
+ fake_remotes = [
+ {"name": "host-b", "addr": "https://10.0.0.2:8443", "protocol": "simplestreams"},
+ {"name": "host-c", "addr": "https://10.0.0.3:8443", "protocol": "simplestreams"},
+ ]
+
+ async def fake_remote_list():
+ return fake_remotes
+
+ monkeypatch.setattr(
+ "tinyagentos.routes.cluster_migrate.remote_list",
+ fake_remote_list,
+ )
+ resp = await client.get("/api/cluster/remotes")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+ assert len(data) == 2
+ assert data[0]["name"] == "host-b"
+ assert data[0]["addr"] == "https://10.0.0.2:8443"
+ assert data[0]["protocol"] == "simplestreams"
diff --git a/tests/test_routes_desktop.py b/tests/test_routes_desktop.py
index 6246dfb91..98e6bbc8a 100644
--- a/tests/test_routes_desktop.py
+++ b/tests/test_routes_desktop.py
@@ -48,6 +48,20 @@ def test_sw_js_returns_404_when_not_built(client, monkeypatch, tmp_path):
assert r.status_code == 404
+def test_app_html_served_without_auth(client, monkeypatch, tmp_path):
+ """The generic standalone PWA shell /app.html is served (with the ?app=
+ query) and is auth-exempt -- the mobile Install button opens it."""
+ fake_dir = tmp_path / "fake-spa"
+ fake_dir.mkdir()
+ (fake_dir / "app.html").write_text("app ")
+ monkeypatch.setattr("tinyagentos.routes.desktop.SPA_DIR", fake_dir)
+ r = client.get("/app.html?app=messages")
+ assert r.status_code == 200
+ # The shell HTML must revalidate so an updated bundle is not served stale.
+ assert "no-cache" in r.headers.get("Cache-Control", "")
+ assert "text/html" in r.headers.get("content-type", "")
+
+
# ---------------------------------------------------------------------------
# PWA shell pre-login accessibility
# The service worker must be able to precache the shell HTML without a
diff --git a/tests/test_routes_events.py b/tests/test_routes_events.py
new file mode 100644
index 000000000..472cb500f
--- /dev/null
+++ b/tests/test_routes_events.py
@@ -0,0 +1,211 @@
+"""Endpoint tests for tinyagentos/routes/events.py."""
+
+from __future__ import annotations
+
+import time
+from unittest.mock import patch
+
+import pytest
+import pytest_asyncio
+
+from tinyagentos.events.bus import SystemEvent
+from tinyagentos.events.store import SystemEventStore
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _init_system_events(client, tmp_path):
+ """Ensure app.state.system_events is initialised for every test."""
+ store = SystemEventStore(tmp_path / "system-events.db")
+ await store.init()
+ client._transport.app.state.system_events = store
+ yield
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_list_events_returns_200(client):
+ resp = await client.get("/api/events")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_events_response_shape(client):
+ data = (await client.get("/api/events")).json()
+ assert "events" in data
+ assert "count" in data
+ assert isinstance(data["events"], list)
+ assert isinstance(data["count"], int)
+ assert data["count"] == len(data["events"])
+
+
+@pytest.mark.asyncio
+async def test_list_events_empty_store(client):
+ data = (await client.get("/api/events")).json()
+ assert data["events"] == []
+ assert data["count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_list_events_returns_events_newest_first(client):
+ store = client._transport.app.state.system_events
+ now = time.time()
+ for i in range(3):
+ await store.add(SystemEvent(
+ kind="test",
+ source="pytest",
+ targets=["t1"],
+ level="info",
+ payload={"idx": i},
+ ts=now + i,
+ trace_id=f"trace-{i}",
+ ))
+ data = (await client.get("/api/events")).json()
+ assert data["count"] == 3
+ timestamps = [e["ts"] for e in data["events"]]
+ assert timestamps == sorted(timestamps, reverse=True)
+
+
+@pytest.mark.asyncio
+async def test_list_events_limit_param(client):
+ store = client._transport.app.state.system_events
+ now = time.time()
+ for i in range(5):
+ await store.add(SystemEvent(
+ kind="test",
+ source="pytest",
+ targets=["t1"],
+ level="info",
+ payload={"idx": i},
+ ts=now + i,
+ trace_id=f"trace-{i}",
+ ))
+ data = (await client.get("/api/events?limit=2")).json()
+ assert data["count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_list_events_limit_clamped_high(client):
+ """Limit above 1000 is clamped to 1000 by the store."""
+ store = client._transport.app.state.system_events
+ now = time.time()
+ for i in range(3):
+ await store.add(SystemEvent(
+ kind="test",
+ source="pytest",
+ targets=["t1"],
+ level="info",
+ payload={"idx": i},
+ ts=now + i,
+ trace_id=f"trace-{i}",
+ ))
+ data = (await client.get("/api/events?limit=9999")).json()
+ assert data["count"] == 3
+
+
+@pytest.mark.asyncio
+async def test_list_events_limit_clamped_low(client):
+ """Limit below 1 is clamped to 1 by the store."""
+ store = client._transport.app.state.system_events
+ now = time.time()
+ for i in range(3):
+ await store.add(SystemEvent(
+ kind="test",
+ source="pytest",
+ targets=["t1"],
+ level="info",
+ payload={"idx": i},
+ ts=now + i,
+ trace_id=f"trace-{i}",
+ ))
+ data = (await client.get("/api/events?limit=0")).json()
+ assert data["count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_list_events_kind_filter(client):
+ store = client._transport.app.state.system_events
+ now = time.time()
+ await store.add(SystemEvent(
+ kind="agent.lifecycle",
+ source="orchestrator",
+ targets=["agent-1"],
+ level="info",
+ payload={"action": "start"},
+ ts=now,
+ trace_id="t-1",
+ ))
+ await store.add(SystemEvent(
+ kind="user.message",
+ source="gateway",
+ targets=["agent-1"],
+ level="info",
+ payload={"text": "hello"},
+ ts=now + 1,
+ trace_id="t-2",
+ ))
+ data = (await client.get("/api/events?kind=agent.lifecycle")).json()
+ assert data["count"] == 1
+ assert data["events"][0]["kind"] == "agent.lifecycle"
+
+
+@pytest.mark.asyncio
+async def test_list_events_kind_filter_no_match(client):
+ store = client._transport.app.state.system_events
+ now = time.time()
+ await store.add(SystemEvent(
+ kind="agent.lifecycle",
+ source="orchestrator",
+ targets=["agent-1"],
+ level="info",
+ payload={"action": "start"},
+ ts=now,
+ trace_id="t-1",
+ ))
+ data = (await client.get("/api/events?kind=nonexistent")).json()
+ assert data["count"] == 0
+ assert data["events"] == []
+
+
+@pytest.mark.asyncio
+async def test_list_events_event_fields(client):
+ """Each event dict should expose the expected keys."""
+ store = client._transport.app.state.system_events
+ now = time.time()
+ await store.add(SystemEvent(
+ kind="test.kind",
+ source="pytest",
+ targets=["target-a"],
+ level="warn",
+ payload={"key": "value"},
+ ts=now,
+ trace_id="abc-123",
+ ))
+ data = (await client.get("/api/events")).json()
+ event = data["events"][0]
+ for key in ("id", "kind", "source", "targets", "level", "payload", "ts", "trace_id"):
+ assert key in event, f"missing event key: {key}"
+ assert event["kind"] == "test.kind"
+ assert event["source"] == "pytest"
+ assert event["targets"] == ["target-a"]
+ assert event["level"] == "warn"
+ assert event["payload"] == {"key": "value"}
+ assert event["trace_id"] == "abc-123"
+
+
+@pytest.mark.asyncio
+async def test_list_events_default_limit_is_100(client):
+ """Without a limit param the default is 100."""
+ store = client._transport.app.state.system_events
+ now = time.time()
+ for i in range(150):
+ await store.add(SystemEvent(
+ kind="flood",
+ source="pytest",
+ targets=[],
+ level="info",
+ payload={"i": i},
+ ts=now + i,
+ trace_id=f"t-{i}",
+ ))
+ data = (await client.get("/api/events")).json()
+ assert data["count"] == 100
diff --git a/tests/test_routes_feedback.py b/tests/test_routes_feedback.py
new file mode 100644
index 000000000..82657342d
--- /dev/null
+++ b/tests/test_routes_feedback.py
@@ -0,0 +1,44 @@
+"""Route tests for POST/GET /api/feedback endpoints."""
+import pytest
+
+
+class TestFeedbackRoutes:
+ @pytest.mark.asyncio
+ async def test_post_feedback_returns_201_with_id(self, client):
+ resp = await client.post(
+ "/api/feedback",
+ json={"type": "bug", "title": "Login fails", "body": "Cannot sign in"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert "id" in data
+ assert data["type"] == "bug"
+ assert data["title"] == "Login fails"
+ assert "created_at" in data
+
+ @pytest.mark.asyncio
+ async def test_get_feedback_includes_posted_item(self, client):
+ posted = await client.post(
+ "/api/feedback",
+ json={"type": "feature", "title": "Add dark mode", "body": ""},
+ )
+ item_id = posted.json()["id"]
+
+ resp = await client.get("/api/feedback")
+ assert resp.status_code == 200
+ items = resp.json()
+ ids = [item["id"] for item in items]
+ assert item_id in ids
+
+ @pytest.mark.asyncio
+ async def test_get_feedback_unknown_id_returns_404(self, client):
+ resp = await client.get("/api/feedback/does-not-exist")
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_post_feedback_missing_required_field_returns_422(self, client):
+ resp = await client.post(
+ "/api/feedback",
+ json={"title": "Missing type field"},
+ )
+ assert resp.status_code == 422
diff --git a/tests/test_routes_framework.py b/tests/test_routes_framework.py
new file mode 100644
index 000000000..67996539f
--- /dev/null
+++ b/tests/test_routes_framework.py
@@ -0,0 +1,318 @@
+"""Endpoint tests for tinyagentos/routes/framework.py."""
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, patch
+
+from tinyagentos.frameworks import FRAMEWORKS
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_COUNTER = [0]
+
+
+def _make_agent(name=None, framework="openclaw", **overrides):
+ _COUNTER[0] += 1
+ if name is None:
+ name = f"fw-test-agent-{_COUNTER[0]}"
+ agent = {
+ "name": name,
+ "host": "127.0.0.1",
+ "qmd_index": "test",
+ "color": "#abc",
+ "framework": framework,
+ "framework_version_tag": "v1.0.0",
+ "framework_version_sha": "sha-old",
+ "framework_update_status": "idle",
+ "framework_update_started_at": None,
+ "framework_update_last_error": None,
+ "framework_last_snapshot": None,
+ }
+ agent.update(overrides)
+ return agent
+
+
+def _fw_cache(tag="v2.0.0", sha="sha-new"):
+ return {
+ "openclaw": {
+ "tag": tag,
+ "sha": sha,
+ "published_at": "2026-01-01T00:00:00Z",
+ }
+ }
+
+
+# ---------------------------------------------------------------------------
+# GET /api/agents/{slug}/framework
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+class TestGetAgentFramework:
+ async def test_returns_framework_info(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["framework"] == "openclaw"
+ assert data["installed"]["tag"] == "v1.0.0"
+ assert data["installed"]["sha"] == "sha-old"
+ assert data["latest"]["tag"] == "v2.0.0"
+ assert data["update_available"] is True
+ assert data["update_status"] == "idle"
+
+ async def test_no_update_when_sha_matches(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(framework_version_sha="sha-new")
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache(sha="sha-new")
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["update_available"] is False
+
+ async def test_no_latest_release(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = {}
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["latest"] is None
+ assert data["update_available"] is False
+
+ async def test_agent_not_found(self, client):
+ r = await client.get("/api/agents/does-not-exist/framework")
+ assert r.status_code == 404
+ assert "error" in r.json()
+
+ async def test_latest_framework_versions_missing_attr(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ if hasattr(app.state, "latest_framework_versions"):
+ app.state.latest_framework_versions = None
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["latest"] is None
+
+ async def test_update_status_reflects_agent_state(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(
+ framework_update_status="updating",
+ framework_update_started_at=1234567890,
+ )
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["update_status"] == "updating"
+ assert data["update_started_at"] == 1234567890
+
+ async def test_last_error_included(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(
+ framework_update_status="failed",
+ framework_update_last_error="install script rc=1",
+ )
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.get(f"/api/agents/{agent['name']}/framework")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["last_error"] == "install script rc=1"
+
+
+# ---------------------------------------------------------------------------
+# POST /api/agents/{slug}/framework/update
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+class TestPostUpdate:
+ async def test_accepted(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ manifest = dict(FRAMEWORKS["openclaw"])
+ manifest["release_source"] = "github"
+ with patch("tinyagentos.routes.framework.FRAMEWORKS", {**FRAMEWORKS, "openclaw": manifest}):
+ with patch("tinyagentos.framework_update.start_update", new_callable=AsyncMock):
+ r = await client.post(f"/api/agents/{agent['name']}/framework/update", json={})
+ assert r.status_code == 202
+ data = r.json()
+ assert data["status"] == "accepted"
+ assert data["update_status"] == "updating"
+
+ async def test_agent_not_found(self, client):
+ r = await client.post("/api/agents/does-not-exist/framework/update", json={})
+ assert r.status_code == 404
+
+ async def test_already_updating(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(framework_update_status="updating")
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.post(f"/api/agents/{agent['name']}/framework/update", json={})
+ assert r.status_code == 409
+ assert "error" in r.json()
+
+ async def test_failed_state_also_blocked(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(framework_update_status="failed")
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.post(f"/api/agents/{agent['name']}/framework/update", json={})
+ assert r.status_code == 409
+
+ async def test_no_release_source(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(framework="generic")
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.post(f"/api/agents/{agent['name']}/framework/update", json={})
+ assert r.status_code == 400
+ assert "error" in r.json()
+
+ async def test_no_latest_cached_release(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = {}
+ manifest = dict(FRAMEWORKS["openclaw"])
+ manifest["release_source"] = "github"
+ with patch("tinyagentos.routes.framework.FRAMEWORKS", {**FRAMEWORKS, "openclaw": manifest}):
+ r = await client.post(f"/api/agents/{agent['name']}/framework/update", json={})
+ assert r.status_code == 409
+ assert "error" in r.json()
+
+ async def test_target_version_mismatch(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache(tag="v2.0.0")
+ manifest = dict(FRAMEWORKS["openclaw"])
+ manifest["release_source"] = "github"
+ with patch("tinyagentos.routes.framework.FRAMEWORKS", {**FRAMEWORKS, "openclaw": manifest}):
+ r = await client.post(
+ f"/api/agents/{agent['name']}/framework/update",
+ json={"target_version": "v3.0.0"},
+ )
+ assert r.status_code == 400
+ assert "error" in r.json()
+
+ async def test_target_version_matches(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent()
+ app.state.config.agents.append(agent)
+ app.state.latest_framework_versions = _fw_cache(tag="v2.0.0")
+ manifest = dict(FRAMEWORKS["openclaw"])
+ manifest["release_source"] = "github"
+ with patch("tinyagentos.routes.framework.FRAMEWORKS", {**FRAMEWORKS, "openclaw": manifest}):
+ with patch("tinyagentos.framework_update.start_update", new_callable=AsyncMock):
+ r = await client.post(
+ f"/api/agents/{agent['name']}/framework/update",
+ json={"target_version": "v2.0.0"},
+ )
+ assert r.status_code == 202
+
+
+# ---------------------------------------------------------------------------
+# GET /api/frameworks/latest
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+class TestGetLatest:
+ async def test_returns_cached_versions(self, client, app):
+ app.state.latest_framework_versions = _fw_cache()
+ r = await client.get("/api/frameworks/latest")
+ assert r.status_code == 200
+ data = r.json()
+ assert "openclaw" in data
+ assert data["openclaw"]["tag"] == "v2.0.0"
+
+ async def test_refresh_triggers_poll(self, client, app):
+ app.state.latest_framework_versions = {}
+ app.state.http_client = AsyncMock()
+ with patch("tinyagentos.auto_update.poll_frameworks", new_callable=AsyncMock) as mock_poll:
+ r = await client.get("/api/frameworks/latest?refresh=true")
+ assert r.status_code == 200
+ mock_poll.assert_awaited_once()
+
+ async def test_no_refresh_returns_cached(self, client, app):
+ app.state.latest_framework_versions = _fw_cache()
+ with patch("tinyagentos.auto_update.poll_frameworks", new_callable=AsyncMock) as mock_poll:
+ r = await client.get("/api/frameworks/latest")
+ assert r.status_code == 200
+ mock_poll.assert_not_awaited()
+
+
+# ---------------------------------------------------------------------------
+# GET /api/frameworks/slash-commands
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+class TestSlashCommandsManifest:
+ async def test_returns_commands_for_agents(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(name="my-agent", framework="openclaw")
+ app.state.config.agents.append(agent)
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ data = r.json()
+ assert "my-agent" in data
+ names = [c["name"] for c in data["my-agent"]]
+ assert "help" in names
+ assert "clear" in names
+
+ async def test_unknown_framework_returns_empty(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(name="orphan", framework="nonexistent-fw")
+ app.state.config.agents.append(agent)
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["orphan"] == []
+
+ async def test_no_agents(self, client, app):
+ app.state.config.agents = []
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ assert r.json() == {}
+
+ async def test_agent_without_name_skipped(self, client, app):
+ app.state.config.agents = []
+ agent = {"framework": "openclaw", "host": "127.0.0.1"}
+ app.state.config.agents.append(agent)
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ data = r.json()
+ assert agent.get("name") not in data
+
+ async def test_framework_without_slash_commands(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(name="plain", framework="generic")
+ app.state.config.agents.append(agent)
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["plain"] == []
+
+ async def test_command_structure(self, client, app):
+ app.state.config.agents = []
+ agent = _make_agent(name="cmd-agent", framework="openclaw")
+ app.state.config.agents.append(agent)
+ r = await client.get("/api/frameworks/slash-commands")
+ assert r.status_code == 200
+ cmds = r.json()["cmd-agent"]
+ for cmd in cmds:
+ assert "name" in cmd
+ assert "description" in cmd
diff --git a/tests/test_routes_games.py b/tests/test_routes_games.py
new file mode 100644
index 000000000..1cf48c723
--- /dev/null
+++ b/tests/test_routes_games.py
@@ -0,0 +1,153 @@
+"""Route tests for games endpoints."""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import ASGITransport, AsyncClient, Request as HttpxRequest, Response
+
+LEGAL_MOVES = ["e2e4", "d2d4", "g1f3"]
+CHESS_BODY = {
+ "agent_name": "chess-bot",
+ "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
+ "legal_moves": LEGAL_MOVES,
+ "history": ["e2e4", "e7e5"],
+}
+
+
+def _mock_httpx_response(content: str):
+ mock_request = HttpxRequest("POST", "http://localhost:9999/message")
+ return Response(
+ status_code=200,
+ json={"content": content},
+ request=mock_request,
+ )
+
+
+def _set_hub_router_port(app, port: int = 9999):
+ hub_router = MagicMock()
+ hub_router.get_adapter_port.return_value = port
+ app.state.channel_hub_router = hub_router
+ return hub_router
+
+
+@pytest.mark.asyncio
+class TestChessMove:
+ async def test_missing_agent_name_returns_400(self, client):
+ resp = await client.post(
+ "/api/games/chess/move",
+ json={"legal_moves": LEGAL_MOVES},
+ )
+ assert resp.status_code == 400
+ assert resp.json()["error"] == "Missing agent_name or legal_moves"
+
+ async def test_missing_legal_moves_returns_400(self, client):
+ resp = await client.post(
+ "/api/games/chess/move",
+ json={"agent_name": "chess-bot"},
+ )
+ assert resp.status_code == 400
+ assert resp.json()["error"] == "Missing agent_name or legal_moves"
+
+ async def test_empty_legal_moves_returns_400(self, client):
+ resp = await client.post(
+ "/api/games/chess/move",
+ json={"agent_name": "chess-bot", "legal_moves": []},
+ )
+ assert resp.status_code == 400
+
+ async def test_unreachable_agent_returns_random_legal_move(self, client):
+ app = client._transport.app
+ app.state.channel_hub_router = None
+ app.state.channel_hub = None
+
+ resp = await client.post("/api/games/chess/move", json=CHESS_BODY)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["move"] in LEGAL_MOVES
+ assert "not reachable" in data["commentary"].lower()
+
+ async def test_agent_move_via_hub_router_port(self, client):
+ app = client._transport.app
+ _set_hub_router_port(app, 9999)
+
+ with patch("tinyagentos.routes.games.httpx.AsyncClient") as MockClient:
+ mock_instance = AsyncMock()
+ mock_instance.post.return_value = _mock_httpx_response("Best move: e2e4")
+ mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+ mock_instance.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value = mock_instance
+
+ resp = await client.post("/api/games/chess/move", json=CHESS_BODY)
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["move"] == "e2e4"
+ assert "e2e4" in data["commentary"]
+ mock_instance.post.assert_awaited_once()
+ call_url = mock_instance.post.await_args.args[0]
+ assert call_url == "http://localhost:9999/message"
+
+ async def test_agent_move_via_channel_hub_adapter_url(self, client):
+ app = client._transport.app
+ app.state.channel_hub_router = None
+ adapter_mgr = MagicMock()
+ adapter_mgr.get_adapter_url.return_value = "http://localhost:8888"
+ app.state.channel_hub = adapter_mgr
+
+ with patch("tinyagentos.routes.games.httpx.AsyncClient") as MockClient:
+ mock_instance = AsyncMock()
+ mock_instance.post.return_value = _mock_httpx_response("d2d4")
+ mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+ mock_instance.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value = mock_instance
+
+ resp = await client.post("/api/games/chess/move", json=CHESS_BODY)
+
+ assert resp.status_code == 200
+ assert resp.json()["move"] == "d2d4"
+ adapter_mgr.get_adapter_url.assert_called_once_with("chess-bot")
+
+ async def test_invalid_agent_response_falls_back_to_random_move(self, client):
+ app = client._transport.app
+ _set_hub_router_port(app, 9999)
+
+ with patch("tinyagentos.routes.games.httpx.AsyncClient") as MockClient:
+ mock_instance = AsyncMock()
+ mock_instance.post.return_value = _mock_httpx_response("I cannot decide")
+ mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+ mock_instance.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value = mock_instance
+
+ resp = await client.post("/api/games/chess/move", json=CHESS_BODY)
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["move"] in LEGAL_MOVES
+ assert "Agent returned" in data["commentary"]
+
+ async def test_httpx_error_falls_back_to_random_move(self, client):
+ app = client._transport.app
+ _set_hub_router_port(app, 9999)
+
+ with patch("tinyagentos.routes.games.httpx.AsyncClient") as MockClient:
+ mock_instance = AsyncMock()
+ mock_instance.post.side_effect = ConnectionError("connection refused")
+ mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+ mock_instance.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value = mock_instance
+
+ resp = await client.post("/api/games/chess/move", json=CHESS_BODY)
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["move"] in LEGAL_MOVES
+ assert data["commentary"].startswith("Error:")
+
+ async def test_unauthenticated_returns_401(self, app):
+ async with AsyncClient(
+ transport=ASGITransport(app=app),
+ base_url="http://test",
+ ) as c:
+ resp = await c.post("/api/games/chess/move", json=CHESS_BODY)
+ assert resp.status_code in (401, 403)
\ No newline at end of file
diff --git a/tests/test_routes_guides.py b/tests/test_routes_guides.py
new file mode 100644
index 000000000..315fe5f61
--- /dev/null
+++ b/tests/test_routes_guides.py
@@ -0,0 +1,258 @@
+"""Endpoint tests for tinyagentos/routes/guides.py."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+import tinyagentos.routes.guides as guides_mod
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+_SAMPLE_GUIDES = {
+ "hardware_tiers": {
+ "pi-16gb": {
+ "label": "Raspberry Pi (16 GB)",
+ "description": "ARM-based SBC with 16 GB RAM.",
+ "icon": "cpu",
+ },
+ "nvidia-12gb": {
+ "label": "NVIDIA GPU (12 GB)",
+ "description": "Desktop GPU with 12 GB VRAM.",
+ "icon": "monitor",
+ },
+ "cpu-only": {
+ "label": "CPU Only",
+ "description": "No dedicated GPU.",
+ "icon": "server",
+ },
+ },
+ "use_cases": {
+ "chat": {
+ "label": "Chat",
+ "description": "Conversational AI.",
+ "icon": "message-circle",
+ },
+ "coding": {
+ "label": "Coding",
+ "description": "Code generation.",
+ "icon": "code",
+ },
+ },
+ "recommendations": {
+ "pi-16gb": {
+ "chat": [
+ {
+ "model": "Qwen3 1.7B (Q4_K_M)",
+ "reason": "Best quality for 16 GB RAM.",
+ "note": "~1.2 GB on disk.",
+ },
+ ],
+ "coding": [
+ {
+ "model": "Qwen3 1.7B (Q4_K_M)",
+ "reason": "Best coding model that fits in 16 GB.",
+ },
+ ],
+ },
+ "nvidia-12gb": {
+ "chat": [
+ {
+ "model": "Qwen3 8B (Q4_K_M)",
+ "reason": "Sweet spot for 12 GB VRAM.",
+ },
+ ],
+ },
+ "cpu-only": {
+ "chat": [
+ {
+ "model": "Qwen3 4B (Q4_K_M)",
+ "reason": "Best balance for CPU inference.",
+ },
+ ],
+ },
+ },
+}
+
+
+@pytest.fixture(autouse=True)
+def _inject_guides(tmp_data_dir, monkeypatch):
+ """Write a sample guides.yaml into the test data dir and reset cache."""
+ import yaml as _yaml
+
+ guides_path = tmp_data_dir / "guides.yaml"
+ guides_path.write_text(_yaml.dump(_SAMPLE_GUIDES))
+ monkeypatch.setattr(guides_mod, "_guides", None)
+ monkeypatch.setattr(guides_mod, "_cached_data_dir", None)
+
+
+# ---------------------------------------------------------------------------
+# GET /api/guides/recommendations
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_recommendations_happy_path(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "pi-16gb", "use_case": "chat"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["hardware"] == "pi-16gb"
+ assert data["use_case"] == "chat"
+ assert isinstance(data["recommendations"], list)
+ assert len(data["recommendations"]) > 0
+ rec = data["recommendations"][0]
+ assert "model" in rec
+ assert "reason" in rec
+
+
+@pytest.mark.asyncio
+async def test_recommendations_nvidia_coding(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "pi-16gb", "use_case": "coding"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["use_case"] == "coding"
+ assert data["recommendations"][0]["model"] == "Qwen3 1.7B (Q4_K_M)"
+
+
+@pytest.mark.asyncio
+async def test_recommendations_unknown_hardware(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "nonexistent", "use_case": "chat"},
+ )
+ assert resp.status_code == 404
+ detail = resp.json()["detail"]
+ assert "nonexistent" in detail
+
+
+@pytest.mark.asyncio
+async def test_recommendations_unknown_use_case(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "pi-16gb", "use_case": "nonexistent"},
+ )
+ assert resp.status_code == 404
+ detail = resp.json()["detail"]
+ assert "nonexistent" in detail
+
+
+@pytest.mark.asyncio
+async def test_recommendations_missing_hardware_param(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"use_case": "chat"},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_recommendations_missing_use_case_param(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "pi-16gb"},
+ )
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_recommendations_response_shape(client):
+ resp = await client.get(
+ "/api/guides/recommendations",
+ params={"hardware": "pi-16gb", "use_case": "chat"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ for key in ("hardware", "use_case", "recommendations"):
+ assert key in data
+ rec = data["recommendations"][0]
+ for key in ("model", "reason"):
+ assert key in rec
+
+
+# ---------------------------------------------------------------------------
+# GET /api/guides/tiers
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_tiers_returns_200(client):
+ resp = await client.get("/api/guides/tiers")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_tiers_shape(client):
+ data = (await client.get("/api/guides/tiers")).json()
+ assert "tiers" in data
+ assert isinstance(data["tiers"], dict)
+
+
+@pytest.mark.asyncio
+async def test_list_tiers_contains_known_tiers(client):
+ data = (await client.get("/api/guides/tiers")).json()
+ tiers = data["tiers"]
+ for tier_key in ("pi-16gb", "nvidia-12gb", "cpu-only"):
+ assert tier_key in tiers, f"missing tier: {tier_key}"
+ assert "label" in tiers[tier_key]
+ assert "description" in tiers[tier_key]
+
+
+@pytest.mark.asyncio
+async def test_list_tiers_empty_when_no_guides(client, monkeypatch):
+ monkeypatch.setattr(guides_mod, "_guides", None)
+ monkeypatch.setattr(guides_mod, "_cached_data_dir", None)
+ with patch.object(guides_mod, "_load_guides", return_value={}):
+ resp = await client.get("/api/guides/tiers")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["tiers"] == {}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/guides/use-cases
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_use_cases_returns_200(client):
+ resp = await client.get("/api/guides/use-cases")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_use_cases_shape(client):
+ data = (await client.get("/api/guides/use-cases")).json()
+ assert "use_cases" in data
+ assert isinstance(data["use_cases"], dict)
+
+
+@pytest.mark.asyncio
+async def test_list_use_cases_contains_known_cases(client):
+ data = (await client.get("/api/guides/use-cases")).json()
+ cases = data["use_cases"]
+ for case_key in ("chat", "coding"):
+ assert case_key in cases, f"missing use case: {case_key}"
+ assert "label" in cases[case_key]
+ assert "description" in cases[case_key]
+
+
+@pytest.mark.asyncio
+async def test_list_use_cases_empty_when_no_guides(client, monkeypatch):
+ monkeypatch.setattr(guides_mod, "_guides", None)
+ monkeypatch.setattr(guides_mod, "_cached_data_dir", None)
+ with patch.object(guides_mod, "_load_guides", return_value={}):
+ resp = await client.get("/api/guides/use-cases")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["use_cases"] == {}
diff --git a/tests/test_routes_jobs.py b/tests/test_routes_jobs.py
new file mode 100644
index 000000000..437728bec
--- /dev/null
+++ b/tests/test_routes_jobs.py
@@ -0,0 +1,245 @@
+"""Endpoint tests for tinyagentos/routes/jobs.py."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+
+def _make_request(app):
+ req = MagicMock()
+ req.app.state = app.state
+ return req
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_returns_200(client):
+ resp = await client.get("/api/jobs")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_returns_list(client):
+ resp = await client.get("/api/jobs")
+ assert isinstance(resp.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_empty(client):
+ resp = await client.get("/api/jobs")
+ assert resp.json() == []
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_with_status_filter(client):
+ resp = await client.get("/api/jobs", params={"status": "pending"})
+ assert resp.status_code == 200
+ assert isinstance(resp.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_with_limit(client):
+ resp = await client.get("/api/jobs", params={"limit": 5})
+ assert resp.status_code == 200
+ assert len(resp.json()) <= 5
+
+
+@pytest.mark.asyncio
+async def test_job_stats_returns_200(client):
+ resp = await client.get("/api/jobs/stats")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_job_stats_shape(client):
+ data = (await client.get("/api/jobs/stats")).json()
+ assert "counts" in data
+ assert "running_by_resource" in data
+ assert "limits" in data
+ assert "total_pending" in data
+ assert "total_running" in data
+ assert isinstance(data["counts"], dict)
+ assert isinstance(data["running_by_resource"], dict)
+ assert isinstance(data["limits"], dict)
+
+
+@pytest.mark.asyncio
+async def test_job_stats_empty_queue(client):
+ data = (await client.get("/api/jobs/stats")).json()
+ assert data["total_pending"] == 0
+ assert data["total_running"] == 0
+
+
+@pytest.mark.asyncio
+async def test_running_jobs_returns_200(client):
+ resp = await client.get("/api/jobs/running")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_running_jobs_returns_list(client):
+ resp = await client.get("/api/jobs/running")
+ assert isinstance(resp.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_running_jobs_empty(client):
+ resp = await client.get("/api/jobs/running")
+ assert resp.json() == []
+
+
+@pytest.mark.asyncio
+async def test_get_job_not_found(client):
+ resp = await client.get("/api/jobs/nonexistent-id")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Job not found"
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_not_found(client):
+ resp = await client.post("/api/jobs/nonexistent-id/cancel")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Job not found or not pending"
+
+
+@pytest.mark.asyncio
+async def test_cleanup_jobs_returns_200(client):
+ resp = await client.post("/api/jobs/cleanup")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_cleanup_jobs_shape(client):
+ data = (await client.post("/api/jobs/cleanup")).json()
+ assert "removed" in data
+ assert isinstance(data["removed"], int)
+
+
+@pytest.mark.asyncio
+async def test_cleanup_jobs_empty(client):
+ data = (await client.post("/api/jobs/cleanup")).json()
+ assert data["removed"] == 0
+
+
+@pytest.mark.asyncio
+async def test_list_jobs_after_enqueue(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ job_id = await queue.enqueue(job_type="embed", payload={"text": "hello"})
+
+ app.state.job_queue = queue
+
+ resp = await client.get("/api/jobs")
+ assert resp.status_code == 200
+ jobs = resp.json()
+ assert len(jobs) == 1
+ assert jobs[0]["id"] == job_id
+ assert jobs[0]["job_type"] == "embed"
+ assert jobs[0]["status"] == "pending"
+
+ await queue.close()
+ app.state.job_queue = None
+
+
+@pytest.mark.asyncio
+async def test_get_job_after_enqueue(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ job_id = await queue.enqueue(job_type="extract", agent_name="test-agent")
+
+ app.state.job_queue = queue
+
+ resp = await client.get(f"/api/jobs/{job_id}")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == job_id
+ assert data["job_type"] == "extract"
+ assert data["agent_name"] == "test-agent"
+
+ await queue.close()
+ app.state.job_queue = None
+
+
+@pytest.mark.asyncio
+async def test_cancel_job_after_enqueue(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ job_id = await queue.enqueue(job_type="enrich")
+
+ app.state.job_queue = queue
+
+ resp = await client.post(f"/api/jobs/{job_id}/cancel")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "cancelled"
+ assert resp.json()["job_id"] == job_id
+
+ await queue.close()
+ app.state.job_queue = None
+
+
+@pytest.mark.asyncio
+async def test_cancel_already_cancelled_returns_404(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ job_id = await queue.enqueue(job_type="split")
+
+ app.state.job_queue = queue
+
+ await client.post(f"/api/jobs/{job_id}/cancel")
+ resp = await client.post(f"/api/jobs/{job_id}/cancel")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Job not found or not pending"
+
+ await queue.close()
+ app.state.job_queue = None
+
+
+@pytest.mark.asyncio
+async def test_stats_after_enqueue(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ await queue.enqueue(job_type="embed")
+ await queue.enqueue(job_type="extract")
+
+ app.state.job_queue = queue
+
+ resp = await client.get("/api/jobs/stats")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total_pending"] == 2
+ assert data["counts"]["pending"] == 2
+
+ await queue.close()
+ app.state.job_queue = None
+
+
+@pytest.mark.asyncio
+async def test_running_jobs_after_dequeue(client):
+ from tinyagentos.routes.jobs import _get_queue
+
+ app = client._transport.app
+ queue = await _get_queue(_make_request(app))
+ await queue.enqueue(job_type="embed")
+ await queue.dequeue()
+
+ app.state.job_queue = queue
+
+ resp = await client.get("/api/jobs/running")
+ assert resp.status_code == 200
+ jobs = resp.json()
+ assert len(jobs) == 1
+ assert jobs[0]["status"] == "running"
+
+ await queue.close()
+ app.state.job_queue = None
diff --git a/tests/test_routes_knowledge.py b/tests/test_routes_knowledge.py
new file mode 100644
index 000000000..985efc061
--- /dev/null
+++ b/tests/test_routes_knowledge.py
@@ -0,0 +1,88 @@
+"""Endpoint tests for tinyagentos/routes/knowledge.py (read endpoints)."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+
+@pytest.mark.asyncio
+class TestListItems:
+ async def test_list_items_returns_200_with_shape(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.list_items.return_value = []
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/items")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "items" in data
+ assert "count" in data
+ assert data["count"] == 0
+
+ async def test_list_items_returns_seeded_items(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.list_items.return_value = [
+ {"id": "item-1", "title": "Test", "status": "done"},
+ ]
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/items")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["count"] == 1
+ assert data["items"][0]["id"] == "item-1"
+
+
+@pytest.mark.asyncio
+class TestGetItem:
+ async def test_get_item_not_found(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.get_item.return_value = None
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/items/unknown-id-1234")
+ assert resp.status_code == 404
+
+ async def test_get_item_returns_item(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.get_item.return_value = {
+ "id": "item-1", "title": "Test", "status": "done",
+ }
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/items/item-1")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == "item-1"
+
+
+@pytest.mark.asyncio
+class TestListRules:
+ async def test_list_rules_returns_200(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.list_rules.return_value = []
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/rules")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "rules" in data
+
+ async def test_list_rules_returns_seeded_rules(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.list_rules.return_value = [
+ {"id": 1, "pattern": "test-*", "match_on": "title", "category": "tests", "priority": 10},
+ ]
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.get("/api/knowledge/rules")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["rules"]) == 1
+ assert data["rules"][0]["pattern"] == "test-*"
+
+
+@pytest.mark.asyncio
+class TestDeleteItem:
+ async def test_delete_item_not_found(self, client, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.get_item.return_value = None
+ monkeypatch.setattr(client._transport.app.state, "knowledge_store", mock_store)
+ resp = await client.delete("/api/knowledge/items/unknown-id-5678")
+ assert resp.status_code == 404
diff --git a/tests/test_routes_knowledge_graph.py b/tests/test_routes_knowledge_graph.py
new file mode 100644
index 000000000..de506a268
--- /dev/null
+++ b/tests/test_routes_knowledge_graph.py
@@ -0,0 +1,251 @@
+"""Endpoint tests for tinyagentos/routes/knowledge_graph.py."""
+
+from __future__ import annotations
+
+import pytest
+import pytest_asyncio
+
+from taosmd import KnowledgeGraph as TemporalKnowledgeGraph
+
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _init_knowledge_graph(app, tmp_data_dir):
+ """The client fixture does not init knowledge_graph (lifespan is skipped).
+
+ Create and attach a fresh TemporalKnowledgeGraph for each test so
+ routes that read request.app.state.knowledge_graph work correctly."""
+ kg = TemporalKnowledgeGraph(db_path=tmp_data_dir / "knowledge-graph.db")
+ await kg.init()
+ app.state.knowledge_graph = kg
+ yield
+ await kg.close()
+
+
+class TestAddEntity:
+ async def test_add_entity_returns_id_and_status(self, client):
+ resp = await client.post(
+ "/api/kg/entities",
+ json={"name": "Alice", "type": "person", "properties": '{"age": 30}'},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "id" in data
+ assert data["status"] == "ok"
+
+ async def test_add_entity_minimal_body(self, client):
+ resp = await client.post("/api/kg/entities", json={"name": "Bob"})
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+
+
+class TestListEntities:
+ async def test_list_entities_returns_list_and_count(self, client):
+ await client.post("/api/kg/entities", json={"name": "Carol"})
+ resp = await client.get("/api/kg/entities")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "entities" in data
+ assert "count" in data
+ assert isinstance(data["entities"], list)
+ assert data["count"] == len(data["entities"])
+
+ async def test_list_entities_filter_by_type(self, client):
+ await client.post(
+ "/api/kg/entities",
+ json={"name": "Widget", "type": "object"},
+ )
+ resp = await client.get("/api/kg/entities", params={"type": "object"})
+ assert resp.status_code == 200
+ for ent in resp.json()["entities"]:
+ assert ent["type"] == "object"
+
+
+class TestGetEntity:
+ async def test_get_entity_returns_entity(self, client):
+ await client.post("/api/kg/entities", json={"name": "Dave", "type": "person"})
+ resp = await client.get("/api/kg/entities/Dave")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["name"] == "Dave"
+ assert data["type"] == "person"
+
+ async def test_get_entity_not_found_returns_404(self, client):
+ resp = await client.get("/api/kg/entities/NoSuchEntity")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "not found"
+
+
+class TestAddTriple:
+ async def test_add_triple_returns_id_and_status(self, client):
+ await client.post("/api/kg/entities", json={"name": "Eve"})
+ await client.post("/api/kg/entities", json={"name": "Frank"})
+ resp = await client.post(
+ "/api/kg/triples",
+ json={"subject": "Eve", "predicate": "knows", "object": "Frank"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "id" in data
+ assert data["status"] == "ok"
+
+ async def test_add_triple_with_metadata(self, client):
+ await client.post("/api/kg/entities", json={"name": "Grace"})
+ await client.post("/api/kg/entities", json={"name": "Heidi"})
+ resp = await client.post(
+ "/api/kg/triples",
+ json={
+ "subject": "Grace",
+ "predicate": "works_with",
+ "object": "Heidi",
+ "confidence": 0.9,
+ "source": "test",
+ "subject_type": "person",
+ "object_type": "person",
+ },
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "ok"
+
+
+class TestInvalidateTriple:
+ async def test_invalidate_returns_status_invalidated(self, client):
+ await client.post("/api/kg/entities", json={"name": "Ivan"})
+ await client.post("/api/kg/entities", json={"name": "Judy"})
+ triple_resp = await client.post(
+ "/api/kg/triples",
+ json={"subject": "Ivan", "predicate": "knows", "object": "Judy"},
+ )
+ triple_id = triple_resp.json()["id"]
+ resp = await client.post(
+ "/api/kg/triples/invalidate",
+ json={"triple_id": triple_id},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "invalidated"
+
+ async def test_invalidate_unknown_id_returns_404(self, client):
+ resp = await client.post(
+ "/api/kg/triples/invalidate",
+ json={"triple_id": "does-not-exist"},
+ )
+ assert resp.status_code == 404
+ assert "not found" in resp.json()["error"]
+
+
+class TestUpdateFact:
+ async def test_update_fact_returns_id_and_status(self, client):
+ await client.post("/api/kg/entities", json={"name": "Karl"})
+ await client.post("/api/kg/entities", json={"name": "Lisa"})
+ await client.post("/api/kg/entities", json={"name": "Mona"})
+ await client.post(
+ "/api/kg/triples",
+ json={"subject": "Karl", "predicate": "knows", "object": "Lisa"},
+ )
+ resp = await client.post(
+ "/api/kg/triples/update",
+ json={
+ "subject": "Karl",
+ "predicate": "knows",
+ "old_object": "Lisa",
+ "new_object": "Mona",
+ },
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "id" in data
+ assert data["status"] == "updated"
+
+
+class TestQueryEntity:
+ async def test_query_entity_returns_results(self, client):
+ await client.post("/api/kg/entities", json={"name": "Nina"})
+ await client.post("/api/kg/entities", json={"name": "Oscar"})
+ await client.post(
+ "/api/kg/triples",
+ json={"subject": "Nina", "predicate": "knows", "object": "Oscar"},
+ )
+ resp = await client.get("/api/kg/query/Nina")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "results" in data
+ assert "count" in data
+ assert data["count"] >= 1
+
+ async def test_query_entity_unknown_returns_empty(self, client):
+ resp = await client.get("/api/kg/query/NoOne")
+ assert resp.status_code == 200
+ assert resp.json()["count"] == 0
+
+
+class TestQueryPredicate:
+ async def test_query_predicate_returns_results(self, client):
+ await client.post("/api/kg/entities", json={"name": "Pat"})
+ await client.post("/api/kg/entities", json={"name": "Quinn"})
+ await client.post(
+ "/api/kg/triples",
+ json={"subject": "Pat", "predicate": "knows", "object": "Quinn"},
+ )
+ resp = await client.get("/api/kg/query/predicate/knows")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "results" in data
+ assert data["count"] >= 1
+
+ async def test_query_predicate_unknown_returns_empty(self, client):
+ resp = await client.get("/api/kg/query/predicate/nonexistent")
+ assert resp.status_code == 200
+ assert resp.json()["count"] == 0
+
+
+class TestTimeline:
+ async def test_timeline_returns_events(self, client):
+ await client.post("/api/kg/entities", json={"name": "Rita"})
+ resp = await client.get("/api/kg/timeline")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "events" in data
+ assert "count" in data
+
+ async def test_timeline_with_name_filter(self, client):
+ await client.post("/api/kg/entities", json={"name": "Sam"})
+ await client.post("/api/kg/entities", json={"name": "Tina"})
+ await client.post(
+ "/api/kg/triples",
+ json={"subject": "Sam", "predicate": "knows", "object": "Tina"},
+ )
+ resp = await client.get("/api/kg/timeline", params={"name": "Sam"})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["count"] >= 1
+
+
+class TestStats:
+ async def test_stats_returns_entity_and_triple_counts(self, client):
+ resp = await client.get("/api/kg/stats")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "entities" in data
+ assert "triples" in data
+ assert isinstance(data["entities"], int)
+ assert isinstance(data["triples"], int)
+
+
+class TestClassify:
+ async def test_classify_returns_type(self, client):
+ resp = await client.post(
+ "/api/kg/classify",
+ json={"text": "The capital of France is Paris"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "type" in data
+ assert "text" in data
+ assert data["type"] in ("fact", "reflection", "preference", "meta")
+
+ async def test_classify_empty_text(self, client):
+ resp = await client.post("/api/kg/classify", json={"text": ""})
+ assert resp.status_code == 200
+ assert "type" in resp.json()
diff --git a/tests/test_routes_librarian.py b/tests/test_routes_librarian.py
new file mode 100644
index 000000000..ee0ae2f7b
--- /dev/null
+++ b/tests/test_routes_librarian.py
@@ -0,0 +1,27 @@
+import types
+
+import pytest
+
+
+class TestMemoryModelEndpoint:
+ @pytest.mark.asyncio
+ async def test_get_memory_model_returns_200_with_keys(self, client, monkeypatch):
+ fake = types.SimpleNamespace(get_memory_model=lambda: "qwen3-8b")
+ monkeypatch.setattr("tinyagentos.routes.librarian.taosmd", fake)
+ resp = await client.get("/api/memory/model")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "model" in data
+ assert "supported" in data
+ assert data["supported"] is True
+ assert data["model"] == "qwen3-8b"
+
+ @pytest.mark.asyncio
+ async def test_get_memory_model_unsupported_returns_none(self, client, monkeypatch):
+ fake = types.SimpleNamespace()
+ monkeypatch.setattr("tinyagentos.routes.librarian.taosmd", fake)
+ resp = await client.get("/api/memory/model")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["model"] is None
+ assert data["supported"] is False
diff --git a/tests/test_routes_manifest.py b/tests/test_routes_manifest.py
new file mode 100644
index 000000000..7026f0db1
--- /dev/null
+++ b/tests/test_routes_manifest.py
@@ -0,0 +1,59 @@
+import pytest
+
+
+class TestManifestEndpoint:
+ @pytest.mark.asyncio
+ async def test_get_manifest_returns_200(self, client):
+ resp = await client.get("/manifest?app=messages")
+ assert resp.status_code == 200
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_returns_json_body(self, client):
+ resp = await client.get("/manifest?app=messages")
+ data = resp.json()
+ assert isinstance(data, dict)
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_has_required_keys(self, client):
+ resp = await client.get("/manifest?app=messages")
+ data = resp.json()
+ assert "name" in data
+ assert "short_name" in data
+ assert "start_url" in data
+ assert "display" in data
+ assert "icons" in data
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_values_for_messages_app(self, client):
+ resp = await client.get("/manifest?app=messages")
+ data = resp.json()
+ assert data["name"] == "taOS talk"
+ assert data["short_name"] == "taOS talk"
+ assert data["start_url"] == "/app.html?app=messages"
+ assert data["id"] == "/app.html?app=messages"
+ assert data["scope"] == "/"
+ assert data["display"] == "standalone"
+ assert data["theme_color"] == "#141415"
+ assert data["background_color"] == "#141415"
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_icons_structure(self, client):
+ resp = await client.get("/manifest?app=messages")
+ icons = resp.json()["icons"]
+ assert len(icons) == 2
+ assert icons[0]["src"] == "/static/icon-192.png"
+ assert icons[0]["sizes"] == "192x192"
+ assert icons[0]["type"] == "image/png"
+ assert icons[1]["src"] == "/static/icon-512.png"
+ assert icons[1]["sizes"] == "512x512"
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_content_type(self, client):
+ resp = await client.get("/manifest?app=messages")
+ content_type = resp.headers["content-type"]
+ assert "application/manifest+json" in content_type
+
+ @pytest.mark.asyncio
+ async def test_get_manifest_unknown_app_returns_404(self, client):
+ resp = await client.get("/manifest?app=nonexistent")
+ assert resp.status_code == 404
diff --git a/tests/test_routes_memory_management.py b/tests/test_routes_memory_management.py
new file mode 100644
index 000000000..58640cef1
--- /dev/null
+++ b/tests/test_routes_memory_management.py
@@ -0,0 +1,95 @@
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+class TestMemoryStats:
+ @pytest.mark.asyncio
+ async def test_get_stats_returns_200(self, client, monkeypatch):
+ mock_backend = AsyncMock()
+ mock_backend.get_stats = AsyncMock(return_value={
+ "agents": 0,
+ "total_entries": 0,
+ "stores": [],
+ })
+
+ def _mock_backend(request):
+ return mock_backend
+
+ monkeypatch.setattr(
+ "tinyagentos.routes.memory_management._backend",
+ _mock_backend,
+ )
+
+ resp = await client.get("/api/memory/stats")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "agents" in data
+ assert "total_entries" in data
+ assert "stores" in data
+
+
+class TestMemorySettings:
+ @pytest.mark.asyncio
+ async def test_get_settings_returns_200(self, client, monkeypatch):
+ mock_backend = AsyncMock()
+ mock_backend.get_settings = AsyncMock(return_value={
+ "retention_days": 30,
+ "auto_cleanup": True,
+ })
+
+ def _mock_backend(request):
+ return mock_backend
+
+ monkeypatch.setattr(
+ "tinyagentos.routes.memory_management._backend",
+ _mock_backend,
+ )
+
+ resp = await client.get("/api/memory/settings")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, dict)
+
+
+class TestMemoryBackendCapabilities:
+ @pytest.mark.asyncio
+ async def test_get_capabilities_returns_200(self, client, monkeypatch):
+ mock_backend = AsyncMock()
+ mock_backend.name = "taosmd"
+ mock_backend.version = "0.3.0"
+ mock_backend.capabilities = ["kg", "vector", "archive"]
+
+ import taosmd
+ monkeypatch.setattr(taosmd, "TaOSmdBackend", mock_backend)
+
+ resp = await client.get("/api/memory/backend/capabilities")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "name" in data
+ assert "version" in data
+ assert "capabilities" in data
+ assert isinstance(data["capabilities"], list)
+
+
+class TestMemoryBackendSettingsSchema:
+ @pytest.mark.asyncio
+ async def test_get_settings_schema_returns_200(self, client, monkeypatch):
+ mock_backend = AsyncMock()
+ mock_backend.get_settings_schema = AsyncMock(return_value={
+ "type": "object",
+ "properties": {},
+ })
+
+ def _mock_backend(request):
+ return mock_backend
+
+ monkeypatch.setattr(
+ "tinyagentos.routes.memory_management._backend",
+ _mock_backend,
+ )
+
+ resp = await client.get("/api/memory/backend/settings-schema")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "type" in data
diff --git a/tests/test_routes_music.py b/tests/test_routes_music.py
new file mode 100644
index 000000000..71934396b
--- /dev/null
+++ b/tests/test_routes_music.py
@@ -0,0 +1,122 @@
+import base64
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient, Request as HttpxRequest, Response
+
+from tinyagentos.app import create_app
+
+
+@pytest.fixture
+def music_app(tmp_data_dir):
+ app = create_app(data_dir=tmp_data_dir)
+ app.state.data_dir = str(tmp_data_dir)
+ return app
+
+
+@pytest_asyncio.fixture
+async def music_client(music_app):
+ store = music_app.state.metrics
+ if store._db is not None:
+ await store.close()
+ await store.init()
+ await music_app.state.qmd_client.init()
+ music_app.state.auth.setup_user("admin", "Test Admin", "", "testpass")
+ _rec = music_app.state.auth.find_user("admin")
+ _token = music_app.state.auth.create_session(user_id=_rec["id"] if _rec else "", long_lived=True)
+ transport = ASGITransport(app=music_app)
+ async with AsyncClient(transport=transport, base_url="http://test", cookies={"taos_session": _token}) as c:
+ yield c
+ await store.close()
+ await music_app.state.qmd_client.close()
+ await music_app.state.http_client.aclose()
+
+
+@pytest.mark.asyncio
+class TestMusicCompose:
+ async def test_compose_no_backend_returns_503(self, music_client):
+ with patch("tinyagentos.routes.music.shutil.which", return_value=None):
+ resp = await music_client.post("/api/music/compose", json={"prompt": "lofi beat"})
+ assert resp.status_code == 503
+ assert "error" in resp.json()
+
+ async def test_compose_empty_prompt_returns_400(self, music_app, music_client):
+ music_app.state.config.server["music_backend_url"] = "http://localhost:9000"
+ resp = await music_client.post("/api/music/compose", json={"prompt": " "})
+ assert resp.status_code == 400
+
+ async def test_compose_with_mocked_http_backend(self, music_app, music_client):
+ music_app.state.config.server["music_backend_url"] = "http://localhost:9000"
+
+ fake_wav = base64.b64encode(b"fake-wav-data").decode()
+ mock_request = HttpxRequest("POST", "http://localhost:9000/v1/audio/generations")
+ mock_response = Response(
+ status_code=200,
+ json={"data": [{"b64_json": fake_wav}]},
+ request=mock_request,
+ )
+
+ with (
+ patch("tinyagentos.routes.music._http_backend_reachable", new=AsyncMock(return_value=True)),
+ patch("tinyagentos.routes.music.httpx.AsyncClient") as MockClient,
+ ):
+ mock_instance = AsyncMock()
+ mock_instance.post.return_value = mock_response
+ mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+ mock_instance.__aexit__ = AsyncMock(return_value=False)
+ MockClient.return_value = mock_instance
+
+ resp = await music_client.post("/api/music/compose", json={
+ "prompt": "warm lo-fi beat",
+ "duration": 8,
+ })
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["status"] == "generated"
+ assert data["prompt"] == "warm lo-fi beat"
+ assert data["duration"] == 8
+ assert data["filename"].endswith(".wav")
+
+ music_dir = music_app.state.config_path.parent / "workspace" / "music" / "generated"
+ saved = list(music_dir.glob("*.wav"))
+ assert len(saved) == 1
+ assert saved[0].read_bytes() == b"fake-wav-data"
+
+ meta_files = list(music_dir.glob("*.json"))
+ assert len(meta_files) == 1
+ meta = json.loads(meta_files[0].read_text())
+ assert meta["prompt"] == "warm lo-fi beat"
+
+ async def test_status_reports_config_backend(self, music_app, music_client):
+ music_app.state.config.server["music_backend_url"] = "http://localhost:9000"
+ with patch("tinyagentos.routes.music._http_backend_reachable", new=AsyncMock(return_value=True)):
+ resp = await music_client.get("/api/music/status")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["available"] is True
+ assert data["mode"] == "http"
+
+ async def test_status_ignores_stable_audio_venv(self, music_app, music_client, tmp_path):
+ apps_dir = tmp_path / "apps"
+ stable_dir = apps_dir / "stable-audio-open" / "venv" / "bin"
+ stable_dir.mkdir(parents=True)
+ (stable_dir / "python").write_text("")
+ music_app.state.apps_dir = str(apps_dir)
+
+ with patch("tinyagentos.routes.music.shutil.which", return_value=None):
+ resp = await music_client.get("/api/music/status")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "stable-audio-open" not in data["installed"]
+ assert data["available"] is False
+
+
+@pytest.mark.asyncio
+class TestMusicList:
+ async def test_list_empty(self, music_client):
+ resp = await music_client.get("/api/music")
+ assert resp.status_code == 200
+ assert resp.json()["tracks"] == []
\ No newline at end of file
diff --git a/tests/test_routes_office.py b/tests/test_routes_office.py
new file mode 100644
index 000000000..1ccb0622b
--- /dev/null
+++ b/tests/test_routes_office.py
@@ -0,0 +1,129 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_create_list_get_update_delete_doc(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "write", "title": "Launch note", "content": "Hello world"},
+ )
+ assert resp.status_code == 200
+ created = resp.json()
+ doc_id = created["id"]
+ assert created["kind"] == "write"
+ assert created["title"] == "Launch note"
+ assert created["content"] == "Hello world"
+ assert isinstance(created["updated_at"], int)
+
+ resp = await client.get("/api/office/docs")
+ assert resp.status_code == 200
+ items = resp.json()
+ assert len(items) == 1
+ assert items[0]["id"] == doc_id
+ assert "content" not in items[0]
+
+ resp = await client.get(f"/api/office/docs/{doc_id}")
+ assert resp.status_code == 200
+ assert resp.json()["content"] == "Hello world"
+
+ resp = await client.put(
+ f"/api/office/docs/{doc_id}",
+ json={"title": "Updated", "content": "Revised body"},
+ )
+ assert resp.status_code == 200
+ updated = resp.json()
+ assert updated["title"] == "Updated"
+ assert updated["content"] == "Revised body"
+ assert updated["updated_at"] >= created["updated_at"]
+
+ resp = await client.delete(f"/api/office/docs/{doc_id}")
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "deleted"
+
+ resp = await client.get(f"/api/office/docs/{doc_id}")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_create_rejects_invalid_kind(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "spreadsheet", "title": "X", "content": ""},
+ )
+ assert resp.status_code == 400
+ assert "kind" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_create_rejects_missing_title(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "write", "title": " ", "content": ""},
+ )
+ assert resp.status_code == 400
+ assert "title" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_create_rejects_non_string_content(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "write", "title": "X", "content": 42},
+ )
+ assert resp.status_code == 400
+ assert "content" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_get_missing_doc_returns_404(client):
+ resp = await client.get("/api/office/docs/does-not-exist")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_update_missing_doc_returns_404(client):
+ resp = await client.put(
+ "/api/office/docs/does-not-exist",
+ json={"title": "Nope"},
+ )
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_update_rejects_invalid_kind(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "write", "title": "Doc", "content": ""},
+ )
+ doc_id = resp.json()["id"]
+
+ resp = await client.put(
+ f"/api/office/docs/{doc_id}",
+ json={"kind": "invalid"},
+ )
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_delete_missing_doc_returns_404(client):
+ resp = await client.delete("/api/office/docs/missing-id")
+ assert resp.status_code == 404
+
+@pytest.mark.asyncio
+async def test_update_persists_valid_kind(client):
+ resp = await client.post(
+ "/api/office/docs",
+ json={"kind": "write", "title": "Doc", "content": "body"},
+ )
+ doc_id = resp.json()["id"]
+
+ resp = await client.put(
+ f"/api/office/docs/{doc_id}",
+ json={"kind": "calc", "title": "Doc", "content": "body"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["kind"] == "calc"
+
+ # The change is persisted, not just echoed.
+ resp = await client.get(f"/api/office/docs/{doc_id}")
+ assert resp.json()["kind"] == "calc"
diff --git a/tests/test_routes_project_canvas.py b/tests/test_routes_project_canvas.py
new file mode 100644
index 000000000..5405ffdf7
--- /dev/null
+++ b/tests/test_routes_project_canvas.py
@@ -0,0 +1,285 @@
+"""Endpoint tests for tinyagentos/routes/project_canvas.py."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+from unittest.mock import patch, AsyncMock
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _ensure_canvas_store(client, tmp_path_factory):
+ """Initialize project_canvas_store if the lifespan didn't run (test client)."""
+ store = client._transport.app.state.project_canvas_store
+ if store._db is not None:
+ try:
+ asyncio.get_event_loop().run_until_complete(store.close())
+ except Exception:
+ pass
+ # Use a fresh DB per test session via tmp_path. BaseStore reads self.db_path
+ # (a Path) in init(); the previous override set a non-existent `_db_path`
+ # string attr, so init() silently fell back to the production canvas DB.
+ tmp_dir = tmp_path_factory.mktemp("canvas_test")
+ store.db_path = tmp_dir / "test_projects.db"
+ asyncio.get_event_loop().run_until_complete(store.init())
+ yield
+ try:
+ asyncio.get_event_loop().run_until_complete(store.close())
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# List elements
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_elements_returns_200(client):
+ resp = await client.get("/api/projects/proj-1/canvas/elements")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_elements_returns_elements_key(client):
+ data = (await client.get("/api/projects/proj-1/canvas/elements")).json()
+ assert "elements" in data
+ assert isinstance(data["elements"], list)
+
+
+# ---------------------------------------------------------------------------
+# Create element
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_create_note_element_returns_201(client):
+ body = {
+ "kind": "note",
+ "x": 10, "y": 20, "w": 200, "h": 100,
+ "payload": {"text": "hello", "color": "yellow", "font_size": 14},
+ }
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 201, resp.text
+
+
+@pytest.mark.asyncio
+async def test_create_note_element_response_shape(client):
+ body = {
+ "kind": "note",
+ "x": 10, "y": 20, "w": 200, "h": 100,
+ "payload": {"text": "hello", "color": "yellow", "font_size": 14},
+ }
+ data = (await client.post("/api/projects/proj-1/canvas/elements", json=body)).json()
+ assert "element" in data
+ el = data["element"]
+ assert el["kind"] == "note"
+ assert el["x"] == 10
+ assert el["y"] == 20
+ assert el["w"] == 200
+ assert el["h"] == 100
+ assert el["payload"]["text"] == "hello"
+ assert "id" in el
+ assert "project_id" in el
+
+
+@pytest.mark.asyncio
+async def test_create_element_invalid_kind_returns_422(client):
+ """Pydantic Literal validation rejects invalid 'kind' with 422."""
+ body = {"kind": "invalid", "x": 0, "y": 0, "w": 1, "h": 1}
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_create_link_element_without_url_returns_400(client):
+ body = {"kind": "link", "x": 0, "y": 0, "w": 100, "h": 50, "payload": {}}
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 400
+ assert resp.json()["error"] == "link element requires payload.url"
+
+
+@pytest.mark.asyncio
+async def test_create_link_element_with_url_fetches_metadata(client):
+ body = {
+ "kind": "link",
+ "x": 0, "y": 0, "w": 100, "h": 50,
+ "payload": {"url": "https://example.com"},
+ }
+ fake_meta = {
+ "url": "https://example.com",
+ "title": "Example",
+ "description": "",
+ "preview_image_url": "",
+ "favicon_url": "",
+ "fetched_at": 0.0,
+ }
+ with patch(
+ "tinyagentos.routes.project_canvas.fetch_link_metadata",
+ new_callable=AsyncMock,
+ return_value=fake_meta,
+ ):
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 201, resp.text
+ el = resp.json()["element"]
+ assert el["kind"] == "link"
+ assert el["payload"]["title"] == "Example"
+
+
+@pytest.mark.asyncio
+async def test_create_image_element_returns_201(client):
+ body = {
+ "kind": "image",
+ "x": 5, "y": 5, "w": 300, "h": 200,
+ "payload": {"alt": "a photo"},
+ }
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 201, resp.text
+ assert resp.json()["element"]["kind"] == "image"
+
+
+@pytest.mark.asyncio
+async def test_create_user_shape_element_returns_201(client):
+ body = {
+ "kind": "user_shape",
+ "x": 0, "y": 0, "w": 50, "h": 50,
+ "payload": {"shape": "rectangle"},
+ }
+ resp = await client.post("/api/projects/proj-1/canvas/elements", json=body)
+ assert resp.status_code == 201, resp.text
+ assert resp.json()["element"]["kind"] == "user_shape"
+
+
+# ---------------------------------------------------------------------------
+# Update element
+# ---------------------------------------------------------------------------
+
+
+async def _create_note(client, project_id="proj-1"):
+ body = {
+ "kind": "note",
+ "x": 0, "y": 0, "w": 100, "h": 50,
+ "payload": {"text": "original"},
+ }
+ resp = await client.post(f"/api/projects/{project_id}/canvas/elements", json=body)
+ return resp.json()["element"]
+
+
+@pytest.mark.asyncio
+async def test_update_element_returns_200(client):
+ el = await _create_note(client)
+ resp = await client.patch(
+ f"/api/projects/proj-1/canvas/elements/{el['id']}",
+ json={"x": 99, "payload": {"text": "edited"}},
+ )
+ assert resp.status_code == 200, resp.text
+
+
+@pytest.mark.asyncio
+async def test_update_element_applies_patch(client):
+ el = await _create_note(client)
+ data = (await client.patch(
+ f"/api/projects/proj-1/canvas/elements/{el['id']}",
+ json={"x": 99, "payload": {"text": "edited"}},
+ )).json()
+ updated = data["element"]
+ assert updated["x"] == 99
+ assert updated["payload"]["text"] == "edited"
+
+
+@pytest.mark.asyncio
+async def test_update_element_not_found_returns_404(client):
+ resp = await client.patch(
+ "/api/projects/proj-1/canvas/elements/nonexistent",
+ json={"x": 1},
+ )
+ assert resp.status_code == 404
+ assert "error" in resp.json()
+
+
+# ---------------------------------------------------------------------------
+# Delete element
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_delete_element_returns_204(client):
+ el = await _create_note(client)
+ resp = await client.delete(f"/api/projects/proj-1/canvas/elements/{el['id']}")
+ assert resp.status_code == 204
+
+
+@pytest.mark.asyncio
+async def test_delete_element_removes_from_list(client):
+ el = await _create_note(client)
+ await client.delete(f"/api/projects/proj-1/canvas/elements/{el['id']}")
+ data = (await client.get("/api/projects/proj-1/canvas/elements")).json()
+ ids = [e["id"] for e in data["elements"]]
+ assert el["id"] not in ids
+
+
+@pytest.mark.asyncio
+async def test_delete_element_not_found_returns_204(client):
+ """Delete of a nonexistent element returns 204 (soft-delete is idempotent)."""
+ resp = await client.delete("/api/projects/proj-1/canvas/elements/nonexistent")
+ assert resp.status_code == 204
+
+
+# ---------------------------------------------------------------------------
+# Snapshot PNG
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_snapshot_png_project_not_found_returns_404(client):
+ resp = await client.get("/api/projects/nonexistent/canvas/snapshot.png")
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "project not found"
+
+
+# ---------------------------------------------------------------------------
+# Snapshot TLDR
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_snapshot_tldr_requires_snapshotter(client):
+ """snapshot.tldr needs a live CanvasSnapshotter (container backend); skip."""
+ snap = client._transport.app.state.canvas_snapshotter
+ if snap is None:
+ pytest.skip("canvas_snapshotter not available; needs container backend")
+ resp = await client.get("/api/projects/nonexistent/canvas/snapshot.tldr")
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# Permissions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_set_permission_member_not_found_returns_404(client):
+ resp = await client.patch(
+ "/api/projects/proj-1/canvas/permissions/agent-1",
+ json={"can_edit_canvas": True},
+ )
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "member not found"
+
+
+# ---------------------------------------------------------------------------
+# SSE stream
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_canvas_stream_endpoint_exists(client):
+ """canvas.stream is an infinite SSE endpoint; verify it is registered."""
+ # The stream endpoint is infinite, so a normal client.get() would block
+ # forever. Instead, confirm the route is registered on the app.
+ app = client._transport.app
+ paths = [r.path for r in app.routes]
+ assert "/api/projects/{project_id}/canvas/stream" in paths
diff --git a/tests/test_routes_service_proxy.py b/tests/test_routes_service_proxy.py
new file mode 100644
index 000000000..61eacb2ed
--- /dev/null
+++ b/tests/test_routes_service_proxy.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+
+class TestTrailingSlashRedirect:
+ @pytest.mark.asyncio
+ async def test_redirect_no_slash_returns_307(self, client):
+ resp = await client.get("/apps/myapp", follow_redirects=False)
+ assert resp.status_code == 307
+ assert resp.headers["location"] == "/apps/myapp/"
+
+
+class TestServiceProxyErrors:
+ @pytest.mark.asyncio
+ async def test_returns_503_when_installed_apps_missing(self, client, monkeypatch):
+ monkeypatch.setattr(client._transport.app.state, "installed_apps", None)
+ resp = await client.get("/apps/myapp/")
+ assert resp.status_code == 503
+ data = resp.json()
+ assert "error" in data
+ assert "Service registry unavailable" in data["error"]
+
+ @pytest.mark.asyncio
+ async def test_returns_404_for_not_installed_app(self, client, monkeypatch):
+ mock_store = MagicMock()
+ mock_store.get_runtime_location = AsyncMock(return_value=None)
+ mock_store.is_installed = AsyncMock(return_value=False)
+ monkeypatch.setattr(client._transport.app.state, "installed_apps", mock_store)
+ resp = await client.get("/apps/unknown-app/")
+ assert resp.status_code == 404
+ data = resp.json()
+ assert "error" in data
+ assert "not installed" in data["error"]
+
+ @pytest.mark.asyncio
+ async def test_returns_503_when_no_runtime_location(self, client, monkeypatch):
+ mock_store = MagicMock()
+ mock_store.get_runtime_location = AsyncMock(return_value=None)
+ mock_store.is_installed = AsyncMock(return_value=True)
+ monkeypatch.setattr(client._transport.app.state, "installed_apps", mock_store)
+ resp = await client.get("/apps/myapp/")
+ assert resp.status_code == 503
+ data = resp.json()
+ assert "error" in data
+ assert "no runtime location" in data["error"]
diff --git a/tests/test_routes_shortcuts.py b/tests/test_routes_shortcuts.py
new file mode 100644
index 000000000..9584b6981
--- /dev/null
+++ b/tests/test_routes_shortcuts.py
@@ -0,0 +1,220 @@
+"""Endpoint tests for tinyagentos/routes/shortcuts.py."""
+
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cluster.manager import ClusterManager
+from tinyagentos.cluster.worker_protocol import WorkerInfo
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _setup_worker_registry(monkeypatch) -> None:
+ """Inject a test ClusterManager with an enrolled local worker."""
+ import tinyagentos.cluster.worker_registry as wr_mod
+
+ mgr = ClusterManager()
+ mgr._workers["local"] = WorkerInfo(
+ name="local",
+ url="http://127.0.0.1:6969",
+ worker_url="http://127.0.0.1:6969",
+ signing_key=b"test-signing-key-32-bytes-padded",
+ platform="local",
+ )
+ monkeypatch.setattr(wr_mod, "_active_manager", mgr)
+
+
+def _seed_agent(client, framework="openclaw", shortcuts=None):
+ """Append a test agent to app.state.config.agents and patch FRAMEWORKS."""
+ import uuid
+
+ import tinyagentos.frameworks as fw_mod
+
+ if shortcuts is None:
+ shortcuts = [
+ {
+ "kind": "container-terminal",
+ "label": "Container shell",
+ "icon": "terminal",
+ "requires_capability": "agent.shell",
+ },
+ {
+ "kind": "dashboard",
+ "label": "Gateway dashboard",
+ "icon": "dashboard",
+ "requires_capability": "agent.dashboard",
+ "port": 18789,
+ "path": "/",
+ "auth": {"type": "none", "token_source": None},
+ },
+ ]
+
+ original = fw_mod.FRAMEWORKS.get(framework, {"id": framework, "name": framework})
+ patched_entry = {**original, "shortcuts": shortcuts}
+ fw_mod.FRAMEWORKS[framework] = patched_entry
+
+ agent_id = uuid.uuid4().hex[:12]
+ agent = {
+ "id": agent_id,
+ "name": f"test-agent-{agent_id}",
+ "host": "127.0.0.1",
+ "qmd_index": "test",
+ "color": "#abcdef",
+ "framework": framework,
+ }
+ client._transport.app.state.config.agents.append(agent)
+ return agent
+
+
+# ---------------------------------------------------------------------------
+# GET /api/agents/{agent_id}/shortcuts
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_returns_200(client):
+ agent = _seed_agent(client)
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_returns_list(client):
+ agent = _seed_agent(client)
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ data = resp.json()
+ assert isinstance(data, list)
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_admin_sees_all(client):
+ agent = _seed_agent(client)
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ data = resp.json()
+ assert len(data) == 2
+ assert data[0]["kind"] == "container-terminal"
+ assert data[0]["idx"] == 0
+ assert data[0]["label"] == "Container shell"
+ assert data[0]["icon"] == "terminal"
+ assert data[1]["kind"] == "dashboard"
+ assert data[1]["idx"] == 1
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_unknown_agent_returns_404(client):
+ resp = await client.get("/api/agents/nonexistent-id/shortcuts")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_by_name(client):
+ agent = _seed_agent(client)
+ resp = await client.get(f"/api/agents/{agent['name']}/shortcuts")
+ assert resp.status_code == 200
+ assert isinstance(resp.json(), list)
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_capability_filtered(client):
+ """Shortcuts requiring agent.admin are not visible to a normal admin."""
+ agent = _seed_agent(
+ client,
+ shortcuts=[
+ {
+ "kind": "tui",
+ "label": "Special tool",
+ "icon": "lock",
+ "requires_capability": "agent.admin",
+ },
+ ],
+ )
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data == []
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_no_shortcuts_returns_empty(client):
+ """Agent with a framework that has no shortcuts returns empty list."""
+ agent = _seed_agent(client, shortcuts=[])
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ assert resp.status_code == 200
+ assert resp.json() == []
+
+
+@pytest.mark.asyncio
+async def test_list_shortcuts_does_not_expose_requires_capability(client):
+ """The requires_capability field must not leak to the frontend."""
+ agent = _seed_agent(client)
+ resp = await client.get(f"/api/agents/{agent['id']}/shortcuts")
+ for entry in resp.json():
+ assert "requires_capability" not in entry
+
+
+# ---------------------------------------------------------------------------
+# POST /api/agents/{agent_id}/shortcuts/{idx}/launch
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_returns_redirect_url(client, monkeypatch):
+ _setup_worker_registry(monkeypatch)
+ agent = _seed_agent(client)
+ resp = await client.post(f"/api/agents/{agent['id']}/shortcuts/0/launch")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "redirect_url" in data
+ assert data["expires_in"] == 30
+ assert "redeem" in data["redirect_url"]
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_includes_ticket_token(client, monkeypatch):
+ _setup_worker_registry(monkeypatch)
+ agent = _seed_agent(client)
+ resp = await client.post(f"/api/agents/{agent['id']}/shortcuts/0/launch")
+ redirect_url = resp.json()["redirect_url"]
+ assert "t=" in redirect_url
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_idx_out_of_range(client, monkeypatch):
+ _setup_worker_registry(monkeypatch)
+ agent = _seed_agent(client)
+ resp = await client.post(f"/api/agents/{agent['id']}/shortcuts/99/launch")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_unknown_agent(client, monkeypatch):
+ _setup_worker_registry(monkeypatch)
+ resp = await client.post("/api/agents/ghost-id/shortcuts/0/launch")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_negative_idx(client, monkeypatch):
+ _setup_worker_registry(monkeypatch)
+ agent = _seed_agent(client)
+ resp = await client.post(f"/api/agents/{agent['id']}/shortcuts/-1/launch")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_launch_shortcut_no_worker_raises_error(client, monkeypatch):
+ """Without an active ClusterManager, launch raises RuntimeError.
+
+ _active_manager is a module global in worker_registry that other tests in
+ the suite enroll; reset it to None here so this assertion is independent of
+ test ordering.
+ """
+ from tinyagentos.cluster import worker_registry
+
+ monkeypatch.setattr(worker_registry, "_active_manager", None)
+ agent = _seed_agent(client)
+ with pytest.raises(RuntimeError, match="No active ClusterManager"):
+ await client.post(f"/api/agents/{agent['id']}/shortcuts/0/launch")
diff --git a/tests/test_routes_skill_exec.py b/tests/test_routes_skill_exec.py
new file mode 100644
index 000000000..dc9b70869
--- /dev/null
+++ b/tests/test_routes_skill_exec.py
@@ -0,0 +1,91 @@
+"""Endpoint tests for tinyagentos/routes/skill_exec.py (GET / read-only)."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+
+class TestSkillExecTools:
+ @pytest.mark.asyncio
+ async def test_list_tools_returns_assigned_skills(self, client, app, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.get_agent_skills.return_value = [
+ {
+ "id": "memory_search",
+ "name": "Memory Search",
+ "category": "search",
+ "description": "Search the agent's knowledge base",
+ "tool_schema": {
+ "name": "memory_search",
+ "description": "Search stored documents and memories",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "limit": {"type": "integer", "default": 10},
+ },
+ "required": ["query"],
+ },
+ },
+ "frameworks": {},
+ "agent_config": {},
+ },
+ {
+ "id": "file_read",
+ "name": "File Read",
+ "category": "files",
+ "description": "Read files from the agent workspace",
+ "tool_schema": {
+ "name": "file_read",
+ "description": "Read a file from the workspace",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ },
+ "required": ["path"],
+ },
+ },
+ "frameworks": {},
+ "agent_config": {},
+ },
+ ]
+ monkeypatch.setattr(app.state, "skills", mock_store)
+
+ resp = await client.get(
+ "/api/skill-exec/tools",
+ params={"agent_name": "test-agent"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "tools" in body
+ assert len(body["tools"]) == 2
+ tool = body["tools"][0]
+ assert tool["type"] == "function"
+ assert "function" in tool
+ assert "name" in tool["function"]
+ assert "description" in tool["function"]
+ assert "parameters" in tool["function"]
+ assert "skill_id" in tool
+ assert "exec_url" in tool
+ assert tool["exec_url"].startswith("/api/skill-exec/")
+ assert tool["exec_url"].endswith("/call")
+
+ @pytest.mark.asyncio
+ async def test_list_tools_empty_for_unknown_agent(self, client, app, monkeypatch):
+ mock_store = AsyncMock()
+ mock_store.get_agent_skills.return_value = []
+ monkeypatch.setattr(app.state, "skills", mock_store)
+
+ resp = await client.get(
+ "/api/skill-exec/tools",
+ params={"agent_name": "no-such-agent"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body == {"tools": []}
+
+ @pytest.mark.asyncio
+ async def test_list_tools_requires_agent_name(self, client):
+ resp = await client.get("/api/skill-exec/tools")
+ assert resp.status_code == 422
diff --git a/tests/test_routes_system.py b/tests/test_routes_system.py
new file mode 100644
index 000000000..8cee4a06a
--- /dev/null
+++ b/tests/test_routes_system.py
@@ -0,0 +1,22 @@
+import pytest
+from tinyagentos.restart_orchestrator import RestartOrchestrator
+
+
+class TestSystemRoutes:
+ @pytest.mark.asyncio
+ async def test_restart_status_returns_idle_state(self, client, monkeypatch):
+ fake_orchestrator = RestartOrchestrator(client._transport.app.state)
+ monkeypatch.setattr(
+ client._transport.app.state, "orchestrator", fake_orchestrator
+ )
+ resp = await client.get("/api/system/restart/status")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "phase" in data
+ assert "reason" in data
+ assert "started_at" in data
+ assert "agents" in data
+ assert data["phase"] == "idle"
+ assert data["reason"] == ""
+ assert data["started_at"] == 0
+ assert data["agents"] == {}
diff --git a/tests/test_routes_themes.py b/tests/test_routes_themes.py
new file mode 100644
index 000000000..31453c9c7
--- /dev/null
+++ b/tests/test_routes_themes.py
@@ -0,0 +1,22 @@
+import pytest
+
+
+class TestThemesRoutes:
+ @pytest.mark.asyncio
+ async def test_list_themes_returns_200_and_list(self, client):
+ resp = await client.get("/api/themes")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+
+ @pytest.mark.asyncio
+ async def test_delete_nonexistent_theme_returns_200_with_removed_false(self, client):
+ resp = await client.delete("/api/themes/nonexistent-theme-xyz")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["removed"] is False
+
+ @pytest.mark.asyncio
+ async def test_install_theme_empty_body_returns_422(self, client):
+ resp = await client.post("/api/themes/install")
+ assert resp.status_code == 422
diff --git a/tests/test_routes_user_memory.py b/tests/test_routes_user_memory.py
new file mode 100644
index 000000000..09c926869
--- /dev/null
+++ b/tests/test_routes_user_memory.py
@@ -0,0 +1,295 @@
+"""Endpoint tests for tinyagentos/routes/user_memory.py."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+import pytest_asyncio
+
+
+@pytest_asyncio.fixture
+async def user_memory(client, tmp_path):
+ """Ensure user_memory store is initialized on the test app."""
+ store = client._transport.app.state.user_memory
+ if store._db is not None:
+ await store.close()
+ # Point the store at a tmp db so tests are isolated
+ store.db_path = tmp_path / "user_memory.db"
+ await store.init()
+ yield store
+ await store.close()
+
+
+@pytest.mark.asyncio
+class TestGetStats:
+ async def test_empty_stats(self, client, user_memory):
+ resp = await client.get("/api/user-memory/stats")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 0
+ assert data["collections"] == {}
+
+ async def test_stats_after_save(self, client, user_memory):
+ await user_memory.save_chunk("user", "hello world", "test", "snippets")
+ resp = await client.get("/api/user-memory/stats")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+ assert "snippets" in data["collections"]
+
+
+@pytest.mark.asyncio
+class TestGetSettings:
+ async def test_default_settings(self, client, user_memory):
+ resp = await client.get("/api/user-memory/settings")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "capture_conversations" in data
+ assert "capture_files" in data
+ assert "capture_searches" in data
+ assert "capture_notes" in data
+ assert data["capture_conversations"] is True
+
+ async def test_settings_shape(self, client, user_memory):
+ resp = await client.get("/api/user-memory/settings")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, dict)
+ assert len(data) >= 4
+
+
+@pytest.mark.asyncio
+class TestUpdateSettings:
+ async def test_update_single_setting(self, client, user_memory):
+ resp = await client.put(
+ "/api/user-memory/settings",
+ json={"capture_conversations": False},
+ )
+ assert resp.status_code == 200
+ assert resp.json() == {"ok": True}
+
+ async def test_update_persists(self, client, user_memory):
+ await client.put(
+ "/api/user-memory/settings",
+ json={"capture_searches": True},
+ )
+ settings = await user_memory.get_settings("user")
+ assert settings["capture_searches"] is True
+
+ async def test_update_defaults_preserved(self, client, user_memory):
+ await client.put(
+ "/api/user-memory/settings",
+ json={"capture_notes": False},
+ )
+ settings = await user_memory.get_settings("user")
+ # Defaults not in the update payload should remain
+ assert settings["capture_conversations"] is True
+
+
+@pytest.mark.asyncio
+class TestBrowse:
+ async def test_empty_browse(self, client, user_memory):
+ resp = await client.get("/api/user-memory/browse")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "chunks" in data
+ assert data["chunks"] == []
+
+ async def test_browse_returns_saved_chunks(self, client, user_memory):
+ await user_memory.save_chunk("user", "chunk one", "title1", "snippets")
+ await user_memory.save_chunk("user", "chunk two", "title2", "notes")
+ resp = await client.get("/api/user-memory/browse")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["chunks"]) == 2
+
+ async def test_browse_filter_by_collection(self, client, user_memory):
+ await user_memory.save_chunk("user", "snippet content", "s", "snippets")
+ await user_memory.save_chunk("user", "note content", "n", "notes")
+ resp = await client.get("/api/user-memory/browse?collection=snippets")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["chunks"]) == 1
+ assert data["chunks"][0]["collection"] == "snippets"
+
+
+@pytest.mark.asyncio
+class TestSave:
+ async def test_save_returns_hash(self, client, user_memory):
+ resp = await client.post(
+ "/api/user-memory/save",
+ json={"content": "hello memory", "title": "greeting"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ assert isinstance(data["hash"], str)
+ assert len(data["hash"]) == 16
+
+ async def test_save_missing_content(self, client, user_memory):
+ resp = await client.post(
+ "/api/user-memory/save",
+ json={"title": "no content"},
+ )
+ assert resp.status_code == 400
+ assert resp.json()["error"] == "content required"
+
+ async def test_save_default_collection(self, client, user_memory):
+ resp = await client.post(
+ "/api/user-memory/save",
+ json={"content": "test content"},
+ )
+ assert resp.status_code == 200
+ chunks = await user_memory.browse("user")
+ assert len(chunks) == 1
+ assert chunks[0]["collection"] == "snippets"
+
+ async def test_save_custom_collection(self, client, user_memory):
+ resp = await client.post(
+ "/api/user-memory/save",
+ json={"content": "test", "collection": "journal"},
+ )
+ assert resp.status_code == 200
+ chunks = await user_memory.browse("user", collection="journal")
+ assert len(chunks) == 1
+
+ async def test_save_taosmd_ingest_failure_is_nonfatal(self, client, user_memory):
+ """Save still returns 200 even if taosmd ingest fails."""
+ with patch.object(
+ client._transport.app.state.http_client,
+ "post",
+ side_effect=ConnectionError("taosmd down"),
+ ):
+ resp = await client.post(
+ "/api/user-memory/save",
+ json={"content": "test fail ingest"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["ok"] is True
+
+
+@pytest.mark.asyncio
+class TestDeleteChunk:
+ async def test_delete_existing(self, client, user_memory):
+ h = await user_memory.save_chunk("user", "to delete", "del", "snippets")
+ resp = await client.delete(f"/api/user-memory/chunk/{h}")
+ assert resp.status_code == 200
+ assert resp.json()["ok"] is True
+
+ async def test_delete_nonexistent(self, client, user_memory):
+ resp = await client.delete("/api/user-memory/chunk/nonexist")
+ assert resp.status_code == 200
+ assert resp.json()["ok"] is False
+
+ async def test_delete_actually_removes(self, client, user_memory):
+ h = await user_memory.save_chunk("user", "temp", "t", "snippets")
+ await client.delete(f"/api/user-memory/chunk/{h}")
+ chunks = await user_memory.browse("user")
+ assert len(chunks) == 0
+
+
+@pytest.mark.asyncio
+class TestSearch:
+ async def test_search_returns_results(self, client, user_memory):
+ await user_memory.save_chunk("user", "python async tutorial", "async")
+ resp = await client.get("/api/user-memory/search?q=python")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "results" in data
+ assert "query" in data
+ assert data["query"] == "python"
+ assert len(data["results"]) >= 1
+
+ async def test_search_no_match(self, client, user_memory):
+ await user_memory.save_chunk("user", "hello world", "hw")
+ resp = await client.get("/api/user-memory/search?q=zzzznonexistent")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["results"] == []
+
+ async def test_search_result_shape(self, client, user_memory):
+ await user_memory.save_chunk("user", "test content", "test")
+ resp = await client.get("/api/user-memory/search?q=test")
+ assert resp.status_code == 200
+ data = resp.json()
+ if data["results"]:
+ chunk = data["results"][0]
+ assert "hash" in chunk
+ assert "collection" in chunk
+ assert "title" in chunk
+ assert "content" in chunk
+
+
+@pytest.mark.asyncio
+class TestAgentSearch:
+ async def test_agent_search_requires_permission(self, client, user_memory):
+ resp = await client.get(
+ "/api/user-memory/agent-search?q=test&agent_name=unauthorized-agent",
+ )
+ assert resp.status_code == 403
+ assert "error" in resp.json()
+
+ async def test_agent_search_with_permission(self, client, user_memory):
+ """Grant an agent can_read_user_memory and verify 200."""
+ config = client._transport.app.state.config
+ new_agent = {"name": "memory-agent", "host": "127.0.0.1", "can_read_user_memory": True}
+ config.agents.append(new_agent)
+ resp = await client.get(
+ "/api/user-memory/agent-search?q=test&agent_name=memory-agent",
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "results" in data
+ assert data["agent"] == "memory-agent"
+
+ async def test_agent_search_missing_agent(self, client, user_memory):
+ resp = await client.get(
+ "/api/user-memory/agent-search?q=test&agent_name=no-such-agent",
+ )
+ assert resp.status_code == 403
+
+
+@pytest.mark.asyncio
+class TestListCollections:
+ async def test_empty_collections(self, client, user_memory):
+ resp = await client.get("/api/user-memory/collections")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["collections"] == []
+
+ async def test_collections_after_save(self, client, user_memory):
+ await user_memory.save_chunk("user", "a", "a", "snippets")
+ await user_memory.save_chunk("user", "b", "b", "journal")
+ resp = await client.get("/api/user-memory/collections")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert sorted(data["collections"]) == ["journal", "snippets"]
+
+
+@pytest.mark.asyncio
+class TestMigrate:
+ async def test_migrate_unreachable_taosmd(self, client, user_memory):
+ with patch.object(
+ client._transport.app.state.http_client,
+ "get",
+ side_effect=ConnectionError("taosmd down"),
+ ):
+ resp = await client.post("/api/user-memory/migrate")
+ assert resp.status_code == 503
+ assert resp.json()["error"] == "taosmd unreachable"
+
+ async def test_migrate_unhealthy_taosmd(self, client, user_memory):
+ mock_resp = AsyncMock()
+ mock_resp.status_code = 500
+ with patch.object(
+ client._transport.app.state.http_client,
+ "get",
+ return_value=mock_resp,
+ ):
+ resp = await client.post("/api/user-memory/migrate")
+ assert resp.status_code == 503
+ assert resp.json()["error"] == "taosmd not healthy"
+
+ # migrate with live taosmd is skipped: requires a real taosmd service
diff --git a/tests/test_routes_user_personas.py b/tests/test_routes_user_personas.py
new file mode 100644
index 000000000..ff7fd731c
--- /dev/null
+++ b/tests/test_routes_user_personas.py
@@ -0,0 +1,166 @@
+"""Endpoint tests for tinyagentos/routes/user_personas.py."""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.mark.asyncio
+class TestListPersonas:
+ async def test_empty_list(self, client):
+ resp = await client.get("/api/user-personas")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "personas" in data
+ assert isinstance(data["personas"], list)
+ assert data["personas"] == []
+
+ async def test_list_returns_created_personas(self, client):
+ created = await client.post(
+ "/api/user-personas",
+ json={"name": "helper", "soul_md": "be helpful", "agent_md": "assist"},
+ )
+ assert created.status_code == 201
+ resp = await client.get("/api/user-personas")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data["personas"]) == 1
+ assert data["personas"][0]["name"] == "helper"
+
+
+@pytest.mark.asyncio
+class TestCreatePersona:
+ async def test_create_returns_201(self, client):
+ resp = await client.post(
+ "/api/user-personas",
+ json={"name": "tester", "soul_md": "test soul", "agent_md": "test agent"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert "id" in data
+ assert isinstance(data["id"], str)
+
+ async def test_create_persists_fields(self, client):
+ resp = await client.post(
+ "/api/user-personas",
+ json={
+ "name": "fulldetail",
+ "soul_md": "soul content",
+ "agent_md": "agent content",
+ "description": "a test persona",
+ },
+ )
+ assert resp.status_code == 201
+ pid = resp.json()["id"]
+ get_resp = await client.get(f"/api/user-personas/{pid}")
+ assert get_resp.status_code == 200
+ data = get_resp.json()
+ assert data["name"] == "fulldetail"
+ assert data["soul_md"] == "soul content"
+ assert data["agent_md"] == "agent content"
+ assert data["description"] == "a test persona"
+
+ async def test_create_minimal_payload(self, client):
+ resp = await client.post(
+ "/api/user-personas",
+ json={"name": "minimal"},
+ )
+ assert resp.status_code == 201
+ pid = resp.json()["id"]
+ data = (await client.get(f"/api/user-personas/{pid}")).json()
+ assert data["name"] == "minimal"
+ assert data["soul_md"] == ""
+ assert data["agent_md"] == ""
+
+
+@pytest.mark.asyncio
+class TestGetPersona:
+ async def test_get_existing(self, client):
+ created = await client.post(
+ "/api/user-personas",
+ json={"name": "getme", "soul_md": "soul"},
+ )
+ pid = created.json()["id"]
+ resp = await client.get(f"/api/user-personas/{pid}")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == pid
+ assert data["name"] == "getme"
+ assert "soul_md" in data
+ assert "agent_md" in data
+ assert "created_at" in data
+
+ async def test_get_not_found(self, client):
+ resp = await client.get("/api/user-personas/nonexistentid123")
+ assert resp.status_code == 404
+ data = resp.json()
+ assert "error" in data
+
+
+@pytest.mark.asyncio
+class TestUpdatePersona:
+ async def test_update_name(self, client):
+ created = await client.post(
+ "/api/user-personas",
+ json={"name": "old-name"},
+ )
+ pid = created.json()["id"]
+ resp = await client.patch(
+ f"/api/user-personas/{pid}",
+ json={"name": "new-name"},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ updated = (await client.get(f"/api/user-personas/{pid}")).json()
+ assert updated["name"] == "new-name"
+
+ async def test_update_soul_and_agent(self, client):
+ created = await client.post(
+ "/api/user-personas",
+ json={"name": "updateme", "soul_md": "old soul"},
+ )
+ pid = created.json()["id"]
+ resp = await client.patch(
+ f"/api/user-personas/{pid}",
+ json={
+ "soul_md": "new soul",
+ "agent_md": "new agent",
+ "description": "updated",
+ },
+ )
+ assert resp.status_code == 200
+ updated = (await client.get(f"/api/user-personas/{pid}")).json()
+ assert updated["soul_md"] == "new soul"
+ assert updated["agent_md"] == "new agent"
+ assert updated["description"] == "updated"
+
+ async def test_update_not_found(self, client):
+ resp = await client.patch(
+ "/api/user-personas/nonexistentid123",
+ json={"name": "ghost"},
+ )
+ assert resp.status_code == 404
+ data = resp.json()
+ assert "error" in data
+
+
+@pytest.mark.asyncio
+class TestDeletePersona:
+ async def test_delete_existing(self, client):
+ created = await client.post(
+ "/api/user-personas",
+ json={"name": "deleteme"},
+ )
+ pid = created.json()["id"]
+ resp = await client.delete(f"/api/user-personas/{pid}")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["ok"] is True
+ get_resp = await client.get(f"/api/user-personas/{pid}")
+ assert get_resp.status_code == 404
+
+ async def test_delete_not_found_still_returns_200(self, client):
+ resp = await client.delete("/api/user-personas/nonexistentid123")
+ assert resp.status_code == 200
+ assert resp.json()["ok"] is True
diff --git a/tests/test_routes_userspace_apps.py b/tests/test_routes_userspace_apps.py
new file mode 100644
index 000000000..4754feae2
--- /dev/null
+++ b/tests/test_routes_userspace_apps.py
@@ -0,0 +1,567 @@
+"""Endpoint tests for tinyagentos/routes/userspace_apps.py."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from tinyagentos.userspace.store import UserspaceAppStore
+from tinyagentos.userspace.data_store import UserspaceDataStore
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+async def _init_userspace_stores(app, tmp_data_dir):
+ """Initialize userspace stores the same way the lifespan does."""
+ store = app.state.userspace_apps
+ if store._db is not None:
+ await store.close()
+ await store.init()
+ data_store = app.state.userspace_data
+ if data_store._db is not None:
+ await data_store.close()
+ await data_store.init()
+
+
+async def _install_test_app(store, app_id="test-app", name="Test App",
+ permissions=None, trust="community"):
+ """Insert a test app directly into the store."""
+ await store.install(
+ app_id=app_id,
+ name=name,
+ version="1.0.0",
+ app_type="web",
+ entry="index.html",
+ icon="icon.png",
+ permissions_requested=permissions or [],
+ trust=trust,
+ )
+
+
+def _make_minimal_pkg(app_id="uploaded-app", name="Uploaded App",
+ version="0.1.0", app_type="web",
+ permissions=None, entry_name="index.html"):
+ """Return bytes of a valid .taosapp (zip) containing a minimal package."""
+ import io
+ import zipfile
+
+ manifest = (
+ f"id: {app_id}\n"
+ f"name: {name}\n"
+ f"version: {version}\n"
+ f"app_type: {app_type}\n"
+ f"entry: {entry_name}\n"
+ f"icon: icon.png\n"
+ f"permissions:\n"
+ )
+ if permissions:
+ for p in permissions:
+ manifest += f" - {p}\n"
+ else:
+ manifest += " []\n"
+
+ entry = b"hello"
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("manifest.yaml", manifest)
+ zf.writestr(entry_name, entry)
+ return buf.getvalue()
+
+
+# ---------------------------------------------------------------------------
+# GET /api/userspace-apps/sdk.js
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_sdk_returns_200(client):
+ resp = await client.get("/api/userspace-apps/sdk.js")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_sdk_content_type_is_js(client):
+ resp = await client.get("/api/userspace-apps/sdk.js")
+ assert "javascript" in resp.headers.get("content-type", "")
+
+
+@pytest.mark.asyncio
+async def test_sdk_cache_control_no_cache(client):
+ resp = await client.get("/api/userspace-apps/sdk.js")
+ assert resp.headers.get("cache-control") == "no-cache"
+
+
+# ---------------------------------------------------------------------------
+# GET /api/userspace-apps (list_installed)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_list_apps_returns_200(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.get("/api/userspace-apps")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_list_apps_returns_list(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ data = (await client.get("/api/userspace-apps")).json()
+ assert isinstance(data, list)
+
+
+@pytest.mark.asyncio
+async def test_list_apps_empty_when_none_installed(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ data = (await client.get("/api/userspace-apps")).json()
+ assert data == []
+
+
+@pytest.mark.asyncio
+async def test_list_apps_returns_installed_app(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ data = (await client.get("/api/userspace-apps")).json()
+ assert len(data) == 1
+ assert data[0]["app_id"] == "test-app"
+ assert data[0]["name"] == "Test App"
+
+
+# ---------------------------------------------------------------------------
+# POST /api/userspace-apps/install
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_install_upload_returns_200(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ pkg = _make_minimal_pkg()
+ resp = await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("test.taosapp", pkg, "application/zip")},
+ )
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_install_upload_returns_app_id(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ pkg = _make_minimal_pkg()
+ data = (await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("test.taosapp", pkg, "application/zip")},
+ )).json()
+ assert "app_id" in data
+ assert data["app_id"] == "uploaded-app"
+
+
+@pytest.mark.asyncio
+async def test_install_upload_returns_permissions_requested(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ pkg = _make_minimal_pkg()
+ data = (await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("test.taosapp", pkg, "application/zip")},
+ )).json()
+ assert "permissions_requested" in data
+ assert isinstance(data["permissions_requested"], list)
+
+
+@pytest.mark.asyncio
+async def test_install_no_package_no_json_returns_400(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.post(
+ "/api/userspace-apps/install",
+ data=b"",
+ )
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_install_json_without_source_url_returns_400(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.post(
+ "/api/userspace-apps/install",
+ json={"foo": "bar"},
+ )
+ assert resp.status_code == 400
+ assert "source_url or package required" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_install_private_url_returns_400(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.post(
+ "/api/userspace-apps/install",
+ json={"source_url": "http://192.168.1.1/package.tgz"},
+ )
+ assert resp.status_code == 400
+ assert "not allowed" in resp.json()["error"]
+
+
+@pytest.mark.asyncio
+async def test_install_container_package_returns_501(client):
+ """Container app_type must be rejected with 501."""
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+
+ manifest = (
+ "id: container-app\n"
+ "name: Container App\n"
+ "version: 1.0.0\n"
+ "app_type: container\n"
+ "entry: index.html\n"
+ "icon: icon.png\n"
+ "permissions: []\n"
+ "container:\n"
+ " image: test:latest\n"
+ " ports: [8080]\n"
+ ).encode()
+
+ import io
+ import zipfile
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("manifest.yaml", manifest)
+ pkg = buf.getvalue()
+
+ resp = await client.post(
+ "/api/userspace-apps/install",
+ files={"package": ("container.taosapp", pkg, "application/zip")},
+ )
+ assert resp.status_code == 501
+ assert "container packages are not supported" in resp.json()["error"]
+
+
+# ---------------------------------------------------------------------------
+# POST /api/userspace-apps/{app_id}/permissions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_returns_200(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=["app.net"])
+ resp = await client.post(
+ "/api/userspace-apps/test-app/permissions",
+ json={"granted": ["app.net"]},
+ )
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_returns_granted_list(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=["app.net", "app.agent"])
+ data = (await client.post(
+ "/api/userspace-apps/test-app/permissions",
+ json={"granted": ["app.net", "app.agent"]},
+ )).json()
+ assert data["status"] == "ok"
+ assert set(data["granted"]) == {"app.net", "app.agent"}
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_unknown_app_returns_404(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.post(
+ "/api/userspace-apps/no-such-app/permissions",
+ json={"granted": ["app.net"]},
+ )
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_ignores_unrequested(client):
+ """Permissions not in the app's manifest must be silently dropped."""
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=["app.net"])
+ data = (await client.post(
+ "/api/userspace-apps/test-app/permissions",
+ json={"granted": ["app.net", "app.llm"]},
+ )).json()
+ assert data["granted"] == ["app.net"]
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_invalid_json_returns_400(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.post(
+ "/api/userspace-apps/test-app/permissions",
+ content=b"not json",
+ headers={"content-type": "application/json"},
+ )
+ assert resp.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# POST /api/userspace-apps/{app_id}/enable
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_enable_returns_200(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.post("/api/userspace-apps/test-app/enable")
+ assert resp.status_code == 200
+ assert resp.json() == {"status": "ok"}
+
+
+@pytest.mark.asyncio
+async def test_enable_persists(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ await client.post("/api/userspace-apps/test-app/enable")
+ rec = await store.get("test-app")
+ assert rec["enabled"] == 1
+
+
+# ---------------------------------------------------------------------------
+# POST /api/userspace-apps/{app_id}/disable
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_disable_returns_200(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.post("/api/userspace-apps/test-app/disable")
+ assert resp.status_code == 200
+ assert resp.json() == {"status": "ok"}
+
+
+@pytest.mark.asyncio
+async def test_disable_persists(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ await client.post("/api/userspace-apps/test-app/disable")
+ rec = await store.get("test-app")
+ assert rec["enabled"] == 0
+
+
+# ---------------------------------------------------------------------------
+# DELETE /api/userspace-apps/{app_id}
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_uninstall_returns_200(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.delete("/api/userspace-apps/test-app")
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_uninstall_returns_removed_true(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ data = (await client.delete("/api/userspace-apps/test-app")).json()
+ assert data["removed"] is True
+
+
+@pytest.mark.asyncio
+async def test_uninstall_returns_removed_false_for_unknown(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ data = (await client.delete(
+ "/api/userspace-apps/nonexistent",
+ )).json()
+ assert data["removed"] is False
+
+
+@pytest.mark.asyncio
+async def test_uninstall_removes_from_store(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ await client.delete("/api/userspace-apps/test-app")
+ rec = await store.get("test-app")
+ assert rec is None
+
+
+# ---------------------------------------------------------------------------
+# GET /api/userspace-apps/{app_id}/icon
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_serve_icon_no_app_returns_404(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.get("/api/userspace-apps/no-app/icon")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_serve_icon_app_without_icon_returns_404(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=[])
+ resp = await client.get("/api/userspace-apps/test-app/icon")
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# POST /api/userspace-apps/{app_id}/broker
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_broker_app_not_found_returns_404(client):
+ await _init_userspace_stores(
+ client._transport.app,
+ client._transport.app.state.data_dir,
+ )
+ resp = await client.post(
+ "/api/userspace-apps/missing-app/broker",
+ json={"capability": "app.kv.get", "args": {"key": "x"}},
+ )
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_broker_disabled_app_returns_404(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ await store.set_enabled("test-app", False)
+ resp = await client.post(
+ "/api/userspace-apps/test-app/broker",
+ json={"capability": "app.kv.get", "args": {"key": "x"}},
+ )
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_broker_free_capability_returns_result(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.post(
+ "/api/userspace-apps/test-app/broker",
+ json={"capability": "app.kv.keys", "args": {}},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "result" in data
+
+
+@pytest.mark.asyncio
+async def test_broker_unknown_capability_returns_error(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store)
+ resp = await client.post(
+ "/api/userspace-apps/test-app/broker",
+ json={"capability": "app.nonexistent", "args": {}},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data.get("error") == "unknown_capability"
+
+
+@pytest.mark.asyncio
+async def test_broker_gated_cap_without_permission_denied(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=["app.net"])
+ # app.net is NOT in permissions_granted by default, so it should be denied
+ resp = await client.post(
+ "/api/userspace-apps/test-app/broker",
+ json={"capability": "app.net.fetch", "args": {"url": "http://example.com"}},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data.get("error") == "permission_denied"
+
+
+@pytest.mark.asyncio
+async def test_broker_gated_cap_with_permission_allowed(client):
+ app = client._transport.app
+ await _init_userspace_stores(app, app.state.data_dir)
+ store = app.state.userspace_apps
+ await _install_test_app(store, permissions=["app.net"])
+ await store.set_permissions_granted("test-app", ["app.net"])
+ # app.net is now granted, but the fetch will fail since there is no real
+ # network in tests. We just check the broker does not return permission_denied.
+ resp = await client.post(
+ "/api/userspace-apps/test-app/broker",
+ json={"capability": "app.net.fetch", "args": {"url": "http://example.com"}},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ # The result may be an error (e.g. network unreachable), but it must NOT
+ # be permission_denied since we granted the capability.
+ assert data.get("error") != "permission_denied"
diff --git a/tests/test_shibaclaw_adapter.py b/tests/test_shibaclaw_adapter.py
new file mode 100644
index 000000000..f927858bc
--- /dev/null
+++ b/tests/test_shibaclaw_adapter.py
@@ -0,0 +1,176 @@
+"""Tests for the ShibaClaw adapter: health endpoint, message proxying,
+retry behaviour, and error handling."""
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import httpx
+import pytest
+
+import tinyagentos.adapters.shibaclaw_adapter as sc_mod
+from tinyagentos.adapters.shibaclaw_adapter import app
+
+
+@pytest.fixture(autouse=True)
+def _instant_retry_backoff(monkeypatch):
+ # with_retry backs off with asyncio.sleep between attempts. The retry tests
+ # exhaust all 7 attempts, so without this each would sleep ~31s of real time.
+ # Make the backoff instant; the retry COUNT is unchanged.
+ import asyncio
+
+ async def _no_sleep(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr(asyncio, "sleep", _no_sleep)
+
+
+# ---------------------------------------------------------------------------
+# health endpoint
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_health_returns_ok():
+ from httpx import ASGITransport, AsyncClient
+
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ resp = await client.get("/health")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["status"] == "ok"
+ assert data["framework"] == "shibaclaw"
+
+
+# ---------------------------------------------------------------------------
+# handle_message — happy path
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_handle_message_returns_content_on_200(monkeypatch):
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {"content": "hello from shibaclaw"}
+
+ async def _mock_post(url, json):
+ return mock_resp
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ result = await sc_mod.handle_message({"text": "hi"})
+ assert result["content"] == "hello from shibaclaw"
+
+
+@pytest.mark.asyncio
+async def test_handle_message_sends_text_to_correct_url(monkeypatch):
+ sent = {}
+
+ async def _mock_post(url, json):
+ sent["url"] = url
+ sent["json"] = json
+ return MagicMock(status_code=200, json=MagicMock(return_value={"content": "ok"}))
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ monkeypatch.setenv("SHIBACLAW_URL", "http://shibaclaw:19999")
+ await sc_mod.handle_message({"text": "ping"})
+ assert sent["url"] == "http://shibaclaw:19999/api/message"
+ assert sent["json"] == {"text": "ping"}
+
+
+# ---------------------------------------------------------------------------
+# handle_message — non-200 status
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_handle_message_returns_status_text_on_non_200(monkeypatch):
+ call_count = 0
+
+ async def _mock_post(url, json):
+ nonlocal call_count
+ call_count += 1
+ return httpx.Response(503, request=httpx.Request("POST", url))
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ result = await sc_mod.handle_message({"text": "hi"})
+ assert "503" in result["content"]
+ assert call_count == 7 # with_retry(max_attempts=7) exhausts all retries
+
+
+# ---------------------------------------------------------------------------
+# handle_message — exception handling
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_handle_message_returns_error_on_exception(monkeypatch):
+ call_count = 0
+
+ async def _mock_post(url, json):
+ nonlocal call_count
+ call_count += 1
+ raise httpx.ConnectError("refused")
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ result = await sc_mod.handle_message({"text": "hi"})
+ assert "ShibaClaw not available" in result["content"]
+ assert "refused" in result["content"]
+ assert call_count == 7 # with_retry(max_attempts=7) exhausts all retries
+
+
+@pytest.mark.asyncio
+async def test_handle_message_includes_agent_name_in_error(monkeypatch):
+ call_count = 0
+
+ async def _mock_post(url, json):
+ nonlocal call_count
+ call_count += 1
+ raise httpx.ConnectError("down")
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ monkeypatch.setenv("TAOS_AGENT_NAME", "my-agent")
+ result = await sc_mod.handle_message({"text": "hi"})
+ assert "[my-agent]" in result["content"]
+ assert call_count == 7
+
+
+# ---------------------------------------------------------------------------
+# handle_message — edge cases
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_handle_message_empty_text(monkeypatch):
+ sent = {}
+
+ async def _mock_post(url, json):
+ sent["json"] = json
+ return MagicMock(status_code=200, json=MagicMock(return_value={"content": "ack"}))
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ result = await sc_mod.handle_message({})
+ assert sent["json"]["text"] == ""
+ assert result["content"] == "ack"
+
+
+@pytest.mark.asyncio
+async def test_handle_message_uses_default_url_when_env_not_set(monkeypatch):
+ sent = {}
+
+ async def _mock_post(url, json):
+ sent["url"] = url
+ return MagicMock(status_code=200, json=MagicMock(return_value={"content": "ok"}))
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ monkeypatch.delenv("SHIBACLAW_URL", raising=False)
+ await sc_mod.handle_message({"text": "x"})
+ assert sent["url"] == "http://localhost:19999/api/message"
+
+
+@pytest.mark.asyncio
+async def test_handle_message_falls_back_to_resp_text_when_no_content_key(monkeypatch):
+ async def _mock_post(url, json):
+ resp = MagicMock()
+ resp.status_code = 200
+ resp.json.return_value = {}
+ resp.text = "raw text response"
+ return resp
+
+ monkeypatch.setattr(sc_mod, "_controller_post", _mock_post)
+ result = await sc_mod.handle_message({"text": "hi"})
+ assert result["content"] == "raw text response"
diff --git a/tests/test_system_stats.py b/tests/test_system_stats.py
new file mode 100644
index 000000000..ac29ce820
--- /dev/null
+++ b/tests/test_system_stats.py
@@ -0,0 +1,914 @@
+"""Unit tests for tinyagentos/system_stats.py."""
+
+from __future__ import annotations
+
+import subprocess
+import time
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+# ---------------------------------------------------------------------------
+# read_rknpu_load
+# ---------------------------------------------------------------------------
+
+
+class TestReadRknpuLoad:
+ @patch("tinyagentos.system_stats.Path")
+ def test_averages_multiple_cores(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "NPU load: Core0: 12%, Core1: 0%, Core2: 0%,"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ result = read_rknpu_load()
+ assert result == pytest.approx(4.0)
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_single_core(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "Core0: 50%"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ result = read_rknpu_load()
+ assert result == pytest.approx(50.0)
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_returns_none_when_no_files(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.side_effect = FileNotFoundError
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ assert read_rknpu_load() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_skips_permission_error(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.side_effect = PermissionError
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ assert read_rknpu_load() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_ignores_non_percent_tokens(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "NPU load: Core0: abc%, Core1: 25%"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ result = read_rknpu_load()
+ assert result == pytest.approx(25.0)
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_falls_through_to_second_path(self, mock_path_cls):
+ first = MagicMock()
+ first.read_text.side_effect = FileNotFoundError
+ second = MagicMock()
+ second.read_text.return_value = "Core0: 80%"
+ mock_path_cls.side_effect = [first, second]
+ from tinyagentos.system_stats import read_rknpu_load
+
+ result = read_rknpu_load()
+ assert result == pytest.approx(80.0)
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_percent_tokens_returns_none(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "no data here"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import read_rknpu_load
+
+ assert read_rknpu_load() is None
+
+
+# ---------------------------------------------------------------------------
+# read_nvidia_vram
+# ---------------------------------------------------------------------------
+
+
+class TestReadNvidiaVram:
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_returns_used_total(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="5000, 8192\n"
+ )
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ result = read_nvidia_vram()
+ assert result == (5000, 8192)
+
+ @patch("tinyagentos.system_stats.shutil.which", return_value=None)
+ def test_returns_none_when_no_binary(self, mock_which):
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ assert read_nvidia_vram() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_nonzero_returncode(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=1, stdout="", stderr="error"
+ )
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ assert read_nvidia_vram() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_empty_stdout(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout=""
+ )
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ assert read_nvidia_vram() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_subprocess_error(self, mock_which, mock_run):
+ mock_run.side_effect = subprocess.SubprocessError("boom")
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ assert read_nvidia_vram() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_malformed_output(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="not_a_number, 8192"
+ )
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ assert read_nvidia_vram() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_extra_lines_ignored(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="1000, 4096\n2000, 8192\n"
+ )
+ from tinyagentos.system_stats import read_nvidia_vram
+
+ result = read_nvidia_vram()
+ assert result == (1000, 4096)
+
+
+# ---------------------------------------------------------------------------
+# read_nvidia_gpu_load
+# ---------------------------------------------------------------------------
+
+
+class TestReadNvidiaGpuLoad:
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_returns_float(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="42\n"
+ )
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ result = read_nvidia_gpu_load()
+ assert result == pytest.approx(42.0)
+
+ @patch("tinyagentos.system_stats.shutil.which", return_value=None)
+ def test_no_binary(self, mock_which):
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ assert read_nvidia_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_nonzero_rc(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=1, stdout="", stderr="err"
+ )
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ assert read_nvidia_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_empty_output(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout=" "
+ )
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ assert read_nvidia_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_file_not_found_error(self, mock_which, mock_run):
+ mock_run.side_effect = FileNotFoundError
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ assert read_nvidia_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.subprocess.run")
+ @patch("tinyagentos.system_stats.shutil.which", return_value="/usr/bin/nvidia-smi")
+ def test_non_numeric_output(self, mock_which, mock_run):
+ mock_run.return_value = subprocess.CompletedProcess(
+ args=[], returncode=0, stdout="N/A\n"
+ )
+ from tinyagentos.system_stats import read_nvidia_gpu_load
+
+ assert read_nvidia_gpu_load() is None
+
+
+# ---------------------------------------------------------------------------
+# get_npu_usage
+# ---------------------------------------------------------------------------
+
+
+class TestGetNpuUsage:
+ @patch("tinyagentos.system_stats.read_rknpu_load", return_value=15.0)
+ def test_rknpu_dispatch(self, mock_read):
+ from tinyagentos.system_stats import get_npu_usage
+
+ result = get_npu_usage("rknpu")
+ assert result == pytest.approx(15.0)
+ mock_read.assert_called_once()
+
+ def test_unknown_type(self):
+ from tinyagentos.system_stats import get_npu_usage
+
+ assert get_npu_usage("unknown") is None
+
+ def test_empty_string(self):
+ from tinyagentos.system_stats import get_npu_usage
+
+ assert get_npu_usage("") is None
+
+ @patch("tinyagentos.system_stats.read_rknpu_load", return_value=None)
+ def test_rknpu_returns_none(self, mock_read):
+ from tinyagentos.system_stats import get_npu_usage
+
+ assert get_npu_usage("rknpu") is None
+
+
+# ---------------------------------------------------------------------------
+# get_vram_usage
+# ---------------------------------------------------------------------------
+
+
+class TestGetVramUsage:
+ @patch("tinyagentos.system_stats.read_nvidia_vram", return_value=(2048, 8192))
+ def test_nvidia_percent(self, mock_read):
+ from tinyagentos.system_stats import get_vram_usage
+
+ pct, used, total = get_vram_usage("nvidia")
+ assert used == 2048
+ assert total == 8192
+ assert pct == pytest.approx(25.0)
+
+ @patch("tinyagentos.system_stats.read_nvidia_vram", return_value=(0, 0))
+ def test_zero_total(self, mock_read):
+ from tinyagentos.system_stats import get_vram_usage
+
+ pct, used, total = get_vram_usage("nvidia")
+ assert used == 0
+ assert total == 0
+ assert pct is None
+
+ @patch("tinyagentos.system_stats.read_nvidia_vram", return_value=None)
+ def test_nvidia_unavailable(self, mock_read):
+ from tinyagentos.system_stats import get_vram_usage
+
+ result = get_vram_usage("nvidia")
+ assert result == (None, None, None)
+
+ def test_unknown_gpu(self):
+ from tinyagentos.system_stats import get_vram_usage
+
+ assert get_vram_usage("amd") == (None, None, None)
+
+
+# ---------------------------------------------------------------------------
+# get_npu_per_core
+# ---------------------------------------------------------------------------
+
+
+class TestGetNpuPerCore:
+ @patch("tinyagentos.system_stats.Path")
+ def test_parses_cores(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "NPU load: Core0: 12%, Core1: 30%, Core2: 5%,"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import get_npu_per_core
+
+ result = get_npu_per_core()
+ assert result == [
+ {"core": 0, "load_percent": 12},
+ {"core": 1, "load_percent": 30},
+ {"core": 2, "load_percent": 5},
+ ]
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_returns_none_on_oserror(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.side_effect = OSError
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import get_npu_per_core
+
+ assert get_npu_per_core() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_cores_returns_none(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.return_value = "no core data"
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import get_npu_per_core
+
+ assert get_npu_per_core() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_permission_error(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.side_effect = PermissionError
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import get_npu_per_core
+
+ assert get_npu_per_core() is None
+
+
+# ---------------------------------------------------------------------------
+# get_npu_frequency
+# ---------------------------------------------------------------------------
+
+
+class TestGetNpuFrequency:
+ @patch("tinyagentos.system_stats.Path")
+ def test_first_path_works(self, mock_path_cls):
+ first = MagicMock()
+ first.read_text.return_value = "600000000\n"
+ second = MagicMock()
+ second.read_text.return_value = "700000000\n"
+ mock_path_cls.side_effect = [first, second]
+ from tinyagentos.system_stats import get_npu_frequency
+
+ result = get_npu_frequency()
+ assert result == 600000000
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_falls_through_to_second(self, mock_path_cls):
+ first = MagicMock()
+ first.read_text.side_effect = FileNotFoundError
+ second = MagicMock()
+ second.read_text.return_value = "700000000\n"
+ mock_path_cls.side_effect = [first, second]
+ from tinyagentos.system_stats import get_npu_frequency
+
+ result = get_npu_frequency()
+ assert result == 700000000
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_both_fail(self, mock_path_cls):
+ first = MagicMock()
+ first.read_text.side_effect = FileNotFoundError
+ second = MagicMock()
+ second.read_text.side_effect = PermissionError
+ mock_path_cls.side_effect = [first, second]
+ from tinyagentos.system_stats import get_npu_frequency
+
+ assert get_npu_frequency() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_invalid_value(self, mock_path_cls):
+ first = MagicMock()
+ first.read_text.return_value = "not_a_number"
+ second = MagicMock()
+ second.read_text.side_effect = FileNotFoundError
+ mock_path_cls.side_effect = [first, second]
+ from tinyagentos.system_stats import get_npu_frequency
+
+ assert get_npu_frequency() is None
+
+
+# ---------------------------------------------------------------------------
+# get_cpu_per_core
+# ---------------------------------------------------------------------------
+
+
+class TestGetCpuPerCore:
+ @patch("tinyagentos.system_stats.Path")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_no_cpufreq_dir(self, mock_psutil, mock_path_cls):
+ mock_psutil.cpu_percent.return_value = [5.0]
+
+ base = MagicMock()
+ base.exists.return_value = False
+ mock_path_cls.return_value = base
+ from tinyagentos.system_stats import get_cpu_per_core
+
+ result = get_cpu_per_core()
+ assert len(result) == 1
+ assert result[0]["core"] == 0
+ assert result[0]["load_percent"] == 5.0
+ assert "freq_khz" not in result[0]
+
+ @patch("tinyagentos.system_stats.Path")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_cpufreq_read_error(self, mock_psutil, mock_path_cls):
+ mock_psutil.cpu_percent.return_value = [5.0]
+
+ base = MagicMock()
+ base.exists.return_value = True
+ child = MagicMock()
+ child.read_text.side_effect = OSError
+ base.__truediv__ = lambda self, name: child
+ mock_path_cls.return_value = base
+ from tinyagentos.system_stats import get_cpu_per_core
+
+ result = get_cpu_per_core()
+ assert len(result) == 1
+ assert "freq_khz" not in result[0]
+
+
+# ---------------------------------------------------------------------------
+# get_thermal_zones
+# ---------------------------------------------------------------------------
+
+
+class TestGetThermalZones:
+ def _clear_cache(self):
+ from tinyagentos.system_stats import _thermal_zones
+
+ _thermal_zones.cache_clear()
+
+ def setup_method(self):
+ self._clear_cache()
+
+ def teardown_method(self):
+ self._clear_cache()
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_thermal_dir(self, mock_path_cls):
+ base = MagicMock()
+ base.exists.return_value = False
+ mock_path_cls.return_value = base
+ from tinyagentos.system_stats import get_thermal_zones
+
+ assert get_thermal_zones() == []
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_temp_read_error(self, mock_path_cls):
+ zone_dir = MagicMock()
+ zone_dir.name = "thermal_zone0"
+
+ base = MagicMock()
+ base.exists.return_value = True
+ base.iterdir.return_value = [zone_dir]
+
+ def zone_child(name):
+ m = MagicMock()
+ if name == "type":
+ m.read_text.return_value = "cpu-thermal\n"
+ elif name == "temp":
+ m.read_text.side_effect = OSError
+ return m
+
+ zone_dir.__truediv__ = lambda self, name: zone_child(name)
+ mock_path_cls.side_effect = lambda p: base if p == "/sys/class/thermal" else MagicMock()
+ from tinyagentos.system_stats import get_thermal_zones
+
+ result = get_thermal_zones()
+ assert result == []
+
+
+# ---------------------------------------------------------------------------
+# get_gpu_load
+# ---------------------------------------------------------------------------
+
+
+class TestGetGpuLoad:
+ @patch("tinyagentos.system_stats.Path")
+ def test_panthor_format(self, mock_path_cls):
+ panthor = MagicMock()
+ panthor.read_text.return_value = "120@800000000\n"
+ mali = MagicMock()
+ mali.read_text.side_effect = FileNotFoundError
+
+ def path_factory(p):
+ if "fb000000.gpu" in str(p):
+ return panthor
+ return mali
+
+ mock_path_cls.side_effect = path_factory
+ from tinyagentos.system_stats import get_gpu_load
+
+ result = get_gpu_load()
+ assert result == {"load_percent": 120, "freq_hz": 800000000}
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_mali_format(self, mock_path_cls):
+ panthor = MagicMock()
+ panthor.read_text.side_effect = FileNotFoundError
+ mali = MagicMock()
+ mali.read_text.return_value = "busy_time: 300\nidle_time: 700\n"
+
+ def path_factory(p):
+ if "mali0" in str(p):
+ return mali
+ return panthor
+
+ mock_path_cls.side_effect = path_factory
+ from tinyagentos.system_stats import get_gpu_load
+
+ result = get_gpu_load()
+ assert result == {"load_percent": 30, "freq_hz": None}
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_mali_zero_total(self, mock_path_cls):
+ panthor = MagicMock()
+ panthor.read_text.side_effect = FileNotFoundError
+ mali = MagicMock()
+ mali.read_text.return_value = "busy_time: 0\nidle_time: 0\n"
+
+ def path_factory(p):
+ if "mali0" in str(p):
+ return mali
+ return panthor
+
+ mock_path_cls.side_effect = path_factory
+ from tinyagentos.system_stats import get_gpu_load
+
+ assert get_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_gpu_data(self, mock_path_cls):
+ mock_path = MagicMock()
+ mock_path.read_text.side_effect = FileNotFoundError
+ mock_path_cls.return_value = mock_path
+ from tinyagentos.system_stats import get_gpu_load
+
+ assert get_gpu_load() is None
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_panthor_no_at_sign(self, mock_path_cls):
+ panthor = MagicMock()
+ panthor.read_text.return_value = "no_at_sign"
+ mali = MagicMock()
+ mali.read_text.side_effect = FileNotFoundError
+
+ def path_factory(p):
+ if "fb000000.gpu" in str(p):
+ return panthor
+ return mali
+
+ mock_path_cls.side_effect = path_factory
+ from tinyagentos.system_stats import get_gpu_load
+
+ assert get_gpu_load() is None
+
+
+# ---------------------------------------------------------------------------
+# get_zram_stats
+# ---------------------------------------------------------------------------
+
+
+class TestGetZramStats:
+ @patch("tinyagentos.system_stats.Path")
+ def test_single_device(self, mock_path_cls):
+ zram0 = MagicMock()
+ zram0.name = "zram0"
+ mm_stat = MagicMock()
+ mm_stat.exists.return_value = True
+ mm_stat.read_text.return_value = "1073741824 536870912 2097152 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"
+
+ zram0.__truediv__ = lambda self, name: mm_stat if name == "mm_stat" else MagicMock(
+ exists=MagicMock(return_value=False)
+ )
+
+ base = MagicMock()
+ base.exists.return_value = True
+ base.glob.return_value = [zram0]
+
+ mock_path_cls.side_effect = lambda p: base if str(p) == "/sys/block" else MagicMock()
+ from tinyagentos.system_stats import get_zram_stats
+
+ result = get_zram_stats()
+ assert len(result) == 1
+ assert result[0]["device"] == "zram0"
+ assert result[0]["orig_mb"] == 1024
+ assert result[0]["compr_mb"] == 512
+ assert result[0]["used_mb"] == 2
+ assert result[0]["ratio"] == pytest.approx(2.0)
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_block_dir(self, mock_path_cls):
+ base = MagicMock()
+ base.exists.return_value = False
+ mock_path_cls.return_value = base
+ from tinyagentos.system_stats import get_zram_stats
+
+ assert get_zram_stats() == []
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_no_mm_stat(self, mock_path_cls):
+ zram0 = MagicMock()
+ zram0.name = "zram0"
+ mm_stat = MagicMock()
+ mm_stat.exists.return_value = False
+ zram0.__truediv__ = lambda self, name: mm_stat
+
+ base = MagicMock()
+ base.exists.return_value = True
+ base.glob.return_value = [zram0]
+
+ mock_path_cls.side_effect = lambda p: base if str(p) == "/sys/block" else MagicMock()
+ from tinyagentos.system_stats import get_zram_stats
+
+ assert get_zram_stats() == []
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_zero_compressed(self, mock_path_cls):
+ zram0 = MagicMock()
+ zram0.name = "zram0"
+ mm_stat = MagicMock()
+ mm_stat.exists.return_value = True
+ mm_stat.read_text.return_value = "100 0 0 0 0"
+
+ zram0.__truediv__ = lambda self, name: mm_stat if name == "mm_stat" else MagicMock(
+ exists=MagicMock(return_value=False)
+ )
+
+ base = MagicMock()
+ base.exists.return_value = True
+ base.glob.return_value = [zram0]
+
+ mock_path_cls.side_effect = lambda p: base if str(p) == "/sys/block" else MagicMock()
+ from tinyagentos.system_stats import get_zram_stats
+
+ result = get_zram_stats()
+ assert len(result) == 1
+ assert result[0]["ratio"] == 0
+
+ @patch("tinyagentos.system_stats.Path")
+ def test_too_few_fields(self, mock_path_cls):
+ zram0 = MagicMock()
+ zram0.name = "zram0"
+ mm_stat = MagicMock()
+ mm_stat.exists.return_value = True
+ mm_stat.read_text.return_value = "100 200"
+
+ zram0.__truediv__ = lambda self, name: mm_stat if name == "mm_stat" else MagicMock(
+ exists=MagicMock(return_value=False)
+ )
+
+ base = MagicMock()
+ base.exists.return_value = True
+ base.glob.return_value = [zram0]
+
+ mock_path_cls.side_effect = lambda p: base if str(p) == "/sys/block" else MagicMock()
+ from tinyagentos.system_stats import get_zram_stats
+
+ assert get_zram_stats() == []
+
+
+# ---------------------------------------------------------------------------
+# get_disk_io_rate
+# ---------------------------------------------------------------------------
+
+
+class TestGetDiskIoRate:
+ def setup_method(self):
+ from tinyagentos.system_stats import _DISK_IO_LAST
+
+ _DISK_IO_LAST["ts"] = 0.0
+ _DISK_IO_LAST["read"] = 0
+ _DISK_IO_LAST["write"] = 0
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_first_call_returns_zero(self, mock_psutil, mock_time):
+ mock_psutil.disk_io_counters.return_value = MagicMock(
+ read_bytes=1000, write_bytes=2000
+ )
+ mock_time.time.return_value = 100.0
+ from tinyagentos.system_stats import get_disk_io_rate
+
+ result = get_disk_io_rate()
+ assert result == {"read_bps": 0, "write_bps": 0}
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_second_call_computes_rate(self, mock_psutil, mock_time):
+ io1 = MagicMock(read_bytes=1000, write_bytes=2000)
+ io2 = MagicMock(read_bytes=101000, write_bytes=202000)
+ mock_psutil.disk_io_counters.side_effect = [io1, io2]
+ mock_time.time.side_effect = [100.0, 101.0]
+ from tinyagentos.system_stats import get_disk_io_rate
+
+ get_disk_io_rate()
+ result = get_disk_io_rate()
+ assert result["read_bps"] == 100000
+ assert result["write_bps"] == 200000
+
+ @patch("tinyagentos.system_stats.psutil")
+ def test_none_counters(self, mock_psutil):
+ mock_psutil.disk_io_counters.return_value = None
+ from tinyagentos.system_stats import get_disk_io_rate
+
+ result = get_disk_io_rate()
+ assert result == {"read_bps": 0, "write_bps": 0}
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_negative_rate_clamped(self, mock_psutil, mock_time):
+ io1 = MagicMock(read_bytes=100000, write_bytes=200000)
+ io2 = MagicMock(read_bytes=50000, write_bytes=100000)
+ mock_psutil.disk_io_counters.side_effect = [io1, io2]
+ mock_time.time.side_effect = [100.0, 101.0]
+ from tinyagentos.system_stats import get_disk_io_rate
+
+ get_disk_io_rate()
+ result = get_disk_io_rate()
+ assert result["read_bps"] == 0
+ assert result["write_bps"] == 0
+
+
+# ---------------------------------------------------------------------------
+# get_network_rates
+# ---------------------------------------------------------------------------
+
+
+class TestGetNetworkRates:
+ def setup_method(self):
+ from tinyagentos.system_stats import _NET_IO_LAST
+
+ _NET_IO_LAST.clear()
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_filters_loopback(self, mock_psutil, mock_time):
+ mock_time.time.return_value = 100.0
+ lo = MagicMock(bytes_recv=999, bytes_sent=999)
+ mock_psutil.net_io_counters.return_value = {"lo": lo}
+ from tinyagentos.system_stats import get_network_rates
+
+ result = get_network_rates()
+ assert result == []
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_filters_docker_interfaces(self, mock_psutil, mock_time):
+ mock_time.time.return_value = 100.0
+ docker0 = MagicMock(bytes_recv=500, bytes_sent=500)
+ br_fake = MagicMock(bytes_recv=600, bytes_sent=600)
+ veth_fake = MagicMock(bytes_recv=700, bytes_sent=700)
+ mock_psutil.net_io_counters.return_value = {
+ "docker0": docker0,
+ "br-abc123": br_fake,
+ "veth123": veth_fake,
+ }
+ from tinyagentos.system_stats import get_network_rates
+
+ result = get_network_rates()
+ assert result == []
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_real_interface(self, mock_psutil, mock_time):
+ mock_time.time.return_value = 100.0
+ eth0 = MagicMock(bytes_recv=1000000, bytes_sent=2000000)
+ mock_psutil.net_io_counters.return_value = {"eth0": eth0}
+ from tinyagentos.system_stats import get_network_rates
+
+ result = get_network_rates()
+ assert len(result) == 1
+ assert result[0]["name"] == "eth0"
+ assert result[0]["rx_bps"] == 0
+ assert result[0]["tx_bps"] == 0
+ assert result[0]["rx_total"] == 1000000
+ assert result[0]["tx_total"] == 2000000
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_rate_calculation(self, mock_psutil, mock_time):
+ mock_time.time.side_effect = [100.0, 101.0]
+ eth0_first = MagicMock(bytes_recv=0, bytes_sent=0)
+ eth0_second = MagicMock(bytes_recv=1000, bytes_sent=2000)
+ mock_psutil.net_io_counters.side_effect = [
+ {"eth0": eth0_first},
+ {"eth0": eth0_second},
+ ]
+ from tinyagentos.system_stats import get_network_rates
+
+ get_network_rates()
+ result = get_network_rates()
+ assert len(result) == 1
+ assert result[0]["rx_bps"] == 1000
+ assert result[0]["tx_bps"] == 2000
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_virbr_filtered(self, mock_psutil, mock_time):
+ mock_time.time.return_value = 100.0
+ virbr0 = MagicMock(bytes_recv=100, bytes_sent=100)
+ mock_psutil.net_io_counters.return_value = {"virbr0": virbr0}
+ from tinyagentos.system_stats import get_network_rates
+
+ assert get_network_rates() == []
+
+ @patch("tinyagentos.system_stats.time")
+ @patch("tinyagentos.system_stats.psutil")
+ def test_dummy_filtered(self, mock_psutil, mock_time):
+ mock_time.time.return_value = 100.0
+ dummy0 = MagicMock(bytes_recv=100, bytes_sent=100)
+ mock_psutil.net_io_counters.return_value = {"dummy0": dummy0}
+ from tinyagentos.system_stats import get_network_rates
+
+ assert get_network_rates() == []
+
+
+# ---------------------------------------------------------------------------
+# get_top_processes
+# ---------------------------------------------------------------------------
+
+
+class TestGetTopProcesses:
+ @patch("tinyagentos.system_stats.psutil")
+ def test_sorts_by_rss(self, mock_psutil):
+ p1 = MagicMock()
+ p1.info = {
+ "pid": 1,
+ "name": "big",
+ "memory_info": MagicMock(rss=200 * 1024 * 1024),
+ "cpu_percent": 10.0,
+ "username": "root",
+ }
+ p2 = MagicMock()
+ p2.info = {
+ "pid": 2,
+ "name": "small",
+ "memory_info": MagicMock(rss=50 * 1024 * 1024),
+ "cpu_percent": 5.0,
+ "username": "root",
+ }
+ mock_psutil.process_iter.return_value = [p2, p1]
+ from tinyagentos.system_stats import get_top_processes
+
+ result = get_top_processes()
+ assert len(result) == 2
+ assert result[0]["name"] == "big"
+ assert result[0]["rss_mb"] == 200
+ assert result[1]["name"] == "small"
+ assert result[1]["rss_mb"] == 50
+
+ @patch("tinyagentos.system_stats.psutil")
+ def test_respects_limit(self, mock_psutil):
+ procs = []
+ for i in range(20):
+ p = MagicMock()
+ p.info = {
+ "pid": i,
+ "name": f"proc{i}",
+ "memory_info": MagicMock(rss=i * 1024 * 1024),
+ "cpu_percent": 0.0,
+ "username": "user",
+ }
+ procs.append(p)
+ mock_psutil.process_iter.return_value = procs
+ from tinyagentos.system_stats import get_top_processes
+
+ result = get_top_processes(limit=5)
+ assert len(result) == 5
+
+ @patch("tinyagentos.system_stats.psutil")
+ def test_handles_none_name(self, mock_psutil):
+ p = MagicMock()
+ p.info = {
+ "pid": 1,
+ "name": None,
+ "memory_info": MagicMock(rss=1024 * 1024),
+ "cpu_percent": 0.0,
+ "username": None,
+ }
+ mock_psutil.process_iter.return_value = [p]
+ from tinyagentos.system_stats import get_top_processes
+
+ result = get_top_processes()
+ assert result[0]["name"] == "?"
+ assert result[0]["user"] == "?"
+
+ @patch("tinyagentos.system_stats.psutil")
+ def test_handles_none_cpu_percent(self, mock_psutil):
+ p = MagicMock()
+ p.info = {
+ "pid": 1,
+ "name": "test",
+ "memory_info": MagicMock(rss=1024 * 1024),
+ "cpu_percent": None,
+ "username": "user",
+ }
+ mock_psutil.process_iter.return_value = [p]
+ from tinyagentos.system_stats import get_top_processes
+
+ result = get_top_processes()
+ assert result[0]["cpu_percent"] == 0.0
diff --git a/tests/test_taosctl.py b/tests/test_taosctl.py
new file mode 100644
index 000000000..444f502ac
--- /dev/null
+++ b/tests/test_taosctl.py
@@ -0,0 +1,164 @@
+"""Tests for the taosctl CLI: config resolution, error mapping, command
+dispatch, exit codes, and output rendering."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import output
+from tinyagentos.cli.taosctl import __main__ as cli_main
+from tinyagentos.cli.taosctl.commands import iter_noun_modules
+
+
+# ---- config resolution -------------------------------------------------------
+
+def test_resolve_prefers_flags_over_env(monkeypatch, tmp_path):
+ monkeypatch.setattr(cli_client, "CONFIG_PATH", tmp_path / "none.json")
+ monkeypatch.setenv("TAOS_URL", "http://env:1")
+ monkeypatch.setenv("TAOS_TOKEN", "envtok")
+ url, tok = cli_client.resolve("http://flag:2", "flagtok")
+ assert url == "http://flag:2" and tok == "flagtok"
+
+
+def test_resolve_falls_back_to_env_then_default(monkeypatch, tmp_path):
+ monkeypatch.setattr(cli_client, "CONFIG_PATH", tmp_path / "none.json")
+ monkeypatch.delenv("TAOS_URL", raising=False)
+ monkeypatch.setenv("TAOS_TOKEN", "envtok")
+ url, tok = cli_client.resolve(None, None)
+ assert url == cli_client.DEFAULT_URL and tok == "envtok"
+
+
+def test_resolve_reads_config_file_when_no_flag_or_env(monkeypatch, tmp_path):
+ cfg = tmp_path / "config.json"
+ cfg.write_text(json.dumps({"url": "http://cfg:9", "token": "cfgtok"}))
+ monkeypatch.setattr(cli_client, "CONFIG_PATH", cfg)
+ monkeypatch.delenv("TAOS_URL", raising=False)
+ monkeypatch.delenv("TAOS_TOKEN", raising=False)
+ url, tok = cli_client.resolve(None, None)
+ assert url == "http://cfg:9" and tok == "cfgtok"
+
+
+# ---- error extraction --------------------------------------------------------
+
+def test_extract_error_uses_json_detail():
+ raw = json.dumps({"detail": "not your project"}).encode()
+ assert cli_client._extract_error(raw, 403) == "not your project"
+
+
+def test_extract_error_falls_back_to_status():
+ assert cli_client._extract_error(b"", 500) == "HTTP 500"
+
+
+# ---- discovery ---------------------------------------------------------------
+
+def test_agents_and_auth_nouns_are_discovered():
+ nouns = {m.NOUN for m in iter_noun_modules()}
+ assert {"agents", "auth"} <= nouns
+
+
+# ---- command dispatch + exit codes (fake client) -----------------------------
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ return {"items": [{"name": "alpha", "status": "running"}]}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_agents_list_calls_endpoint_and_succeeds(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["agents", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/agents") in fake.calls
+ assert "alpha" in capsys.readouterr().out
+
+
+def test_agents_get_targets_named_agent(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "agents", "get", "alpha"], fake)
+ assert rc == 0
+ assert ("GET", "/api/agents/alpha") in fake.calls
+
+
+def test_agents_get_url_encodes_the_name(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "agents", "get", "a/b c"], fake)
+ assert rc == 0
+ assert ("GET", "/api/agents/a%2Fb%20c") in fake.calls
+
+
+def test_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(404, "no such agent")
+ rc = _run(monkeypatch, ["agents", "get", "ghost"], fake)
+ assert rc == 2
+ assert "no such agent" in capsys.readouterr().err
+
+
+def test_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["agents", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
+
+
+# ---- output rendering --------------------------------------------------------
+
+def test_render_json_emits_valid_json(capsys):
+ output.render({"a": 1, "b": [1, 2]}, as_json=True)
+ assert json.loads(capsys.readouterr().out) == {"a": 1, "b": [1, 2]}
+
+
+def test_render_table_unwraps_items_and_shows_rows(capsys):
+ output.render({"items": [{"name": "x", "status": "ok"}]}, as_json=False)
+ out = capsys.readouterr().out
+ assert "NAME" in out and "x" in out and "ok" in out
+
+
+# ---- auth login writes the credential file owner-only ------------------------
+
+def test_auth_login_writes_config_owner_only(monkeypatch, tmp_path):
+ import argparse
+ import stat
+
+ from tinyagentos.cli.taosctl.commands import auth as auth_cmd
+
+ cfg = tmp_path / "sub" / "config.json"
+ monkeypatch.setattr(cli_client, "CONFIG_PATH", cfg)
+ monkeypatch.delenv("TAOS_URL", raising=False)
+ monkeypatch.delenv("TAOS_TOKEN", raising=False)
+ auth_cmd._login(argparse.Namespace(url="http://h:1", token="sekret"), None)
+ assert cfg.exists()
+ assert stat.S_IMODE(cfg.stat().st_mode) == 0o600
+ assert json.loads(cfg.read_text())["token"] == "sekret"
+
+
+def test_auth_login_does_not_persist_env_only_token(monkeypatch, tmp_path):
+ import argparse
+
+ from tinyagentos.cli.taosctl.commands import auth as auth_cmd
+
+ cfg = tmp_path / "config.json"
+ monkeypatch.setattr(cli_client, "CONFIG_PATH", cfg)
+ monkeypatch.setenv("TAOS_TOKEN", "env-secret")
+ monkeypatch.delenv("TAOS_URL", raising=False)
+ # bare `auth login` (no --token): the env token must NOT be written to disk
+ auth_cmd._login(argparse.Namespace(url="http://h:1", token=None), None)
+ saved = json.loads(cfg.read_text())
+ assert saved["token"] is None
+ assert saved["url"] == "http://h:1"
diff --git a/tests/test_taosctl_apps.py b/tests/test_taosctl_apps.py
new file mode 100644
index 000000000..287f3868e
--- /dev/null
+++ b/tests/test_taosctl_apps.py
@@ -0,0 +1,92 @@
+"""Tests for the taosctl apps command group."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ if path == "/api/apps/installed":
+ return [{"app_id": "gitea-lxc", "display_name": "Gitea", "status": "running"}]
+ return {"items": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ if self._raise:
+ raise self._raise
+ return {"status": "ok"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_apps_list_calls_installed_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["apps", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/apps/installed") in fake.calls
+
+
+def test_apps_get_filters_installed_list_by_id(monkeypatch):
+ # No single-app backend route exists, so get fetches the list and filters.
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["apps", "get", "gitea-lxc"], fake)
+ assert rc == 0
+ assert ("GET", "/api/apps/installed") in fake.calls
+
+
+def test_apps_get_unknown_id_errors(monkeypatch):
+ fake = _FakeClient()
+ with pytest.raises(SystemExit):
+ _run(monkeypatch, ["apps", "get", "ghost-app"], fake)
+
+
+def test_apps_installed_calls_optional_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["apps", "installed"], fake)
+ assert rc == 0
+ assert ("GET", "/api/apps/optional/installed") in fake.calls
+
+
+def test_apps_install_posts_to_optional_install(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["apps", "install", "reddit"], fake)
+ assert rc == 0
+ assert ("POST", "/api/apps/optional/reddit/install") in fake.calls
+
+
+def test_apps_uninstall_posts_to_optional_uninstall(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["apps", "uninstall", "reddit"], fake)
+ assert rc == 0
+ assert ("POST", "/api/apps/optional/reddit/uninstall") in fake.calls
+
+
+def test_apps_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(404, "no such app")
+ rc = _run(monkeypatch, ["apps", "get", "ghost"], fake)
+ assert rc == 2
+ assert "no such app" in capsys.readouterr().err
+
+
+def test_apps_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["apps", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_bookmarks.py b/tests/test_taosctl_bookmarks.py
new file mode 100644
index 000000000..fc57adf3c
--- /dev/null
+++ b/tests/test_taosctl_bookmarks.py
@@ -0,0 +1,94 @@
+"""Tests for the taosctl bookmarks command group: dispatch, endpoint paths,
+URL-encoding, and error mapping."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path, params))
+ if self._raise:
+ raise self._raise
+ return {"bookmarks": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path, body, params))
+ if self._raise:
+ raise self._raise
+ return {"bookmark_id": "bm-1"}
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path, params))
+ if self._raise:
+ raise self._raise
+ return None
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_bookmarks_list_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["bookmarks", "list", "--profile-id", "prof-1"], fake)
+ assert rc == 0
+ assert ("GET", "/api/desktop/browser/bookmarks", {"profile_id": "prof-1"}) in fake.calls
+
+
+def test_bookmarks_create_posts_body(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(
+ monkeypatch,
+ ["bookmarks", "create", "--profile-id", "prof-1", "--url", "https://example.com", "--title", "Example"],
+ fake,
+ )
+ assert rc == 0
+ assert (
+ "POST",
+ "/api/desktop/browser/bookmarks",
+ {"profile_id": "prof-1", "url": "https://example.com", "title": "Example"},
+ None,
+ ) in fake.calls
+
+
+def test_bookmarks_delete_url_encodes_id(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(
+ monkeypatch,
+ ["bookmarks", "delete", "bm/1 2", "--profile-id", "prof-1"],
+ fake,
+ )
+ assert rc == 0
+ assert (
+ "DELETE",
+ "/api/desktop/browser/bookmarks/bm%2F1%202",
+ {"profile_id": "prof-1"},
+ ) in fake.calls
+
+
+def test_bookmarks_api_error_maps_to_exit_2(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import ApiError
+
+ fake = _FakeClient()
+ fake._raise = ApiError(400, "bad request")
+ rc = _run(monkeypatch, ["bookmarks", "list", "--profile-id", "prof-1"], fake)
+ assert rc == 2
+ assert "bad request" in capsys.readouterr().err
+
+
+def test_bookmarks_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import TransportError
+
+ fake = _FakeClient()
+ fake._raise = TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["bookmarks", "list", "--profile-id", "prof-1"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_browsing_history.py b/tests/test_taosctl_browsing_history.py
new file mode 100644
index 000000000..dcdd84be5
--- /dev/null
+++ b/tests/test_taosctl_browsing_history.py
@@ -0,0 +1,84 @@
+"""Tests for the taosctl browsing_history command group."""
+
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path, params))
+ if self._raise:
+ raise self._raise
+ return {"items": [], "count": 0}
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path, params))
+ if self._raise:
+ raise self._raise
+ return {"deleted": 0}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_browsing_history_list_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["browsing_history", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/browsing-history", None) in fake.calls
+
+
+def test_browsing_history_list_with_params(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["browsing_history", "list", "--source-type", "web", "--limit", "10"], fake)
+ assert rc == 0
+ assert ("GET", "/api/browsing-history", {"source_type": "web", "limit": 10}) in fake.calls
+
+
+def test_browsing_history_list_explicit_default_limit(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["browsing_history", "list", "--limit", "50"], fake)
+ assert rc == 0
+ assert ("GET", "/api/browsing-history", {"limit": 50}) in fake.calls
+
+
+def test_browsing_history_clear_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["browsing_history", "clear"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/browsing-history", None) in fake.calls
+
+
+def test_browsing_history_clear_with_source_type(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["browsing_history", "clear", "--source-type", "web"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/browsing-history", {"source_type": "web"}) in fake.calls
+
+
+def test_browsing_history_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(500, "server error")
+ rc = _run(monkeypatch, ["browsing_history", "list"], fake)
+ assert rc == 2
+ assert "server error" in capsys.readouterr().err
+
+
+def test_browsing_history_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["browsing_history", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_canvas.py b/tests/test_taosctl_canvas.py
new file mode 100644
index 000000000..41e44348a
--- /dev/null
+++ b/tests/test_taosctl_canvas.py
@@ -0,0 +1,65 @@
+"""Tests for the taosctl canvas command group."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ return {"canvases": []}
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path))
+ return {"status": "deleted"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_canvas_list_calls_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["canvas", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/canvas") in fake.calls
+
+
+def test_canvas_get_targets_by_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "canvas", "get", "abc123"], fake)
+ assert rc == 0
+ assert ("GET", "/api/canvas/abc123/data") in fake.calls
+
+
+def test_canvas_get_url_encodes_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "canvas", "get", "a/b c"], fake)
+ assert rc == 0
+ assert ("GET", "/api/canvas/a%2Fb%20c/data") in fake.calls
+
+
+def test_canvas_delete_targets_by_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "canvas", "delete", "abc123"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/canvas/abc123") in fake.calls
+
+
+def test_canvas_delete_url_encodes_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "canvas", "delete", "x/y"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/canvas/x%2Fy") in fake.calls
+
+
+def test_canvas_noun_is_discovered(monkeypatch):
+ from tinyagentos.cli.taosctl.commands import iter_noun_modules
+ nouns = {m.NOUN for m in iter_noun_modules()}
+ assert "canvas" in nouns
diff --git a/tests/test_taosctl_client.py b/tests/test_taosctl_client.py
new file mode 100644
index 000000000..8e786b23d
--- /dev/null
+++ b/tests/test_taosctl_client.py
@@ -0,0 +1,83 @@
+"""Direct unit tests for the taosctl HTTP client.
+
+The noun command tests use fake clients, so they cannot catch a real
+TaosClient signature or URL-building regression. These tests exercise the
+real client against a stubbed urlopen.
+"""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import client as cli_client
+
+
+class _FakeResp:
+ def __init__(self, body=b'{"ok": true}'):
+ self._body = body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def read(self):
+ return self._body
+
+
+def _capture(monkeypatch):
+ seen = {}
+
+ def fake_urlopen(req, timeout=None):
+ seen["url"] = req.full_url
+ seen["method"] = req.get_method()
+ seen["data"] = req.data
+ return _FakeResp()
+
+ monkeypatch.setattr(cli_client.urllib.request, "urlopen", fake_urlopen)
+ return seen
+
+
+def test_delete_threads_query_params_into_url(monkeypatch):
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.delete("/api/bookmarks/b1", params={"profile_id": "p1"})
+ assert seen["method"] == "DELETE"
+ assert seen["url"] == "http://x/api/bookmarks/b1?profile_id=p1"
+
+
+def test_delete_without_params_has_no_query(monkeypatch):
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.delete("/api/bookmarks/b1")
+ assert seen["url"] == "http://x/api/bookmarks/b1"
+
+
+def test_get_drops_none_valued_params(monkeypatch):
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.get("/api/things", params={"a": "1", "b": None})
+ assert seen["url"] == "http://x/api/things?a=1"
+
+
+def test_post_accepts_json_as_alias_for_body(monkeypatch):
+ # Callers habitually pass json= (the requests/httpx convention); the client
+ # treats it as the body so that habit is not a silent bug.
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.post("/api/memory/search", json={"q": "hello"})
+ assert seen["method"] == "POST"
+ assert seen["data"] == b'{"q": "hello"}'
+
+
+def test_post_body_wins_over_json_when_both_given(monkeypatch):
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.post("/api/x", body={"from": "body"}, json={"from": "json"})
+ assert seen["data"] == b'{"from": "body"}'
+
+
+def test_patch_accepts_json_alias(monkeypatch):
+ seen = _capture(monkeypatch)
+ c = cli_client.TaosClient(url="http://x", token=None)
+ c.patch("/api/x/1", json={"name": "n"})
+ assert seen["method"] == "PATCH"
+ assert seen["data"] == b'{"name": "n"}'
diff --git a/tests/test_taosctl_frameworks.py b/tests/test_taosctl_frameworks.py
new file mode 100644
index 000000000..bfb307eb3
--- /dev/null
+++ b/tests/test_taosctl_frameworks.py
@@ -0,0 +1,60 @@
+"""Tests for the taosctl frameworks command: dispatch, endpoint paths, and exit
+codes. Mirrors the structure in tests/test_taosctl.py.
+"""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ return {"items": [{"name": "crewai", "verified": True}]}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_frameworks_list_calls_endpoint_and_succeeds(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["frameworks", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/frameworks") in fake.calls
+ assert "crewai" in capsys.readouterr().out
+
+
+def test_frameworks_list_json_output(monkeypatch, capsys):
+ import json
+
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "frameworks", "list"], fake)
+ assert rc == 0
+ data = json.loads(capsys.readouterr().out)
+ assert data["items"][0]["name"] == "crewai"
+
+
+def test_frameworks_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(500, "internal error")
+ rc = _run(monkeypatch, ["frameworks", "list"], fake)
+ assert rc == 2
+ assert "internal error" in capsys.readouterr().err
+
+
+def test_frameworks_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["frameworks", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_guides.py b/tests/test_taosctl_guides.py
new file mode 100644
index 000000000..f7cb58818
--- /dev/null
+++ b/tests/test_taosctl_guides.py
@@ -0,0 +1,74 @@
+"""Tests for taosctl guides command group: dispatch, arg parsing, and exit codes."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ if "/api/guides/recommendations" in path:
+ return {"hardware": params["hardware"], "use_case": params["use_case"], "recommendations": []}
+ if "/api/guides/tiers" in path:
+ return {"tiers": {"pi-16gb": {"label": "Raspberry Pi 16 GB"}}}
+ if "/api/guides/use-cases" in path:
+ return {"use_cases": {"chat": {"label": "Chat"}}}
+ return {}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+
+ def patch(self, path, body=None):
+ self.calls.append(("PATCH", path))
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path))
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_guides_recommendations_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["guides", "recommendations", "nvidia-12gb", "coding"], fake)
+ assert rc == 0
+ assert ("GET", "/api/guides/recommendations") in fake.calls
+ out = capsys.readouterr().out
+ assert "nvidia-12gb" in out
+
+
+def test_guides_tiers_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "guides", "tiers"], fake)
+ assert rc == 0
+ assert ("GET", "/api/guides/tiers") in fake.calls
+ out = capsys.readouterr().out
+ assert "pi-16gb" in out
+
+
+def test_guides_use_cases_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "guides", "use-cases"], fake)
+ assert rc == 0
+ assert ("GET", "/api/guides/use-cases") in fake.calls
+ out = capsys.readouterr().out
+ assert "chat" in out
+
+
+def test_guides_is_discovered():
+ from tinyagentos.cli.taosctl.commands import iter_noun_modules
+
+ nouns = {m.NOUN for m in iter_noun_modules()}
+ assert "guides" in nouns
diff --git a/tests/test_taosctl_memory.py b/tests/test_taosctl_memory.py
new file mode 100644
index 000000000..fa2008836
--- /dev/null
+++ b/tests/test_taosctl_memory.py
@@ -0,0 +1,109 @@
+"""Tests for the taosctl memory command group: dispatch, endpoint paths, and
+exit codes via a fake client driven through tinyagentos.cli.taosctl.__main__."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ return {"chunks": []}
+
+ def post(self, path, json=None):
+ self.calls.append(("POST", path))
+ if self._raise:
+ raise self._raise
+ return {"results": []}
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path))
+ if self._raise:
+ raise self._raise
+ return {"status": "ok"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_memory_list_calls_browse(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/memory/browse") in fake.calls
+
+
+def test_memory_list_with_agent(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "list", "--agent", "alpha"], fake)
+ assert rc == 0
+ assert ("GET", "/api/memory/browse") in fake.calls
+
+
+def test_memory_search_calls_search(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "search", "hello world"], fake)
+ assert rc == 0
+ assert ("POST", "/api/memory/search") in fake.calls
+
+
+def test_memory_search_with_mode(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "search", "q", "--mode", "semantic"], fake)
+ assert rc == 0
+ assert ("POST", "/api/memory/search") in fake.calls
+
+
+def test_memory_collections_calls_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "collections", "alpha"], fake)
+ assert rc == 0
+ assert ("GET", "/api/memory/collections/alpha") in fake.calls
+
+
+def test_memory_collections_url_encodes_agent(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "collections", "a/b c"], fake)
+ assert rc == 0
+ assert ("GET", "/api/memory/collections/a%2Fb%20c") in fake.calls
+
+
+def test_memory_delete_calls_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "delete", "abc123"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/memory/chunk/abc123") in fake.calls
+
+
+def test_memory_delete_url_encodes_hash(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["memory", "delete", "ab/c d"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/memory/chunk/ab%2Fc%20d") in fake.calls
+
+
+def test_memory_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(502, "qmd unavailable")
+ rc = _run(monkeypatch, ["memory", "list"], fake)
+ assert rc == 2
+ assert "qmd unavailable" in capsys.readouterr().err
+
+
+def test_memory_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["memory", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_models.py b/tests/test_taosctl_models.py
new file mode 100644
index 000000000..75ba0007d
--- /dev/null
+++ b/tests/test_taosctl_models.py
@@ -0,0 +1,91 @@
+"""Tests for the taosctl models command group."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ return {"items": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ return {"status": "ok"}
+
+ def delete(self, path):
+ self.calls.append(("DELETE", path))
+ return {"status": "deleted"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_models_list(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models") in fake.calls
+
+
+def test_models_get(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "get", "llama-3"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models/llama-3") in fake.calls
+
+
+def test_models_get_url_encodes_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "get", "a/b c"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models/a%2Fb%20c") in fake.calls
+
+
+def test_models_delete(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "delete", "llama-3"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/models/llama-3") in fake.calls
+
+
+def test_models_recommended(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "recommended"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models/recommended") in fake.calls
+
+
+def test_models_loaded(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "loaded"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models/loaded") in fake.calls
+
+
+def test_models_downloads(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "downloads"], fake)
+ assert rc == 0
+ assert ("GET", "/api/models/downloads") in fake.calls
+
+
+def test_models_download(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "download", "--app-id", "llama", "--variant-id", "q4"], fake)
+ assert rc == 0
+ assert ("POST", "/api/models/download") in fake.calls
+
+
+def test_models_pull(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["models", "pull", "--model-name", "llama3"], fake)
+ assert rc == 0
+ assert ("POST", "/api/models/pull") in fake.calls
diff --git a/tests/test_taosctl_music.py b/tests/test_taosctl_music.py
new file mode 100644
index 000000000..69fc75d26
--- /dev/null
+++ b/tests/test_taosctl_music.py
@@ -0,0 +1,66 @@
+"""Tests for the taosctl music command group."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ if path == "/api/music":
+ return {"tracks": [{"filename": "song.wav", "prompt": "lofi"}]}
+ if path == "/api/music/status":
+ return {"available": True, "backend": "musicgpt", "mode": "http"}
+ return {}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ if self._raise:
+ raise self._raise
+ return {"status": "ok"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_music_list_calls_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["music", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/music") in fake.calls
+
+
+def test_music_status_calls_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["music", "status"], fake)
+ assert rc == 0
+ assert ("GET", "/api/music/status") in fake.calls
+
+
+def test_music_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(500, "backend unavailable")
+ rc = _run(monkeypatch, ["music", "list"], fake)
+ assert rc == 2
+ assert "backend unavailable" in capsys.readouterr().err
+
+
+def test_music_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["music", "status"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_projects.py b/tests/test_taosctl_projects.py
new file mode 100644
index 000000000..b015d594e
--- /dev/null
+++ b/tests/test_taosctl_projects.py
@@ -0,0 +1,78 @@
+"""Tests for the taosctl projects command group."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ return {"items": [{"id": "p1", "name": "alpha"}]}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ self.last_body = body
+ return {"id": "p2", "name": (body or {}).get("name", "x")}
+
+ def patch(self, path, body=None):
+ self.calls.append(("PATCH", path))
+ self.last_body = body
+ return {"id": "p1", "name": (body or {}).get("name", "x")}
+
+ def delete(self, path):
+ self.calls.append(("DELETE", path))
+ return {"id": "p1", "status": "deleted"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_projects_list_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/projects") in fake.calls
+
+
+def test_projects_get_targets_by_id(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "get", "p1"], fake)
+ assert rc == 0
+ assert ("GET", "/api/projects/p1") in fake.calls
+
+
+def test_projects_create_sends_post_with_body(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "create", "alpha", "alpha-slug"], fake)
+ assert rc == 0
+ assert ("POST", "/api/projects") in fake.calls
+ assert fake.last_body == {"name": "alpha", "slug": "alpha-slug", "description": ""}
+
+
+def test_projects_update_sends_patch_with_fields(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "update", "p1", "--name", "beta"], fake)
+ assert rc == 0
+ assert ("PATCH", "/api/projects/p1") in fake.calls
+ assert fake.last_body == {"name": "beta"}
+
+
+def test_projects_delete_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "delete", "p1"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/projects/p1") in fake.calls
+
+
+def test_projects_archive_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["projects", "archive", "p1"], fake)
+ assert rc == 0
+ assert ("POST", "/api/projects/p1/archive") in fake.calls
diff --git a/tests/test_taosctl_scheduler.py b/tests/test_taosctl_scheduler.py
new file mode 100644
index 000000000..c7cf70e31
--- /dev/null
+++ b/tests/test_taosctl_scheduler.py
@@ -0,0 +1,61 @@
+"""Tests for the taosctl scheduler command group."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path, params))
+ return {"ok": True}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_scheduler_stats_hits_stats_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "scheduler", "stats"], fake)
+ assert rc == 0
+ assert ("GET", "/api/scheduler/stats", None) in fake.calls
+
+
+def test_scheduler_tasks_hits_tasks_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "scheduler", "tasks"], fake)
+ assert rc == 0
+ assert ("GET", "/api/scheduler/tasks", None) in fake.calls
+
+
+def test_scheduler_tasks_passes_limit_param(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "scheduler", "tasks", "--limit", "50"], fake)
+ assert rc == 0
+ calls_for_tasks = [c for c in fake.calls if c[1] == "/api/scheduler/tasks"]
+ assert len(calls_for_tasks) == 1
+ assert calls_for_tasks[0][2] == {"limit": 50}
+
+
+def test_scheduler_backends_hits_backends_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "scheduler", "backends"], fake)
+ assert rc == 0
+ assert ("GET", "/api/scheduler/backends", None) in fake.calls
+
+
+def test_scheduler_verbs_return_data(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "scheduler", "stats"], fake)
+ data = json.loads(capsys.readouterr().out)
+ assert data == {"ok": True}
diff --git a/tests/test_taosctl_search.py b/tests/test_taosctl_search.py
new file mode 100644
index 000000000..8e5f4e4b0
--- /dev/null
+++ b/tests/test_taosctl_search.py
@@ -0,0 +1,82 @@
+"""Tests for the taosctl search command: dispatch, endpoint paths, and exit codes."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path, params))
+ if self._raise:
+ raise self._raise
+ return {"results": [], "query": params.get("q", "") if params else "", "total": 0}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_search_list_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["search", "list", "hello"], fake)
+ assert rc == 0
+ assert any(c[0] == "GET" and c[1] == "/api/search" for c in fake.calls)
+
+
+def test_search_list_passes_query_param(monkeypatch, capsys):
+ fake = _FakeClient()
+ _run(monkeypatch, ["search", "list", "hello"], fake)
+ get_calls = [c for c in fake.calls if c[0] == "GET"]
+ assert len(get_calls) == 1
+ assert get_calls[0][2]["q"] == "hello"
+
+
+def test_search_list_default_limit(monkeypatch, capsys):
+ fake = _FakeClient()
+ _run(monkeypatch, ["search", "list", "test"], fake)
+ get_calls = [c for c in fake.calls if c[0] == "GET"]
+ assert get_calls[0][2]["limit"] == 5
+
+
+def test_search_list_custom_limit(monkeypatch, capsys):
+ fake = _FakeClient()
+ _run(monkeypatch, ["search", "list", "test", "--limit", "10"], fake)
+ get_calls = [c for c in fake.calls if c[0] == "GET"]
+ assert get_calls[0][2]["limit"] == 10
+
+
+def test_search_list_json_output(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["--json", "search", "list", "alpha"], fake)
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert '"query"' in out
+
+
+def test_search_api_error_maps_to_exit_2(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import ApiError
+
+ fake = _FakeClient()
+ fake._raise = ApiError(500, "search backend unavailable")
+ rc = _run(monkeypatch, ["search", "list", "fail"], fake)
+ assert rc == 2
+ assert "search backend unavailable" in capsys.readouterr().err
+
+
+def test_search_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import TransportError
+
+ fake = _FakeClient()
+ fake._raise = TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["search", "list", "fail"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_shared_folders.py b/tests/test_taosctl_shared_folders.py
new file mode 100644
index 000000000..11db715c1
--- /dev/null
+++ b/tests/test_taosctl_shared_folders.py
@@ -0,0 +1,94 @@
+"""Tests for the taosctl shared_folders command group."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if params:
+ self.last_params = params
+ if path == "/api/shared-folders":
+ # The list endpoint returns a bare list of folders.
+ return [{"id": "1", "name": "docs"}]
+ return {"items": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ self.last_body = body
+ return {"id": "2", "name": (body or {}).get("name", "x")}
+
+ def delete(self, path):
+ self.calls.append(("DELETE", path))
+ return {"id": "1", "status": "deleted"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_shared_folders_list_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/shared-folders") in fake.calls
+
+
+def test_shared_folders_list_with_agent_name_param(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "list", "--agent-name", "alpha"], fake)
+ assert rc == 0
+ assert ("GET", "/api/shared-folders") in fake.calls
+ assert fake.last_params == {"agent_name": "alpha"}
+
+
+def test_shared_folders_get_filters_list_by_id(monkeypatch):
+ # No single-folder GET route exists, so get fetches the list and filters.
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "get", "1"], fake)
+ assert rc == 0
+ assert ("GET", "/api/shared-folders") in fake.calls
+
+
+def test_shared_folders_create_sends_post_with_body(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "create", "docs"], fake)
+ assert rc == 0
+ assert ("POST", "/api/shared-folders") in fake.calls
+ assert fake.last_body == {"name": "docs", "description": ""}
+
+
+def test_shared_folders_create_with_agents(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "create", "docs", "--agents", "a,b"], fake)
+ assert rc == 0
+ assert fake.last_body == {"name": "docs", "description": "", "agents": ["a", "b"]}
+
+
+def test_shared_folders_delete_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "delete", "1"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/shared-folders/1") in fake.calls
+
+
+def test_shared_folders_files_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "files", "docs"], fake)
+ assert rc == 0
+ assert ("GET", "/api/shared-folders/docs/files") in fake.calls
+
+
+def test_shared_folders_grant_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shared_folders", "grant", "1", "alpha"], fake)
+ assert rc == 0
+ assert ("POST", "/api/shared-folders/1/access") in fake.calls
+ assert fake.last_body == {"agent_name": "alpha", "permission": "readwrite"}
diff --git a/tests/test_taosctl_shortcuts.py b/tests/test_taosctl_shortcuts.py
new file mode 100644
index 000000000..7063cab8b
--- /dev/null
+++ b/tests/test_taosctl_shortcuts.py
@@ -0,0 +1,61 @@
+"""Tests for the taosctl shortcuts command group."""
+from __future__ import annotations
+
+from tinyagentos.cli.taosctl import client as cli_client
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ return {"items": [{"idx": 0, "kind": "shell", "label": "bash", "icon": "terminal"}]}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_shortcuts_list_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shortcuts", "list", "my-agent"], fake)
+ assert rc == 0
+ assert ("GET", "/api/agents/my-agent/shortcuts") in fake.calls
+
+
+def test_shortcuts_list_url_encodes_agent_id(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shortcuts", "list", "a/b c"], fake)
+ assert rc == 0
+ assert ("GET", "/api/agents/a%2Fb%20c/shortcuts") in fake.calls
+
+
+def test_shortcuts_list_outputs_data(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["shortcuts", "list", "alpha"], fake)
+ assert rc == 0
+ assert "bash" in capsys.readouterr().out
+
+
+def test_shortcuts_api_error_maps_to_exit_2(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.ApiError(404, "no such agent")
+ rc = _run(monkeypatch, ["shortcuts", "list", "ghost"], fake)
+ assert rc == 2
+ assert "no such agent" in capsys.readouterr().err
+
+
+def test_shortcuts_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ fake = _FakeClient()
+ fake._raise = cli_client.TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["shortcuts", "list", "alpha"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_taosctl_tasks.py b/tests/test_taosctl_tasks.py
new file mode 100644
index 000000000..caabc1258
--- /dev/null
+++ b/tests/test_taosctl_tasks.py
@@ -0,0 +1,100 @@
+"""Tests for the taosctl tasks command group."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ return {"items": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path))
+ return {"status": "ok"}
+
+ def request(self, method, path, body=None, params=None):
+ self.calls.append((method, path))
+ return {"status": "ok"}
+
+ def delete(self, path):
+ self.calls.append(("DELETE", path))
+ return {"status": "ok"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_tasks_list_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["tasks", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/tasks") in fake.calls
+
+
+def test_tasks_list_passes_agent_filter(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["tasks", "list", "--agent", "myagent"], fake)
+ assert rc == 0
+ assert ("GET", "/api/tasks") in fake.calls
+
+
+def test_tasks_get_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["tasks", "get", "42"], fake)
+ assert rc == 0
+ assert ("GET", "/api/tasks/42") in fake.calls
+
+
+def test_tasks_create_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, [
+ "tasks", "create",
+ "--name", "test",
+ "--schedule", "* * * * *",
+ "--command", "echo hi",
+ ], fake)
+ assert rc == 0
+ assert ("POST", "/api/tasks") in fake.calls
+
+
+def test_tasks_update_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, [
+ "tasks", "update", "42",
+ "--name", "renamed",
+ "--enabled", "false",
+ ], fake)
+ assert rc == 0
+ assert ("PUT", "/api/tasks/42") in fake.calls
+
+
+def test_tasks_update_rejects_invalid_enabled(monkeypatch):
+ fake = _FakeClient()
+ with pytest.raises(SystemExit):
+ _run(monkeypatch, ["tasks", "update", "42", "--enabled", "maybe"], fake)
+ # an unrecognised value must error, not silently disable the task
+ assert ("PUT", "/api/tasks/42") not in fake.calls
+
+
+def test_tasks_delete_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["tasks", "delete", "42"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/tasks/42") in fake.calls
+
+
+def test_tasks_toggle_hits_correct_endpoint(monkeypatch):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["tasks", "toggle", "42"], fake)
+ assert rc == 0
+ assert ("POST", "/api/tasks/42/toggle") in fake.calls
diff --git a/tests/test_taosctl_video.py b/tests/test_taosctl_video.py
new file mode 100644
index 000000000..585f2a273
--- /dev/null
+++ b/tests/test_taosctl_video.py
@@ -0,0 +1,123 @@
+"""Tests for the taosctl video command: dispatch, endpoint paths, and error mapping."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.cli.taosctl import __main__ as cli_main
+
+
+class _FakeClient:
+ def __init__(self, *a, **k):
+ self.calls = []
+ self.base_url = "http://x"
+ self.token = "t"
+ self._raise = None
+
+ def get(self, path, params=None):
+ self.calls.append(("GET", path))
+ if self._raise:
+ raise self._raise
+ return {"videos": []}
+
+ def post(self, path, body=None, params=None):
+ self.calls.append(("POST", path, body))
+ if self._raise:
+ raise self._raise
+ return {"status": "generated"}
+
+ def delete(self, path, params=None):
+ self.calls.append(("DELETE", path))
+ if self._raise:
+ raise self._raise
+ return {"status": "deleted"}
+
+
+def _run(monkeypatch, argv, fake):
+ monkeypatch.setattr(cli_main, "TaosClient", lambda **k: fake)
+ return cli_main.main(argv)
+
+
+def test_video_list_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "list"], fake)
+ assert rc == 0
+ assert ("GET", "/api/video") in fake.calls
+
+
+def test_video_generate_posts_prompt(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "generate", "a cat"], fake)
+ assert rc == 0
+ assert fake.calls[0][0] == "POST"
+ assert fake.calls[0][1] == "/api/video/generate"
+ assert fake.calls[0][2]["prompt"] == "a cat"
+
+
+def test_video_generate_url_encodes_prompt_with_spaces(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "generate", "a cat dancing"], fake)
+ assert rc == 0
+ assert fake.calls[0][2]["prompt"] == "a cat dancing"
+
+
+def test_video_generate_includes_optional_args(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, [
+ "video", "generate", "sunset",
+ "--model", "wan2.1-14b",
+ "--duration", "10",
+ "--resolution", "720x1280",
+ "--seed", "42",
+ ], fake)
+ assert rc == 0
+ body = fake.calls[0][2]
+ assert body == {
+ "prompt": "sunset",
+ "model": "wan2.1-14b",
+ "duration": 10,
+ "resolution": "720x1280",
+ "seed": 42,
+ }
+
+
+def test_video_generate_default_body(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "generate", "test"], fake)
+ assert rc == 0
+ body = fake.calls[0][2]
+ assert body["model"] == "wan2.1-1.3b"
+ assert body["duration"] == 5
+ assert body["resolution"] == "480x832"
+ assert body["seed"] is None
+
+
+def test_video_delete_calls_endpoint(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "delete", "12345_1.mp4"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/video/12345_1.mp4") in fake.calls
+
+
+def test_video_delete_url_encodes_filename(monkeypatch, capsys):
+ fake = _FakeClient()
+ rc = _run(monkeypatch, ["video", "delete", "a b c.mp4"], fake)
+ assert rc == 0
+ assert ("DELETE", "/api/video/a%20b%20c.mp4") in fake.calls
+
+
+def test_video_api_error_maps_to_exit_2(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import ApiError
+ fake = _FakeClient()
+ fake._raise = ApiError(503, "No video backend")
+ rc = _run(monkeypatch, ["video", "generate", "fail"], fake)
+ assert rc == 2
+ assert "No video backend" in capsys.readouterr().err
+
+
+def test_video_transport_error_maps_to_exit_1(monkeypatch, capsys):
+ from tinyagentos.cli.taosctl.client import TransportError
+ fake = _FakeClient()
+ fake._raise = TransportError("cannot reach http://x: refused")
+ rc = _run(monkeypatch, ["video", "list"], fake)
+ assert rc == 1
+ assert "cannot reach" in capsys.readouterr().err
diff --git a/tests/test_task_lifecycle_notifications.py b/tests/test_task_lifecycle_notifications.py
new file mode 100644
index 000000000..84cdbd398
--- /dev/null
+++ b/tests/test_task_lifecycle_notifications.py
@@ -0,0 +1,51 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_claim_emits_notification(client):
+ pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"]
+ t = (await client.post(f"/api/projects/{pid}/tasks", json={"title": "T1"})).json()
+
+ resp = await client.post(f"/api/projects/{pid}/tasks/{t['id']}/claim", json={"claimer_id": "agent-1"})
+ assert resp.status_code == 200
+
+ notifs = (await client.get("/api/notifications")).json()
+ claimed = [n for n in notifs if n["source"] == "task.claimed"]
+ assert len(claimed) == 1
+ assert t["id"] in claimed[0]["message"]
+ assert "agent-1" in claimed[0]["message"]
+
+
+@pytest.mark.asyncio
+async def test_close_emits_notification(client):
+ pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"]
+ t = (await client.post(f"/api/projects/{pid}/tasks", json={"title": "T1"})).json()
+
+ await client.post(f"/api/projects/{pid}/tasks/{t['id']}/claim", json={"claimer_id": "agent-1"})
+ resp = await client.post(
+ f"/api/projects/{pid}/tasks/{t['id']}/close",
+ json={"closed_by": "agent-1", "reason": "done"},
+ )
+ assert resp.status_code == 200
+
+ notifs = (await client.get("/api/notifications")).json()
+ closed = [n for n in notifs if n["source"] == "task.closed"]
+ assert len(closed) == 1
+ assert t["id"] in closed[0]["message"]
+ assert "agent-1" in closed[0]["message"]
+
+
+@pytest.mark.asyncio
+async def test_mute_suppresses_claim_notification(client):
+ pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"]
+ t = (await client.post(f"/api/projects/{pid}/tasks", json={"title": "T1"})).json()
+
+ store = client._transport.app.state.notifications
+ await store.set_event_muted("task.claimed", True)
+
+ resp = await client.post(f"/api/projects/{pid}/tasks/{t['id']}/claim", json={"claimer_id": "agent-1"})
+ assert resp.status_code == 200
+
+ notifs = (await client.get("/api/notifications")).json()
+ claimed = [n for n in notifs if n["source"] == "task.claimed"]
+ assert len(claimed) == 0
diff --git a/tests/test_task_utils.py b/tests/test_task_utils.py
new file mode 100644
index 000000000..bd6169b5d
--- /dev/null
+++ b/tests/test_task_utils.py
@@ -0,0 +1,240 @@
+import asyncio
+import logging
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from tinyagentos.task_utils import _create_supervised_task, cancel_and_wait
+
+
+# ---------------------------------------------------------------------------
+# _create_supervised_task
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_supervised_task_added_to_set():
+ task_set = set()
+ coro = asyncio.sleep(0)
+ task = _create_supervised_task(coro, task_set)
+ assert task in task_set
+ await task
+ # done callback should have removed it
+ assert task not in task_set
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_removed_on_completion():
+ task_set = set()
+ task = _create_supervised_task(asyncio.sleep(0), task_set)
+ await asyncio.sleep(0.01) # let the done callback fire
+ assert len(task_set) == 0
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_logs_unhandled_exception(caplog):
+ async def boom():
+ raise RuntimeError("something broke")
+
+ task_set = set()
+ with caplog.at_level(logging.ERROR):
+ task = _create_supervised_task(boom(), task_set)
+ await asyncio.sleep(0.05) # let the task complete and callback fire
+
+ assert any("something broke" in r.message for r in caplog.records)
+ assert any("unhandled exception" in r.message for r in caplog.records)
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_no_log_on_success(caplog):
+ async def ok():
+ return 42
+
+ task_set = set()
+ with caplog.at_level(logging.ERROR):
+ task = _create_supervised_task(ok(), task_set)
+ await asyncio.sleep(0.05)
+
+ error_records = [r for r in caplog.records if r.levelno >= logging.ERROR]
+ assert len(error_records) == 0
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_no_log_on_cancel():
+ async def long_running():
+ await asyncio.sleep(100)
+
+ task_set = set()
+ task = _create_supervised_task(long_running(), task_set)
+ task.cancel()
+ await asyncio.sleep(0.05) # let cancel propagate and callback fire
+
+ # The task's done callback should see cancelled=True and NOT log an error.
+ # t.exception() raises CancelledError so the callback checks .cancelled() first.
+ assert task.cancelled()
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_set_discards_done_task():
+ """_on_done should silently discard (not KeyError) even if already removed."""
+ task_set = set()
+ task = _create_supervised_task(asyncio.sleep(0), task_set)
+ task_set.clear() # simulate external removal before callback fires
+ await asyncio.sleep(0.05)
+ # discard should not raise
+ assert len(task_set) == 0
+
+
+@pytest.mark.asyncio
+async def test_supervised_task_exception_includes_task_name(caplog):
+ async def explode():
+ raise ValueError("named boom")
+
+ task_set = set()
+ with caplog.at_level(logging.ERROR):
+ task = _create_supervised_task(explode(), task_set)
+ await asyncio.sleep(0.05)
+
+ # The log message includes the task name
+ assert any("named boom" in r.message for r in caplog.records)
+
+
+# ---------------------------------------------------------------------------
+# cancel_and_wait
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_empty_list():
+ result = await cancel_and_wait([])
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_all_done():
+ """Tasks that are already done should return immediately with no cancels."""
+ task = asyncio.create_task(asyncio.sleep(0))
+ await task
+ result = await cancel_and_wait([task])
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_cancels_pending_tasks():
+ async def never():
+ await asyncio.sleep(1000)
+
+ tasks = [asyncio.create_task(never()) for _ in range(3)]
+ await asyncio.sleep(0.01) # ensure tasks are scheduled
+ result = await cancel_and_wait(tasks, timeout=1.0)
+ for t in tasks:
+ assert t.cancelled()
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_returns_stragglers_on_timeout():
+ """A task that ignores cancellation should appear in the stragglers list."""
+
+ async def stubborn():
+ try:
+ await asyncio.sleep(1000)
+ except asyncio.CancelledError:
+ # Swallow cancellation: refuse to exit
+ await asyncio.sleep(1000)
+
+ task = asyncio.create_task(stubborn())
+ await asyncio.sleep(0.01)
+ stragglers = await cancel_and_wait([task], timeout=0.1)
+ assert len(stragglers) == 1
+ assert stragglers[0] is task
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_logs_stragglers(caplog):
+ async def stubborn():
+ try:
+ await asyncio.sleep(1000)
+ except asyncio.CancelledError:
+ await asyncio.sleep(1000)
+
+ task = asyncio.create_task(stubborn(), name="stubborn-worker")
+ await asyncio.sleep(0.01)
+ with caplog.at_level(logging.WARNING):
+ stragglers = await cancel_and_wait([task], timeout=0.1)
+
+ assert stragglers == [task]
+ assert any("stubborn-worker" in r.message for r in caplog.records)
+ assert any("did not exit" in r.message for r in caplog.records)
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_mixed_done_and_pending():
+ done_task = asyncio.create_task(asyncio.sleep(0))
+ await done_task
+
+ async def never():
+ await asyncio.sleep(1000)
+
+ pending_task = asyncio.create_task(never())
+ await asyncio.sleep(0.01)
+
+ result = await cancel_and_wait([done_task, pending_task], timeout=1.0)
+ assert pending_task.cancelled()
+ assert result == []
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_custom_timeout():
+ async def stubborn():
+ try:
+ await asyncio.sleep(1000)
+ except asyncio.CancelledError:
+ await asyncio.sleep(1000)
+
+ task = asyncio.create_task(stubborn())
+ await asyncio.sleep(0.01)
+
+ stragglers = await cancel_and_wait([task], timeout=0.05)
+ assert len(stragglers) == 1
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_no_stragglers_log_at_info(caplog):
+ async def quick():
+ await asyncio.sleep(1000)
+
+ task = asyncio.create_task(quick())
+ await asyncio.sleep(0.01)
+ with caplog.at_level(logging.DEBUG):
+ result = await cancel_and_wait([task], timeout=1.0)
+
+ assert result == []
+ warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
+ assert not any("did not exit" in r.message for r in warning_records)
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_multiple_stragglers():
+ async def stubborn():
+ try:
+ await asyncio.sleep(1000)
+ except asyncio.CancelledError:
+ await asyncio.sleep(1000)
+
+ tasks = [asyncio.create_task(stubborn(), name=f"worker-{i}") for i in range(3)]
+ await asyncio.sleep(0.01)
+
+ stragglers = await cancel_and_wait(tasks, timeout=0.1)
+ assert len(stragglers) == 3
+ names = {t.get_name() for t in stragglers}
+ assert names == {"worker-0", "worker-1", "worker-2"}
+
+
+@pytest.mark.asyncio
+async def test_cancel_and_wait_single_done_task_skips_cancel():
+ """A single already-done task: no cancelled calls, returns fast."""
+ task = asyncio.create_task(asyncio.sleep(0))
+ await task
+ # If cancel is called on a done task, it's a no-op internally, but we
+ # verify cancel_and_wait itself doesn't try to cancel done tasks.
+ result = await cancel_and_wait([task])
+ assert result == []
diff --git a/tests/test_threads.py b/tests/test_threads.py
new file mode 100644
index 000000000..ee534d119
--- /dev/null
+++ b/tests/test_threads.py
@@ -0,0 +1,504 @@
+"""Unit tests for tinyagentos/chat/threads.py."""
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from tinyagentos.chat.threads import resolve_thread_recipients
+
+
+def _ch(members, muted=None, channel_id="c1"):
+ return {
+ "id": channel_id,
+ "type": "group",
+ "members": members,
+ "settings": {"muted": muted or []},
+ }
+
+
+def _cm(parent=None, prior=None):
+ cm = MagicMock()
+ cm.get_message = AsyncMock(return_value=parent)
+ cm.get_thread_messages = AsyncMock(return_value=prior or [])
+ return cm
+
+
+def _msg(author_id, content, thread_id="t1", author_type="user"):
+ return {
+ "author_id": author_id,
+ "author_type": author_type,
+ "content": content,
+ "thread_id": thread_id,
+ }
+
+
+@pytest.mark.asyncio
+async def test_no_thread_id_returns_empty():
+ cm = _cm()
+ msg = {"author_id": "user", "author_type": "user", "content": "hi"}
+ recipients, forced = await resolve_thread_recipients(msg, _ch(["user", "tom"]), cm)
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_none_thread_id_returns_empty():
+ cm = _cm()
+ msg = {"author_id": "user", "author_type": "user", "content": "hi", "thread_id": None}
+ recipients, forced = await resolve_thread_recipients(msg, _ch(["user", "tom"]), cm)
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_empty_thread_id_returns_empty():
+ cm = _cm()
+ msg = {"author_id": "user", "author_type": "user", "content": "hi", "thread_id": ""}
+ recipients, forced = await resolve_thread_recipients(msg, _ch(["user", "tom"]), cm)
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_parent_agent_author_added():
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hello?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert "tom" in recipients
+ assert "don" not in recipients
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_parent_user_author_not_added():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "hello?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_parent_none_not_added():
+ cm = _cm(parent=None)
+ msg = _msg("user", "hello?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_parent_with_none_author_id_not_added():
+ cm = _cm(parent={"id": "p1", "author_id": None, "author_type": "agent"})
+ msg = _msg("user", "hello?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == []
+
+
+@pytest.mark.asyncio
+async def test_parent_same_as_current_author_not_added():
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("tom", "follow-up", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_parent_muted_not_added():
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hello?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"], muted=["tom"]), cm
+ )
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_prior_repliers_added():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[
+ {"author_id": "don", "author_type": "agent"},
+ {"author_id": "linus", "author_type": "agent"},
+ ],
+ )
+ msg = _msg("user", "more?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don", "linus"]), cm
+ )
+ assert sorted(recipients) == ["don", "linus"]
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_prior_replier_same_as_author_excluded():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[{"author_id": "tom", "author_type": "agent"}],
+ )
+ msg = _msg("tom", "self-reply", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_prior_replier_muted_excluded():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[{"author_id": "tom", "author_type": "agent"}],
+ )
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"], muted=["tom"]), cm
+ )
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_prior_user_replier_not_added():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[{"author_id": "user", "author_type": "user"}],
+ )
+ msg = _msg("user", "hi again")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+
+
+@pytest.mark.asyncio
+async def test_prior_replier_with_none_author_id_skipped():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[{"author_id": None, "author_type": "agent"}],
+ )
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+
+
+@pytest.mark.asyncio
+async def test_explicit_mention_forces_respond():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@tom please review")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == ["tom"]
+ assert forced == {"tom": True}
+
+
+@pytest.mark.asyncio
+async def test_mention_not_in_members_ignored():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@unknown hello")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_mention_of_muted_agent_ignored():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@tom hello")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"], muted=["tom"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_mention_of_author_excluded():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("tom", "@tom to myself", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_at_all_escalates_to_all_candidates():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@all weigh in")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don", "linus"]), cm
+ )
+ assert sorted(recipients) == ["don", "linus", "tom"]
+ assert forced == {"tom": True, "don": True, "linus": True}
+
+
+@pytest.mark.asyncio
+async def test_at_all_excludes_muted():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@all weigh in")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don", "linus"], muted=["don"]), cm
+ )
+ assert sorted(recipients) == ["linus", "tom"]
+ assert "don" not in forced
+
+
+@pytest.mark.asyncio
+async def test_at_all_excludes_author():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("tom", "@all respond", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert sorted(recipients) == ["don"]
+ assert "tom" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_at_all_excludes_user_member():
+ """'user' is never a candidate even with @all."""
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("don", "@all respond", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert sorted(recipients) == ["tom"]
+ assert "user" not in recipients
+
+
+@pytest.mark.asyncio
+async def test_at_all_empty_channel():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@all anyone?")
+ recipients, forced = await resolve_thread_recipients(msg, _ch(["user"]), cm)
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_combined_parent_prior_and_mentions():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "tom", "author_type": "agent"},
+ prior=[{"author_id": "don", "author_type": "agent"}],
+ )
+ msg = _msg("user", "@linus thoughts?")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don", "linus"]), cm
+ )
+ assert sorted(recipients) == ["don", "linus", "tom"]
+ assert forced == {"linus": True}
+
+
+@pytest.mark.asyncio
+async def test_mention_forces_even_if_already_recipient():
+ """Mentioned agent already a prior replier: still forced."""
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[{"author_id": "tom", "author_type": "agent"}],
+ )
+ msg = _msg("user", "@tom again")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == ["tom"]
+ assert forced == {"tom": True}
+
+
+@pytest.mark.asyncio
+async def test_no_content_no_mentions():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_none_content_no_mentions():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", None)
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_recipients_are_sorted():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "zebra", "author_type": "agent"},
+ prior=[
+ {"author_id": "alpha", "author_type": "agent"},
+ {"author_id": "mango", "author_type": "agent"},
+ ],
+ )
+ msg = _msg("user", "hi all")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "zebra", "alpha", "mango"]), cm
+ )
+ assert recipients == sorted(recipients)
+ assert recipients == ["alpha", "mango", "zebra"]
+
+
+@pytest.mark.asyncio
+async def test_empty_members():
+ """Parent-author check does not filter by members list, so tom is still added."""
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(msg, _ch([]), cm)
+ assert recipients == ["tom"]
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_none_members():
+ """Parent-author check does not filter by members list, so tom is still added."""
+ channel = {"id": "c1", "type": "group", "members": None, "settings": {"muted": []}}
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(msg, channel, cm)
+ assert recipients == ["tom"]
+
+
+@pytest.mark.asyncio
+async def test_empty_settings():
+ channel = {"id": "c1", "type": "group", "members": ["user", "tom"], "settings": {}}
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(msg, channel, cm)
+ assert "tom" in recipients
+
+
+@pytest.mark.asyncio
+async def test_none_settings():
+ channel = {"id": "c1", "type": "group", "members": ["user", "tom"], "settings": None}
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(msg, channel, cm)
+ assert "tom" in recipients
+
+
+@pytest.mark.asyncio
+async def test_none_muted():
+ channel = {
+ "id": "c1",
+ "type": "group",
+ "members": ["user", "tom"],
+ "settings": {"muted": None},
+ }
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(msg, channel, cm)
+ assert "tom" in recipients
+
+
+@pytest.mark.asyncio
+async def test_empty_member_slugs_filtered():
+ """Empty string members are filtered out."""
+ cm = _cm(parent={"id": "p1", "author_id": "tom", "author_type": "agent"})
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "", "tom"]), cm
+ )
+ assert recipients == ["tom"]
+
+
+@pytest.mark.asyncio
+async def test_get_thread_messages_called_with_correct_args():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "hi", thread_id="p1")
+ channel = _ch(["user", "tom"], channel_id="my-channel")
+ await resolve_thread_recipients(msg, channel, cm)
+ cm.get_thread_messages.assert_called_once_with(
+ channel_id="my-channel", parent_id="p1", limit=200
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_message_called_with_thread_id():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "hi", thread_id="parent-msg-id")
+ await resolve_thread_recipients(msg, _ch(["user", "tom"]), cm)
+ cm.get_message.assert_called_once_with("parent-msg-id")
+
+
+@pytest.mark.asyncio
+async def test_duplicate_prior_repliers_deduplicated():
+ cm = _cm(
+ parent={"id": "p1", "author_id": "user", "author_type": "user"},
+ prior=[
+ {"author_id": "tom", "author_type": "agent"},
+ {"author_id": "tom", "author_type": "agent"},
+ {"author_id": "tom", "author_type": "agent"},
+ ],
+ )
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == ["tom"]
+
+
+@pytest.mark.asyncio
+async def test_parent_and_prior_same_agent():
+ """Same agent is parent and prior replier: appears once."""
+ cm = _cm(
+ parent={"id": "p1", "author_id": "tom", "author_type": "agent"},
+ prior=[{"author_id": "tom", "author_type": "agent"}],
+ )
+ msg = _msg("user", "hi")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == ["tom"]
+
+
+@pytest.mark.asyncio
+async def test_at_all_with_no_other_agents():
+ """@all in a channel with only the author agent."""
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("tom", "@all anyone?", author_type="agent")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom"]), cm
+ )
+ assert recipients == []
+ assert forced == {}
+
+
+@pytest.mark.asyncio
+async def test_multiple_mentions():
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@tom @don please")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don", "linus"]), cm
+ )
+ assert sorted(recipients) == ["don", "tom"]
+ assert forced == {"tom": True, "don": True}
+
+
+@pytest.mark.asyncio
+async def test_mention_case_insensitive():
+ """Mentions are case-insensitive (parse_mentions lowercases)."""
+ cm = _cm(parent={"id": "p1", "author_id": "user", "author_type": "user"})
+ msg = _msg("user", "@Tom hello")
+ recipients, forced = await resolve_thread_recipients(
+ msg, _ch(["user", "tom", "don"]), cm
+ )
+ assert recipients == ["tom"]
+ assert forced == {"tom": True}
diff --git a/tests/test_userspace_seed.py b/tests/test_userspace_seed.py
new file mode 100644
index 000000000..d88c7c95d
--- /dev/null
+++ b/tests/test_userspace_seed.py
@@ -0,0 +1,147 @@
+"""Unit tests for userspace boot-seeding of first-party bundled apps."""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from tinyagentos.userspace.seed import _DEFAULT_SEED_DIR, seed_bundled_apps
+from tinyagentos.userspace.store import UserspaceAppStore
+
+
+def _write_seed_app(seed_dir: Path, app_id: str, version: str = "1.0.0") -> Path:
+ app_dir = seed_dir / app_id
+ app_dir.mkdir(parents=True, exist_ok=True)
+ (app_dir / "manifest.yaml").write_text(
+ f"id: {app_id}\nname: Test App\nversion: {version}\n"
+ "app_type: web\nentry: index.html\nicon: \"\"\npermissions:\n - app.kv\n"
+ )
+ (app_dir / "index.html").write_text("hello ")
+ return app_dir
+
+
+async def _store(tmp_path) -> UserspaceAppStore:
+ s = UserspaceAppStore(tmp_path / "userspace_apps.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_seed_installs_app_as_first_party(tmp_path):
+ seed_dir = tmp_path / "seed"
+ apps_root = tmp_path / "apps"
+ _write_seed_app(seed_dir, "my-app")
+ store = await _store(tmp_path)
+ try:
+ await seed_bundled_apps(store, apps_root, seed_dir)
+
+ row = await store.get("my-app")
+ assert row is not None
+ assert row["trust"] == "first-party"
+ assert row["version"] == "1.0.0"
+ assert row["app_type"] == "web"
+ assert "app.kv" in row["permissions_requested"]
+ assert (apps_root / "my-app" / "index.html").exists()
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_seed_is_idempotent_for_same_version(tmp_path):
+ seed_dir = tmp_path / "seed"
+ apps_root = tmp_path / "apps"
+ _write_seed_app(seed_dir, "my-app", version="1.0.0")
+ store = await _store(tmp_path)
+ try:
+ await seed_bundled_apps(store, apps_root, seed_dir)
+ first = await store.get("my-app")
+ installed_at = first["installed_at"]
+
+ await seed_bundled_apps(store, apps_root, seed_dir)
+ second = await store.get("my-app")
+
+ assert second["trust"] == "first-party"
+ assert second["version"] == "1.0.0"
+ assert second["installed_at"] == installed_at
+ assert len([a for a in await store.list_installed() if a["app_id"] == "my-app"]) == 1
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_seed_reseeds_on_version_bump(tmp_path):
+ seed_dir = tmp_path / "seed"
+ apps_root = tmp_path / "apps"
+ _write_seed_app(seed_dir, "my-app", version="1.0.0")
+ store = await _store(tmp_path)
+ try:
+ await seed_bundled_apps(store, apps_root, seed_dir)
+ assert (await store.get("my-app"))["version"] == "1.0.0"
+
+ (seed_dir / "my-app" / "manifest.yaml").write_text(
+ "id: my-app\nname: Test App\nversion: 2.0.0\n"
+ "app_type: web\nentry: index.html\nicon: \"\"\npermissions:\n - app.kv\n"
+ )
+ await seed_bundled_apps(store, apps_root, seed_dir)
+
+ row = await store.get("my-app")
+ assert row["version"] == "2.0.0"
+ assert row["trust"] == "first-party"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_seed_reseeds_non_first_party_same_version(tmp_path):
+ seed_dir = tmp_path / "seed"
+ apps_root = tmp_path / "apps"
+ _write_seed_app(seed_dir, "my-app", version="1.0.0")
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="my-app",
+ name="Impostor",
+ version="1.0.0",
+ app_type="web",
+ entry="index.html",
+ icon="",
+ permissions_requested=[],
+ trust="community",
+ )
+
+ await seed_bundled_apps(store, apps_root, seed_dir)
+
+ row = await store.get("my-app")
+ assert row["trust"] == "first-party"
+ assert row["version"] == "1.0.0"
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_seed_missing_seed_dir_is_noop(tmp_path):
+ apps_root = tmp_path / "apps"
+ store = await _store(tmp_path)
+ try:
+ await seed_bundled_apps(store, apps_root, tmp_path / "missing-seed")
+ assert await store.list_installed() == []
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_real_bundled_welcome_app_seeds(tmp_path):
+ apps_root = tmp_path / "apps"
+ store = await _store(tmp_path)
+ try:
+ await seed_bundled_apps(store, apps_root, _DEFAULT_SEED_DIR)
+
+ row = await store.get("taos-welcome")
+ assert row is not None
+ assert row["trust"] == "first-party"
+ assert row["version"] == "1.0.0"
+ assert "app.kv" in row["permissions_requested"]
+ assert "app.notify" in row["permissions_requested"]
+ assert (apps_root / "taos-welcome" / "index.html").exists()
+ finally:
+ await store.close()
\ No newline at end of file
diff --git a/tests/test_userspace_store.py b/tests/test_userspace_store.py
new file mode 100644
index 000000000..0291c384d
--- /dev/null
+++ b/tests/test_userspace_store.py
@@ -0,0 +1,188 @@
+"""Unit tests for UserspaceAppStore CRUD and permission helpers."""
+from __future__ import annotations
+
+import pytest
+
+from tinyagentos.userspace.store import UserspaceAppStore
+
+
+async def _store(tmp_path) -> UserspaceAppStore:
+ s = UserspaceAppStore(tmp_path / "userspace_apps.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_get_returns_none_for_missing_app(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ assert await store.get("missing") is None
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_install_get_list_and_uninstall(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="notes",
+ name="Notes",
+ version="2.0.0",
+ app_type="web",
+ entry="app.html",
+ icon="notes.png",
+ permissions_requested=["app.kv", "app.files"],
+ trust="first-party",
+ )
+ row = await store.get("notes")
+ assert row is not None
+ assert row["app_id"] == "notes"
+ assert row["name"] == "Notes"
+ assert row["version"] == "2.0.0"
+ assert row["app_type"] == "web"
+ assert row["entry"] == "app.html"
+ assert row["icon"] == "notes.png"
+ assert row["permissions_requested"] == ["app.kv", "app.files"]
+ assert row["permissions_granted"] == []
+ assert row["enabled"] == 1
+ assert row["trust"] == "first-party"
+ assert isinstance(row["installed_at"], int)
+
+ listed = await store.list_installed()
+ assert len(listed) == 1
+ assert listed[0]["app_id"] == "notes"
+
+ assert await store.uninstall("notes") is True
+ assert await store.get("notes") is None
+ assert await store.list_installed() == []
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_uninstall_missing_returns_false(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ assert await store.uninstall("ghost") is False
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_install_upsert_updates_metadata(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="calc",
+ name="Calc",
+ version="1.0.0",
+ app_type="web",
+ entry="index.html",
+ icon="calc.png",
+ permissions_requested=["app.net"],
+ )
+ await store.set_permissions_granted("calc", ["app.net"])
+ await store.set_enabled("calc", False)
+
+ await store.install(
+ app_id="calc",
+ name="Calculator",
+ version="1.1.0",
+ app_type="web",
+ entry="main.html",
+ icon="calc2.png",
+ permissions_requested=["app.kv"],
+ trust="community",
+ )
+ row = await store.get("calc")
+ assert row["name"] == "Calculator"
+ assert row["version"] == "1.1.0"
+ assert row["entry"] == "main.html"
+ assert row["icon"] == "calc2.png"
+ assert row["permissions_requested"] == ["app.kv"]
+ assert row["trust"] == "community"
+ # upsert does not reset granted perms or enabled flag
+ assert row["permissions_granted"] == ["app.net"]
+ assert row["enabled"] == 0
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_set_permissions_granted_and_enabled_toggle(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="mail",
+ name="Mail",
+ version="1",
+ app_type="container",
+ entry="index.html",
+ icon="",
+ permissions_requested=["app.net", "app.memory"],
+ )
+ await store.set_permissions_granted("mail", ["app.net"])
+ await store.set_enabled("mail", False)
+ disabled = await store.get("mail")
+ assert disabled["permissions_granted"] == ["app.net"]
+ assert disabled["enabled"] == 0
+
+ await store.set_enabled("mail", True)
+ enabled = await store.get("mail")
+ assert enabled["enabled"] == 1
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_list_installed_orders_by_installed_at(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="first",
+ name="First",
+ version="1",
+ app_type="web",
+ entry="index.html",
+ icon="",
+ permissions_requested=[],
+ )
+ await store.install(
+ app_id="second",
+ name="Second",
+ version="1",
+ app_type="web",
+ entry="index.html",
+ icon="",
+ permissions_requested=[],
+ )
+ ids = [row["app_id"] for row in await store.list_installed()]
+ assert ids == ["first", "second"]
+ assert (
+ (await store.get("first"))["installed_at"]
+ <= (await store.get("second"))["installed_at"]
+ )
+ finally:
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_set_runtime_location(tmp_path):
+ store = await _store(tmp_path)
+ try:
+ await store.install(
+ app_id="svc",
+ name="Svc",
+ version="1",
+ app_type="container",
+ entry="index.html",
+ icon="",
+ permissions_requested=[],
+ )
+ await store.set_runtime_location("svc", "10.0.0.5", 8080)
+ row = await store.get("svc")
+ assert row["container_host"] == "10.0.0.5"
+ assert row["container_port"] == 8080
+ finally:
+ await store.close()
\ No newline at end of file
diff --git a/tests/test_webhook_notifier.py b/tests/test_webhook_notifier.py
new file mode 100644
index 000000000..012811a60
--- /dev/null
+++ b/tests/test_webhook_notifier.py
@@ -0,0 +1,259 @@
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from tinyagentos.webhook_notifier import WebhookNotifier
+
+
+def _make_config(*wh_types):
+ webhooks = []
+ for i, t in enumerate(wh_types):
+ wh = {"url": f"https://example.com/hook/{i}", "type": t}
+ if t == "telegram":
+ wh["bot_token"] = "123456:ABC"
+ wh["chat_id"] = "-100123"
+ webhooks.append(wh)
+ return {"webhooks": webhooks}
+
+
+# --- notify: no webhooks configured ---
+
+@pytest.mark.asyncio
+async def test_notify_no_webhooks_returns_immediately():
+ notifier = WebhookNotifier({"webhooks": []})
+ # Should not raise, should return without doing anything
+ await notifier.notify("title", "msg")
+
+
+@pytest.mark.asyncio
+async def test_notify_missing_webhooks_key():
+ notifier = WebhookNotifier({})
+ await notifier.notify("title", "msg")
+
+
+# --- notify: generic webhook ---
+
+@pytest.mark.asyncio
+async def test_notify_generic_posts_correct_payload():
+ config = _make_config("generic")
+ notifier = WebhookNotifier(config)
+ wh = config["webhooks"][0]
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("Hello", "World", "info")
+
+ mock_client.post.assert_called_once_with(
+ wh["url"],
+ json={"title": "Hello", "message": "World", "level": "info"},
+ )
+
+
+# --- notify: slack webhook ---
+
+@pytest.mark.asyncio
+async def test_notify_slack_posts_correct_payload():
+ config = _make_config("slack")
+ notifier = WebhookNotifier(config)
+ wh = config["webhooks"][0]
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("Alert", "Something happened", "warning")
+
+ mock_client.post.assert_called_once_with(
+ wh["url"],
+ json={"text": "*Alert*\nSomething happened"},
+ )
+
+
+# --- notify: discord webhook ---
+
+@pytest.mark.asyncio
+async def test_notify_discord_info_color():
+ config = _make_config("discord")
+ notifier = WebhookNotifier(config)
+ wh = config["webhooks"][0]
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "info")
+
+ call_kwargs = mock_client.post.call_args
+ assert call_kwargs[0][0] == wh["url"]
+ body = call_kwargs[1]["json"]
+ assert body["embeds"][0]["color"] == 3066993
+
+
+@pytest.mark.asyncio
+async def test_notify_discord_warning_color():
+ config = _make_config("discord")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "warning")
+
+ body = mock_client.post.call_args[1]["json"]
+ assert body["embeds"][0]["color"] == 16776960
+
+
+@pytest.mark.asyncio
+async def test_notify_discord_error_color():
+ config = _make_config("discord")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "error")
+
+ body = mock_client.post.call_args[1]["json"]
+ assert body["embeds"][0]["color"] == 15158332
+
+
+@pytest.mark.asyncio
+async def test_notify_discord_unknown_level_defaults_to_info_color():
+ config = _make_config("discord")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "critical")
+
+ body = mock_client.post.call_args[1]["json"]
+ assert body["embeds"][0]["color"] == 3066993
+
+
+# --- notify: telegram webhook ---
+
+@pytest.mark.asyncio
+async def test_notify_telegram_posts_to_correct_url():
+ config = _make_config("telegram")
+ notifier = WebhookNotifier(config)
+ wh = config["webhooks"][0]
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "info")
+
+ expected_url = (
+ f"https://api.telegram.org/bot{wh['bot_token']}/sendMessage"
+ )
+ call_kwargs = mock_client.post.call_args
+ assert call_kwargs[0][0] == expected_url
+ body = call_kwargs[1]["json"]
+ assert body["chat_id"] == wh["chat_id"]
+ assert body["text"] == "*T*\nM"
+ assert body["parse_mode"] == "Markdown"
+
+
+# --- notify: multiple webhooks ---
+
+@pytest.mark.asyncio
+async def test_notify_sends_to_all_configured_webhooks():
+ config = _make_config("generic", "slack", "discord")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "info")
+
+ assert mock_client.post.call_count == 3
+
+
+# --- notify: transport error is swallowed ---
+
+@pytest.mark.asyncio
+async def test_notify_swallows_transport_error():
+ config = _make_config("generic")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client.post.side_effect = httpx.TransportError("connection refused")
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ # Must not raise
+ await notifier.notify("T", "M", "info")
+
+
+@pytest.mark.asyncio
+async def test_notify_swallows_error_and_continues_to_next_webhook():
+ config = _make_config("generic", "generic")
+ notifier = WebhookNotifier(config)
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client.post.side_effect = [
+ httpx.TransportError("fail"),
+ None,
+ ]
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier.notify("T", "M", "info")
+
+ assert mock_client.post.call_count == 2
+
+
+# --- _send: default type is generic ---
+
+@pytest.mark.asyncio
+async def test_send_without_type_defaults_to_generic():
+ notifier = WebhookNotifier({"webhooks": []})
+ webhook = {"url": "https://example.com/hook"}
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier._send(webhook, "T", "M", "info")
+
+ mock_client.post.assert_called_once_with(
+ "https://example.com/hook",
+ json={"title": "T", "message": "M", "level": "info"},
+ )
+
+
+# --- _send: httpx.AsyncClient timeout ---
+
+@pytest.mark.asyncio
+async def test_send_creates_client_with_timeout():
+ notifier = WebhookNotifier({"webhooks": []})
+ webhook = {"url": "https://example.com/hook"}
+
+ with patch("tinyagentos.webhook_notifier.httpx.AsyncClient") as mock_client_cls:
+ mock_client = AsyncMock()
+ mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
+
+ await notifier._send(webhook, "T", "M", "info")
+
+ mock_client_cls.assert_called_once_with(timeout=10)
diff --git a/tests/test_worker_auth.py b/tests/test_worker_auth.py
new file mode 100644
index 000000000..6543ddf03
--- /dev/null
+++ b/tests/test_worker_auth.py
@@ -0,0 +1,421 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import time
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from tinyagentos.cluster.worker_auth import _err, _HMACError, require_worker_hmac
+
+
+# ---------------------------------------------------------------------------
+# _err
+# ---------------------------------------------------------------------------
+
+class TestErr:
+ def test_returns_json_response(self):
+ resp = _err("some_code", "some message", 403)
+ assert resp.status_code == 403
+ import json as _json
+ data = _json.loads(resp.body)
+ assert data == {"error": "some message", "code": "some_code"}
+
+ def test_status_401(self):
+ resp = _err("worker_not_paired", "not paired", 401)
+ assert resp.status_code == 401
+
+ def test_empty_code_and_message(self):
+ resp = _err("", "", 500)
+ assert resp.status_code == 500
+ import json as _json
+ data = _json.loads(resp.body)
+ assert data == {"error": "", "code": ""}
+
+
+# ---------------------------------------------------------------------------
+# _HMACError
+# ---------------------------------------------------------------------------
+
+class TestHMACError:
+ def test_wraps_response(self):
+ resp = _err("c", "m", 401)
+ err = _HMACError(resp)
+ assert err.response is resp
+
+ def test_is_exception(self):
+ resp = _err("c", "m", 401)
+ err = _HMACError(resp)
+ assert isinstance(err, Exception)
+
+ def test_response_body_is_valid_json(self):
+ resp = _err("c", "m", 401)
+ import json as _json
+ data = _json.loads(resp.body)
+ assert "error" in data
+ assert "code" in data
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- missing headers
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacMissingHeaders:
+ @pytest.fixture
+ def make_request(self):
+ def _make(**headers):
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": headers.get(key, default)
+ return req
+ return _make
+
+ @pytest.mark.asyncio
+ async def test_missing_worker_name_raises(self, make_request):
+ req = make_request(
+ **{"x-taos-timestamp": "123", "x-taos-signature": "abc"}
+ )
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"worker_not_paired" in exc_info.value.response.body
+
+ @pytest.mark.asyncio
+ async def test_missing_timestamp_raises(self, make_request):
+ req = make_request(
+ **{"x-taos-worker-name": "w1", "x-taos-signature": "abc"}
+ )
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_missing_signature_raises(self, make_request):
+ req = make_request(
+ **{"x-taos-worker-name": "w1", "x-taos-timestamp": "123"}
+ )
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_all_headers_missing_raises(self, make_request):
+ req = make_request()
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_empty_worker_name_raises(self, make_request):
+ req = make_request(
+ **{"x-taos-worker-name": "", "x-taos-timestamp": "123", "x-taos-signature": "abc"}
+ )
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_whitespace_only_worker_name_raises(self, make_request):
+ req = make_request(
+ **{"x-taos-worker-name": " ", "x-taos-timestamp": "123", "x-taos-signature": "abc"}
+ )
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- invalid timestamp
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacInvalidTimestamp:
+ @pytest.mark.asyncio
+ async def test_non_numeric_timestamp_raises(self):
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": "not-a-number",
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"stale_timestamp" in exc_info.value.response.body
+
+ @pytest.mark.asyncio
+ async def test_empty_timestamp_raises(self):
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": "",
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_stale_timestamp_raises(self):
+ old_ts = str(int(time.time()) - 600)
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": old_ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"stale_timestamp" in exc_info.value.response.body
+
+ @pytest.mark.asyncio
+ async def test_future_timestamp_within_window_passes(self):
+ future_ts = str(int(time.time()) + 60)
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": future_ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ req.app.state.cluster_pairing = None
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"worker_not_paired" in exc_info.value.response.body
+
+ @pytest.mark.asyncio
+ async def test_timestamp_exactly_at_boundary_raises(self):
+ boundary_ts = str(int(time.time()) - 301)
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": boundary_ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"stale_timestamp" in exc_info.value.response.body
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- no pairing store
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacNoPairingStore:
+ @pytest.mark.asyncio
+ async def test_no_cluster_pairing_attr_raises(self):
+ ts = str(int(time.time()))
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ req.app.state = MagicMock(spec=[])
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"worker_not_paired" in exc_info.value.response.body
+
+ @pytest.mark.asyncio
+ async def test_cluster_pairing_is_none_raises(self):
+ ts = str(int(time.time()))
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ req.app.state.cluster_pairing = None
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"worker_not_paired" in exc_info.value.response.body
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- unknown worker
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacUnknownWorker:
+ @pytest.mark.asyncio
+ async def test_unknown_worker_raises(self):
+ ts = str(int(time.time()))
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "unknown-worker",
+ "x-taos-timestamp": ts,
+ "x-taos-signature": "abc",
+ }.get(key, default)
+ store = AsyncMock()
+ store.get_signing_key.return_value = None
+ req.app.state.cluster_pairing = store
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"worker_not_paired" in exc_info.value.response.body
+ store.get_signing_key.assert_awaited_once_with("unknown-worker")
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- bad signature
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacBadSignature:
+ @pytest.mark.asyncio
+ async def test_wrong_signature_raises(self):
+ ts = str(int(time.time()))
+ signing_key = b"\x01" * 32
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": "w1",
+ "x-taos-timestamp": ts,
+ "x-taos-signature": "deadbeef",
+ }.get(key, default)
+ req.method = "POST"
+ req.url.path = "/api/cluster/workers"
+ req.body = AsyncMock(return_value=b'{"name":"w1"}')
+ store = AsyncMock()
+ store.get_signing_key.return_value = signing_key
+ req.app.state.cluster_pairing = store
+ with pytest.raises(_HMACError) as exc_info:
+ await require_worker_hmac(req)
+ assert exc_info.value.response.status_code == 401
+ assert b"bad_signature" in exc_info.value.response.body
+
+
+# ---------------------------------------------------------------------------
+# require_worker_hmac -- happy path
+# ---------------------------------------------------------------------------
+
+class TestRequireWorkerHmacHappyPath:
+ @pytest.mark.asyncio
+ async def test_valid_request_sets_hmac_worker_name(self):
+ signing_key = b"\x42" * 32
+ worker_name = "my-worker"
+ method = "POST"
+ path = "/api/cluster/workers"
+ raw_body = b'{"name":"my-worker"}'
+ ts = str(int(time.time()))
+ body_hash = hashlib.sha256(raw_body).hexdigest()
+ message = f"{ts}.{method}.{path}.{body_hash}".encode()
+ sig = hmac.new(signing_key, message, hashlib.sha256).hexdigest()
+
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": worker_name,
+ "x-taos-timestamp": ts,
+ "x-taos-signature": sig,
+ }.get(key, default)
+ req.method = method
+ req.url.path = path
+ req.body = AsyncMock(return_value=raw_body)
+ store = AsyncMock()
+ store.get_signing_key.return_value = signing_key
+ req.app.state.cluster_pairing = store
+
+ result = await require_worker_hmac(req)
+
+ assert result is None
+ assert req.state.hmac_worker_name == worker_name
+ store.get_signing_key.assert_awaited_once_with(worker_name)
+
+ @pytest.mark.asyncio
+ async def test_get_method_works(self):
+ signing_key = b"\x01" * 32
+ worker_name = "w-get"
+ method = "GET"
+ path = "/api/cluster/workers/status"
+ raw_body = b""
+ ts = str(int(time.time()))
+ body_hash = hashlib.sha256(raw_body).hexdigest()
+ message = f"{ts}.{method}.{path}.{body_hash}".encode()
+ sig = hmac.new(signing_key, message, hashlib.sha256).hexdigest()
+
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": worker_name,
+ "x-taos-timestamp": ts,
+ "x-taos-signature": sig,
+ }.get(key, default)
+ req.method = method
+ req.url.path = path
+ req.body = AsyncMock(return_value=raw_body)
+ store = AsyncMock()
+ store.get_signing_key.return_value = signing_key
+ req.app.state.cluster_pairing = store
+
+ result = await require_worker_hmac(req)
+ assert result is None
+ assert req.state.hmac_worker_name == worker_name
+
+ @pytest.mark.asyncio
+ async def test_lowercase_method_normalized(self):
+ signing_key = b"\x02" * 32
+ worker_name = "w-lower"
+ method = "post"
+ path = "/api/test"
+ raw_body = b'{"key":"val"}'
+ ts = str(int(time.time()))
+ body_hash = hashlib.sha256(raw_body).hexdigest()
+ message = f"{ts}.POST.{path}.{body_hash}".encode()
+ sig = hmac.new(signing_key, message, hashlib.sha256).hexdigest()
+
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": worker_name,
+ "x-taos-timestamp": ts,
+ "x-taos-signature": sig,
+ }.get(key, default)
+ req.method = method
+ req.url.path = path
+ req.body = AsyncMock(return_value=raw_body)
+ store = AsyncMock()
+ store.get_signing_key.return_value = signing_key
+ req.app.state.cluster_pairing = store
+
+ result = await require_worker_hmac(req)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_large_body(self):
+ signing_key = b"\x03" * 32
+ worker_name = "w-large"
+ method = "POST"
+ path = "/api/upload"
+ raw_body = b"x" * 100_000
+ ts = str(int(time.time()))
+ body_hash = hashlib.sha256(raw_body).hexdigest()
+ message = f"{ts}.{method}.{path}.{body_hash}".encode()
+ sig = hmac.new(signing_key, message, hashlib.sha256).hexdigest()
+
+ req = MagicMock()
+ req.headers = MagicMock()
+ req.headers.get = lambda key, default="": {
+ "x-taos-worker-name": worker_name,
+ "x-taos-timestamp": ts,
+ "x-taos-signature": sig,
+ }.get(key, default)
+ req.method = method
+ req.url.path = path
+ req.body = AsyncMock(return_value=raw_body)
+ store = AsyncMock()
+ store.get_signing_key.return_value = signing_key
+ req.app.state.cluster_pairing = store
+
+ result = await require_worker_hmac(req)
+ assert result is None
diff --git a/tests/userspace/test_broker.py b/tests/userspace/test_broker.py
index 1e009e862..971f3c20c 100644
--- a/tests/userspace/test_broker.py
+++ b/tests/userspace/test_broker.py
@@ -156,3 +156,82 @@ async def test_files_write_to_jail_root_rejected(tmp_path):
granted=[], data_store=s, app_dir=tmp_path / "todo", services={})
assert out["error"] == "invalid_path"
await s.close()
+
+
+@pytest.mark.asyncio
+async def test_files_escape_and_absolute_paths_rejected(tmp_path):
+ s = await _store(tmp_path)
+ app_dir = tmp_path / "todo"
+ (app_dir / "files").mkdir(parents=True)
+ for cap in ("app.files.read", "app.files.write"):
+ out = await handle_capability("todo", cap, {"path": "../escape.txt", "content": "x"},
+ granted=[], data_store=s, app_dir=app_dir, services={})
+ assert out == {"error": "invalid_path"}
+ out = await handle_capability("todo", cap, {"path": "/etc/passwd", "content": "x"},
+ granted=[], data_store=s, app_dir=app_dir, services={})
+ assert out == {"error": "invalid_path"}
+ wr = await handle_capability("todo", "app.files.write", {"path": "safe.txt", "content": "ok"},
+ granted=[], data_store=s, app_dir=app_dir, services={})
+ assert wr == {"result": True}
+ rd = await handle_capability("todo", "app.files.read", {"path": "safe.txt"},
+ granted=[], data_store=s, app_dir=app_dir, services={})
+ assert rd == {"result": "ok"}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_files_write_to_existing_directory_rejected(tmp_path):
+ s = await _store(tmp_path)
+ app_dir = tmp_path / "todo"
+ subdir = app_dir / "files" / "nested"
+ subdir.mkdir(parents=True)
+ out = await handle_capability("todo", "app.files.write", {"path": "nested", "content": "x"},
+ granted=[], data_store=s, app_dir=app_dir, services={})
+ assert out == {"error": "invalid_path"}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_missing_arg_matrix(tmp_path):
+ s = await _store(tmp_path)
+ cases = [
+ ("app.kv.get", {}, "key"),
+ ("app.kv.set", {}, "key"),
+ ("app.kv.delete", {}, "key"),
+ ("app.table.insert", {}, "table"),
+ ("app.table.query", {}, "table"),
+ ("app.table.delete", {}, "table"),
+ ("app.table.delete", {"table": "t"}, "id"),
+ ]
+ for cap, args, arg in cases:
+ out = await handle_capability("todo", cap, args,
+ granted=[], data_store=s, app_dir=tmp_path / "todo", services={})
+ assert out == {"error": "missing_arg", "arg": arg}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_unknown_bogus_capability_rejected(tmp_path):
+ s = await _store(tmp_path)
+ out = await handle_capability("todo", "app.bogus.thing", {},
+ granted=[], data_store=s, app_dir=tmp_path / "todo", services={})
+ assert out == {"error": "unknown_capability", "capability": "app.bogus.thing"}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_app_net_denied_without_grant(tmp_path):
+ s = await _store(tmp_path)
+ out = await handle_capability("todo", "app.net", {"path": "/ping"},
+ granted=[], data_store=s, app_dir=tmp_path / "todo", services={})
+ assert out == {"error": "permission_denied", "capability": "app.net"}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_app_net_no_backend_when_granted(tmp_path):
+ s = await _store(tmp_path)
+ out = await handle_capability("todo", "app.net", {"path": "/ping"},
+ granted=["app.net"], data_store=s, app_dir=tmp_path / "todo", services={})
+ assert out == {"error": "no_backend"}
+ await s.close()
diff --git a/tests/userspace/test_package.py b/tests/userspace/test_package.py
index 7bbab9a93..6e185b8cc 100644
--- a/tests/userspace/test_package.py
+++ b/tests/userspace/test_package.py
@@ -87,3 +87,28 @@ def test_extract_rejects_zip_bomb(tmp_path, monkeypatch):
data = _zip(WEB_MANIFEST, {"index.html": "more than eight bytes "})
with pytest.raises(PackageError, match="uncompressed size too large"):
extract_package(data, apps_root=tmp_path)
+
+
+def test_network_permission_origins_validated():
+ from tinyagentos.userspace.package import parse_manifest, PackageError
+ import pytest
+ base = "id: x\nname: X\nversion: 1.0.0\napp_type: web\n"
+ ok = parse_manifest(base + "permissions:\n - 'network:wss://irc-ws.chat.twitch.tv'\n - 'network:https://*.pusher.com'\n - 'network:https://youtube.googleapis.com:443'\n")
+ assert "network:wss://irc-ws.chat.twitch.tv" in ok["permissions"]
+ for bad in [
+ "network:javascript:alert(1)",
+ "network:wss://h.com; script-src 'unsafe-inline'",
+ "network:wss://h.com/path",
+ "network:ftp://h.com",
+ "network:*",
+ "network:'unsafe-inline'",
+ "network:wss://h.com\n",
+ "network:wss://h.com\n; script-src x",
+ ]:
+ with pytest.raises(PackageError):
+ parse_manifest(base + "permissions: ['" + bad + "']\n")
+ # gitar finding: `$` would match before a trailing newline; \Z must not.
+ from tinyagentos.userspace.package import _NET_ORIGIN_RE
+ assert _NET_ORIGIN_RE.match("wss://irc-ws.chat.twitch.tv")
+ assert not _NET_ORIGIN_RE.match("wss://evil.com\n")
+ assert not _NET_ORIGIN_RE.match("wss://evil.com\n; script-src 'unsafe-inline'")
diff --git a/tests/userspace/test_routes.py b/tests/userspace/test_routes.py
index 31fe93174..32aae0f28 100644
--- a/tests/userspace/test_routes.py
+++ b/tests/userspace/test_routes.py
@@ -134,3 +134,30 @@ async def test_container_install_rejected_with_no_stored_state(client):
# No app row stored.
rows = (await client.get("/api/userspace-apps")).json()
assert all(a["app_id"] != "ctapp" for a in rows)
+
+
+def _net_zip():
+ manifest = ("id: net\nname: Net\nversion: 1.0.0\napp_type: web\nentry: index.html\n"
+ "icon: icon.png\npermissions:\n - 'network:wss://irc-ws.chat.twitch.tv'\n")
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as z:
+ z.writestr("manifest.yaml", manifest)
+ z.writestr("index.html", "net ")
+ z.writestr("icon.png", "x")
+ return buf.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_bundle_csp_reflects_granted_network_permission(client):
+ await client.post("/api/userspace-apps/install",
+ files={"package": ("net.taosapp", _net_zip(), "application/zip")})
+ # Before granting, connect-src stays locked to 'self'.
+ r = await client.get("/api/userspace-apps/net/bundle/index.html")
+ csp = r.headers["content-security-policy"]
+ assert "connect-src 'self';" in csp and "twitch" not in csp
+ # Grant the declared network permission -> the origin appears in connect-src.
+ await client.post("/api/userspace-apps/net/permissions",
+ json={"granted": ["network:wss://irc-ws.chat.twitch.tv"]})
+ r = await client.get("/api/userspace-apps/net/bundle/index.html")
+ csp = r.headers["content-security-policy"]
+ assert "connect-src 'self' wss://irc-ws.chat.twitch.tv;" in csp
diff --git a/tests/userspace/test_url_guard.py b/tests/userspace/test_url_guard.py
index 417bdf698..1681d5b09 100644
--- a/tests/userspace/test_url_guard.py
+++ b/tests/userspace/test_url_guard.py
@@ -61,3 +61,38 @@ def test_resolve_rejects_mixed_public_and_private():
# if ANY resolved address is non-public, reject the whole host
with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34") + _gai("10.0.0.1")):
assert resolve_safe_public_ip("https://example.com/x") is None
+
+
+# Regression coverage for DNS-rebind and alternate-IP-encoding SSRF vectors
+# (userinfo host extraction, IPv6 literals, ULA/link-local resolution).
+
+
+def test_resolve_rejects_userinfo_urls_with_blocked_hosts():
+ # urlparse must use the host after @, not credentials in userinfo
+ assert resolve_safe_public_ip("http://user:pass@127.0.0.1/") is None
+ assert resolve_safe_public_ip("http://anything@169.254.169.254/") is None
+
+
+def test_resolve_rejects_ipv6_loopback_ula_and_link_local():
+ assert resolve_safe_public_ip("http://[::1]/") is None
+ assert resolve_safe_public_ip("http://[fc00::1]/") is None
+ assert resolve_safe_public_ip("http://[fe80::1]/") is None
+
+
+def test_resolve_allows_public_ipv6_literal():
+ assert resolve_safe_public_ip("http://[2606:4700:4700::1111]/") == "2606:4700:4700::1111"
+
+
+def _gai6(ip):
+ return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ip, 0, 0, 0))]
+
+
+def test_resolve_rejects_hostname_resolving_to_ipv6_ula():
+ with patch("socket.getaddrinfo", return_value=_gai6("fc00::dead:beef")):
+ assert resolve_safe_public_ip("https://evil.example/x") is None
+
+
+def test_resolve_rejects_public_first_then_private_in_result_set():
+ # ordering must not matter: first public + later private still rejects
+ with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34") + _gai("192.168.0.1")):
+ assert resolve_safe_public_ip("https://example.com/x") is None
diff --git a/tinyagentos/app.py b/tinyagentos/app.py
index 6c2d89806..083afc049 100644
--- a/tinyagentos/app.py
+++ b/tinyagentos/app.py
@@ -50,6 +50,7 @@ async def get_response(self, path, scope):
from tinyagentos.download_manager import DownloadManager
from tinyagentos.metrics import MetricsStore
from tinyagentos.notifications import NotificationStore
+from tinyagentos.coding_workspaces import CodingWorkspaceStore
from tinyagentos.qmd_client import QmdClient
from tinyagentos.backend_adapters import check_backend_health
from tinyagentos.benchmark import BenchmarkStore
@@ -84,10 +85,12 @@ async def get_response(self, path, scope):
from tinyagentos.chat.hub import ChatHub
from tinyagentos.chat.canvas import CanvasStore
from tinyagentos.desktop_settings import DesktopSettingsStore
+from tinyagentos.feedback_store import FeedbackStore
from tinyagentos.user_memory import UserMemoryStore
from tinyagentos.user_personas import UserPersonaStore
from tinyagentos.installed_apps import InstalledAppsStore
from tinyagentos.skills import SkillStore
+from tinyagentos.office_docs import OfficeDocStore
from tinyagentos.knowledge_store import KnowledgeStore
from tinyagentos.knowledge_ingest import IngestPipeline
from tinyagentos.knowledge_categories import CategoryEngine
@@ -340,10 +343,16 @@ async def _probe_backend(backend: dict) -> dict:
user_memory = UserMemoryStore(data_dir / "user_memory.db")
user_personas = UserPersonaStore(data_dir / "user_personas.db")
installed_apps = InstalledAppsStore(data_dir / "installed_apps.db")
+ feedback_store = FeedbackStore(data_dir / "feedback.db")
from tinyagentos.userspace.store import UserspaceAppStore
from tinyagentos.userspace.data_store import UserspaceDataStore
userspace_apps = UserspaceAppStore(data_dir / "userspace_apps.db")
userspace_data = UserspaceDataStore(data_dir / "userspace_data.db")
+ office_docs = OfficeDocStore(data_dir / "office_docs.db")
+ coding_workspaces_store = CodingWorkspaceStore(
+ data_dir / "coding_workspaces.db",
+ data_dir / "coding-workspaces",
+ )
skills = SkillStore(data_dir / "skills.db")
from tinyagentos.themes.store import ThemeStore
themes = ThemeStore(data_dir / "themes.sqlite3")
@@ -420,10 +429,16 @@ async def lifespan(app: FastAPI):
await desktop_settings.init()
await user_memory.init()
await installed_apps.init()
+ await feedback_store.init()
+ app.state.feedback_store = feedback_store
await userspace_apps.init()
app.state.userspace_apps = userspace_apps
await userspace_data.init()
app.state.userspace_data = userspace_data
+ await office_docs.init()
+ app.state.office_docs = office_docs
+ await coding_workspaces_store.init()
+ app.state.coding_workspaces = coding_workspaces_store
try:
from tinyagentos.userspace.seed import seed_bundled_apps
await seed_bundled_apps(userspace_apps, data_dir / "apps")
@@ -672,6 +687,8 @@ async def _browser_reap_loop(app: FastAPI) -> None:
app.state.installed_apps = installed_apps
app.state.userspace_apps = userspace_apps
app.state.userspace_data = userspace_data
+ app.state.office_docs = office_docs
+ app.state.coding_workspaces = coding_workspaces_store
app.state.skills = skills
app.state.benchmark_store = benchmark_store
app.state.score_cache = score_cache
@@ -1124,8 +1141,11 @@ async def _reload_llm_proxy_on_catalog_change() -> None:
await knowledge_graph.close()
await archive.close()
await installed_apps.close()
+ await feedback_store.close()
await userspace_apps.close()
await userspace_data.close()
+ await office_docs.close()
+ await coding_workspaces_store.close()
await user_memory.close()
await desktop_settings.close()
await canvas_store.close()
@@ -1298,8 +1318,11 @@ async def dispatch(self, request, call_next):
app.state.user_memory = user_memory
app.state.user_personas = user_personas
app.state.installed_apps = installed_apps
+ app.state.feedback_store = feedback_store
app.state.userspace_apps = userspace_apps
app.state.userspace_data = userspace_data
+ app.state.office_docs = office_docs
+ app.state.coding_workspaces = coding_workspaces_store
app.state.skills = skills
app.state.themes = themes
app.state.knowledge_store = knowledge_store
diff --git a/tinyagentos/auth_middleware.py b/tinyagentos/auth_middleware.py
index 2d9ec4969..d90760c78 100644
--- a/tinyagentos/auth_middleware.py
+++ b/tinyagentos/auth_middleware.py
@@ -7,7 +7,7 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import RedirectResponse
-EXEMPT_PATHS = {"/auth/login", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/api/agents/registry/pubkey"}
+EXEMPT_PATHS = {"/auth/login", "/auth/setup", "/auth/status", "/auth/me", "/auth/complete", "/auth/lock", "/api/health", "/api/version", "/setup", "/setup/complete", "/redeem", "/api/desktop/browser/push/vapid-public-key", "/api/desktop/browser/proxy-config", "/sw.js", "/desktop", "/desktop/index.html", "/chat-pwa", "/app.html", "/manifest", "/api/agents/registry/pubkey"}
# Registry feed endpoints accept EITHER an admin session OR a registry JWT.
# When a Bearer token is present for these paths the request bypasses the
diff --git a/tinyagentos/auto_update.py b/tinyagentos/auto_update.py
index f97b925c8..5c84033a8 100644
--- a/tinyagentos/auto_update.py
+++ b/tinyagentos/auto_update.py
@@ -200,7 +200,11 @@ async def resolve_tracked_branch(settings_store, project_dir: Path) -> str:
return await update_tracking_branch(project_dir)
-_DOC_EXTENSIONS = (".md", ".markdown", ".rst", ".txt")
+# .txt is deliberately excluded: a root-level .txt is ambiguous (e.g.
+# requirements.txt / constraints.txt), and misclassifying it as docs would
+# silently suppress a real dependency update. A .txt under docs/ is still
+# treated as docs via the docs/ branch below.
+_DOC_EXTENSIONS = (".md", ".markdown", ".rst")
_CODE_EXTENSIONS = (
".py", ".ts", ".tsx", ".js", ".jsx", ".json",
".yaml", ".yml", ".sh", ".toml", ".cfg", ".ini",
diff --git a/tinyagentos/cli/taosctl/__init__.py b/tinyagentos/cli/taosctl/__init__.py
new file mode 100644
index 000000000..7f69a1321
--- /dev/null
+++ b/tinyagentos/cli/taosctl/__init__.py
@@ -0,0 +1,15 @@
+"""taosctl: a kubectl-style CLI over the taOS REST API.
+
+Shell-driveable surface so coding-CLI agents, scripts, and humans can do
+anything a taOS user can do without poking the UI. Subcommands map 1:1 to API
+verbs: ``taosctl [args]``. Noun modules live in
+``tinyagentos/cli/taosctl/commands/`` and are auto-discovered, so a new noun is
+a new file with no central registry edit (conflict-free to add in parallel).
+
+Run via the ``taosctl`` console script or directly:
+
+ python -m tinyagentos.cli.taosctl ...
+"""
+from __future__ import annotations
+
+__version__ = "0.1.0"
diff --git a/tinyagentos/cli/taosctl/__main__.py b/tinyagentos/cli/taosctl/__main__.py
new file mode 100644
index 000000000..8ca7eaf3f
--- /dev/null
+++ b/tinyagentos/cli/taosctl/__main__.py
@@ -0,0 +1,55 @@
+"""taosctl entry point: build the parser from auto-discovered noun modules,
+dispatch the chosen handler, render the result, and map failures to exit codes.
+
+Exit codes (per the agent-friendliness design):
+ 0 success
+ 1 transport / local error (could not reach the server, bad usage)
+ 2 API error (server returned non-2xx; its message is printed to stderr)
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from typing import Optional
+
+from tinyagentos.cli.taosctl import __version__, output
+from tinyagentos.cli.taosctl.client import ApiError, TaosClient, TransportError
+from tinyagentos.cli.taosctl.commands import iter_noun_modules
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="taosctl",
+ description="kubectl-style CLI over the taOS REST API (taosctl ).",
+ )
+ parser.add_argument("--version", action="version", version=f"taosctl {__version__}")
+ parser.add_argument("--url", help="Server base URL (else TAOS_URL or config)")
+ parser.add_argument("--token", help="API token (else TAOS_TOKEN or config)")
+ parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
+ nouns = parser.add_subparsers(dest="noun", required=True, metavar="")
+ for mod in sorted(iter_noun_modules(), key=lambda m: m.NOUN):
+ mod.register(nouns)
+ return parser
+
+
+def main(argv: Optional[list] = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if not getattr(args, "func", None):
+ parser.print_help(sys.stderr)
+ return 1
+ client = TaosClient(url=args.url, token=args.token)
+ try:
+ result = args.func(args, client)
+ except ApiError as exc:
+ output.error(f"API error ({exc.status}): {exc.message}")
+ return 2
+ except TransportError as exc:
+ output.error(str(exc))
+ return 1
+ output.render(result, as_json=args.json)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tinyagentos/cli/taosctl/client.py b/tinyagentos/cli/taosctl/client.py
new file mode 100644
index 000000000..ef7e34a5a
--- /dev/null
+++ b/tinyagentos/cli/taosctl/client.py
@@ -0,0 +1,117 @@
+"""Authenticated HTTP client for taosctl. Stdlib only (urllib), so the CLI
+installs and runs anywhere with zero extra dependencies.
+
+Config resolution (first match wins) for base URL and token:
+ 1. explicit --url / --token flags (passed in by __main__)
+ 2. env TAOS_URL / TAOS_TOKEN (how an agent container has its token injected)
+ 3. ~/.config/taosctl/config.json ({"url": ..., "token": ...})
+ 4. url defaults to http://127.0.0.1:6969; token may be absent (anonymous)
+
+The token is sent as ``Authorization: Bearer `` (the header the taOS
+auth middleware accepts in place of a session cookie).
+"""
+from __future__ import annotations
+
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any, Optional
+
+DEFAULT_URL = "http://127.0.0.1:6969"
+CONFIG_PATH = Path(os.path.expanduser("~/.config/taosctl/config.json"))
+
+
+class ApiError(Exception):
+ """Raised on a non-2xx API response. Carries the HTTP status and the
+ server's actionable message (from the JSON error/detail field if present)."""
+
+ def __init__(self, status: int, message: str):
+ super().__init__(message)
+ self.status = status
+ self.message = message
+
+
+class TransportError(Exception):
+ """Raised on a local/transport failure (connection refused, DNS, etc.)."""
+
+
+def _load_config() -> dict:
+ try:
+ return json.loads(CONFIG_PATH.read_text())
+ except Exception:
+ return {}
+
+
+def resolve(url: Optional[str], token: Optional[str]) -> tuple[str, Optional[str]]:
+ cfg = _load_config()
+ base = url or os.environ.get("TAOS_URL") or cfg.get("url") or DEFAULT_URL
+ tok = token or os.environ.get("TAOS_TOKEN") or cfg.get("token")
+ return base.rstrip("/"), tok
+
+
+class TaosClient:
+ def __init__(self, url: Optional[str] = None, token: Optional[str] = None, timeout: float = 30.0):
+ self.base_url, self.token = resolve(url, token)
+ self.timeout = timeout
+
+ def request(self, method: str, path: str, params: Optional[dict] = None,
+ body: Optional[Any] = None) -> Any:
+ full = self.base_url + path
+ if params:
+ clean = {k: v for k, v in params.items() if v is not None}
+ if clean:
+ full = full + "?" + urllib.parse.urlencode(clean)
+ data = None
+ headers = {"Accept": "application/json"}
+ if self.token:
+ headers["Authorization"] = f"Bearer {self.token}"
+ if body is not None:
+ data = json.dumps(body).encode()
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(full, data=data, method=method.upper(), headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+ raw = resp.read()
+ except urllib.error.HTTPError as exc:
+ raw = exc.read() if exc.fp else b""
+ raise ApiError(exc.code, _extract_error(raw, exc.code)) from None
+ except urllib.error.URLError as exc:
+ raise TransportError(f"cannot reach {self.base_url}: {exc.reason}") from None
+ if not raw:
+ return None
+ try:
+ return json.loads(raw)
+ except ValueError:
+ return raw.decode(errors="replace")
+
+ def get(self, path: str, params: Optional[dict] = None) -> Any:
+ return self.request("GET", path, params=params)
+
+ def post(self, path: str, body: Optional[Any] = None, params: Optional[dict] = None,
+ json: Optional[Any] = None) -> Any:
+ # `json` is accepted as an alias for `body`: it is the conventional kwarg
+ # for a JSON payload in requests/httpx, so callers reach for it by habit.
+ # `body` wins if both are given.
+ return self.request("POST", path, params=params, body=body if body is not None else json)
+
+ def patch(self, path: str, body: Optional[Any] = None, json: Optional[Any] = None) -> Any:
+ return self.request("PATCH", path, body=body if body is not None else json)
+
+ def delete(self, path: str, params: Optional[dict] = None) -> Any:
+ return self.request("DELETE", path, params=params)
+
+
+def _extract_error(raw: bytes, status: int) -> str:
+ try:
+ doc = json.loads(raw)
+ if isinstance(doc, dict):
+ for key in ("error", "detail", "message"):
+ if doc.get(key):
+ return str(doc[key])
+ except Exception:
+ pass
+ text = raw.decode(errors="replace").strip()
+ return text or f"HTTP {status}"
diff --git a/tinyagentos/cli/taosctl/commands/__init__.py b/tinyagentos/cli/taosctl/commands/__init__.py
new file mode 100644
index 000000000..8b4bc201c
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/__init__.py
@@ -0,0 +1,28 @@
+"""Auto-discovery of taosctl noun modules.
+
+Every module in this package (except those starting with ``_``) is a noun. A
+noun module must expose:
+ NOUN: str -- the subcommand word, e.g. "agents"
+ register(subparsers) -> None -- add its parser + verb subparsers,
+ each verb's handler set via
+ parser.set_defaults(func=handler), where
+ handler(args, client) -> data | None
+
+Adding a noun is adding a file here; no central list to edit, so parallel
+contributors never collide on a shared registry.
+"""
+from __future__ import annotations
+
+import importlib
+import pkgutil
+from typing import Iterator
+
+
+def iter_noun_modules() -> Iterator[object]:
+ import tinyagentos.cli.taosctl.commands as pkg
+ for info in pkgutil.iter_modules(pkg.__path__):
+ if info.name.startswith("_"):
+ continue
+ mod = importlib.import_module(f"{pkg.__name__}.{info.name}")
+ if hasattr(mod, "register") and hasattr(mod, "NOUN"):
+ yield mod
diff --git a/tinyagentos/cli/taosctl/commands/agents.py b/tinyagentos/cli/taosctl/commands/agents.py
new file mode 100644
index 000000000..fe3d1fe48
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/agents.py
@@ -0,0 +1,38 @@
+"""taosctl agents -- inspect and manage agents.
+
+Reference noun: shows the pattern every other noun module follows (a NOUN, a
+register() that wires verb subparsers, and small handlers that call the client
+and return data for the framework to render).
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "agents"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage agents")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List all agents")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one agent by name")
+ gp.add_argument("name", help="Agent name")
+ gp.set_defaults(func=_get)
+
+ ap = verbs.add_parser("archived", help="List archived agents")
+ ap.set_defaults(func=_archived)
+
+
+def _list(args, client):
+ return client.get("/api/agents")
+
+
+def _get(args, client):
+ return client.get(f"/api/agents/{quote(args.name, safe='')}")
+
+
+def _archived(args, client):
+ return client.get("/api/agents/archived")
diff --git a/tinyagentos/cli/taosctl/commands/apps.py b/tinyagentos/cli/taosctl/commands/apps.py
new file mode 100644
index 000000000..ad44f9e6e
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/apps.py
@@ -0,0 +1,62 @@
+"""taosctl apps -- inspect and manage installed apps.
+
+Reference noun: shows the pattern every other noun module follows (a NOUN, a
+register() that wires verb subparsers, and small handlers that call the client
+and return data for the framework to render).
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "apps"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage installed apps")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List installed apps with a runtime location")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one installed app by id")
+ gp.add_argument("app_id", help="App id")
+ gp.set_defaults(func=_get)
+
+ ip = verbs.add_parser("installed", help="List optional frontend app install state")
+ ip.set_defaults(func=_installed)
+
+ cp = verbs.add_parser("install", help="Install an optional frontend app")
+ cp.add_argument("app_id", help="Optional app id")
+ cp.set_defaults(func=_install)
+
+ up = verbs.add_parser("uninstall", help="Uninstall an optional frontend app")
+ up.add_argument("app_id", help="Optional app id")
+ up.set_defaults(func=_uninstall)
+
+ # POST/PATCH/DELETE with file upload, multipart, streaming, or complex
+ # nested bodies are skipped: create, update, delete of full app records.
+
+
+def _list(args, client):
+ return client.get("/api/apps/installed")
+
+
+def _get(args, client):
+ # No single-app backend route exists; fetch the installed list and filter.
+ rows = client.get("/api/apps/installed")
+ for row in rows or []:
+ if row.get("app_id") == args.app_id:
+ return row
+ raise SystemExit(f"no installed app with id: {args.app_id}")
+
+
+def _installed(args, client):
+ return client.get("/api/apps/optional/installed")
+
+
+def _install(args, client):
+ return client.post(f"/api/apps/optional/{quote(args.app_id, safe='')}/install")
+
+
+def _uninstall(args, client):
+ return client.post(f"/api/apps/optional/{quote(args.app_id, safe='')}/uninstall")
diff --git a/tinyagentos/cli/taosctl/commands/auth.py b/tinyagentos/cli/taosctl/commands/auth.py
new file mode 100644
index 000000000..9e93a8c5b
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/auth.py
@@ -0,0 +1,53 @@
+"""taosctl auth -- configure and check the CLI's connection to a taOS server."""
+from __future__ import annotations
+
+import json
+import os
+
+from tinyagentos.cli.taosctl import client as _client
+
+NOUN = "auth"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Configure and check the connection")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("login", help="Save a server URL + token to the config file")
+ lp.add_argument("--url", help="Server base URL")
+ lp.add_argument("--token", help="API token (Bearer)")
+ lp.set_defaults(func=_login)
+
+ sp = verbs.add_parser("status", help="Show the resolved URL + whether a token is set")
+ sp.set_defaults(func=_status)
+
+ wp = verbs.add_parser("whoami", help="Verify the token by calling the server")
+ wp.set_defaults(func=_whoami)
+
+
+def _login(args, client):
+ # Persist only what the user explicitly passes, falling back to the existing
+ # config file (NOT env vars). resolve() would pull in TAOS_TOKEN, which would
+ # silently write an env-only token to disk on a bare `auth login`.
+ existing = _client._load_config()
+ url = args.url or existing.get("url") or _client.DEFAULT_URL
+ token = args.token if args.token is not None else existing.get("token")
+ _client.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ payload = json.dumps({"url": url, "token": token}, indent=2)
+ # The config holds a credential, so create it owner-only (0o600) and
+ # atomically, never world-readable between create and a later chmod.
+ fd = os.open(str(_client.CONFIG_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as fh:
+ fh.write(payload)
+ return {"saved": str(_client.CONFIG_PATH), "url": url, "token_set": bool(token)}
+
+
+def _status(args, client):
+ return {"url": client.base_url, "token_set": bool(client.token)}
+
+
+def _whoami(args, client):
+ # No dedicated whoami endpoint; a successful authed call to /api/agents
+ # confirms the token is accepted by the server.
+ client.get("/api/agents")
+ return {"url": client.base_url, "authenticated": True}
diff --git a/tinyagentos/cli/taosctl/commands/bookmarks.py b/tinyagentos/cli/taosctl/commands/bookmarks.py
new file mode 100644
index 000000000..8f9ef6073
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/bookmarks.py
@@ -0,0 +1,52 @@
+"""taosctl bookmarks -- manage browser bookmarks.
+
+Reference noun: mirrors the agents pattern (NOUN, register(), small handlers).
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "bookmarks"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Manage browser bookmarks")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List bookmarks for a profile")
+ lp.add_argument("--profile-id", required=True, help="Browser profile ID")
+ lp.set_defaults(func=_list)
+
+ cp = verbs.add_parser("create", help="Add a new bookmark")
+ cp.add_argument("--profile-id", required=True, help="Browser profile ID")
+ cp.add_argument("--url", required=True, help="Bookmark URL")
+ cp.add_argument("--title", required=True, help="Bookmark title")
+ cp.set_defaults(func=_create)
+
+ dp = verbs.add_parser("delete", help="Remove a bookmark by ID")
+ dp.add_argument("bookmark_id", help="Bookmark ID")
+ dp.add_argument("--profile-id", required=True, help="Browser profile ID")
+ dp.set_defaults(func=_delete)
+
+
+def _list(args, client):
+ return client.get(
+ "/api/desktop/browser/bookmarks",
+ params={"profile_id": args.profile_id},
+ )
+
+
+def _create(args, client):
+ return client.post(
+ "/api/desktop/browser/bookmarks",
+ body={
+ "profile_id": args.profile_id,
+ "url": args.url,
+ "title": args.title,
+ },
+ )
+
+
+def _delete(args, client):
+ path = f"/api/desktop/browser/bookmarks/{quote(args.bookmark_id, safe='')}"
+ return client.delete(path, params={"profile_id": args.profile_id})
diff --git a/tinyagentos/cli/taosctl/commands/browsing_history.py b/tinyagentos/cli/taosctl/commands/browsing_history.py
new file mode 100644
index 000000000..2b5fcad22
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/browsing_history.py
@@ -0,0 +1,35 @@
+"""taosctl browsing_history -- inspect and manage browsing history."""
+
+from __future__ import annotations
+
+NOUN = "browsing_history"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage browsing history")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List browsing history")
+ lp.add_argument("--source-type", dest="source_type", default=None, help="Filter by source type")
+ lp.add_argument("--limit", type=int, default=None, help="Max items to return")
+ lp.set_defaults(func=_list)
+
+ cp = verbs.add_parser("clear", help="Clear browsing history")
+ cp.add_argument("--source-type", dest="source_type", default=None, help="Clear only a specific source type")
+ cp.set_defaults(func=_clear)
+
+
+def _list(args, client):
+ params = {}
+ if args.source_type is not None:
+ params["source_type"] = args.source_type
+ if args.limit is not None:
+ params["limit"] = args.limit
+ return client.get("/api/browsing-history", params=params or None)
+
+
+def _clear(args, client):
+ params = {}
+ if args.source_type is not None:
+ params["source_type"] = args.source_type
+ return client.delete("/api/browsing-history", params=params or None)
diff --git a/tinyagentos/cli/taosctl/commands/canvas.py b/tinyagentos/cli/taosctl/commands/canvas.py
new file mode 100644
index 000000000..711f3a118
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/canvas.py
@@ -0,0 +1,36 @@
+"""taosctl canvas -- inspect and manage canvas pages."""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "canvas"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage canvas pages")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List all canvases")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one canvas by id")
+ gp.add_argument("id", help="Canvas id")
+ gp.set_defaults(func=_get)
+
+ dp = verbs.add_parser("delete", help="Delete a canvas by id")
+ dp.add_argument("id", help="Canvas id")
+ dp.set_defaults(func=_delete)
+
+
+def _list(args, client):
+ return client.get("/api/canvas")
+
+
+def _get(args, client):
+ # The single-canvas read route is /api/canvas/{id}/data; the bare
+ # /api/canvas/{id} path only has a DELETE handler.
+ return client.get(f"/api/canvas/{quote(args.id, safe='')}/data")
+
+
+def _delete(args, client):
+ return client.delete(f"/api/canvas/{quote(args.id, safe='')}")
diff --git a/tinyagentos/cli/taosctl/commands/frameworks.py b/tinyagentos/cli/taosctl/commands/frameworks.py
new file mode 100644
index 000000000..3ce4cd6cd
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/frameworks.py
@@ -0,0 +1,24 @@
+"""taosctl frameworks -- inspect and manage agent frameworks.
+
+Reference noun: follows the same shape as agents.py and projects.py.
+"""
+from __future__ import annotations
+
+NOUN = "frameworks"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage agent frameworks")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List all registered frameworks")
+ lp.set_defaults(func=_list)
+
+ # GET /api/frameworks/{id} -- not yet implemented in routes/frameworks.py
+ # POST /api/frameworks -- not yet implemented in routes/frameworks.py
+ # PATCH /api/frameworks/{id} -- not yet implemented in routes/frameworks.py
+ # DELETE /api/frameworks/{id} -- not yet implemented in routes/frameworks.py
+
+
+def _list(args, client):
+ return client.get("/api/frameworks")
diff --git a/tinyagentos/cli/taosctl/commands/guides.py b/tinyagentos/cli/taosctl/commands/guides.py
new file mode 100644
index 000000000..f1ff774ba
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/guides.py
@@ -0,0 +1,35 @@
+"""taosctl guides -- query hardware guides and model recommendations."""
+from __future__ import annotations
+
+NOUN = "guides"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Query hardware guides and model recommendations")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ rp = verbs.add_parser("recommendations", help="Model recommendations for a hardware tier and use case")
+ rp.add_argument("hardware", help="Hardware tier, e.g. pi-16gb, nvidia-12gb, cpu-only")
+ rp.add_argument("use_case", help="Use case, e.g. chat, coding, embedding, vision, voice")
+ rp.set_defaults(func=_recommendations)
+
+ tp = verbs.add_parser("tiers", help="List hardware tiers with labels and descriptions")
+ tp.set_defaults(func=_tiers)
+
+ up = verbs.add_parser("use-cases", help="List use cases with labels and descriptions")
+ up.set_defaults(func=_use_cases)
+
+
+def _recommendations(args, client):
+ return client.get(
+ "/api/guides/recommendations",
+ params={"hardware": args.hardware, "use_case": args.use_case},
+ )
+
+
+def _tiers(args, client):
+ return client.get("/api/guides/tiers")
+
+
+def _use_cases(args, client):
+ return client.get("/api/guides/use-cases")
diff --git a/tinyagentos/cli/taosctl/commands/jobs.py b/tinyagentos/cli/taosctl/commands/jobs.py
new file mode 100644
index 000000000..796e690fd
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/jobs.py
@@ -0,0 +1,61 @@
+"""taosctl jobs -- inspect and manage scheduled jobs.
+
+Reference noun: mirrors the agents module pattern (NOUN, register(), small handlers).
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "jobs"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage scheduled jobs")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List recent jobs")
+ lp.add_argument("--status", help="Filter by status", default=None)
+ lp.add_argument("--limit", help="Max results", type=int, default=50)
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one job by id")
+ gp.add_argument("job_id", help="Job id")
+ gp.set_defaults(func=_get)
+
+ sp = verbs.add_parser("stats", help="Job queue statistics")
+ sp.set_defaults(func=_stats)
+
+ rp = verbs.add_parser("running", help="Currently running jobs")
+ rp.set_defaults(func=_running)
+
+ cp = verbs.add_parser("cancel", help="Cancel a pending job")
+ cp.add_argument("job_id", help="Job id")
+ cp.set_defaults(func=_cancel)
+
+ clp = verbs.add_parser("cleanup", help="Remove old completed/failed jobs")
+ clp.set_defaults(func=_cleanup)
+
+
+def _list(args, client):
+ params = {"status": args.status, "limit": args.limit}
+ return client.get("/api/jobs", params=params)
+
+
+def _get(args, client):
+ return client.get(f"/api/jobs/{quote(args.job_id, safe='')}")
+
+
+def _stats(args, client):
+ return client.get("/api/jobs/stats")
+
+
+def _running(args, client):
+ return client.get("/api/jobs/running")
+
+
+def _cancel(args, client):
+ return client.post(f"/api/jobs/{quote(args.job_id, safe='')}/cancel")
+
+
+def _cleanup(args, client):
+ return client.post("/api/jobs/cleanup")
diff --git a/tinyagentos/cli/taosctl/commands/memory.py b/tinyagentos/cli/taosctl/commands/memory.py
new file mode 100644
index 000000000..9e6919a3c
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/memory.py
@@ -0,0 +1,92 @@
+"""taosctl memory -- inspect and manage agent memory.
+
+Maps the memory REST endpoints to verbs:
+ list -> GET /api/memory/browse
+ search -> POST /api/memory/search (keyword or semantic)
+ collections -> GET /api/memory/collections/{agent}
+ delete -> DELETE /api/memory/chunk/{hash}
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "memory"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage agent memory")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List memory chunks (browse)")
+ lp.add_argument("--agent", help="Agent name (omit for user scope)")
+ lp.add_argument("--collection", help="Collection filter")
+ lp.add_argument("--limit", type=int, default=20, help="Max results")
+ lp.add_argument("--offset", type=int, default=0, help="Offset")
+ lp.set_defaults(func=_list)
+
+ sp = verbs.add_parser("search", help="Search memory (keyword or semantic)")
+ sp.add_argument("query", help="Search query")
+ sp.add_argument("--agent", help="Agent name (omit for user scope)")
+ sp.add_argument("--collection", help="Collection filter")
+ sp.add_argument("--limit", type=int, default=20, help="Max results")
+ sp.add_argument("--mode", choices=["keyword", "semantic"], default="keyword",
+ help="Search mode (default: keyword)")
+ sp.add_argument("--conversation-id", help="W3C trace conversation id")
+ sp.set_defaults(func=_search)
+
+ cp = verbs.add_parser("collections", help="List collections for an agent")
+ cp.add_argument("agent_name", help="Agent name")
+ cp.add_argument("--conversation-id", help="W3C trace conversation id")
+ cp.set_defaults(func=_collections)
+
+ dp = verbs.add_parser("delete", help="Delete a memory chunk by hash")
+ dp.add_argument("content_hash", help="Chunk content hash")
+ dp.add_argument("--agent", help="Agent name (omit for user scope)")
+ dp.add_argument("--conversation-id", help="W3C trace conversation id")
+ dp.set_defaults(func=_delete)
+
+
+def _list(args, client):
+ params: dict = {"limit": args.limit, "offset": args.offset}
+ if args.agent:
+ params["agent"] = args.agent
+ if args.collection:
+ params["collection"] = args.collection
+ return client.get("/api/memory/browse", params=params)
+
+
+def _search(args, client):
+ payload: dict = {
+ "query": args.query,
+ "mode": args.mode,
+ "limit": args.limit,
+ }
+ if args.agent:
+ payload["agent"] = args.agent
+ if args.collection:
+ payload["collection"] = args.collection
+ if args.conversation_id:
+ payload["conversation_id"] = args.conversation_id
+ return client.post("/api/memory/search", json=payload)
+
+
+def _collections(args, client):
+ params: dict = {}
+ if args.conversation_id:
+ params["conversation_id"] = args.conversation_id
+ return client.get(
+ f"/api/memory/collections/{quote(args.agent_name, safe='')}",
+ params=params,
+ )
+
+
+def _delete(args, client):
+ params: dict = {}
+ if args.agent:
+ params["agent"] = args.agent
+ if args.conversation_id:
+ params["conversation_id"] = args.conversation_id
+ return client.delete(
+ f"/api/memory/chunk/{quote(args.content_hash, safe='')}",
+ params=params,
+ )
diff --git a/tinyagentos/cli/taosctl/commands/models.py b/tinyagentos/cli/taosctl/commands/models.py
new file mode 100644
index 000000000..93d29e678
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/models.py
@@ -0,0 +1,78 @@
+"""taosctl models -- inspect and manage models."""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "models"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage models")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List all models")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one model by id")
+ gp.add_argument("id", help="Model id")
+ gp.set_defaults(func=_get)
+
+ dp = verbs.add_parser("delete", help="Delete a model")
+ dp.add_argument("id", help="Model id")
+ dp.set_defaults(func=_delete)
+
+ rp = verbs.add_parser("recommended", help="List recommended models")
+ rp.set_defaults(func=_recommended)
+
+ ldp = verbs.add_parser("loaded", help="List loaded models")
+ ldp.set_defaults(func=_loaded)
+
+ dlp = verbs.add_parser("downloads", help="List downloads")
+ dlp.set_defaults(func=_downloads)
+
+ dwp = verbs.add_parser("download", help="Start a model download")
+ dwp.add_argument("--app-id", required=True, help="App id")
+ dwp.add_argument("--variant-id", required=True, help="Variant id")
+ dwp.set_defaults(func=_download)
+
+ pp = verbs.add_parser("pull", help="Pull an Ollama model")
+ pp.add_argument("--model-name", required=True, help="Model name")
+ pp.set_defaults(func=_pull)
+
+ # SKIP: search (needs query params + multi-source fan-out)
+ # SKIP: files/{model_id} (path with slashes, needs special encoding)
+ # SKIP: downloads/{download_id} (single-resource sub-path)
+
+
+def _list(args, client):
+ return client.get("/api/models")
+
+
+def _get(args, client):
+ return client.get(f"/api/models/{quote(args.id, safe='')}")
+
+
+def _delete(args, client):
+ return client.delete(f"/api/models/{quote(args.id, safe='')}")
+
+
+def _recommended(args, client):
+ return client.get("/api/models/recommended")
+
+
+def _loaded(args, client):
+ return client.get("/api/models/loaded")
+
+
+def _downloads(args, client):
+ return client.get("/api/models/downloads")
+
+
+def _download(args, client):
+ body = {"app_id": args.app_id, "variant_id": args.variant_id}
+ return client.post("/api/models/download", body=body)
+
+
+def _pull(args, client):
+ body = {"model_name": args.model_name}
+ return client.post("/api/models/pull", body=body)
diff --git a/tinyagentos/cli/taosctl/commands/music.py b/tinyagentos/cli/taosctl/commands/music.py
new file mode 100644
index 000000000..75e8eccbb
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/music.py
@@ -0,0 +1,25 @@
+"""taosctl music -- inspect and control music generation."""
+from __future__ import annotations
+
+NOUN = "music"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and control music generation")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List generated tracks")
+ lp.set_defaults(func=_list)
+
+ sp = verbs.add_parser("status", help="Show music backend status")
+ sp.set_defaults(func=_status)
+
+ # POST /api/music/compose skipped: requires a complex body (prompt, duration).
+
+
+def _list(args, client):
+ return client.get("/api/music")
+
+
+def _status(args, client):
+ return client.get("/api/music/status")
diff --git a/tinyagentos/cli/taosctl/commands/projects.py b/tinyagentos/cli/taosctl/commands/projects.py
new file mode 100644
index 000000000..0ee4ecf24
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/projects.py
@@ -0,0 +1,66 @@
+"""taosctl projects -- inspect and manage projects."""
+from __future__ import annotations
+
+NOUN = "projects"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage projects")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List projects")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one project by id")
+ gp.add_argument("id", help="Project id")
+ gp.set_defaults(func=_get)
+
+ cp = verbs.add_parser("create", help="Create a project")
+ cp.add_argument("name", help="Project name")
+ cp.add_argument("slug", help="URL-friendly slug")
+ cp.add_argument("--description", default="", help="Short description")
+ cp.set_defaults(func=_create)
+
+ up = verbs.add_parser("update", help="Update a project")
+ up.add_argument("id", help="Project id")
+ up.add_argument("--name", default=None, help="New name")
+ up.add_argument("--description", default=None, help="New description")
+ up.set_defaults(func=_update)
+
+ dp = verbs.add_parser("delete", help="Delete a project")
+ dp.add_argument("id", help="Project id")
+ dp.set_defaults(func=_delete)
+
+ ap = verbs.add_parser("archive", help="Archive a project")
+ ap.add_argument("id", help="Project id")
+ ap.set_defaults(func=_archive)
+
+
+def _list(args, client):
+ return client.get("/api/projects")
+
+
+def _get(args, client):
+ return client.get(f"/api/projects/{args.id}")
+
+
+def _create(args, client):
+ body = {"name": args.name, "slug": args.slug, "description": args.description}
+ return client.post("/api/projects", body=body)
+
+
+def _update(args, client):
+ body = {}
+ if args.name is not None:
+ body["name"] = args.name
+ if args.description is not None:
+ body["description"] = args.description
+ return client.patch(f"/api/projects/{args.id}", body=body)
+
+
+def _delete(args, client):
+ return client.delete(f"/api/projects/{args.id}")
+
+
+def _archive(args, client):
+ return client.post(f"/api/projects/{args.id}/archive")
diff --git a/tinyagentos/cli/taosctl/commands/scheduler.py b/tinyagentos/cli/taosctl/commands/scheduler.py
new file mode 100644
index 000000000..0b9d995ff
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/scheduler.py
@@ -0,0 +1,40 @@
+"""taosctl scheduler -- inspect scheduler state and backends.
+
+All current scheduler endpoints are reads (observability only); there are no
+create / update / delete surfaces, so no POST / PATCH / DELETE verbs yet.
+Skipped (not simple JSON body endpoints):
+ N/A -- the route file has no POST/PATCH/DELETE handlers at all.
+"""
+from __future__ import annotations
+
+NOUN = "scheduler"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect scheduler state and backends")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ sp = verbs.add_parser("stats", help="Live scheduler stats")
+ sp.set_defaults(func=_stats)
+
+ tp = verbs.add_parser("tasks", help="Recent task history")
+ tp.add_argument("--limit", type=int, default=None, help="Max tasks (1-500)")
+ tp.set_defaults(func=_tasks)
+
+ bp = verbs.add_parser("backends", help="Live backend catalog")
+ bp.set_defaults(func=_backends)
+
+
+def _stats(args, client):
+ return client.get("/api/scheduler/stats")
+
+
+def _tasks(args, client):
+ params = {}
+ if args.limit is not None:
+ params["limit"] = args.limit
+ return client.get("/api/scheduler/tasks", params=params or None)
+
+
+def _backends(args, client):
+ return client.get("/api/scheduler/backends")
diff --git a/tinyagentos/cli/taosctl/commands/search.py b/tinyagentos/cli/taosctl/commands/search.py
new file mode 100644
index 000000000..e3d76e588
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/search.py
@@ -0,0 +1,18 @@
+"""taosctl search -- query the global search index."""
+from __future__ import annotations
+
+NOUN = "search"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Query the global search index")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="Search across all platform data")
+ lp.add_argument("q", help="Search query string")
+ lp.add_argument("--limit", type=int, default=5, help="Max results (default: 5)")
+ lp.set_defaults(func=_list)
+
+
+def _list(args, client):
+ return client.get("/api/search", params={"q": args.q, "limit": args.limit})
diff --git a/tinyagentos/cli/taosctl/commands/shared_folders.py b/tinyagentos/cli/taosctl/commands/shared_folders.py
new file mode 100644
index 000000000..499f3defa
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/shared_folders.py
@@ -0,0 +1,77 @@
+"""taosctl shared_folders -- inspect and manage shared folders."""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "shared_folders"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage shared folders")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List shared folders")
+ lp.add_argument("--agent-name", default=None, help="Filter by agent name")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one shared folder by id")
+ gp.add_argument("id", help="Folder id")
+ gp.set_defaults(func=_get)
+
+ cp = verbs.add_parser("create", help="Create a shared folder")
+ cp.add_argument("name", help="Folder name")
+ cp.add_argument("--description", default="", help="Short description")
+ cp.add_argument("--agents", default=None, help="Comma-separated agent names")
+ cp.set_defaults(func=_create)
+
+ dp = verbs.add_parser("delete", help="Delete a shared folder")
+ dp.add_argument("id", help="Folder id")
+ dp.set_defaults(func=_delete)
+
+ fp = verbs.add_parser("files", help="List files in a shared folder")
+ fp.add_argument("name", help="Folder name")
+ fp.set_defaults(func=_files)
+
+ # POST /api/shared-folders/{name}/upload -- skipped (file upload / multipart)
+
+ ap = verbs.add_parser("grant", help="Grant access to a shared folder")
+ ap.add_argument("folder_id", help="Folder id")
+ ap.add_argument("agent_name", help="Agent name")
+ ap.add_argument("--permission", default="readwrite", help="Permission level")
+ ap.set_defaults(func=_grant)
+
+
+def _list(args, client):
+ params = {}
+ if args.agent_name:
+ params["agent_name"] = args.agent_name
+ return client.get("/api/shared-folders", params=params or None)
+
+
+def _get(args, client):
+ # No single-folder GET route exists; fetch the list and filter by id.
+ rows = client.get("/api/shared-folders")
+ for row in rows or []:
+ if str(row.get("id")) == str(args.id):
+ return row
+ raise SystemExit(f"no shared folder with id: {args.id}")
+
+
+def _create(args, client):
+ body = {"name": args.name, "description": args.description}
+ if args.agents:
+ body["agents"] = [a.strip() for a in args.agents.split(",")]
+ return client.post("/api/shared-folders", body=body)
+
+
+def _delete(args, client):
+ return client.delete(f"/api/shared-folders/{quote(args.id, safe='')}")
+
+
+def _files(args, client):
+ return client.get(f"/api/shared-folders/{quote(args.name, safe='')}/files")
+
+
+def _grant(args, client):
+ body = {"agent_name": args.agent_name, "permission": args.permission}
+ return client.post(f"/api/shared-folders/{quote(args.folder_id, safe='')}/access", body=body)
diff --git a/tinyagentos/cli/taosctl/commands/shortcuts.py b/tinyagentos/cli/taosctl/commands/shortcuts.py
new file mode 100644
index 000000000..8353c9d55
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/shortcuts.py
@@ -0,0 +1,24 @@
+"""taosctl shortcuts -- list agent shortcuts.
+
+Reference noun: shows the pattern every other noun module follows (a NOUN, a
+register() that wires verb subparsers, and small handlers that call the client
+and return data for the framework to render).
+"""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "shortcuts"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="List agent shortcuts")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List shortcuts for an agent")
+ lp.add_argument("agent_id", help="Agent name or id")
+ lp.set_defaults(func=_list)
+
+
+def _list(args, client):
+ return client.get(f"/api/agents/{quote(args.agent_id, safe='')}/shortcuts")
diff --git a/tinyagentos/cli/taosctl/commands/tasks.py b/tinyagentos/cli/taosctl/commands/tasks.py
new file mode 100644
index 000000000..c0e82a1eb
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/tasks.py
@@ -0,0 +1,96 @@
+"""taosctl tasks -- inspect and manage scheduled tasks."""
+from __future__ import annotations
+
+NOUN = "tasks"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="Inspect and manage scheduled tasks")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List all tasks")
+ lp.add_argument("--agent", help="Filter by agent name")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("get", help="Get one task by id")
+ gp.add_argument("id", help="Task id")
+ gp.set_defaults(func=_get)
+
+ cp = verbs.add_parser("create", help="Create a new task")
+ cp.add_argument("--name", required=True, help="Task name")
+ cp.add_argument("--schedule", required=True, help="Cron expression")
+ cp.add_argument("--command", required=True, help="Command to run")
+ cp.add_argument("--agent-name", help="Agent to run as")
+ cp.add_argument("--description", default="", help="Task description")
+ cp.set_defaults(func=_create)
+
+ up = verbs.add_parser("update", help="Update an existing task")
+ up.add_argument("id", help="Task id")
+ up.add_argument("--name", help="Task name")
+ up.add_argument("--schedule", help="Cron expression")
+ up.add_argument("--command", help="Command to run")
+ up.add_argument("--description", help="Task description")
+ up.add_argument("--enabled", help="true or false")
+ up.set_defaults(func=_update)
+
+ dp = verbs.add_parser("delete", help="Delete a task")
+ dp.add_argument("id", help="Task id")
+ dp.set_defaults(func=_delete)
+
+ tp = verbs.add_parser("toggle", help="Toggle a task enabled/disabled")
+ tp.add_argument("id", help="Task id")
+ tp.set_defaults(func=_toggle)
+
+ # Skipped: GET /api/tasks/presets (list_presets) -- no matching verb needed yet
+ # Skipped: POST /api/tasks/presets/{id}/apply (apply_preset) -- complex nested body
+
+
+def _list(args, client):
+ params = {}
+ if args.agent:
+ params["agent"] = args.agent
+ return client.get("/api/tasks", params=params or None)
+
+
+def _get(args, client):
+ return client.get(f"/api/tasks/{args.id}")
+
+
+def _create(args, client):
+ body = {
+ "name": args.name,
+ "schedule": args.schedule,
+ "command": args.command,
+ "agent_name": args.agent_name,
+ "description": args.description,
+ }
+ return client.post("/api/tasks", body=body)
+
+
+def _update(args, client):
+ body = {}
+ if args.name is not None:
+ body["name"] = args.name
+ if args.schedule is not None:
+ body["schedule"] = args.schedule
+ if args.command is not None:
+ body["command"] = args.command
+ if args.description is not None:
+ body["description"] = args.description
+ if args.enabled is not None:
+ v = args.enabled.strip().lower()
+ if v in ("true", "1", "yes"):
+ body["enabled"] = True
+ elif v in ("false", "0", "no"):
+ body["enabled"] = False
+ else:
+ raise SystemExit(f"--enabled expects true or false, got: {args.enabled}")
+ return client.request("PUT", f"/api/tasks/{args.id}", body=body)
+
+
+def _delete(args, client):
+ return client.delete(f"/api/tasks/{args.id}")
+
+
+def _toggle(args, client):
+ return client.post(f"/api/tasks/{args.id}/toggle")
diff --git a/tinyagentos/cli/taosctl/commands/video.py b/tinyagentos/cli/taosctl/commands/video.py
new file mode 100644
index 000000000..407c4dde6
--- /dev/null
+++ b/tinyagentos/cli/taosctl/commands/video.py
@@ -0,0 +1,45 @@
+"""taosctl video -- list and manage generated videos."""
+from __future__ import annotations
+
+from urllib.parse import quote
+
+NOUN = "video"
+
+
+def register(subparsers) -> None:
+ p = subparsers.add_parser(NOUN, help="List and manage generated videos")
+ verbs = p.add_subparsers(dest="verb", required=True, metavar="")
+
+ lp = verbs.add_parser("list", help="List generated videos")
+ lp.set_defaults(func=_list)
+
+ gp = verbs.add_parser("generate", help="Generate a video from a prompt")
+ gp.add_argument("prompt", help="Text prompt for the video")
+ gp.add_argument("--model", default="wan2.1-1.3b", help="Model id")
+ gp.add_argument("--duration", type=int, default=5, help="Duration in seconds")
+ gp.add_argument("--resolution", default="480x832", help="Resolution WxH")
+ gp.add_argument("--seed", type=int, default=None, help="Random seed")
+ gp.set_defaults(func=_generate)
+
+ dp = verbs.add_parser("delete", help="Delete a generated video")
+ dp.add_argument("filename", help="Video filename")
+ dp.set_defaults(func=_delete)
+
+
+def _list(args, client):
+ return client.get("/api/video")
+
+
+def _generate(args, client):
+ body = {
+ "prompt": args.prompt,
+ "model": args.model,
+ "duration": args.duration,
+ "resolution": args.resolution,
+ "seed": args.seed,
+ }
+ return client.post("/api/video/generate", body=body)
+
+
+def _delete(args, client):
+ return client.delete(f"/api/video/{quote(args.filename, safe='')}")
diff --git a/tinyagentos/cli/taosctl/output.py b/tinyagentos/cli/taosctl/output.py
new file mode 100644
index 000000000..d2500d545
--- /dev/null
+++ b/tinyagentos/cli/taosctl/output.py
@@ -0,0 +1,64 @@
+"""Output rendering for taosctl. Default is human-readable; --json prints the
+raw machine-readable JSON. Data goes to stdout, diagnostics to stderr."""
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any
+
+
+def render(data: Any, as_json: bool) -> None:
+ if as_json:
+ print(json.dumps(data, indent=2, default=str))
+ return
+ if data is None:
+ return
+ # Unwrap the common {"items": [...]} list envelope.
+ if isinstance(data, dict) and "items" in data and isinstance(data["items"], list):
+ data = data["items"]
+ if isinstance(data, list):
+ _render_table(data)
+ elif isinstance(data, dict):
+ _render_kv(data)
+ else:
+ print(data)
+
+
+def _render_table(rows: list) -> None:
+ if not rows:
+ print("(none)")
+ return
+ if not all(isinstance(r, dict) for r in rows):
+ for r in rows:
+ print(r)
+ return
+ # Prefer a small set of identifying columns when present, else the first
+ # few keys of the first row.
+ preferred = ["id", "name", "slug", "status", "state", "title", "framework"]
+ first = rows[0]
+ cols = [c for c in preferred if c in first] or list(first.keys())[:5]
+ widths = {c: max(len(c), *(len(_cell(r.get(c))) for r in rows)) for c in cols}
+ header = " ".join(c.upper().ljust(widths[c]) for c in cols)
+ print(header)
+ for r in rows:
+ print(" ".join(_cell(r.get(c)).ljust(widths[c]) for c in cols))
+
+
+def _render_kv(obj: dict) -> None:
+ width = max((len(k) for k in obj), default=0)
+ for k, v in obj.items():
+ if isinstance(v, (dict, list)):
+ v = json.dumps(v, default=str)
+ print(f"{k.ljust(width)} {v}")
+
+
+def _cell(v: Any) -> str:
+ if v is None:
+ return "-"
+ if isinstance(v, (dict, list)):
+ return json.dumps(v, default=str)
+ return str(v)
+
+
+def error(msg: str) -> None:
+ print(f"taosctl: {msg}", file=sys.stderr)
diff --git a/tinyagentos/coding_workspaces.py b/tinyagentos/coding_workspaces.py
new file mode 100644
index 000000000..4cfc08749
--- /dev/null
+++ b/tinyagentos/coding_workspaces.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import asyncio
+import secrets
+import shutil
+import time
+from pathlib import Path
+
+from tinyagentos.base_store import BaseStore
+
+_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
+
+CODING_WORKSPACES_SCHEMA = """
+CREATE TABLE IF NOT EXISTS coding_workspaces (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+"""
+
+
+def _new_workspace_id() -> str:
+ suffix = "".join(secrets.choice(_ALPHABET) for _ in range(6))
+ return f"cws-{suffix}"
+
+
+class CodingWorkspaceStore(BaseStore):
+ SCHEMA = CODING_WORKSPACES_SCHEMA
+
+ def __init__(self, db_path: Path, workspaces_root: Path):
+ super().__init__(db_path)
+ self.workspaces_root = workspaces_root
+
+ async def _git_init(self, workspace_dir: Path) -> None:
+ # git init can transiently fail under parallel CI/test load. Retry once
+ # and capture stderr so a real failure is diagnosable instead of an
+ # opaque "git init failed". -q suppresses the default-branch hint.
+ last_err = ""
+ for _ in range(2):
+ proc = await asyncio.create_subprocess_exec(
+ "git",
+ "init",
+ "-q",
+ cwd=str(workspace_dir),
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _out, err = await proc.communicate()
+ if proc.returncode == 0:
+ return
+ last_err = (err or b"").decode("utf-8", "replace").strip()
+ raise RuntimeError(f"git init failed: {last_err or 'unknown error'}")
+
+ async def create(self, name: str) -> dict:
+ self.workspaces_root.mkdir(parents=True, exist_ok=True)
+ for _ in range(8):
+ wid = _new_workspace_id()
+ async with self._db.execute(
+ "SELECT 1 FROM coding_workspaces WHERE id = ?", (wid,)
+ ) as cur:
+ if await cur.fetchone() is None:
+ break
+ else:
+ raise RuntimeError("could not allocate workspace id")
+
+ workspace_dir = (self.workspaces_root / wid).resolve()
+ root = self.workspaces_root.resolve()
+ if not workspace_dir.is_relative_to(root):
+ raise RuntimeError("invalid workspace path")
+ workspace_dir.mkdir(parents=True, exist_ok=False)
+ try:
+ await self._git_init(workspace_dir)
+ now = int(time.time())
+ row = {
+ "id": wid,
+ "name": name,
+ "path": str(workspace_dir),
+ "created_at": now,
+ }
+ await self._db.execute(
+ """INSERT INTO coding_workspaces (id, name, path, created_at)
+ VALUES (?, ?, ?, ?)""",
+ (row["id"], row["name"], row["path"], row["created_at"]),
+ )
+ await self._db.commit()
+ return row
+ except Exception:
+ shutil.rmtree(workspace_dir, ignore_errors=True)
+ raise
+
+ async def list(self) -> list[dict]:
+ async with self._db.execute(
+ "SELECT id, name, path, created_at FROM coding_workspaces ORDER BY created_at ASC"
+ ) as cur:
+ rows = await cur.fetchall()
+ return [
+ {"id": r[0], "name": r[1], "path": r[2], "created_at": r[3]}
+ for r in rows
+ ]
+
+ async def get(self, workspace_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT id, name, path, created_at FROM coding_workspaces WHERE id = ?",
+ (workspace_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None:
+ return None
+ return {"id": row[0], "name": row[1], "path": row[2], "created_at": row[3]}
+
+ async def delete(self, workspace_id: str) -> bool:
+ row = await self.get(workspace_id)
+ if row is None:
+ return False
+
+ workspace_dir = Path(row["path"]).resolve()
+ root = self.workspaces_root.resolve()
+ if workspace_dir.is_relative_to(root) and workspace_dir != root and workspace_dir.exists():
+ shutil.rmtree(workspace_dir)
+ if workspace_dir.exists():
+ raise RuntimeError("workspace directory still exists after removal")
+
+ await self._db.execute(
+ "DELETE FROM coding_workspaces WHERE id = ?", (workspace_id,)
+ )
+ await self._db.commit()
+ return True
\ No newline at end of file
diff --git a/tinyagentos/desktop_rebuild.py b/tinyagentos/desktop_rebuild.py
index 7f16fb3ab..d002364d1 100644
--- a/tinyagentos/desktop_rebuild.py
+++ b/tinyagentos/desktop_rebuild.py
@@ -8,12 +8,18 @@
from __future__ import annotations
import asyncio
+import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
+# Marker recording the package-lock.json hash of the last successful
+# `npm install`. Lives inside node_modules so it is naturally per-install
+# (wiped whenever node_modules is) and never tracked by git.
+_DEPS_MARKER = "node_modules/.taos-deps-lock"
+
@dataclass(frozen=True)
class RebuildResult:
@@ -50,6 +56,45 @@ def _is_bundle_stale(project_root: Path) -> bool:
return False
+def _lockfile_hash(desktop_dir: Path) -> str | None:
+ """SHA-256 of desktop/package-lock.json, or None if it doesn't exist."""
+ lock = desktop_dir / "package-lock.json"
+ if not lock.is_file():
+ return None
+ return hashlib.sha256(lock.read_bytes()).hexdigest()
+
+
+def _deps_install_needed(desktop_dir: Path) -> bool:
+ """True if ``npm install`` must run before a build.
+
+ Skips the install only when node_modules already exists and the
+ package-lock.json hash matches the last successful install. Any of
+ {no node_modules, no lockfile to compare, lockfile changed, marker
+ unreadable} forces the install — so the gate never trades correctness
+ for speed.
+ """
+ if not (desktop_dir / "node_modules").is_dir():
+ return True
+ current = _lockfile_hash(desktop_dir)
+ if current is None:
+ return True # no lockfile to trust — always install
+ try:
+ return (desktop_dir / _DEPS_MARKER).read_text().strip() != current
+ except OSError:
+ return True
+
+
+def _record_deps_install(desktop_dir: Path) -> None:
+ """Record the current package-lock hash after a successful npm install."""
+ current = _lockfile_hash(desktop_dir)
+ if current is None:
+ return
+ try:
+ (desktop_dir / _DEPS_MARKER).write_text(current)
+ except OSError as exc: # node_modules vanished mid-build, read-only fs, etc.
+ logger.warning("Could not write deps marker (%s) — next update reinstalls.", exc)
+
+
async def rebuild_desktop_bundle_if_stale(
project_root: Path,
*,
@@ -88,17 +133,52 @@ async def rebuild_desktop_bundle_if_stale(
logger.info("Desktop source is ahead of bundle — rebuilding...")
try:
- proc = await asyncio.create_subprocess_exec(
- "npm", "install", "--silent",
- cwd=str(desktop_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- _, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
- if proc.returncode != 0:
- msg = f"npm install failed (rc={proc.returncode}): {stderr.decode(errors='replace')[-500:]}"
- logger.error(msg)
- return RebuildResult(rebuilt=True, success=False, message=msg)
+ if _deps_install_needed(desktop_dir):
+ # Use `npm ci`, not `npm install`: ci installs exactly from the
+ # committed package-lock.json and NEVER rewrites it. `npm install`
+ # rewrites the lockfile, which leaves the tracked desktop/package-
+ # lock.json dirty and makes the next in-app `git pull` update abort
+ # with "local changes would be overwritten by merge" (the deadlock a
+ # user hit when their installed version predated the #852 restore).
+ logger.info("Dependencies changed or missing — running npm ci...")
+ proc = await asyncio.create_subprocess_exec(
+ "npm", "ci", "--silent",
+ cwd=str(desktop_dir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
+ if proc.returncode != 0:
+ # `npm ci` fails if package.json and the lockfile are out of sync
+ # (rare). Fall back to `npm install`, then restore the lockfile so
+ # the tree stays clean for the next update.
+ logger.warning(
+ "npm ci failed (rc=%s), falling back to npm install: %s",
+ proc.returncode, stderr.decode(errors="replace")[-300:],
+ )
+ proc = await asyncio.create_subprocess_exec(
+ "npm", "install", "--silent",
+ cwd=str(desktop_dir),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
+ if proc.returncode != 0:
+ msg = f"npm install failed (rc={proc.returncode}): {stderr.decode(errors='replace')[-500:]}"
+ logger.error(msg)
+ return RebuildResult(rebuilt=True, success=False, message=msg)
+ # Discard the lockfile rewrite npm install just made so the
+ # working tree is clean for the next git-pull update.
+ restore = await asyncio.create_subprocess_exec(
+ "git", "checkout", "--", "package-lock.json",
+ cwd=str(desktop_dir),
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+ await restore.communicate()
+ _record_deps_install(desktop_dir)
+ else:
+ logger.info("Dependencies unchanged — skipping npm install.")
proc = await asyncio.create_subprocess_exec(
"npm", "run", "build",
diff --git a/tinyagentos/feedback_store.py b/tinyagentos/feedback_store.py
new file mode 100644
index 000000000..a959c8a7f
--- /dev/null
+++ b/tinyagentos/feedback_store.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import json
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+from tinyagentos.base_store import BaseStore
+
+# Maximum allowed screenshot size (base64 data-URL string length, not raw bytes).
+# A 2 MB raw image becomes ~2.73 MB as base64; 4 MB covers that with headroom.
+MAX_SCREENSHOT_LEN = 4_000_000
+
+# Maximum body text length.
+MAX_BODY_LEN = 20_000
+
+
+class FeedbackStore(BaseStore):
+ SCHEMA = """
+ CREATE TABLE IF NOT EXISTS feedback (
+ id TEXT NOT NULL PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ type TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL DEFAULT '',
+ screenshot TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS feedback_user_created
+ ON feedback (user_id, created_at DESC);
+ """
+
+ async def create(
+ self,
+ *,
+ user_id: str,
+ type: str,
+ title: str,
+ body: str,
+ screenshot: str = "",
+ app: str = "",
+ ) -> dict:
+ assert self._db is not None
+ item_id = str(uuid.uuid4())
+ created_at = datetime.now(timezone.utc).isoformat()
+ await self._db.execute(
+ """
+ INSERT INTO feedback (id, user_id, type, title, body, screenshot, app, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (item_id, user_id, type, title, body, screenshot, app, created_at),
+ )
+ await self._db.commit()
+ return {
+ "id": item_id,
+ "user_id": user_id,
+ "type": type,
+ "title": title,
+ "body": body,
+ "screenshot": screenshot,
+ "app": app,
+ "created_at": created_at,
+ }
+
+ async def list_for_user(self, user_id: str) -> list[dict]:
+ """Return all submissions for a user, most recent first, without screenshot blobs."""
+ assert self._db is not None
+ cursor = await self._db.execute(
+ """
+ SELECT id, user_id, type, title, body, app, created_at,
+ (screenshot != '') AS has_screenshot
+ FROM feedback
+ WHERE user_id = ?
+ ORDER BY created_at DESC
+ """,
+ (user_id,),
+ )
+ rows = await cursor.fetchall()
+ return [
+ {
+ "id": r[0],
+ "user_id": r[1],
+ "type": r[2],
+ "title": r[3],
+ "body": r[4],
+ "app": r[5],
+ "created_at": r[6],
+ "has_screenshot": bool(r[7]),
+ }
+ for r in rows
+ ]
+
+ async def get_by_id(self, item_id: str, user_id: str) -> dict | None:
+ """Return a single feedback item including the screenshot, scoped to the user."""
+ assert self._db is not None
+ cursor = await self._db.execute(
+ """
+ SELECT id, user_id, type, title, body, screenshot, app, created_at
+ FROM feedback
+ WHERE id = ? AND user_id = ?
+ """,
+ (item_id, user_id),
+ )
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return {
+ "id": row[0],
+ "user_id": row[1],
+ "type": row[2],
+ "title": row[3],
+ "body": row[4],
+ "screenshot": row[5],
+ "app": row[6],
+ "created_at": row[7],
+ "has_screenshot": bool(row[5]),
+ }
diff --git a/tinyagentos/notifications.py b/tinyagentos/notifications.py
index a88aa7e08..db00dd6ce 100644
--- a/tinyagentos/notifications.py
+++ b/tinyagentos/notifications.py
@@ -32,7 +32,7 @@ class NotificationStore(BaseStore):
EVENT_TYPES = [
"worker.join", "worker.online", "worker.leave", "backend.up", "backend.down",
"training.complete", "training.failed", "app.installed", "app.failed",
- "disk_quota",
+ "disk_quota", "task.claimed", "task.closed",
]
def __init__(self, *args, **kwargs):
@@ -79,9 +79,10 @@ async def mark_read(self, notif_id: int) -> None:
await self._db.execute("UPDATE notifications SET read = 1 WHERE id = ?", (notif_id,))
await self._db.commit()
- async def mark_all_read(self) -> None:
- await self._db.execute("UPDATE notifications SET read = 1")
+ async def mark_all_read(self) -> int:
+ cursor = await self._db.execute("UPDATE notifications SET read = 1 WHERE read = 0")
await self._db.commit()
+ return cursor.rowcount
async def cleanup(self, max_age_days: int = 30) -> int:
cutoff = int(time.time()) - (max_age_days * 86400)
diff --git a/tinyagentos/office_docs.py b/tinyagentos/office_docs.py
new file mode 100644
index 000000000..4d1f45a59
--- /dev/null
+++ b/tinyagentos/office_docs.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import secrets
+import time
+from pathlib import Path
+
+from tinyagentos.base_store import BaseStore
+
+_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
+
+VALID_KINDS = frozenset({"write", "calc", "db", "slides"})
+
+OFFICE_DOCS_SCHEMA = """
+CREATE TABLE IF NOT EXISTS documents (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+"""
+
+
+def _new_doc_id() -> str:
+ suffix = "".join(secrets.choice(_ALPHABET) for _ in range(8))
+ return f"doc-{suffix}"
+
+
+class OfficeDocStore(BaseStore):
+ SCHEMA = OFFICE_DOCS_SCHEMA
+
+ def __init__(self, db_path: Path):
+ super().__init__(db_path)
+
+ async def create(self, kind: str, title: str, content: str) -> dict:
+ if kind not in VALID_KINDS:
+ raise ValueError(f"kind must be one of {', '.join(sorted(VALID_KINDS))}")
+ for _ in range(8):
+ doc_id = _new_doc_id()
+ async with self._db.execute(
+ "SELECT 1 FROM documents WHERE id = ?", (doc_id,)
+ ) as cur:
+ if await cur.fetchone() is None:
+ break
+ else:
+ raise RuntimeError("could not allocate document id")
+
+ now = int(time.time())
+ row = {
+ "id": doc_id,
+ "kind": kind,
+ "title": title,
+ "content": content,
+ "created_at": now,
+ "updated_at": now,
+ }
+ await self._db.execute(
+ """INSERT INTO documents (id, kind, title, content, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (row["id"], row["kind"], row["title"], row["content"], row["created_at"], row["updated_at"]),
+ )
+ await self._db.commit()
+ return row
+
+ async def list(self) -> list[dict]:
+ async with self._db.execute(
+ "SELECT id, kind, title, created_at, updated_at FROM documents ORDER BY updated_at DESC"
+ ) as cur:
+ rows = await cur.fetchall()
+ return [
+ {"id": r[0], "kind": r[1], "title": r[2], "created_at": r[3], "updated_at": r[4]}
+ for r in rows
+ ]
+
+ async def get(self, doc_id: str) -> dict | None:
+ async with self._db.execute(
+ "SELECT id, kind, title, content, created_at, updated_at FROM documents WHERE id = ?",
+ (doc_id,),
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None:
+ return None
+ return {
+ "id": row[0],
+ "kind": row[1],
+ "title": row[2],
+ "content": row[3],
+ "created_at": row[4],
+ "updated_at": row[5],
+ }
+
+ async def update(
+ self, doc_id: str, title: str, content: str, kind: str | None = None
+ ) -> dict | None:
+ now = int(time.time())
+ if kind is not None:
+ if kind not in VALID_KINDS:
+ raise ValueError(f"kind must be one of {', '.join(sorted(VALID_KINDS))}")
+ await self._db.execute(
+ "UPDATE documents SET kind = ?, title = ?, content = ?, updated_at = ? WHERE id = ?",
+ (kind, title, content, now, doc_id),
+ )
+ else:
+ await self._db.execute(
+ "UPDATE documents SET title = ?, content = ?, updated_at = ? WHERE id = ?",
+ (title, content, now, doc_id),
+ )
+ await self._db.commit()
+ return await self.get(doc_id)
+
+ async def delete(self, doc_id: str) -> bool:
+ async with self._db.execute(
+ "SELECT 1 FROM documents WHERE id = ?", (doc_id,)
+ ) as cur:
+ exists = await cur.fetchone() is not None
+ if not exists:
+ return False
+ await self._db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
+ await self._db.commit()
+ return True
diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py
index ee8dafbcc..1b159714d 100644
--- a/tinyagentos/projects/task_store.py
+++ b/tinyagentos/projects/task_store.py
@@ -247,6 +247,26 @@ async def close_task(
await self._publish(existing["project_id"], "task.closed", {"id": task_id, "closed_by": closed_by})
return changed
+ async def reopen_task(self, task_id: str, reopened_by: str) -> bool:
+ """Undo a close: a closed task returns to the open pool (claimer stays
+ cleared, so a free agent can pick it up again). Only acts on a closed
+ task; returns False otherwise."""
+ now = time.time()
+ cursor = await self._db.execute(
+ """UPDATE project_tasks
+ SET status = 'open', closed_by = NULL, closed_at = NULL, close_reason = NULL,
+ claimed_by = NULL, claimed_at = NULL, updated_at = ?
+ WHERE id = ? AND status = 'closed'""",
+ (now, task_id),
+ )
+ await self._db.commit()
+ changed = cursor.rowcount == 1
+ if changed:
+ existing = await self.get_task(task_id)
+ if existing is not None:
+ await self._publish(existing["project_id"], "task.reopened", {"id": task_id, "reopened_by": reopened_by})
+ return changed
+
async def add_relationship(
self,
project_id: str,
diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py
index 489c045f2..eda4e4d71 100644
--- a/tinyagentos/routes/__init__.py
+++ b/tinyagentos/routes/__init__.py
@@ -60,6 +60,9 @@ def register_all_routers(app):
from tinyagentos.routes.images import router as images_router
app.include_router(images_router)
+ from tinyagentos.routes.music import router as music_router
+ app.include_router(music_router)
+
from tinyagentos.routes.images_edit import router as images_edit_router
app.include_router(images_edit_router)
@@ -292,3 +295,17 @@ def register_all_routers(app):
from tinyagentos.routes.userspace_apps import router as userspace_apps_router
app.include_router(userspace_apps_router)
+
+ from tinyagentos.routes.feedback import router as feedback_router
+ app.include_router(feedback_router)
+
+ from tinyagentos.routes.account_proxy import router as account_proxy_router
+ app.include_router(account_proxy_router)
+
+ from tinyagentos.routes.office import router as office_router
+ app.include_router(office_router)
+ from tinyagentos.routes.coding import router as coding_router
+ app.include_router(coding_router)
+
+ from tinyagentos.routes.manifest import router as manifest_router
+ app.include_router(manifest_router)
diff --git a/tinyagentos/routes/account_proxy.py b/tinyagentos/routes/account_proxy.py
new file mode 100644
index 000000000..425796e4b
--- /dev/null
+++ b/tinyagentos/routes/account_proxy.py
@@ -0,0 +1,145 @@
+"""Proxy for the taOSgo account service (taos.my) -- taOSgo Phase 1.
+
+The taOS client (Settings > Account, the off-network screen) calls same-origin
+/api/account/* so the taos.my base URL stays server-side and there is no CORS.
+We forward to {TAOS_ACCOUNT_BASE_URL}/api/auth/* with cookie pass-through both
+ways, so the taos.my session cookie round-trips through this host origin.
+
+If TAOS_ACCOUNT_BASE_URL is unset the proxy returns 503 and the Account pane
+renders its 'service unavailable' state, so the client ships ahead of taos.my.
+"""
+from __future__ import annotations
+
+import os
+
+import httpx
+from fastapi import APIRouter, Request, Response
+from fastapi.responses import JSONResponse
+
+router = APIRouter()
+
+# Only these account actions are proxied. The upstream base is operator config
+# (env), never user input, so there is no open-proxy / SSRF surface.
+_ACTIONS: dict[str, tuple[str, str]] = {
+ "me": ("GET", "/api/auth/me"),
+ "login": ("POST", "/api/auth/login"),
+ "register": ("POST", "/api/auth/register"),
+ "logout": ("POST", "/api/auth/logout"),
+}
+
+_TIMEOUT = httpx.Timeout(15.0)
+
+
+def _base_url() -> str | None:
+ base = os.environ.get("TAOS_ACCOUNT_BASE_URL", "").strip()
+ return base.rstrip("/") or None
+
+
+def _trust_forwarded_proto() -> bool:
+ """X-Forwarded-Proto is client-spoofable unless a trusted proxy sets it. Only
+ honor it when the deployment opts in (the taOSgo relay, which terminates TLS
+ and forwards over http, sets TAOS_TRUST_FORWARDED_PROTO=1)."""
+ return os.environ.get("TAOS_TRUST_FORWARDED_PROTO", "").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+
+
+def _append_header(resp: Response, name: str, value: str) -> None:
+ """Append a raw response header, skipping values that are not latin-1
+ encodable. HTTP/1.1 header bytes are latin-1; a non-encodable relayed value
+ would otherwise raise UnicodeEncodeError and break the whole response."""
+ try:
+ resp.raw_headers.append((name.encode("latin-1"), value.encode("latin-1")))
+ except UnicodeEncodeError:
+ pass
+
+
+def _rewrite_set_cookie(value: str, secure_ok: bool) -> str:
+ """Rescope an upstream Set-Cookie to this proxy origin so the browser
+ accepts it: drop the Domain attribute (the cookie was issued for taos.my but
+ the browser is talking to this host), and drop Secure when the proxy
+ connection is not HTTPS, since a Secure cookie is rejected over plain HTTP."""
+ kept: list[str] = []
+ for part in value.split(";"):
+ p = part.strip()
+ low = p.lower()
+ if low.startswith("domain="):
+ continue
+ if low == "secure" and not secure_ok:
+ continue
+ kept.append(p)
+ return "; ".join(kept)
+
+
+async def _forward(request: Request, action: str) -> Response:
+ base = _base_url()
+ if base is None:
+ return JSONResponse(
+ {"error": "account service not configured"}, status_code=503
+ )
+ method, path = _ACTIONS[action]
+ headers: dict[str, str] = {}
+ cookie = request.headers.get("cookie")
+ if cookie:
+ headers["Cookie"] = cookie
+ body: bytes | None = None
+ if method == "POST":
+ body = await request.body()
+ ctype = request.headers.get("content-type")
+ if ctype:
+ headers["Content-Type"] = ctype
+ try:
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as http:
+ upstream = await http.request(
+ method, base + path, content=body, headers=headers
+ )
+ except httpx.HTTPError:
+ return JSONResponse(
+ {"error": "account service unreachable"}, status_code=503
+ )
+ # Relay the upstream body + content-type verbatim (do not assume JSON), so
+ # error pages, redirects, and non-JSON bodies pass through unmangled.
+ resp = Response(
+ content=upstream.content,
+ status_code=upstream.status_code,
+ media_type=upstream.headers.get("content-type"),
+ )
+ # Derive Secure from the real connection scheme, and from X-Forwarded-Proto
+ # only when the deployment trusts it (the TLS-terminating taOSgo relay
+ # forwards over http). Untrusted, the header is client-spoofable so ignore it.
+ fwd = ""
+ if _trust_forwarded_proto():
+ fwd = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
+ secure_ok = request.url.scheme == "https" or fwd == "https"
+ # Relay the session cookie (rescoped to this origin) plus a small allowlist of
+ # response headers so redirects (Location) and auth challenges survive.
+ _RELAY = {"location", "cache-control", "www-authenticate"}
+ for name, value in upstream.headers.multi_items():
+ low = name.lower()
+ if low == "set-cookie":
+ _append_header(resp, "set-cookie", _rewrite_set_cookie(value, secure_ok))
+ elif low in _RELAY:
+ _append_header(resp, low, value)
+ return resp
+
+
+@router.get("/api/account/me")
+async def account_me(request: Request):
+ return await _forward(request, "me")
+
+
+@router.post("/api/account/login")
+async def account_login(request: Request):
+ return await _forward(request, "login")
+
+
+@router.post("/api/account/register")
+async def account_register(request: Request):
+ return await _forward(request, "register")
+
+
+@router.post("/api/account/logout")
+async def account_logout(request: Request):
+ return await _forward(request, "logout")
diff --git a/tinyagentos/routes/coding.py b/tinyagentos/routes/coding.py
new file mode 100644
index 000000000..72fbd3e7c
--- /dev/null
+++ b/tinyagentos/routes/coding.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+class CreateWorkspaceBody(BaseModel):
+ name: str
+
+
+class WriteFileBody(BaseModel):
+ path: str
+ content: str
+
+
+def _invalid_rel_path(rel: str) -> bool:
+ if rel.startswith("/") or "://" in rel or rel.startswith("//"):
+ return True
+ return ".." in Path(rel).parts
+
+
+_MAX_READ_BYTES = 2_000_000
+
+
+def _resolve_jailed(root: Path, rel: str, *, allow_root: bool = False) -> Path | None:
+ if _invalid_rel_path(rel):
+ return None
+ target = (root / rel).resolve() if rel else root.resolve()
+ if not target.is_relative_to(root):
+ return None
+ if target == root and not allow_root:
+ return None
+ return target
+
+
+async def _workspace_root(request: Request, workspace_id: str) -> tuple[Path | None, JSONResponse | None]:
+ store = request.app.state.coding_workspaces
+ row = await store.get(workspace_id)
+ if row is None:
+ return None, JSONResponse({"error": "workspace not found"}, status_code=404)
+ root = store.workspaces_root.resolve()
+ workspace = Path(row["path"]).resolve()
+ if not workspace.is_relative_to(root) or workspace == root:
+ return None, JSONResponse({"error": "workspace not found"}, status_code=404)
+ return workspace, None
+
+
+@router.post("/api/coding/workspaces")
+async def create_workspace(request: Request, body: CreateWorkspaceBody):
+ name = (body.name or "").strip()
+ if not name:
+ return JSONResponse({"error": "name is required"}, status_code=400)
+ store = request.app.state.coding_workspaces
+ try:
+ row = await store.create(name)
+ except RuntimeError as exc:
+ # workspace creation (git init / id allocation) failed. Log the detail
+ # (may contain git stderr / internal paths) server-side and return a
+ # generic message so nothing internal leaks to the client.
+ logger.warning("coding workspace creation failed: %s", exc)
+ return JSONResponse({"error": "workspace creation failed"}, status_code=503)
+ return row
+
+
+@router.get("/api/coding/workspaces")
+async def list_workspaces(request: Request):
+ store = request.app.state.coding_workspaces
+ return await store.list()
+
+
+@router.get("/api/coding/workspaces/{workspace_id}/files")
+async def list_files(request: Request, workspace_id: str, subpath: str = ""):
+ root, err = await _workspace_root(request, workspace_id)
+ if err is not None:
+ return err
+ target = _resolve_jailed(root, subpath, allow_root=True)
+ if target is None:
+ return JSONResponse({"error": "invalid path"}, status_code=400)
+ if not target.is_dir():
+ return JSONResponse({"error": "not found"}, status_code=404)
+ entries = []
+ for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
+ entries.append({"name": child.name, "is_dir": child.is_dir()})
+ return entries
+
+
+@router.get("/api/coding/workspaces/{workspace_id}/file")
+async def read_file(request: Request, workspace_id: str, path: str):
+ root, err = await _workspace_root(request, workspace_id)
+ if err is not None:
+ return err
+ target = _resolve_jailed(root, path)
+ if target is None:
+ return JSONResponse({"error": "invalid path"}, status_code=400)
+ if not target.is_file():
+ return JSONResponse({"error": "not found"}, status_code=404)
+ if target.stat().st_size > _MAX_READ_BYTES:
+ return JSONResponse({"error": "file too large"}, status_code=400)
+ try:
+ content = target.read_text(encoding="utf-8")
+ except (UnicodeDecodeError, OSError):
+ return JSONResponse({"error": "binary_or_undecodable"}, status_code=400)
+ return {"path": path, "content": content}
+
+
+@router.put("/api/coding/workspaces/{workspace_id}/file")
+async def write_file(request: Request, workspace_id: str, body: WriteFileBody):
+ root, err = await _workspace_root(request, workspace_id)
+ if err is not None:
+ return err
+ rel = body.path or ""
+ target = _resolve_jailed(root, rel)
+ if target is None:
+ return JSONResponse({"error": "invalid path"}, status_code=400)
+ if target == root or target.is_dir():
+ return JSONResponse({"error": "invalid path"}, status_code=400)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(body.content)
+ return {"path": rel, "ok": True}
+
+
+@router.delete("/api/coding/workspaces/{workspace_id}")
+async def delete_workspace(request: Request, workspace_id: str):
+ store = request.app.state.coding_workspaces
+ removed = await store.delete(workspace_id)
+ if not removed:
+ return JSONResponse({"error": "workspace not found"}, status_code=404)
+ return {"ok": True}
\ No newline at end of file
diff --git a/tinyagentos/routes/desktop.py b/tinyagentos/routes/desktop.py
index 75751010c..8b95a2057 100644
--- a/tinyagentos/routes/desktop.py
+++ b/tinyagentos/routes/desktop.py
@@ -105,7 +105,7 @@ async def serve_chat_pwa():
"""Serve the standalone chat PWA."""
chat_html = SPA_DIR / "chat.html"
if chat_html.exists():
- return FileResponse(chat_html, media_type="text/html")
+ return FileResponse(chat_html, media_type="text/html", headers=_HTML_NO_CACHE)
return JSONResponse({"error": "Chat PWA not built"}, status_code=404)
@@ -115,10 +115,21 @@ async def serve_chat_pwa_assets(rest: str = ""):
# Assets are at /desktop/assets/... due to base path — this route just serves index
chat_html = SPA_DIR / "chat.html"
if chat_html.exists():
- return FileResponse(chat_html, media_type="text/html")
+ return FileResponse(chat_html, media_type="text/html", headers=_HTML_NO_CACHE)
return JSONResponse({"error": "Chat PWA not built"}, status_code=404)
+@router.get("/app.html")
+async def serve_app_pwa():
+ """Serve the generic standalone app PWA shell. It reads ?app= at runtime
+ and mounts that app full-screen (assets load from the /desktop/assets base,
+ same as the chat PWA)."""
+ app_html = SPA_DIR / "app.html"
+ if app_html.exists():
+ return FileResponse(app_html, media_type="text/html", headers=_HTML_NO_CACHE)
+ return JSONResponse({"error": "App PWA shell not built"}, status_code=404)
+
+
@router.post("/api/desktop/browser/agent-command")
async def browser_agent_command(request: Request):
"""Execute a natural language command on the current page using browser-use."""
diff --git a/tinyagentos/routes/feedback.py b/tinyagentos/routes/feedback.py
new file mode 100644
index 000000000..c69b5410e
--- /dev/null
+++ b/tinyagentos/routes/feedback.py
@@ -0,0 +1,115 @@
+"""Routes for the in-OS Feedback feature.
+
+POST /api/feedback -- submit a bug report or feature request
+GET /api/feedback -- list the current user's past submissions (no screenshot blob)
+GET /api/feedback/{id} -- fetch a single submission including its screenshot
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, field_validator
+
+from tinyagentos.auth import get_current_user
+from tinyagentos.feedback_store import MAX_BODY_LEN, MAX_SCREENSHOT_LEN
+
+router = APIRouter()
+
+VALID_TYPES = {"bug", "feature"}
+
+
+class FeedbackSubmit(BaseModel):
+ type: str
+ title: str
+ body: str = ""
+ screenshot: str = ""
+ app: str = ""
+
+ @field_validator("type")
+ @classmethod
+ def validate_type(cls, v: str) -> str:
+ if v not in VALID_TYPES:
+ raise ValueError(f"type must be one of {sorted(VALID_TYPES)}")
+ return v
+
+ @field_validator("title")
+ @classmethod
+ def validate_title(cls, v: str) -> str:
+ v = v.strip()
+ if not v:
+ raise ValueError("title must not be empty")
+ if len(v) > 300:
+ raise ValueError("title is too long (max 300 chars)")
+ return v
+
+ @field_validator("body")
+ @classmethod
+ def validate_body(cls, v: str) -> str:
+ if len(v) > MAX_BODY_LEN:
+ raise ValueError(f"body exceeds the maximum length of {MAX_BODY_LEN} characters")
+ return v
+
+ @field_validator("screenshot")
+ @classmethod
+ def validate_screenshot(cls, v: str) -> str:
+ if v and len(v) > MAX_SCREENSHOT_LEN:
+ raise ValueError(
+ f"screenshot is too large (max {MAX_SCREENSHOT_LEN // 1_000_000} MB)"
+ )
+ return v
+
+
+@router.post("/api/feedback", status_code=201)
+async def submit_feedback(
+ payload: FeedbackSubmit,
+ request: Request,
+ current_user: dict[str, Any] = Depends(get_current_user),
+) -> dict:
+ """Create a new feedback submission for the authenticated user."""
+ store = request.app.state.feedback_store
+ item = await store.create(
+ user_id=current_user["id"],
+ type=payload.type,
+ title=payload.title,
+ body=payload.body,
+ screenshot=payload.screenshot,
+ app=payload.app,
+ )
+ # Return without the screenshot blob to keep the response light.
+ return {
+ "id": item["id"],
+ "type": item["type"],
+ "title": item["title"],
+ "body": item["body"],
+ "app": item["app"],
+ "created_at": item["created_at"],
+ "has_screenshot": bool(item["screenshot"]),
+ }
+
+
+@router.get("/api/feedback")
+async def list_feedback(
+ request: Request,
+ current_user: dict[str, Any] = Depends(get_current_user),
+) -> list[dict]:
+ """List the current user's feedback submissions, most recent first.
+
+ Screenshots are omitted; use GET /api/feedback/{id} to retrieve one.
+ """
+ store = request.app.state.feedback_store
+ return await store.list_for_user(current_user["id"])
+
+
+@router.get("/api/feedback/{item_id}")
+async def get_feedback(
+ item_id: str,
+ request: Request,
+ current_user: dict[str, Any] = Depends(get_current_user),
+) -> dict:
+ """Return a single feedback submission including its screenshot."""
+ store = request.app.state.feedback_store
+ item = await store.get_by_id(item_id, current_user["id"])
+ if item is None:
+ raise HTTPException(status_code=404, detail="Feedback item not found")
+ return item
diff --git a/tinyagentos/routes/manifest.py b/tinyagentos/routes/manifest.py
new file mode 100644
index 000000000..ddd2935a6
--- /dev/null
+++ b/tinyagentos/routes/manifest.py
@@ -0,0 +1,64 @@
+"""Dynamic PWA manifest endpoint.
+
+GET /manifest?app= returns a Web App Manifest JSON for apps that are
+flagged pwa:true on the frontend. This mirrors the frontend pwa:true flag in
+app-registry.ts; a fuller DRY source shared between frontend and backend is a
+follow-up.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import JSONResponse
+
+router = APIRouter()
+
+# Maps app id -> manifest metadata for each pwa:true app in app-registry.ts.
+# Mirrors the frontend pwa:true flag; a shared source-of-truth is a follow-up.
+_PWA_APPS: dict[str, dict] = {
+ "messages": {
+ "name": "taOS talk",
+ "short_name": "taOS talk",
+ "theme_color": "#141415",
+ "background_color": "#141415",
+ },
+}
+
+
+@router.get("/manifest")
+async def get_manifest(app: str) -> JSONResponse:
+ """Return a Web App Manifest for a PWA-enabled app.
+
+ Returns 404 for unknown or non-PWA app ids.
+ """
+ meta = _PWA_APPS.get(app)
+ if not meta:
+ raise HTTPException(status_code=404, detail="App not found or not PWA-enabled")
+
+ manifest = {
+ "name": meta["name"],
+ "short_name": meta["short_name"],
+ "id": f"/app.html?app={app}",
+ "start_url": f"/app.html?app={app}",
+ "scope": "/",
+ "display": "standalone",
+ "theme_color": meta["theme_color"],
+ "background_color": meta["background_color"],
+ "icons": [
+ {
+ "src": "/static/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable",
+ },
+ {
+ "src": "/static/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable",
+ },
+ ],
+ }
+ return JSONResponse(
+ content=manifest,
+ headers={"Content-Type": "application/manifest+json"},
+ )
diff --git a/tinyagentos/routes/music.py b/tinyagentos/routes/music.py
new file mode 100644
index 000000000..307597ec9
--- /dev/null
+++ b/tinyagentos/routes/music.py
@@ -0,0 +1,432 @@
+# tinyagentos/routes/music.py
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import shutil
+import time
+import uuid
+from pathlib import Path
+from urllib.parse import urlparse
+
+import httpx
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+# stable-audio-open is cataloged but has no runnable compose path yet.
+MUSIC_SERVICE_IDS = ("musicgpt", "musicgen")
+DEFAULT_MUSICGPT_PORT = 30264
+
+
+class ComposeRequest(BaseModel):
+ prompt: str
+ duration: int = Field(default=10, ge=1, le=30)
+
+
+def _music_dir(request: Request) -> Path:
+ """Return workspace/music/generated, creating it if needed."""
+ config_path = getattr(request.app.state, "config_path", None)
+ if config_path is not None:
+ data_dir = Path(config_path).parent
+ else:
+ data_dir = Path(__file__).parent.parent.parent / "data"
+ d = data_dir / "workspace" / "music" / "generated"
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _music_url_path(filename: str) -> str:
+ return f"/data/workspace/music/generated/{filename}"
+
+
+def _apps_dir(request: Request) -> Path | None:
+ apps_dir = getattr(request.app.state, "apps_dir", None)
+ if apps_dir is not None:
+ return Path(apps_dir)
+ data_dir = getattr(request.app.state, "data_dir", None)
+ if data_dir is not None:
+ return Path(data_dir) / "apps"
+ return None
+
+
+def _service_python(request: Request, app_id: str) -> Path | None:
+ root = _apps_dir(request)
+ if root is None:
+ return None
+ py = root / app_id / "venv" / "bin" / "python"
+ return py if py.exists() else None
+
+
+async def _store_ready(store: object | None) -> bool:
+ return store is not None and getattr(store, "_db", None) is not None
+
+
+async def _is_service_installed(request: Request, app_id: str) -> bool:
+ store = getattr(request.app.state, "installed_apps", None)
+ if await _store_ready(store) and await store.is_installed(app_id):
+ return True
+ registry = getattr(request.app.state, "registry", None)
+ if registry is not None and registry.is_installed(app_id):
+ return True
+ installation = getattr(request.app.state, "installation_state", None)
+ if installation is not None and installation.is_installed(app_id):
+ return True
+ if app_id == "musicgpt" and shutil.which("musicgpt"):
+ return True
+ if app_id == "musicgen" and _service_python(request, app_id):
+ return True
+ return False
+
+
+async def _http_backend_reachable(backend_url: str) -> bool:
+ """Return True when an HTTP music backend responds on a lightweight probe."""
+ base = backend_url.rstrip("/")
+ async with httpx.AsyncClient(timeout=3) as client:
+ for path in ("/health", "/v1/models", "/"):
+ try:
+ resp = await client.get(f"{base}{path}")
+ if resp.status_code < 500:
+ return True
+ except httpx.TransportError:
+ continue
+ return False
+
+
+def _normalized_origin(url: str) -> tuple[str, str, int] | None:
+ parsed = urlparse(url)
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
+ return None
+ port = parsed.port
+ if port is None:
+ port = 443 if parsed.scheme == "https" else 80
+ return (parsed.scheme.lower(), parsed.hostname.lower(), port)
+
+
+def _same_backend_host(download_url: str, backend_url: str) -> bool:
+ """Only follow backend-provided URLs that match the trusted backend origin."""
+ dl_origin = _normalized_origin(download_url)
+ be_origin = _normalized_origin(backend_url)
+ if dl_origin is None or be_origin is None:
+ return False
+ return dl_origin == be_origin
+
+
+async def _resolve_music_backend(
+ request: Request,
+) -> tuple[str | None, str | None, str]:
+ """Return (backend_id, backend_url, mode).
+
+ mode is one of: http, musicgpt-cli, musicgen-cli
+ """
+ config = request.app.state.config
+ override = config.server.get("music_backend_url")
+ if override:
+ url = str(override)
+ if await _http_backend_reachable(url):
+ return "music-backend", url, "http"
+
+ store = getattr(request.app.state, "installed_apps", None)
+ if await _store_ready(store):
+ for app_id in MUSIC_SERVICE_IDS:
+ if not await store.is_installed(app_id):
+ continue
+ loc = await store.get_runtime_location(app_id)
+ if loc and loc.get("runtime_host") and loc.get("runtime_port"):
+ host = loc["runtime_host"]
+ port = loc["runtime_port"]
+ url = f"http://{host}:{port}"
+ if await _http_backend_reachable(url):
+ return app_id, url, "http"
+ if app_id == "musicgpt":
+ url = f"http://127.0.0.1:{DEFAULT_MUSICGPT_PORT}"
+ if await _http_backend_reachable(url):
+ return app_id, url, "http"
+
+ for app_id in MUSIC_SERVICE_IDS:
+ if not await _is_service_installed(request, app_id):
+ continue
+ if app_id == "musicgpt" and shutil.which("musicgpt"):
+ return app_id, None, "musicgpt-cli"
+ if app_id == "musicgen" and _service_python(request, "musicgen"):
+ return app_id, None, "musicgen-cli"
+
+ return None, None, ""
+
+
+def _list_tracks(music_dir: Path) -> list[dict]:
+ results = []
+ for ext in ("*.wav", "*.mp3"):
+ for audio in music_dir.glob(ext):
+ meta_path = audio.with_suffix(".json")
+ metadata: dict = {}
+ if meta_path.exists():
+ try:
+ metadata = json.loads(meta_path.read_text())
+ except (json.JSONDecodeError, OSError):
+ pass
+ results.append({
+ "filename": audio.name,
+ "path": _music_url_path(audio.name),
+ "size_bytes": audio.stat().st_size,
+ "prompt": metadata.get("prompt", ""),
+ "duration": metadata.get("duration", 0),
+ "backend": metadata.get("backend", ""),
+ })
+ results.sort(key=lambda x: x["filename"], reverse=True)
+ return results
+
+
+async def _compose_via_http(
+ backend_url: str,
+ *,
+ prompt: str,
+ duration: int,
+) -> bytes:
+ payload = {
+ "prompt": prompt,
+ "duration": duration,
+ "response_format": "b64_json",
+ }
+ async with httpx.AsyncClient(timeout=300) as client:
+ resp = await client.post(
+ f"{backend_url.rstrip('/')}/v1/audio/generations",
+ json=payload,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+
+ try:
+ entry = data["data"][0]
+ except (KeyError, IndexError) as exc:
+ raise RuntimeError("Unexpected response format from music backend") from exc
+
+ b64 = entry.get("b64_json") or entry.get("b64_wav")
+ if b64:
+ return base64.b64decode(b64)
+
+ url = entry.get("url")
+ if not url:
+ raise RuntimeError("Music backend returned neither b64 data nor url")
+ if not _same_backend_host(url, backend_url):
+ raise RuntimeError("Music backend returned an untrusted download URL")
+
+ async with httpx.AsyncClient(timeout=300) as client:
+ dl = await client.get(url)
+ dl.raise_for_status()
+ return dl.content
+
+
+async def _compose_via_musicgpt_cli(
+ *,
+ prompt: str,
+ duration: int,
+ output_path: Path,
+) -> None:
+ binary = shutil.which("musicgpt")
+ if not binary:
+ raise RuntimeError("musicgpt binary not found on PATH")
+
+ proc = await asyncio.create_subprocess_exec(
+ binary,
+ prompt,
+ "--secs",
+ str(duration),
+ "--output",
+ str(output_path),
+ "--no-playback",
+ "--no-interactive",
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ detail = stderr.decode(errors="replace").strip() or f"exit code {proc.returncode}"
+ raise RuntimeError(f"musicgpt failed: {detail}")
+ if not output_path.exists():
+ raise RuntimeError("musicgpt did not produce an output file")
+
+
+_MUSICGEN_SCRIPT = """
+import sys
+from pathlib import Path
+
+prompt = sys.argv[1]
+duration = float(sys.argv[2])
+output = Path(sys.argv[3])
+
+from audiocraft.models import MusicGen
+
+model = MusicGen.get_pretrained("facebook/musicgen-small")
+model.set_generation_params(duration=duration)
+wav = model.generate([prompt])
+import scipy.io.wavfile
+scipy.io.wavfile.write(str(output), model.sample_rate, wav[0].cpu().numpy().T)
+"""
+
+
+async def _compose_via_musicgen_cli(
+ request: Request,
+ *,
+ prompt: str,
+ duration: int,
+ output_path: Path,
+) -> None:
+ python = _service_python(request, "musicgen")
+ if python is None:
+ raise RuntimeError("musicgen venv not found")
+
+ proc = await asyncio.create_subprocess_exec(
+ str(python),
+ "-c",
+ _MUSICGEN_SCRIPT,
+ prompt,
+ str(duration),
+ str(output_path),
+ stdout=asyncio.subprocess.DEVNULL,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ _stdout, stderr = await proc.communicate()
+ if proc.returncode != 0:
+ detail = stderr.decode(errors="replace").strip() or f"exit code {proc.returncode}"
+ raise RuntimeError(f"musicgen failed: {detail}")
+ if not output_path.exists():
+ raise RuntimeError("musicgen did not produce an output file")
+
+
+@router.get("/api/music/status")
+async def music_status(request: Request):
+ """Report whether a music generation backend is available."""
+ backend_id, backend_url, mode = await _resolve_music_backend(request)
+ installed = []
+ for app_id in MUSIC_SERVICE_IDS:
+ if await _is_service_installed(request, app_id):
+ installed.append(app_id)
+ return {
+ "available": backend_id is not None,
+ "backend": backend_id,
+ "mode": mode or None,
+ "backend_url": backend_url,
+ "installed": installed,
+ }
+
+
+@router.post("/api/music/compose")
+async def compose_music(request: Request, body: ComposeRequest):
+ """Generate audio from a text prompt using an installed music backend."""
+ prompt = body.prompt.strip()
+ if not prompt:
+ return JSONResponse({"error": "prompt is required"}, status_code=400)
+
+ backend_id, backend_url, mode = await _resolve_music_backend(request)
+ if not backend_id:
+ installed = [
+ app_id
+ for app_id in MUSIC_SERVICE_IDS
+ if await _is_service_installed(request, app_id)
+ ]
+ if installed:
+ names = ", ".join(installed)
+ return JSONResponse(
+ {
+ "error": (
+ f"Music backend(s) installed ({names}) but not runnable yet. "
+ "Start the service runtime or install musicgpt/musicgen."
+ ),
+ },
+ status_code=503,
+ )
+ return JSONResponse(
+ {
+ "error": (
+ "No music generation backend installed. "
+ "Install musicgpt or musicgen from the Store."
+ ),
+ },
+ status_code=503,
+ )
+
+ music_dir = _music_dir(request)
+ timestamp = int(time.time())
+ track_id = uuid.uuid4().hex[:8]
+ filename = f"{timestamp}_{track_id}.wav"
+ output_path = music_dir / filename
+
+ try:
+ if mode == "http":
+ assert backend_url is not None
+ audio_bytes = await _compose_via_http(
+ backend_url,
+ prompt=prompt,
+ duration=body.duration,
+ )
+ output_path.write_bytes(audio_bytes)
+ elif mode == "musicgpt-cli":
+ await _compose_via_musicgpt_cli(
+ prompt=prompt,
+ duration=body.duration,
+ output_path=output_path,
+ )
+ elif mode == "musicgen-cli":
+ await _compose_via_musicgen_cli(
+ request,
+ prompt=prompt,
+ duration=body.duration,
+ output_path=output_path,
+ )
+ else:
+ return JSONResponse(
+ {"error": f"Backend {backend_id!r} is installed but not runnable yet."},
+ status_code=503,
+ )
+
+ metadata = {
+ "prompt": prompt,
+ "duration": body.duration,
+ "backend": backend_id,
+ "filename": filename,
+ }
+ (music_dir / f"{timestamp}_{track_id}.json").write_text(
+ json.dumps(metadata, indent=2),
+ )
+
+ return {
+ "status": "generated",
+ "filename": filename,
+ "path": _music_url_path(filename),
+ "size_bytes": output_path.stat().st_size,
+ **metadata,
+ }
+ except httpx.ConnectError:
+ return JSONResponse(
+ {"error": "Cannot connect to music backend. Is it running?"},
+ status_code=503,
+ )
+ except httpx.TimeoutException:
+ return JSONResponse(
+ {"error": "Music generation timed out. The backend may be busy."},
+ status_code=504,
+ )
+ except httpx.HTTPStatusError as exc:
+ return JSONResponse(
+ {"error": f"Music backend returned error: {exc.response.status_code}"},
+ status_code=502,
+ )
+ except Exception:
+ logger.exception("music compose failed")
+ return JSONResponse(
+ {"error": "Music generation failed. Check server logs for details."},
+ status_code=500,
+ )
+
+
+@router.get("/api/music")
+async def list_music(request: Request):
+ """List generated music tracks, newest first."""
+ return {"tracks": _list_tracks(_music_dir(request))}
\ No newline at end of file
diff --git a/tinyagentos/routes/notifications.py b/tinyagentos/routes/notifications.py
index abdf0b32a..a9bdfcc5b 100644
--- a/tinyagentos/routes/notifications.py
+++ b/tinyagentos/routes/notifications.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import time
from fastapi import APIRouter, Request
@@ -61,3 +62,35 @@ async def mark_all_read(request: Request):
store = request.app.state.notifications
await store.mark_all_read()
return {"ok": True}
+
+
+@router.post("/api/notifications/mark-all-read")
+async def mark_all_read_counted(request: Request):
+ store = request.app.state.notifications
+ count = await store.mark_all_read()
+ return {"marked": count}
+
+
+@router.get("/api/notifications/prefs")
+async def get_notification_prefs(request: Request):
+ store = request.app.state.notifications
+ return await store.get_event_prefs()
+
+
+@router.put("/api/notifications/prefs/{event_type}")
+async def set_notification_pref(request: Request, event_type: str):
+ store = request.app.state.notifications
+ if event_type not in store.EVENT_TYPES:
+ return JSONResponse({"error": "unknown_event_type"}, status_code=404)
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+ if not isinstance(body, dict) or "muted" not in body:
+ return JSONResponse({"error": "muted required"}, status_code=400)
+ muted_raw = body["muted"]
+ if not isinstance(muted_raw, bool):
+ return JSONResponse({"error": "muted must be a boolean"}, status_code=400)
+ muted = bool(muted_raw)
+ await store.set_event_muted(event_type, muted)
+ return {"event_type": event_type, "muted": muted}
diff --git a/tinyagentos/routes/office.py b/tinyagentos/routes/office.py
new file mode 100644
index 000000000..ac2761d82
--- /dev/null
+++ b/tinyagentos/routes/office.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+
+from tinyagentos.office_docs import VALID_KINDS, OfficeDocStore
+
+router = APIRouter()
+
+
+def _get_store(request: Request) -> OfficeDocStore:
+ return request.app.state.office_docs
+
+
+def _validate_kind(kind: Any) -> str | None:
+ if not isinstance(kind, str) or kind not in VALID_KINDS:
+ return None
+ return kind
+
+
+def _validate_title(title: Any) -> str | None:
+ if not isinstance(title, str) or not title.strip():
+ return None
+ return title.strip()
+
+
+@router.post("/api/office/docs")
+async def create_doc(request: Request):
+ body = await request.json()
+ kind = _validate_kind(body.get("kind"))
+ if kind is None:
+ return JSONResponse({"error": "kind must be one of write, calc, db, slides"}, status_code=400)
+ title = _validate_title(body.get("title"))
+ if title is None:
+ return JSONResponse({"error": "title is required"}, status_code=400)
+ content = body.get("content", "")
+ if not isinstance(content, str):
+ return JSONResponse({"error": "content must be a string"}, status_code=400)
+
+ store = _get_store(request)
+ doc = await store.create(kind=kind, title=title, content=content)
+ return doc
+
+
+@router.get("/api/office/docs")
+async def list_docs(request: Request):
+ store = _get_store(request)
+ return await store.list()
+
+
+@router.get("/api/office/docs/{doc_id}")
+async def get_doc(request: Request, doc_id: str):
+ store = _get_store(request)
+ doc = await store.get(doc_id)
+ if doc is None:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ return doc
+
+
+@router.put("/api/office/docs/{doc_id}")
+async def update_doc(request: Request, doc_id: str):
+ body = await request.json()
+ store = _get_store(request)
+
+ existing = await store.get(doc_id)
+ if existing is None:
+ return JSONResponse({"error": "not found"}, status_code=404)
+
+ kind = None
+ if "kind" in body:
+ kind = _validate_kind(body.get("kind"))
+ if kind is None:
+ return JSONResponse({"error": "kind must be one of write, calc, db, slides"}, status_code=400)
+
+ title_raw = body.get("title", existing["title"])
+ title = _validate_title(title_raw)
+ if title is None:
+ return JSONResponse({"error": "title is required"}, status_code=400)
+
+ content = body.get("content", existing["content"])
+ if not isinstance(content, str):
+ return JSONResponse({"error": "content must be a string"}, status_code=400)
+
+ doc = await store.update(doc_id=doc_id, title=title, content=content, kind=kind)
+ return doc
+
+
+@router.delete("/api/office/docs/{doc_id}")
+async def delete_doc(request: Request, doc_id: str):
+ store = _get_store(request)
+ deleted = await store.delete(doc_id)
+ if not deleted:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ return {"status": "deleted", "id": doc_id}
diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py
index 0689d62a5..68863773a 100644
--- a/tinyagentos/routes/projects.py
+++ b/tinyagentos/routes/projects.py
@@ -385,6 +385,10 @@ class CloseIn(BaseModel):
reason: str | None = None
+class ReopenIn(BaseModel):
+ reopened_by: str | None = None
+
+
class AddRelIn(BaseModel):
to_task_id: str
kind: str
@@ -555,6 +559,9 @@ async def claim_task(
return JSONResponse({"error": "already claimed"}, status_code=409)
_beads_mark_dirty(request, project_id)
await pstore.log_activity(project_id, payload.claimer_id, "task.claimed", {"task_id": task_id})
+ notifs = getattr(request.app.state, "notifications", None)
+ if notifs is not None:
+ await notifs.emit_event("task.claimed", "Task claimed", f"{task_id} claimed by {payload.claimer_id}")
return await store.get_task(task_id)
@@ -602,6 +609,9 @@ async def close_task(
return JSONResponse({"error": "cannot close"}, status_code=409)
_beads_mark_dirty(request, project_id)
await pstore.log_activity(project_id, payload.closed_by, "task.closed", {"task_id": task_id})
+ notifs = getattr(request.app.state, "notifications", None)
+ if notifs is not None:
+ await notifs.emit_event("task.closed", "Task closed", f"{task_id} closed by {payload.closed_by}")
project = project_or_err
task = await store.get_task(task_id)
qmd = getattr(request.app.state, "qmd_client", None)
@@ -616,6 +626,34 @@ async def close_task(
return task
+@router.post("/api/projects/{project_id}/tasks/{task_id}/reopen")
+async def reopen_task(
+ project_id: str,
+ task_id: str,
+ request: Request,
+ user: CurrentUser = Depends(current_user),
+ payload: ReopenIn | None = None,
+):
+ pstore = request.app.state.project_store
+ project_or_err = await _get_owned_project(pstore, project_id, user)
+ if isinstance(project_or_err, JSONResponse):
+ return project_or_err
+ store = request.app.state.project_task_store
+ existing = await store.get_task(task_id)
+ if existing is None or existing["project_id"] != project_id:
+ return JSONResponse({"error": "not found"}, status_code=404)
+ actor = (payload.reopened_by if payload and payload.reopened_by else None) or "user"
+ ok = await store.reopen_task(task_id, reopened_by=actor)
+ if not ok:
+ return JSONResponse({"error": "task is not closed"}, status_code=409)
+ _beads_mark_dirty(request, project_id)
+ await pstore.log_activity(project_id, actor, "task.reopened", {"task_id": task_id})
+ notifs = getattr(request.app.state, "notifications", None)
+ if notifs is not None:
+ await notifs.emit_event("task.reopened", "Task reopened", f"{task_id} reopened by {actor}")
+ return await store.get_task(task_id)
+
+
@router.post("/api/projects/{project_id}/tasks/{task_id}/relationships")
async def add_relationship(
project_id: str,
diff --git a/tinyagentos/routes/userspace_apps.py b/tinyagentos/routes/userspace_apps.py
index ea567d2d9..88a348bbd 100644
--- a/tinyagentos/routes/userspace_apps.py
+++ b/tinyagentos/routes/userspace_apps.py
@@ -16,42 +16,28 @@
_SDK_PATH = Path(__file__).resolve().parent.parent / "userspace" / "sdk" / "taos-app-sdk.js"
-# Bundle CSP for community (untrusted) packages. The `sandbox allow-scripts`
+# Bundle CSP for sandboxed userspace packages. The `sandbox allow-scripts`
# directive (no allow-same-origin) forces the document into an OPAQUE origin
# even on a direct top-level navigation -- so a userspace bundle can never
# execute on the core origin with the session cookie (defends against stored
# XSS), while still letting the app run its own scripts inside our sandboxed
-# iframe. `default-src 'none'` plus the explicit self/inline allowances keep
-# it locked down.
-_BUNDLE_CSP = (
- "sandbox allow-scripts allow-forms allow-popups; "
- "default-src 'none'; "
- "script-src 'self' 'unsafe-inline' blob:; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src 'self' data: blob:; "
- "font-src 'self' data:; "
- "connect-src 'self'; "
- "frame-ancestors 'self'; base-uri 'none'"
-)
-
-# Relaxed CSP for first-party packages (studios). Still sandboxed -- NEVER
-# add allow-same-origin; that would collapse the opaque-origin isolation and
-# let the frame access session cookies. The relaxations over community:
-# - style-src allows 'unsafe-inline' (community already does, kept the same)
-# - connect-src 'self' (same as community; lets the SDK reach the broker)
-# The intent is that P4 boot-seeding and P2 signature verification are the
-# ONLY paths that write trust='first-party'; this CSP is not itself a trust
-# grant, just a consequence of trust already verified out-of-band.
-_BUNDLE_CSP_FIRST_PARTY = (
- "sandbox allow-scripts allow-forms allow-popups; "
- "default-src 'none'; "
- "script-src 'self' 'unsafe-inline' blob:; "
- "style-src 'self' 'unsafe-inline'; "
- "img-src 'self' data: blob:; "
- "font-src 'self' data:; "
- "connect-src 'self'; "
- "frame-ancestors 'self'; base-uri 'none'"
-)
+# iframe. `default-src 'none'` plus the explicit self/inline allowances keep it
+# locked down. connect-src defaults to 'self' (the broker only); an app the
+# user has granted `network:` permissions gets exactly those origins
+# added to connect-src and nothing else (each origin is strictly validated at
+# manifest-parse time, so it cannot inject other CSP directives).
+def _bundle_csp(net_origins: list[str]) -> str:
+ connect = "connect-src 'self'" + "".join(" " + o for o in net_origins)
+ return (
+ "sandbox allow-scripts allow-forms allow-popups; "
+ "default-src 'none'; "
+ "script-src 'self' 'unsafe-inline' blob:; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data: blob:; "
+ "font-src 'self' data:; "
+ f"{connect}; "
+ "frame-ancestors 'self'; base-uri 'none'"
+ )
def _apps_root(request: Request) -> Path:
@@ -212,9 +198,11 @@ async def serve_bundle(request: Request, app_id: str, path: str):
if not target.is_relative_to(root) or target == root or not target.is_file():
return JSONResponse({"error": "not found"}, status_code=404)
app = await request.app.state.userspace_apps.get(app_id)
- csp = _BUNDLE_CSP_FIRST_PARTY if (app and app.get("trust") == "first-party") else _BUNDLE_CSP
+ granted = (app or {}).get("permissions_granted") or []
+ net_origins = [p[len("network:"):] for p in granted
+ if isinstance(p, str) and p.startswith("network:")]
resp = FileResponse(target)
- resp.headers["Content-Security-Policy"] = csp
+ resp.headers["Content-Security-Policy"] = _bundle_csp(net_origins)
resp.headers["X-Content-Type-Options"] = "nosniff"
return resp
diff --git a/tinyagentos/system_stats.py b/tinyagentos/system_stats.py
index a363c29d5..ae0afeeaf 100644
--- a/tinyagentos/system_stats.py
+++ b/tinyagentos/system_stats.py
@@ -16,6 +16,8 @@
from functools import lru_cache
from pathlib import Path
+import psutil
+
_RKNPU_LOAD_PATHS = (
"/sys/kernel/debug/rknpu/load",
"/sys/class/devfreq/fdab0000.npu/load",
@@ -169,7 +171,6 @@ def get_cpu_per_core() -> list[dict]:
Uses psutil for load, sysfs for freq/governor (Linux only).
"""
- import psutil
loads = psutil.cpu_percent(percpu=True)
cores: list[dict] = []
@@ -304,7 +305,6 @@ def get_zram_stats() -> list[dict]:
def get_disk_io_rate() -> dict:
"""Overall disk read/write bytes per second (across all disks)."""
- import psutil
io = psutil.disk_io_counters()
if io is None:
@@ -331,7 +331,6 @@ def get_disk_io_rate() -> dict:
def get_network_rates() -> list[dict]:
"""Per-interface network rx/tx bytes per second."""
- import psutil
now = time.time()
stats = psutil.net_io_counters(pernic=True)
@@ -365,7 +364,6 @@ def get_network_rates() -> list[dict]:
def get_top_processes(limit: int = 10) -> list[dict]:
"""Top processes by memory usage."""
- import psutil
procs: list[dict] = []
for p in psutil.process_iter(['pid', 'name', 'memory_info', 'cpu_percent', 'username']):
diff --git a/tinyagentos/userspace/package.py b/tinyagentos/userspace/package.py
index 8075ce8aa..1551e2964 100644
--- a/tinyagentos/userspace/package.py
+++ b/tinyagentos/userspace/package.py
@@ -1,11 +1,21 @@
from __future__ import annotations
import io
+import re
import zipfile
from pathlib import Path
import yaml
+# A `network:` permission lets a granted app's bundle connect to that
+# external origin (it is added to the sandbox CSP connect-src). The origin is
+# strictly validated so it can never inject extra CSP directives: scheme://host
+# with an optional leading "*." subdomain wildcard and an optional :port, and
+# nothing else (no spaces, semicolons, quotes, paths, or newlines). \A and \Z
+# anchor the WHOLE string -- `$` would also match just before a trailing
+# newline, which would let "wss://host\n; ..." slip a newline into the value.
+_NET_ORIGIN_RE = re.compile(r"\A(?:wss|https)://(?:\*\.)?[A-Za-z0-9.-]+(?::\d+)?\Z")
+
_ALLOWED_TYPES = {"web", "container"}
_REQUIRED = ("id", "name", "version", "app_type")
@@ -52,6 +62,17 @@ def parse_manifest(text: str) -> dict:
data.setdefault("entry", "index.html")
data.setdefault("icon", "")
data.setdefault("permissions", [])
+ if not isinstance(data["permissions"], list):
+ raise PackageError("manifest 'permissions' must be a list")
+ for perm in data["permissions"]:
+ if isinstance(perm, str) and perm.startswith("network:"):
+ origin = perm[len("network:"):]
+ if not _NET_ORIGIN_RE.match(origin):
+ raise PackageError(
+ f"invalid network permission origin {origin!r}: must be "
+ "wss://host or https://host with an optional *. subdomain "
+ "wildcard and an optional :port, nothing else"
+ )
return data
diff --git a/uv.lock b/uv.lock
index 90c7faaca..613274d41 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3481,7 +3481,7 @@ wheels = [
[[package]]
name = "tinyagentos"
-version = "1.0.0b2"
+version = "1.0.0b3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },