diff --git a/.github/workflows/build-neko-rk3588-image.yml b/.github/workflows/build-neko-rk3588-image.yml
new file mode 100644
index 000000000..8dd9ab8ee
--- /dev/null
+++ b/.github/workflows/build-neko-rk3588-image.yml
@@ -0,0 +1,48 @@
+name: Build Neko RK3588 image
+
+# Builds the RK3588 VPU-encode neko image (MPP + gstreamer-rockchip compiled
+# from source against trixie GStreamer 1.26) on a GitHub arm64 runner, so the
+# Pi is never used for the build. The Pi only does the final validation run.
+# On a PR the image is built but NOT pushed (compile validation only); on
+# dev/master it is built and pushed to GHCR.
+on:
+ push:
+ branches: [master, dev]
+ paths:
+ - 'app-catalog/streaming/neko-browser/Dockerfile.rk3588'
+ - '.github/workflows/build-neko-rk3588-image.yml'
+ pull_request:
+ paths:
+ - 'app-catalog/streaming/neko-browser/Dockerfile.rk3588'
+ - '.github/workflows/build-neko-rk3588-image.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04-arm
+ steps:
+ - uses: actions/checkout@v7
+ - uses: docker/setup-buildx-action@v3
+ - name: Log in to GHCR
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build (push only on dev/master)
+ uses: docker/build-push-action@v6
+ with:
+ context: app-catalog/streaming/neko-browser
+ file: app-catalog/streaming/neko-browser/Dockerfile.rk3588
+ platforms: linux/arm64
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: |
+ ghcr.io/jaylfc/taos-neko-rk3588:latest
+ ghcr.io/jaylfc/taos-neko-rk3588:${{ github.sha }}
+ cache-from: type=gha,scope=neko-rk3588
+ cache-to: type=gha,mode=max,scope=neko-rk3588
diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml
index 14243dd2e..55bd280de 100644
--- a/.github/workflows/dependabot-automerge.yml
+++ b/.github/workflows/dependabot-automerge.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Fetch Dependabot metadata
id: meta
- uses: dependabot/fetch-metadata@v2
+ uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 840819983..7b4efcb70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,3 +115,6 @@ docs/audit/
.understand-anything/
docs/agent-jobs/
.design/
+
+# Update rollback target (written by the updater, never tracked)
+.taos-rollback
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2020c3614..095217b49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio
## [Unreleased]
+## [1.0.0-beta.6] - 2026-06-21
+
+### Added
+- Coding Studio gains a model-agnostic tool-calling loop: agents read, edit, and verify files inside a workspace-jailed sandbox using filesystem tool primitives, driven by a LiteLLM-backed model step.
+- Cluster capability map: worker registration and heartbeats populate a per-node capability and hardware map with admin endpoints, plus a non-destructive stale-node offline sweep.
+- Append-only board audit log: every task transition is recorded, with a project-scoped activity feed and a task audit endpoint, indexed for unbounded growth.
+- `taos rollback`: a CLI recovery path that restores the previous branch and version, so a broken update can be recovered even when the dashboard is unreachable.
+
+### Changed
+- One Browser app: the separate streamed-browser app is gone. The Browser app attaches a Neko streamed session through a toggle, and a RAM-capable Pi host can serve the session itself instead of reporting that it is not capable.
+- The default store no longer seeds the X, Reddit, YouTube, and GitHub apps; they are optional installs.
+
+### Fixed
+- Browser sessions resolve the target worker before creating the session row, so a failed placement no longer leaves an orphaned session.
+- Auto-expiring notification toasts no longer archive themselves into the History view.
+- Dependabot majors updated: actions/checkout v7, dependabot/fetch-metadata v3, and the dev Python dependency group.
+
## [1.0.0-beta.5] - 2026-06-20
### Added
@@ -24,6 +41,25 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio
- Security: dompurify updated to 3.4.11; cryptography and pydantic-settings advisories cleared.
- Install: the core install no longer aborts when optional components fail, and drops to the service user without assuming sudo (WSL robustness).
+## [1.0.0-beta.4.1] - 2026-06-20
+
+### Changed
+- Installs and in-app updates verify the prebuilt bundle's SHA256 before extracting; a corrupted or tampered bundle is rejected and falls back to a local build.
+- Re-installs update the existing install in place instead of forking a second copy.
+
+### Fixed
+- Symlink-safe staging (no fixed /tmp paths as root), atomic-rename swap, and a fix so the bundle is no longer treated as perpetually stale.
+- README corrected (installs download a prebuilt bundle, no local build) and links rebranded to jaylfc/taOS.
+
+## [1.0.0-beta.4] - 2026-06-20
+
+### Added
+- "Reduce effects" toggle (Settings, Accessibility) for low-end devices: disables background blur, heavy shadows, and continuous animations for a smoother UI on older hardware.
+
+### Changed
+- The installer and in-app update download a prebuilt UI bundle instead of building it locally, so installs and upgrades are faster and no longer fail or silently stay on the old version on low-memory machines including WSL. A local build, when still needed, now fails with a clear message instead of half-updating.
+- CI runs on self-hosted runners and gates the desktop test suite.
+
## [1.0.0-beta.3] - 2026-06-16
### Added
diff --git a/app-catalog/streaming/neko-browser/Dockerfile.rk3588 b/app-catalog/streaming/neko-browser/Dockerfile.rk3588
index 3f5cbd342..ef05e382b 100644
--- a/app-catalog/streaming/neko-browser/Dockerfile.rk3588
+++ b/app-catalog/streaming/neko-browser/Dockerfile.rk3588
@@ -1,22 +1,47 @@
-# taOS Neko Chromium for RK3588 (Orange Pi 5 Plus) — HW decode + WebRTC encode via VPU.
-# Device nodes (/dev/mpp_service, /dev/dri, /dev/rga) are passed at `docker run`
-# by the host-tier resolver, not baked in. Software encode is the fallback when
-# this image/devices aren't available (see resolve_neko_image).
-# Multi-arch base (has a linux/arm64 variant — verified on the Pi). The old
-# `arm-chromium` tag does not exist.
-FROM ghcr.io/m1k1o/neko/chromium:latest
+# taOS Neko Chromium for RK3588 — VPU H.264 encode via MPP + gstreamer-rockchip.
+# Multi-stage: stage 1 compiles Rockchip MPP, RGA and the gstreamer-rockchip
+# plugin against GStreamer 1.26 (trixie, matching the neko-cdp runtime, so the
+# plugin ABI lines up). Stage 2 layers the built libs onto the CDP image.
+#
+# Built for the Rockchip VENDOR kernel (6.1.x-vendor-rk35xx) which exposes the
+# VPU via /dev/mpp_service (no V4L2). Shared publicly for the community (#624).
-# Rockchip MPP + RGA + Mali userspace for GStreamer rkmpp HW encode/decode.
+# ---- stage 1: build the rockchip multimedia userspace --------------------
+FROM debian:trixie AS rkbuild
+ENV GIT_TERMINAL_PROMPT=0 LIBDIR=lib/aarch64-linux-gnu
RUN apt-get update && apt-get install -y --no-install-recommends \
- gstreamer1.0-rockchip \
- librockchip-mpp1 \
- librga2 \
- && rm -rf /var/lib/apt/lists/* || true
+ git ca-certificates build-essential cmake meson ninja-build pkg-config \
+ libdrm-dev \
+ libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /src
+# MPP (Media Process Platform) — nyanmisaka's maintained fork builds cleanly.
+RUN git clone --depth=1 -b jellyfin-mpp https://github.com/nyanmisaka/mpp.git mpp \
+ && cmake -S mpp -B mpp/build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=$LIBDIR \
+ -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DBUILD_TEST=OFF \
+ && cmake --build mpp/build -j"$(nproc)" && cmake --install mpp/build
+# RGA (2D raster graphics accel).
+RUN git clone --depth=1 -b jellyfin-rga https://github.com/nyanmisaka/rk-mirrors.git rga \
+ && meson setup rga rga/build --prefix=/usr --libdir=$LIBDIR -Dlibdrm=false -Dlibrga_demo=false --buildtype=release \
+ && meson install -C rga/build
+# gstreamer-rockchip plugin (mpph264enc) — BoxCloud streaming fork, against 1.26.
+RUN git clone --depth=1 https://github.com/BoxCloudIRL/gstreamer-rockchip.git gst \
+ && meson setup gst gst/build --prefix=/usr --libdir=$LIBDIR --buildtype=release \
+ && meson install -C gst/build
+# Collect outputs into a staging dir (tolerant of exact naming); logged for debug.
+RUN mkdir -p /stage/gst \
+ && cp -a /usr/lib/aarch64-linux-gnu/librockchip_mpp.so* /stage/ 2>/dev/null || true \
+ && cp -a /usr/lib/aarch64-linux-gnu/librockchip_vpu.so* /stage/ 2>/dev/null || true \
+ && cp -a /usr/lib/aarch64-linux-gnu/librga.so* /stage/ 2>/dev/null || true \
+ && cp -a /usr/lib/aarch64-linux-gnu/gstreamer-1.0/libgstrockchip*.so* /stage/gst/ 2>/dev/null || true \
+ && echo "=== staged libs ===" && ls -la /stage /stage/gst
-LABEL taos.app.id="neko-browser" \
- taos.streaming.encode="rkmpp" \
- taos.hardware.soc="rk3588"
-
-# Prefer the VPU encoder; Neko reads its pipeline from env / its own config.
-ENV NEKO_VIDEO_CODEC=h264 \
- GST_PLUGIN_FEATURE_RANK="mpph264enc:512"
+# ---- stage 2: layer onto the CDP image -----------------------------------
+FROM ghcr.io/jaylfc/taos-neko-cdp:latest
+COPY --from=rkbuild /stage/ /tmp/rkstage/
+RUN cp -a /tmp/rkstage/*.so* /usr/lib/aarch64-linux-gnu/ 2>/dev/null || true \
+ && cp -a /tmp/rkstage/gst/*.so* /usr/lib/aarch64-linux-gnu/gstreamer-1.0/ 2>/dev/null || true \
+ && rm -rf /tmp/rkstage && ldconfig
+LABEL taos.app.id="neko-browser-rk3588" taos.streaming.encode="rkmpp" \
+ taos.hardware.soc="rk3588" taos.base="taos-neko-cdp"
+ENV NEKO_VIDEO_CODEC=h264 GST_PLUGIN_FEATURE_RANK="mpph264enc:512"
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index f1e7190ba..69c24f00f 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tinyagentos-desktop",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0-beta.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tinyagentos-desktop",
- "version": "1.0.0-beta.5",
+ "version": "1.0.0-beta.6",
"dependencies": {
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language-data": "^6.5.2",
diff --git a/desktop/package.json b/desktop/package.json
index ed09feed8..c22bc53a9 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "tinyagentos-desktop",
"private": true,
- "version": "1.0.0-beta.5",
+ "version": "1.0.0-beta.6",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/src/apps/CalendarApp.test.tsx b/desktop/src/apps/CalendarApp.test.tsx
new file mode 100644
index 000000000..2dca9906d
--- /dev/null
+++ b/desktop/src/apps/CalendarApp.test.tsx
@@ -0,0 +1,103 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { CalendarApp } from "./CalendarApp";
+
+const FIXED_DATE = new Date(2025, 5, 15); // June 15, 2025
+
+function mockToday() {
+ vi.useFakeTimers();
+ vi.setSystemTime(FIXED_DATE);
+}
+
+describe("CalendarApp", () => {
+ beforeEach(() => {
+ mockToday();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders the current month and year in the header", () => {
+ render( );
+ expect(screen.getByText("June 2025")).toBeTruthy();
+ });
+
+ it("renders all seven day-of-week column headers", () => {
+ render( );
+ expect(screen.getByText("Mon")).toBeTruthy();
+ expect(screen.getByText("Tue")).toBeTruthy();
+ expect(screen.getByText("Wed")).toBeTruthy();
+ expect(screen.getByText("Thu")).toBeTruthy();
+ expect(screen.getByText("Fri")).toBeTruthy();
+ expect(screen.getByText("Sat")).toBeTruthy();
+ expect(screen.getByText("Sun")).toBeTruthy();
+ });
+
+ it("renders the correct number of days for the current month", () => {
+ render( );
+ // June 2025 has 30 days; day "1" appears as both a trailing prev-month day
+ // and the first day of June, so use getAllByText for that one.
+ const days1 = screen.getAllByText("1");
+ expect(days1.length).toBeGreaterThanOrEqual(1);
+ const day30 = screen.getAllByText("30");
+ expect(day30.length).toBeGreaterThanOrEqual(1);
+ // There are 30 current-month cells plus leading/trailing fill cells
+ const currentMonthCells = document.querySelectorAll(
+ ".text-shell-text.hover\\:bg-shell-surface",
+ );
+ expect(currentMonthCells.length).toBe(30);
+ });
+
+ it("navigates to the next month when the right arrow is clicked", () => {
+ render( );
+ const nextBtn = screen.getByRole("button", { name: /next month/i });
+ fireEvent.click(nextBtn);
+ expect(screen.getByText("July 2025")).toBeTruthy();
+ });
+
+ it("navigates to the previous month when the left arrow is clicked", () => {
+ render( );
+ const prevBtn = screen.getByRole("button", { name: /previous month/i });
+ fireEvent.click(prevBtn);
+ expect(screen.getByText("May 2025")).toBeTruthy();
+ });
+
+ it("wraps from January to December of the previous year when going prev", () => {
+ // Set "today" to January 2025
+ vi.setSystemTime(new Date(2025, 0, 10));
+ render( );
+ expect(screen.getByText("January 2025")).toBeTruthy();
+ const prevBtn = screen.getByRole("button", { name: /previous month/i });
+ fireEvent.click(prevBtn);
+ expect(screen.getByText("December 2024")).toBeTruthy();
+ });
+
+ it("wraps from December to January of the next year when going next", () => {
+ // Set "today" to December 2025
+ vi.setSystemTime(new Date(2025, 11, 10));
+ render( );
+ expect(screen.getByText("December 2025")).toBeTruthy();
+ const nextBtn = screen.getByRole("button", { name: /next month/i });
+ fireEvent.click(nextBtn);
+ expect(screen.getByText("January 2026")).toBeTruthy();
+ });
+
+ it("highlights today's date with the accent class", () => {
+ render( );
+ // June 15 is "today" per our fixed clock; the cell should have the accent bg
+ const todayCell = screen.getByText("15")?.closest("span");
+ expect(todayCell?.className).toContain("bg-accent");
+ });
+
+ it("returns to the current month when Today is clicked after navigating", () => {
+ render( );
+ const nextBtn = screen.getByRole("button", { name: /next month/i });
+ fireEvent.click(nextBtn);
+ fireEvent.click(nextBtn);
+ expect(screen.getByText("August 2025")).toBeTruthy();
+ const todayBtn = screen.getByRole("button", { name: /today/i });
+ fireEvent.click(todayBtn);
+ expect(screen.getByText("June 2025")).toBeTruthy();
+ });
+});
diff --git a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx b/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx
deleted file mode 100644
index b0e70b044..000000000
--- a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx
+++ /dev/null
@@ -1,434 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
-import { StreamedBrowserApp } from "./StreamedBrowserApp";
-
-// LiveBrowserView renders an iframe — stub it so we can assert on its props
-// without needing a real DOM iframe environment.
-vi.mock("@/apps/BrowserApp/LiveBrowserView", () => ({
- LiveBrowserView: ({ nekoUrl, streamToken }: { nekoUrl: string; streamToken: string }) => (
-
- ),
-}));
-
-// Radix Tooltip uses pointerEvents; jsdom doesn't fire them by default.
-// Mock Tooltip so it renders children + content inline.
-vi.mock("@radix-ui/react-tooltip", () => ({
- Provider: ({ children }: { children: React.ReactNode }) => <>{children}>,
- Root: ({ children }: { children: React.ReactNode }) => <>{children}>,
- Trigger: ({ children }: { children: React.ReactNode; asChild?: boolean }) => <>{children}>,
- Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
- Content: ({ children }: { children: React.ReactNode }) => (
- {children}
- ),
- Arrow: () => null,
-}));
-
-const WINDOW_ID = "win-sb-test";
-
-const originalFetch = global.fetch;
-
-beforeEach(() => {
- vi.useFakeTimers({ shouldAdvanceTime: false });
-});
-
-afterEach(() => {
- global.fetch = originalFetch;
- vi.restoreAllMocks();
- vi.useRealTimers();
-});
-
-// ── Helpers ──────────────────────────────────────────────────────────────────
-
-function mockFetch(status: number, body: unknown): ReturnType {
- return vi.fn().mockResolvedValue({
- ok: status >= 200 && status < 300,
- status,
- json: () => Promise.resolve(body),
- });
-}
-
-/** Build a fetch mock that routes requests by URL fragment. */
-function routedFetch(routes: Record) {
- return vi.fn().mockImplementation((url: string) => {
- for (const [pattern, resp] of Object.entries(routes)) {
- if (url.includes(pattern)) {
- return Promise.resolve({
- ok: resp.status >= 200 && resp.status < 300,
- status: resp.status,
- json: () => Promise.resolve(resp.body),
- });
- }
- }
- return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
- });
-}
-
-const runningMineSession = {
- id: "mine-1",
- owner_type: "user",
- owner_id: "user-1",
- status: "running",
- neko_url: "https://neko.local",
- stream_token: "tok-mine",
-};
-
-const agentSession = {
- id: "agent-sess-1",
- owner_type: "agent",
- owner_id: "researcher",
- status: "running",
- neko_url: "https://neko-agent.local",
- url: "https://example.com",
- stream_token: "tok-agent",
-};
-
-const sessionListWithAgent = {
- sessions: [
- {
- id: "mine-1",
- owner_type: "user",
- owner_id: "user-1",
- status: "running",
- neko_url: "https://neko.local",
- url: null,
- },
- {
- id: "agent-sess-1",
- owner_type: "agent",
- owner_id: "researcher",
- status: "running",
- neko_url: "https://neko-agent.local",
- url: "https://example.com",
- },
- ],
-};
-
-// ── C1 regression: My browser running session ─────────────────────────────────
-
-describe("StreamedBrowserApp — My browser running session (C1 regression)", () => {
- it("renders LiveBrowserView with nekoUrl and streamToken when session is running", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
-
- await act(async () => {
- render( );
- });
-
- const view = screen.getByTestId("live-browser-view");
- expect(view).toBeTruthy();
- expect(view.getAttribute("data-neko-url")).toBe("https://neko.local");
- expect(view.getAttribute("data-stream-token")).toBe("tok-mine");
- });
-
- it("calls /api/browser/sessions/mine with credentials: include", async () => {
- const fetchMock = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
- global.fetch = fetchMock;
-
- await act(async () => {
- render( );
- });
-
- expect(fetchMock).toHaveBeenCalledWith(
- "/api/browser/sessions/mine",
- expect.objectContaining({ credentials: "include" }),
- );
- });
-});
-
-// ── Session switcher ──────────────────────────────────────────────────────────
-
-describe("StreamedBrowserApp — session switcher", () => {
- it("renders 'My browser' entry", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
-
- await act(async () => {
- render( );
- });
-
- expect(screen.getByRole("button", { name: /my browser/i })).toBeTruthy();
- });
-
- it("renders agent sessions from GET /api/browser/sessions", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions": { status: 200, body: sessionListWithAgent },
- });
-
- await act(async () => {
- render( );
- });
-
- // The agent name "researcher" should appear as a button in the rail
- const btn = screen.getAllByRole("button").find((b) => b.textContent?.includes("researcher"));
- expect(btn).toBeTruthy();
- });
-
- it("selecting an agent session fetches /{id} and renders LiveBrowserView", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession },
- "/api/browser/sessions": { status: 200, body: sessionListWithAgent },
- });
-
- await act(async () => {
- render( );
- });
-
- // Click the agent session button
- const agentBtn = screen.getAllByRole("button").find((b) =>
- b.textContent?.includes("researcher"),
- );
- expect(agentBtn).toBeTruthy();
-
- await act(async () => {
- fireEvent.click(agentBtn!);
- });
-
- // Should now show the agent's live stream
- const view = screen.getByTestId("live-browser-view");
- expect(view.getAttribute("data-neko-url")).toBe("https://neko-agent.local");
- expect(view.getAttribute("data-stream-token")).toBe("tok-agent");
- });
-
- it("shows 'Watching ' label when viewing an agent session", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession },
- "/api/browser/sessions": { status: 200, body: sessionListWithAgent },
- });
-
- await act(async () => {
- render( );
- });
-
- const agentBtn = screen.getAllByRole("button").find((b) =>
- b.textContent?.includes("researcher"),
- );
- await act(async () => {
- fireEvent.click(agentBtn!);
- });
-
- expect(screen.getByText(/watching researcher/i)).toBeTruthy();
- });
-
- it("request-control button is disabled on agent sessions", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions/agent-sess-1": { status: 200, body: agentSession },
- "/api/browser/sessions": { status: 200, body: sessionListWithAgent },
- });
-
- await act(async () => {
- render( );
- });
-
- const agentBtn = screen.getAllByRole("button").find((b) =>
- b.textContent?.includes("researcher"),
- );
- await act(async () => {
- fireEvent.click(agentBtn!);
- });
-
- const reqCtrl = screen.getByRole("button", { name: /request control/i });
- expect(reqCtrl).toBeTruthy();
- expect(reqCtrl).toBeDisabled();
- });
-
- it("no request-control button on My browser view", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
-
- await act(async () => {
- render( );
- });
-
- expect(screen.queryByRole("button", { name: /request control/i })).toBeNull();
- });
-});
-
-// ── Migrating state ───────────────────────────────────────────────────────────
-
-describe("StreamedBrowserApp — migrating state", () => {
- it("renders migrating message when mine session status is migrating", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": {
- status: 200,
- body: { id: "mine-1", status: "migrating", neko_url: null, stream_token: null },
- },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- // Poll call returns the same migrating state (no progression needed for this test)
- "/api/browser/sessions/mine-1": {
- status: 200,
- body: { id: "mine-1", status: "migrating", neko_url: null, stream_token: null },
- },
- });
-
- await act(async () => {
- render( );
- });
-
- const status = screen.getByRole("status");
- expect(status.textContent).toMatch(/moving.*another device/i);
- expect(screen.queryByTestId("live-browser-view")).toBeNull();
- });
-
- it("renders migrating message when agent session status is migrating", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 200, body: runningMineSession },
- "/api/browser/sessions/agent-sess-1": {
- status: 200,
- body: { id: "agent-sess-1", owner_type: "agent", owner_id: "researcher", status: "migrating", neko_url: null },
- },
- "/api/browser/sessions": { status: 200, body: sessionListWithAgent },
- });
-
- await act(async () => {
- render( );
- });
-
- const agentBtn = screen.getAllByRole("button").find((b) =>
- b.textContent?.includes("researcher"),
- );
- await act(async () => {
- fireEvent.click(agentBtn!);
- });
-
- const status = screen.getByRole("status");
- expect(status.textContent).toMatch(/moving.*another device/i);
- });
-});
-
-// ── 409 no_capable_node ───────────────────────────────────────────────────────
-
-describe("StreamedBrowserApp — 409 no_capable_node", () => {
- it("shows gate-and-guide message, not a blank screen", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 409, body: { error: "no_capable_node" } },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
-
- await act(async () => {
- render( );
- });
-
- const alert = screen.getByRole("alert");
- expect(alert).toBeTruthy();
- expect(alert.textContent).toMatch(/capable device/i);
- expect(screen.queryByTestId("live-browser-view")).toBeNull();
- });
-});
-
-// ── Error states ──────────────────────────────────────────────────────────────
-
-describe("StreamedBrowserApp — error states", () => {
- it("shows error message and Retry button on server error", async () => {
- global.fetch = routedFetch({
- "/api/browser/sessions/mine": { status: 500, body: {} },
- "/api/browser/sessions": { status: 200, body: { sessions: [] } },
- });
-
- await act(async () => {
- render( );
- });
-
- const alert = screen.getByRole("alert");
- expect(alert).toBeTruthy();
- const retryBtn = screen.getByRole("button", { name: /retry/i });
- expect(retryBtn).toBeTruthy();
- });
-
- it("shows error message and Retry button on network failure", async () => {
- global.fetch = vi.fn().mockImplementation((url: string) => {
- if ((url as string).includes("/mine")) return Promise.reject(new TypeError("Network error"));
- return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ sessions: [] }) });
- });
-
- await act(async () => {
- render( );
- });
-
- expect(screen.getByRole("alert")).toBeTruthy();
- expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
- });
-
- it("re-fetches when Retry is clicked", async () => {
- let mineCallCount = 0;
- global.fetch = vi.fn().mockImplementation((url: string) => {
- if ((url as string).includes("/mine")) {
- mineCallCount += 1;
- if (mineCallCount === 1) {
- // First /mine call — fail
- return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({}) });
- }
- // Retry /mine call — succeed
- return Promise.resolve({
- ok: true, status: 200,
- json: () => Promise.resolve({ id: "s2", status: "running", neko_url: "https://neko.local", stream_token: "tok-xyz" }),
- });
- }
- // /sessions list — always OK
- return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ sessions: [] }) });
- });
-
- await act(async () => {
- render( );
- });
-
- expect(screen.getByRole("alert")).toBeTruthy();
-
- await act(async () => {
- fireEvent.click(screen.getByRole("button", { name: /retry/i }));
- });
-
- expect(screen.getByTestId("live-browser-view")).toBeTruthy();
- });
-});
-
-// ── Connecting/polling state ──────────────────────────────────────────────────
-
-describe("StreamedBrowserApp — connecting/polling state", () => {
- it("shows connecting message when session is pending, then goes live after poll", async () => {
- vi.useRealTimers();
-
- const fetchMock = vi.fn()
- // /sessions list
- .mockResolvedValueOnce({
- ok: true, status: 200,
- json: () => Promise.resolve({ sessions: [] }),
- })
- // /mine returns pending
- .mockResolvedValueOnce({
- ok: true, status: 200,
- json: () => Promise.resolve({ id: "sess-pending", status: "pending", neko_url: null }),
- })
- // Poll call: session now running
- .mockResolvedValueOnce({
- ok: true, status: 200,
- json: () => Promise.resolve({ id: "sess-pending", status: "running", neko_url: "https://neko.local", stream_token: "tok-poll" }),
- });
- global.fetch = fetchMock;
-
- render( );
-
- await waitFor(() => {
- expect(screen.getByRole("status")).toBeTruthy();
- }, { timeout: 3000 });
- expect(screen.getByRole("status").textContent).toMatch(/waiting|starting/i);
-
- await waitFor(() => {
- expect(screen.getByTestId("live-browser-view")).toBeTruthy();
- }, { timeout: 4000 });
-
- expect(screen.getByTestId("live-browser-view").getAttribute("data-stream-token")).toBe("tok-poll");
- }, 10000);
-});
diff --git a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx b/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx
deleted file mode 100644
index 427e55202..000000000
--- a/desktop/src/apps/StreamedBrowserApp/StreamedBrowserApp.tsx
+++ /dev/null
@@ -1,541 +0,0 @@
-/**
- * StreamedBrowserApp — always-on streamed Chromium session with session switcher (C2).
- *
- * Session switcher (slim left rail) lists:
- * • "My browser" — the user's own always-on session via GET /api/browser/sessions/mine
- * • Agent sessions — from GET /api/browser/sessions, polled every ~10 s
- *
- * Selecting "My browser" uses the existing /mine flow (C1 behaviour).
- * Selecting an agent session fetches GET /api/browser/sessions/{id} for its stream_token,
- * then renders LiveBrowserView in watch-only mode with a "watching 's browser" label.
- *
- * Request-control button is rendered disabled — Neko member-control handoff (sub-plan G)
- * is not yet built. The button is a placeholder so the UX slot is reserved.
- *
- * Migrating state (status === "migrating", emitted by sub-plan F) is rendered like the
- * connecting state with the message "Moving your browser to another device…".
- *
- * No browser chrome is built here — Chromium's omnibox/tabs live inside the stream.
- *
- * State machine (per selected session):
- * loading — initial fetch in-flight
- * connecting — session not yet running; polling
- * migrating — session is migrating to another node
- * live — running + neko_url + stream_token present
- * no_node — 409 no_capable_node (user's own session only)
- * error — any other failure (shows Retry)
- */
-import { useCallback, useEffect, useRef, useState } from "react";
-import { LiveBrowserView } from "@/apps/BrowserApp/LiveBrowserView";
-import { useIsMobile } from "@/hooks/use-is-mobile";
-import { Loader2, MonitorPlay, AlertCircle, Monitor, Bot } from "lucide-react";
-import * as Tooltip from "@radix-ui/react-tooltip";
-
-const POLL_INTERVAL_MS = 1500;
-const POLL_MAX_TRIES = 20;
-const SESSION_LIST_POLL_MS = 10_000;
-
-// ─── Types ────────────────────────────────────────────────────────────────────
-
-interface SessionSummary {
- id: string;
- owner_type: "user" | "agent";
- owner_id: string;
- status: string;
- neko_url: string | null;
- url: string | null;
-}
-
-interface BrowserSession extends SessionSummary {
- stream_token?: string | null;
-}
-
-type Selection =
- | { kind: "mine" }
- | { kind: "agent"; sessionId: string; agentName: string };
-
-type ViewState =
- | { phase: "loading" }
- | { phase: "connecting" }
- | { phase: "migrating" }
- | { phase: "live"; nekoUrl: string; streamToken: string; watchLabel?: string }
- | { phase: "no_node" }
- | { phase: "error"; message: string };
-
-interface StreamedBrowserAppProps {
- windowId: string;
-}
-
-// ─── Session Switcher ─────────────────────────────────────────────────────────
-
-interface SwitcherProps {
- sessions: SessionSummary[];
- selected: Selection;
- onSelect: (sel: Selection) => void;
- isMobile: boolean;
-}
-
-function SessionSwitcher({ sessions, selected, onSelect, isMobile }: SwitcherProps) {
- const agentSessions = sessions.filter((s) => s.owner_type === "agent");
- const isMineSel = selected.kind === "mine";
-
- // Mobile: collapse the sidebar to a thin horizontal selector, and hide it
- // entirely when there's only the user's own browser so the stream is full-bleed.
- if (isMobile) {
- if (agentSessions.length === 0 && selected.kind === "mine") return null;
- return (
-
- onSelect({ kind: "mine" })}
- className={[
- "flex items-center gap-1.5 rounded-full px-3 py-1 text-xs whitespace-nowrap shrink-0 transition-colors",
- isMineSel ? "bg-white/[0.12] text-shell-text" : "text-shell-text-secondary hover:bg-white/[0.06]",
- ].join(" ")}
- >
-
- My browser
-
- {agentSessions.map((s) => {
- const isSel = selected.kind === "agent" && selected.sessionId === s.id;
- return (
- onSelect({ kind: "agent", sessionId: s.id, agentName: s.owner_id })}
- className={[
- "flex items-center gap-1.5 rounded-full px-3 py-1 text-xs whitespace-nowrap shrink-0 transition-colors",
- isSel ? "bg-white/[0.12] text-shell-text" : "text-shell-text-secondary hover:bg-white/[0.06]",
- ].join(" ")}
- >
-
- {s.owner_id}
-
- );
- })}
-
- );
- }
-
- return (
-
- {/* My browser */}
- onSelect({ kind: "mine" })}
- className={[
- "flex items-center gap-2 rounded px-2 py-1.5 text-xs text-left w-full transition-colors",
- isMineSel
- ? "bg-white/[0.1] text-shell-text"
- : "text-shell-text-secondary hover:bg-white/[0.06] hover:text-shell-text",
- ].join(" ")}
- >
-
- My browser
-
-
- {agentSessions.length > 0 && (
- <>
-
- Agents
-
- {agentSessions.map((s) => {
- const isSel = selected.kind === "agent" && selected.sessionId === s.id;
- const label = s.owner_id;
- const sublabel = s.url ? truncateUrl(s.url) : s.status;
- return (
- onSelect({ kind: "agent", sessionId: s.id, agentName: s.owner_id })}
- className={[
- "flex items-center gap-2 rounded px-2 py-1.5 text-xs text-left w-full transition-colors",
- isSel
- ? "bg-white/[0.1] text-shell-text"
- : "text-shell-text-secondary hover:bg-white/[0.06] hover:text-shell-text",
- ].join(" ")}
- >
-
-
-
{label}
- {sublabel && (
-
{sublabel}
- )}
-
-
- );
- })}
- >
- )}
-
- );
-}
-
-function truncateUrl(url: string): string {
- try {
- const u = new URL(url);
- return u.hostname + (u.pathname !== "/" ? u.pathname.slice(0, 20) : "");
- } catch {
- return url.slice(0, 24);
- }
-}
-
-// ─── Main App ─────────────────────────────────────────────────────────────────
-
-export function StreamedBrowserApp({ windowId: _windowId }: StreamedBrowserAppProps) {
- const [sessions, setSessions] = useState([]);
- const [selected, setSelected] = useState({ kind: "mine" });
- const [viewState, setViewState] = useState({ phase: "loading" });
-
- const pollRef = useRef | null>(null);
- const triesRef = useRef(0);
- const cancelledRef = useRef(false);
- const listTimerRef = useRef | null>(null);
-
- const stopPolling = useCallback(() => {
- if (pollRef.current) {
- clearTimeout(pollRef.current);
- pollRef.current = null;
- }
- }, []);
-
- // Declared early so fetchMine can capture it in its closure.
- const isMobile = useIsMobile();
-
- // ── Session list poller ──────────────────────────────────────────────────
-
- const fetchSessionList = useCallback(async () => {
- try {
- const resp = await fetch("/api/browser/sessions", { credentials: "include" });
- if (!resp.ok) return;
- const data: { sessions: SessionSummary[] } = await resp.json();
- setSessions(data.sessions ?? []);
- } catch {
- // Non-fatal: list is best-effort; current view keeps working
- }
- }, []);
-
- useEffect(() => {
- void fetchSessionList();
- listTimerRef.current = setInterval(() => void fetchSessionList(), SESSION_LIST_POLL_MS);
- return () => {
- if (listTimerRef.current) clearInterval(listTimerRef.current);
- };
- }, [fetchSessionList]);
-
- // ── My browser flow (C1) ─────────────────────────────────────────────────
-
- const fetchMine = useCallback(async (isRetry = false) => {
- cancelledRef.current = false;
- triesRef.current = 0;
- stopPolling();
-
- if (!isRetry) setViewState({ phase: "loading" });
-
- let resp: Response;
- try {
- const deviceParam = isMobile ? "mobile" : "desktop";
- resp = await fetch(`/api/browser/sessions/mine?device=${deviceParam}`, { credentials: "include" });
- } catch {
- if (!cancelledRef.current) {
- setViewState({ phase: "error", message: "Could not reach the taOS server." });
- }
- return;
- }
-
- if (cancelledRef.current) return;
-
- if (resp.status === 409) {
- let body: { error?: string } = {};
- try { body = await resp.json(); } catch { /* ignore */ }
- if (body.error === "no_capable_node") {
- setViewState({ phase: "no_node" });
- } else {
- setViewState({ phase: "error", message: `Unexpected conflict (${body.error ?? resp.status}).` });
- }
- return;
- }
-
- if (!resp.ok) {
- setViewState({ phase: "error", message: `Server error (${resp.status}).` });
- return;
- }
-
- let session: BrowserSession;
- try {
- session = await resp.json();
- } catch {
- setViewState({ phase: "error", message: "Could not parse server response." });
- return;
- }
-
- if (session.status === "migrating") {
- setViewState({ phase: "migrating" });
- schedulePoll(session.id);
- return;
- }
-
- if (session.status === "running" && session.neko_url && session.stream_token) {
- setViewState({ phase: "live", nekoUrl: session.neko_url, streamToken: session.stream_token });
- return;
- }
-
- setViewState({ phase: "connecting" });
- schedulePoll(session.id);
- }, [stopPolling, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
-
- // ── Agent session flow ───────────────────────────────────────────────────
-
- const fetchAgentSession = useCallback(async (sessionId: string, agentName: string, isRetry = false) => {
- cancelledRef.current = false;
- triesRef.current = 0;
- stopPolling();
-
- if (!isRetry) setViewState({ phase: "loading" });
-
- let resp: Response;
- try {
- resp = await fetch(`/api/browser/sessions/${encodeURIComponent(sessionId)}`, {
- credentials: "include",
- });
- } catch {
- if (!cancelledRef.current) {
- setViewState({ phase: "error", message: "Could not reach the taOS server." });
- }
- return;
- }
-
- if (cancelledRef.current) return;
-
- if (!resp.ok) {
- setViewState({ phase: "error", message: `Could not load agent session (${resp.status}).` });
- return;
- }
-
- let session: BrowserSession;
- try {
- session = await resp.json();
- } catch {
- setViewState({ phase: "error", message: "Could not parse session response." });
- return;
- }
-
- if (session.status === "migrating") {
- setViewState({ phase: "migrating" });
- schedulePoll(sessionId, agentName);
- return;
- }
-
- if (session.status === "running" && session.neko_url && session.stream_token) {
- setViewState({
- phase: "live",
- nekoUrl: session.neko_url,
- streamToken: session.stream_token,
- watchLabel: agentName,
- });
- return;
- }
-
- // Session exists but not yet running
- setViewState({ phase: "connecting" });
- schedulePoll(sessionId, agentName);
- }, [stopPolling]); // eslint-disable-line react-hooks/exhaustive-deps
-
- // ── Poll loop (shared) ───────────────────────────────────────────────────
-
- const schedulePoll = useCallback((sessionId: string, agentName?: string) => {
- if (cancelledRef.current) return;
- triesRef.current += 1;
- if (triesRef.current > POLL_MAX_TRIES) {
- setViewState({ phase: "error", message: "Browser session took too long to start." });
- return;
- }
-
- pollRef.current = setTimeout(async () => {
- if (cancelledRef.current) return;
-
- let resp: Response;
- try {
- resp = await fetch(`/api/browser/sessions/${encodeURIComponent(sessionId)}`, {
- credentials: "include",
- });
- } catch {
- if (!cancelledRef.current) {
- setViewState({ phase: "error", message: "Lost connection while waiting for browser to start." });
- }
- return;
- }
-
- if (cancelledRef.current) return;
-
- if (!resp.ok) {
- setViewState({ phase: "error", message: `Session poll failed (${resp.status}).` });
- return;
- }
-
- let session: BrowserSession;
- try {
- session = await resp.json();
- } catch {
- setViewState({ phase: "error", message: "Could not parse session response." });
- return;
- }
-
- if (session.status === "migrating") {
- setViewState({ phase: "migrating" });
- schedulePoll(sessionId, agentName);
- return;
- }
-
- if (session.status === "running" && session.neko_url && session.stream_token) {
- setViewState({
- phase: "live",
- nekoUrl: session.neko_url,
- streamToken: session.stream_token,
- watchLabel: agentName,
- });
- } else {
- schedulePoll(sessionId, agentName);
- }
- }, POLL_INTERVAL_MS);
- }, []); // stable
-
- // ── Effect: re-fetch when selection changes ──────────────────────────────
-
- useEffect(() => {
- cancelledRef.current = false;
- if (selected.kind === "mine") {
- void fetchMine();
- } else {
- void fetchAgentSession(selected.sessionId, selected.agentName);
- }
- return () => {
- cancelledRef.current = true;
- stopPolling();
- };
- }, [selected, fetchMine, fetchAgentSession, stopPolling]);
-
- // ── Retry handler ────────────────────────────────────────────────────────
-
- const handleRetry = useCallback(() => {
- if (selected.kind === "mine") {
- void fetchMine(true);
- } else {
- void fetchAgentSession(selected.sessionId, selected.agentName, true);
- }
- }, [selected, fetchMine, fetchAgentSession]);
-
- // ── Render ───────────────────────────────────────────────────────────────
-
- const renderContent = () => {
- if (viewState.phase === "live") {
- return (
-
-
- {viewState.watchLabel && (
-
-
- Watching {viewState.watchLabel}'s browser
- {/* Request-control — Neko member-control handoff lands in a later sub-plan */}
-
-
-
-
- Request control
-
-
-
-
- Coming soon
-
-
-
-
-
-
- )}
-
- );
- }
-
- if (viewState.phase === "loading" || viewState.phase === "connecting" || viewState.phase === "migrating") {
- const message =
- viewState.phase === "loading"
- ? "Starting your browser…"
- : viewState.phase === "migrating"
- ? "Moving your browser to another device…"
- : "Waiting for browser to be ready…";
-
- return (
-
-
- {message}
-
- );
- }
-
- if (viewState.phase === "no_node") {
- return (
-
-
-
- This device can't run the browser yet
-
-
- Add a capable device to your cluster and the streamed browser will be available automatically.
-
-
- );
- }
-
- // error phase
- return (
-
-
-
{viewState.message}
-
- Retry
-
-
- );
- };
-
- return (
-
-
- {renderContent()}
-
- );
-}
diff --git a/desktop/src/apps/StreamedBrowserApp/index.ts b/desktop/src/apps/StreamedBrowserApp/index.ts
deleted file mode 100644
index 6dcc5ffaa..000000000
--- a/desktop/src/apps/StreamedBrowserApp/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { StreamedBrowserApp } from "./StreamedBrowserApp";
diff --git a/desktop/src/components/AppErrorBoundary.test.tsx b/desktop/src/components/AppErrorBoundary.test.tsx
new file mode 100644
index 000000000..952ebe1e4
--- /dev/null
+++ b/desktop/src/components/AppErrorBoundary.test.tsx
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AppErrorBoundary } from "./AppErrorBoundary";
+import { BackendUnavailableError } from "@/lib/taos-fetch";
+
+function ThrowOnRender({ error }: { error: Error }) {
+ throw error;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("AppErrorBoundary", () => {
+ it("renders children when no error has occurred", () => {
+ render(
+
+ app content
+ ,
+ );
+ expect(screen.getByText("app content")).toBeInTheDocument();
+ });
+
+ it("shows the waiting skeleton when BackendUnavailableError is caught", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Waiting for taOS to come back\u2026")).toBeInTheDocument();
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+
+ it("shows the chunk reload page when ChunkLoadError is caught", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const err = new Error("Loading chunk 42 failed");
+ err.name = "ChunkLoadError";
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("taOS was updated")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
+ expect(screen.getByText(/load the new version/i)).toBeInTheDocument();
+ });
+
+ it("shows the generic error message for unknown errors", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
+ });
+
+ it("does not render children after an error is caught", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ render(
+
+
+ visible child
+ ,
+ );
+ expect(screen.queryByText("visible child")).not.toBeInTheDocument();
+ expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
+ });
+
+ it("classifies dynamically import failures as chunk errors", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const err = new Error("Failed to fetch dynamically imported module");
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("taOS was updated")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/ContextMenu.test.tsx b/desktop/src/components/ContextMenu.test.tsx
new file mode 100644
index 000000000..6ea1e61f9
--- /dev/null
+++ b/desktop/src/components/ContextMenu.test.tsx
@@ -0,0 +1,156 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ContextMenu } from "./ContextMenu";
+
+vi.mock("@/hooks/use-is-mobile", () => ({
+ useIsMobile: () => false,
+}));
+
+describe("ContextMenu", () => {
+ it("renders all menu item labels", () => {
+ const items = [
+ { label: "Copy", action: vi.fn() },
+ { label: "Paste", action: vi.fn() },
+ { label: "Delete", action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ expect(screen.getByText("Copy")).toBeInTheDocument();
+ expect(screen.getByText("Paste")).toBeInTheDocument();
+ expect(screen.getByText("Delete")).toBeInTheDocument();
+ });
+
+ it("renders a separator between items", () => {
+ const items = [
+ { label: "Copy", action: vi.fn() },
+ { separator: true, label: "" },
+ { label: "Paste", action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ expect(screen.getByText("Copy")).toBeInTheDocument();
+ expect(screen.getByText("Paste")).toBeInTheDocument();
+ // The separator renders as a with a top border
+ const menu = screen.getByRole("menu");
+ const separators = menu.querySelectorAll("div.border-t");
+ expect(separators.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders disabled items and does not fire action on click", () => {
+ const action = vi.fn();
+ const items = [
+ { label: "Disabled Item", action, disabled: true },
+ { label: "Enabled Item", action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ const disabledBtn = screen.getByRole("menuitem", { name: /disabled item/i });
+ expect(disabledBtn).toBeDisabled();
+
+ fireEvent.click(disabledBtn);
+ expect(action).not.toHaveBeenCalled();
+ });
+
+ it("fires action and onClose when an enabled item is clicked", () => {
+ const action = vi.fn();
+ const onClose = vi.fn();
+ const items = [{ label: "Save", action }];
+ render(
);
+
+ fireEvent.click(screen.getByRole("menuitem", { name: /save/i }));
+
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when Escape is pressed", () => {
+ const onClose = vi.fn();
+ const items = [{ label: "One", action: vi.fn() }];
+ render(
);
+
+ const menu = screen.getByRole("menu");
+ fireEvent.keyDown(menu, { key: "Escape" });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("navigates to next item with ArrowDown", () => {
+ const items = [
+ { label: "First", action: vi.fn() },
+ { label: "Second", action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ const menu = screen.getByRole("menu");
+ // First item gets auto-focused by the useEffect
+ const firstItem = screen.getByRole("menuitem", { name: /first/i });
+ expect(document.activeElement).toBe(firstItem);
+
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
+
+ const secondItem = screen.getByRole("menuitem", { name: /second/i });
+ expect(document.activeElement).toBe(secondItem);
+ });
+
+ it("wraps around with ArrowDown at the last item", () => {
+ const items = [
+ { label: "First", action: vi.fn() },
+ { label: "Last", action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ const menu = screen.getByRole("menu");
+ const firstItem = screen.getByRole("menuitem", { name: /first/i });
+ expect(document.activeElement).toBe(firstItem);
+
+ // Move to last
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: /last/i }));
+
+ // Wrap to first
+ fireEvent.keyDown(menu, { key: "ArrowDown" });
+ expect(document.activeElement).toBe(firstItem);
+ });
+
+ it("renders items with icons", () => {
+ const items = [
+ { label: "Copy", icon:
C , action: vi.fn() },
+ ];
+ render(
+
+ );
+
+ expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
+ expect(screen.getByText("Copy")).toBeInTheDocument();
+ });
+
+ it("renders an empty menu without crashing", () => {
+ const onClose = vi.fn();
+ render(
);
+
+ const menu = screen.getByRole("menu");
+ expect(menu).toBeInTheDocument();
+
+ // Pressing Escape with no items still calls onClose
+ fireEvent.keyDown(menu, { key: "Escape" });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("has the correct aria-label on the menu", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("menu")).toHaveAttribute("aria-label", "Context menu");
+ });
+});
diff --git a/desktop/src/components/Dock.test.tsx b/desktop/src/components/Dock.test.tsx
new file mode 100644
index 000000000..f6ccd199a
--- /dev/null
+++ b/desktop/src/components/Dock.test.tsx
@@ -0,0 +1,168 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+let mockPinned = ["messages", "agents", "files", "store", "settings"];
+let mockWindows: Array<{
+ id: string;
+ appId: string;
+ minimized: boolean;
+ focused: boolean;
+ zIndex: number;
+ position: { x: number; y: number };
+ size: { w: number; h: number };
+ maximized: boolean;
+ snapped: null;
+ launchNonce: number;
+}> = [];
+let mockStructure: Record<
+ string,
+ { variant?: string } & Record
+> = {};
+
+const mockOpenWindow = vi.fn();
+const mockFocusWindow = vi.fn();
+const mockRestoreWindow = vi.fn();
+
+vi.mock("@/stores/dock-store", () => ({
+ useDockStore: (sel: (s: Record) => unknown) =>
+ sel({
+ pinned: mockPinned,
+ pin: vi.fn(),
+ unpin: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: (
+ selOrUndefined?: ((s: Record) => unknown) | Record
+ ) => {
+ const state = {
+ windows: mockWindows,
+ openWindow: mockOpenWindow,
+ focusWindow: mockFocusWindow,
+ restoreWindow: mockRestoreWindow,
+ minimizeWindow: vi.fn(),
+ maximizeWindow: vi.fn(),
+ recenterWindow: vi.fn(),
+ closeWindow: vi.fn(),
+ };
+ if (typeof selOrUndefined === "function") {
+ return selOrUndefined(state);
+ }
+ return state;
+ },
+}));
+
+vi.mock("@/stores/theme-store", () => ({
+ useThemeStore: (sel: (s: Record) => unknown) =>
+ sel({
+ structure: mockStructure,
+ }),
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getApp: (id: string) => ({
+ id,
+ name:
+ id === "messages"
+ ? "Messages"
+ : id === "agents"
+ ? "Agents"
+ : id === "files"
+ ? "Files"
+ : id === "store"
+ ? "Store"
+ : id === "settings"
+ ? "Settings"
+ : id === "browser"
+ ? "Browser"
+ : "Test App",
+ icon: "message-circle",
+ category: "platform",
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 400, h: 300 },
+ singleton: true,
+ pinned: true,
+ launchpadOrder: 1,
+ }),
+}));
+
+vi.mock("@/hooks/use-is-mobile", () => ({
+ useIsMobile: () => false,
+}));
+
+import { Dock } from "./Dock";
+
+beforeEach(() => {
+ mockPinned = ["messages", "agents", "files", "store", "settings"];
+ mockWindows = [];
+ mockStructure = {};
+ vi.clearAllMocks();
+});
+
+describe("Dock", () => {
+ it("renders pinned apps and the Launchpad button", () => {
+ const onLaunchpadOpen = vi.fn();
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: /launchpad/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /open messages/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /open agents/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /open files/i })
+ ).toBeInTheDocument();
+ });
+
+ it("renders with empty pinned list shows only Launchpad", () => {
+ mockPinned = [];
+ const onLaunchpadOpen = vi.fn();
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: /launchpad/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /open messages/i })
+ ).toBeNull();
+ });
+
+ it("calls onLaunchpadOpen when Launchpad button is clicked", () => {
+ const onLaunchpadOpen = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole("button", { name: /launchpad/i }));
+ expect(onLaunchpadOpen).toHaveBeenCalledOnce();
+ });
+
+ it("renders running apps not in pinned after a separator", () => {
+ mockWindows = [
+ {
+ id: "win-1",
+ appId: "browser",
+ minimized: false,
+ focused: true,
+ zIndex: 1,
+ position: { x: 0, y: 0 },
+ size: { w: 1024, h: 700 },
+ maximized: false,
+ snapped: null,
+ launchNonce: 0,
+ },
+ ];
+ const onLaunchpadOpen = vi.fn();
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: /open browser/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /open agents/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/EmojiPicker.test.tsx b/desktop/src/components/EmojiPicker.test.tsx
new file mode 100644
index 000000000..7c4bf146f
--- /dev/null
+++ b/desktop/src/components/EmojiPicker.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { EmojiPickerField } from "./EmojiPicker";
+
+describe("EmojiPickerField", () => {
+ it("shows + when value is empty", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ expect(btn).toHaveTextContent("+");
+ });
+
+ it("shows the current emoji value when provided", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ expect(btn).toHaveTextContent("🦉");
+ });
+
+ it("is collapsed by default (aria-expanded false, no dialog)", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ expect(btn).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByRole("dialog", { name: "Emoji picker" })).toBeNull();
+ });
+
+ it("opens the picker dialog on click and sets aria-expanded", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ fireEvent.click(btn);
+ expect(btn).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument();
+ });
+
+ it("toggles the picker closed on a second click", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ fireEvent.click(btn);
+ expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument();
+ fireEvent.click(btn);
+ expect(btn).toHaveAttribute("aria-expanded", "false");
+ expect(screen.queryByRole("dialog", { name: "Emoji picker" })).toBeNull();
+ });
+
+ it("calls onChange with the picked emoji and closes the picker", () => {
+ const onChange = vi.fn();
+ render( );
+ const btn = screen.getByRole("button", { name: "Open emoji picker" });
+ fireEvent.click(btn);
+ expect(screen.getByRole("dialog", { name: "Emoji picker" })).toBeInTheDocument();
+
+ const picker = screen.getByRole("dialog", { name: "Emoji picker" });
+ const emojiButtons = picker.querySelectorAll("button");
+ if (emojiButtons.length > 0) {
+ fireEvent.click(emojiButtons[0]);
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(btn).toHaveAttribute("aria-expanded", "false");
+ }
+ });
+});
diff --git a/desktop/src/components/Launchpad.test.tsx b/desktop/src/components/Launchpad.test.tsx
new file mode 100644
index 000000000..54fcf2cdd
--- /dev/null
+++ b/desktop/src/components/Launchpad.test.tsx
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+vi.mock("@/stores/process-store", () => {
+ const store = { openWindow: vi.fn(() => "win-1") };
+ return {
+ useProcessStore: (sel?: (s: typeof store) => unknown) =>
+ sel ? sel(store) : store,
+ };
+});
+
+vi.mock("@/hooks/use-installed-services", () => ({
+ useInstalledServices: () => [],
+}));
+
+vi.mock("@/hooks/use-installed-optional-apps", () => ({
+ useInstalledOptionalApps: () => new Set(),
+}));
+
+vi.mock("@/hooks/use-installed-userspace-apps", () => ({
+ useInstalledUserspaceApps: () => [],
+}));
+
+vi.mock("@/hooks/use-shortcut-registry", () => ({
+ useShortcut: vi.fn(),
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getLaunchableApps: (installedOptional: Set) => [
+ { id: "messages", name: "Messages", icon: "message-circle", category: "platform", defaultSize: { w: 900, h: 600 } },
+ { id: "mail", name: "Mail", icon: "mail", category: "platform", defaultSize: { w: 1200, h: 800 } },
+ { id: "weather", name: "Weather", icon: "cloud", category: "os", defaultSize: { w: 800, h: 600 } },
+ { id: "chess", name: "Chess", icon: "crown", category: "game", defaultSize: { w: 700, h: 700 } },
+ ].filter((a) => !a.id.startsWith("service:") && !a.id.startsWith("userspace:") && (a.id !== "reddit" || installedOptional.has("reddit"))),
+ getApp: (id: string) => {
+ const apps: Record = {
+ messages: { id: "messages", name: "Messages", defaultSize: { w: 900, h: 600 } },
+ mail: { id: "mail", name: "Mail", defaultSize: { w: 1200, h: 800 } },
+ weather: { id: "weather", name: "Weather", defaultSize: { w: 800, h: 600 } },
+ chess: { id: "chess", name: "Chess", defaultSize: { w: 700, h: 700 } },
+ };
+ return apps[id];
+ },
+ getOrRegisterServiceApp: (appId: string, displayName: string) => ({
+ id: `service:${appId}`,
+ name: displayName,
+ defaultSize: { w: 1100, h: 750 },
+ }),
+}));
+
+import { Launchpad } from "./Launchpad";
+
+describe("Launchpad", () => {
+ const onClose = vi.fn();
+ const onOpenApp = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns null when open is false", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders the dialog with aria-label when open is true", () => {
+ render( );
+ expect(screen.getByRole("dialog", { name: /launchpad/i })).toBeInTheDocument();
+ });
+
+ it("renders the search input", () => {
+ render( );
+ expect(screen.getByPlaceholderText("Search apps...")).toBeInTheDocument();
+ });
+
+ it("renders category headings for the built-in apps", () => {
+ render( );
+ expect(screen.getByText("Platform")).toBeInTheDocument();
+ expect(screen.getByText("Utilities")).toBeInTheDocument();
+ expect(screen.getByText("Games")).toBeInTheDocument();
+ });
+
+ it("renders app names for built-in apps", () => {
+ render( );
+ expect(screen.getByText("Messages")).toBeInTheDocument();
+ expect(screen.getByText("Mail")).toBeInTheDocument();
+ expect(screen.getByText("Weather")).toBeInTheDocument();
+ expect(screen.getByText("Chess")).toBeInTheDocument();
+ });
+
+ it("filters apps by search query", () => {
+ render( );
+ const input = screen.getByPlaceholderText("Search apps...");
+ fireEvent.change(input, { target: { value: "chess" } });
+ expect(screen.getByText("Chess")).toBeInTheDocument();
+ expect(screen.queryByText("Messages")).not.toBeInTheDocument();
+ expect(screen.queryByText("Mail")).not.toBeInTheDocument();
+ });
+
+ it("shows clear button when query is non-empty and clears on click", () => {
+ render( );
+ const input = screen.getByPlaceholderText("Search apps...");
+ fireEvent.change(input, { target: { value: "chess" } });
+ const clearBtn = screen.getByRole("button", { name: /clear search/i });
+ expect(clearBtn).toBeInTheDocument();
+ fireEvent.click(clearBtn);
+ expect(input).toHaveValue("");
+ });
+
+ it("does not show clear button when query is empty", () => {
+ render( );
+ expect(screen.queryByRole("button", { name: /clear search/i })).not.toBeInTheDocument();
+ });
+
+ it("calls onClose when an app icon is clicked", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: /open messages/i });
+ fireEvent.click(btn);
+ // handleLaunch calls onClose, and the click bubbles to the overlay onClick
+ // which also calls onClose. Both fire as expected by the component design.
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it("calls onOpenApp with the window id returned by openWindow when launching an app", () => {
+ render( );
+ const btn = screen.getByRole("button", { name: /open messages/i });
+ fireEvent.click(btn);
+ expect(onOpenApp).toHaveBeenCalledWith("win-1");
+ });
+
+ it("does not render Services section when no services are installed", () => {
+ render( );
+ expect(screen.queryByText("Services")).not.toBeInTheDocument();
+ });
+
+ it("does not render My Apps section when no userspace apps are installed", () => {
+ render( );
+ expect(screen.queryByText("My Apps")).not.toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/LoginScreen.test.tsx b/desktop/src/components/LoginScreen.test.tsx
new file mode 100644
index 000000000..250bda290
--- /dev/null
+++ b/desktop/src/components/LoginScreen.test.tsx
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { LoginScreen } from "./LoginScreen";
+
+let originalUserAgent: string;
+let originalRequestFullscreen: typeof document.documentElement.requestFullscreen;
+
+describe("LoginScreen", () => {
+ beforeEach(() => {
+ originalUserAgent = navigator.userAgent;
+ originalRequestFullscreen = document.documentElement.requestFullscreen;
+ document.documentElement.requestFullscreen = vi.fn().mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ Object.defineProperty(navigator, "userAgent", {
+ value: originalUserAgent,
+ configurable: true,
+ });
+ document.documentElement.requestFullscreen = originalRequestFullscreen;
+ vi.restoreAllMocks();
+ });
+
+ function setUserAgent(ua: string) {
+ Object.defineProperty(navigator, "userAgent", {
+ value: ua,
+ configurable: true,
+ });
+ }
+
+ it("renders the Launch taOS button when not launching", () => {
+ setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ const btn = screen.getByRole("button", { name: /launch taos/i });
+ expect(btn).toBeInTheDocument();
+ expect(btn).not.toBeDisabled();
+ });
+
+ it("shows Launching... text and disables the button while launching", async () => {
+ setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ const btn = screen.getByRole("button", { name: /launch taos/i });
+ fireEvent.click(btn);
+ expect(btn).toBeDisabled();
+ expect(screen.getByText("Launching...")).toBeInTheDocument();
+ });
+
+ it("calls onLaunch after the launch delay", async () => {
+ vi.useFakeTimers();
+ setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /launch taos/i }));
+ expect(onLaunch).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(600);
+ expect(onLaunch).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it("shows 'Full experience available' for Chrome user agent", () => {
+ setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ expect(screen.getByText("Full experience available")).toBeInTheDocument();
+ expect(screen.queryByText(/install taos as an app/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/full keyboard support/i)).not.toBeInTheDocument();
+ });
+
+ it("shows 'Install taOS as an app' for Safari user agent", () => {
+ setUserAgent(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Version/17.0 Safari/604.1.34",
+ );
+ const onLaunch = vi.fn();
+ render( );
+ expect(screen.getByText("Install taOS as an app for the best experience")).toBeInTheDocument();
+ expect(screen.queryByText(/full experience available/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/full keyboard support/i)).not.toBeInTheDocument();
+ });
+
+ it("shows 'use Chrome or Edge' for unknown browsers", () => {
+ setUserAgent("Mozilla/5.0 (compatible; SomeBot/1.0)");
+ const onLaunch = vi.fn();
+ render( );
+ expect(
+ screen.getByText("For full keyboard support, use Chrome or Edge"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/full experience available/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/install taos as an app/i)).not.toBeInTheDocument();
+ });
+
+ it("renders the taOS logo image", () => {
+ setUserAgent("Mozilla/5.0 Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ const img = screen.getByRole("img", { name: /taos/i });
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute("src", "/static/taos-logo.png");
+ });
+
+ it("calls requestFullscreen when the launch button is clicked", () => {
+ setUserAgent("Mozilla/5.0 Chrome/120.0.0.0");
+ const onLaunch = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /launch taos/i }));
+ expect(document.documentElement.requestFullscreen).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/components/NotificationCentre.test.tsx b/desktop/src/components/NotificationCentre.test.tsx
index f2b04387c..7e797d38f 100644
--- a/desktop/src/components/NotificationCentre.test.tsx
+++ b/desktop/src/components/NotificationCentre.test.tsx
@@ -9,6 +9,8 @@ const closeCentre = vi.fn();
const markAllRead = vi.fn();
const clearAll = vi.fn();
const dismiss = vi.fn();
+const archivedNotifications = vi.fn(() => []);
+const clearArchived = vi.fn();
let notifications: Notification[] = [];
@@ -21,6 +23,8 @@ vi.mock("@/stores/notification-store", () => ({
markAllRead,
clearAll,
dismiss,
+ archivedNotifications,
+ clearArchived,
}),
}));
diff --git a/desktop/src/components/NotificationCentre.tsx b/desktop/src/components/NotificationCentre.tsx
index 65e316e71..a0cec2bb4 100644
--- a/desktop/src/components/NotificationCentre.tsx
+++ b/desktop/src/components/NotificationCentre.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { X, Bell, CheckCheck, Trash2 } from "lucide-react";
+import { X, Bell, CheckCheck, Trash2, History } from "lucide-react";
import { useNotificationStore, type Notification } from "@/stores/notification-store";
import { useProcessStore } from "@/stores/process-store";
import { getApp } from "@/registry/app-registry";
@@ -16,10 +16,66 @@ function formatTime(ts: number): string {
return `${Math.floor(delta / 86400_000)}d ago`;
}
+function NotificationItem({
+ n,
+ onDismiss,
+ onItemClick,
+}: {
+ n: Notification;
+ onDismiss: (id: string) => void;
+ onItemClick: (n: Notification) => void;
+}) {
+ return (
+ onItemClick(n)}
+ className={`w-full text-left px-4 py-3 border-b border-white/5 hover:bg-white/5 transition-colors ${!n.read ? "bg-accent/5" : ""} ${n.action ? "cursor-pointer" : ""}`}
+ >
+
+
+
+ {!n.read &&
}
+
{n.title}
+
+ {n.body &&
{n.body}
}
+
+ {formatTime(n.timestamp)}
+ .
+ {n.source}
+
+
+
{
+ e.stopPropagation();
+ onDismiss(n.id);
+ }}
+ className="p-0.5 rounded hover:bg-white/10 shrink-0"
+ aria-label={`Dismiss: ${n.title}`}
+ >
+
+
+
+
+ );
+}
+
export function NotificationCentre() {
- const { notifications, centreOpen, closeCentre, markRead, markAllRead, clearAll, dismiss } = useNotificationStore();
+ const {
+ notifications,
+ centreOpen,
+ closeCentre,
+ markRead,
+ markAllRead,
+ clearAll,
+ dismiss,
+ archivedNotifications,
+ clearArchived,
+ } = useNotificationStore();
const openWindow = useProcessStore((s) => s.openWindow);
const [checklistDismissed, setChecklistDismissed] = useState(false);
+ const [tab, setTab] = useState<"inbox" | "history">("inbox");
+
+ const active = notifications.filter((n) => !n.archived);
+ const archived = archivedNotifications();
// Optimistic local mark-read, plus a best-effort backend write for server
// items so the read state persists across reloads. Network never blocks the UI.
@@ -87,7 +143,7 @@ export function NotificationCentre() {
Notifications
- {notifications.length > 0 && (
+ {tab === "inbox" && active.length > 0 && (
<>
@@ -97,55 +153,86 @@ export function NotificationCentre() {
>
)}
+ {tab === "history" && archived.length > 0 && (
+
+
+
+ )}
+ {/* Tabs */}
+
+ setTab("inbox")}
+ className={`flex-1 px-4 py-2 text-xs font-medium transition-colors ${tab === "inbox" ? "text-accent border-b-2 border-accent" : "text-shell-text-tertiary hover:text-shell-text"}`}
+ >
+ Inbox{active.length > 0 ? ` (${active.length})` : ""}
+
+ setTab("history")}
+ className={`flex-1 px-4 py-2 text-xs font-medium transition-colors flex items-center justify-center gap-1 ${tab === "history" ? "text-accent border-b-2 border-accent" : "text-shell-text-tertiary hover:text-shell-text"}`}
+ >
+
+ History{archived.length > 0 ? ` (${archived.length})` : ""}
+
+
+
{/* List */}
- {!checklistDismissed && (
-
setChecklistDismissed(true)} />
+ {tab === "inbox" && (
+ <>
+ {!checklistDismissed && (
+ setChecklistDismissed(true)} />
+ )}
+ {active.length === 0 ? (
+
+ ) : (
+ active.map((n) => (
+
+ ))
+ )}
+ >
)}
- {notifications.length === 0 ? (
-
- ) : (
- notifications.map((n) => (
- handleItemClick(n)}
- className={`w-full text-left px-4 py-3 border-b border-white/5 hover:bg-white/5 transition-colors ${!n.read ? "bg-accent/5" : ""} ${n.action ? "cursor-pointer" : ""}`}
- >
-
-
-
- {!n.read &&
}
-
{n.title}
-
- {n.body &&
{n.body}
}
-
-
{formatTime(n.timestamp)}
-
·
-
{n.source}
+ {tab === "history" && (
+ <>
+ {archived.length === 0 ? (
+
+
+
No archived notifications
+
+ ) : (
+ archived.map((n) => (
+
+
+
+
{n.title}
+ {n.body &&
{n.body}
}
+
+ {formatTime(n.timestamp)}
+ .
+ {n.source}
+
+
-
{
- e.stopPropagation();
- dismiss(n.id);
- }}
- className="p-0.5 rounded hover:bg-white/10 shrink-0"
- aria-label={`Dismiss: ${n.title}`}
- >
-
-
-
-
- ))
+ ))
+ )}
+ >
)}
diff --git a/desktop/src/components/NotificationToast.test.tsx b/desktop/src/components/NotificationToast.test.tsx
index ddf919c83..4618d7741 100644
--- a/desktop/src/components/NotificationToast.test.tsx
+++ b/desktop/src/components/NotificationToast.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { NotificationToasts } from "./NotificationToast";
const mockDismiss = vi.fn();
@@ -64,4 +64,30 @@ describe("NotificationToasts", () => {
fireEvent.click(screen.getByRole("button", { name: /dismiss notification/i }));
await waitFor(() => expect(mockDismiss).toHaveBeenCalledWith("test-2"));
});
+
+ it("auto-expires the toast after 5s without archiving it", () => {
+ vi.useFakeTimers();
+ try {
+ mockNotifications.length = 0;
+ mockNotifications.push({
+ id: "test-3",
+ source: "system",
+ title: "Synced",
+ body: "Your files are up to date.",
+ level: "success",
+ read: false,
+ timestamp: Date.now(),
+ });
+ mockDismiss.mockClear();
+ render( );
+ expect(screen.getByText("Synced")).toBeInTheDocument();
+ // Toast vanishes from view once the 5s timer fires...
+ act(() => vi.advanceTimersByTime(5000));
+ expect(screen.queryByText("Synced")).not.toBeInTheDocument();
+ // ...but auto-expiry must never archive (dismiss is the explicit action).
+ expect(mockDismiss).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/desktop/src/components/NotificationToast.tsx b/desktop/src/components/NotificationToast.tsx
index f46cfe763..33d95b429 100644
--- a/desktop/src/components/NotificationToast.tsx
+++ b/desktop/src/components/NotificationToast.tsx
@@ -264,7 +264,7 @@ function extractTagged(text: string, tag: string): string | undefined {
/* ToastItem */
/* ------------------------------------------------------------------ */
-function ToastItem({ notif }: { notif: Notification }) {
+function ToastItem({ notif, onExpire }: { notif: Notification; onExpire: () => void }) {
const dismiss = useNotificationStore((s) => s.dismiss);
const Icon = LEVEL_ICONS[notif.level];
const isAgentPaused = notif.source === "agent.paused";
@@ -272,9 +272,12 @@ function ToastItem({ notif }: { notif: Notification }) {
useEffect(() => {
// Agent-paused toasts stay until the user explicitly acts on them.
if (isAgentPaused) return;
- const timer = setTimeout(() => dismiss(notif.id), 5000);
+ // Auto-expiry only hides the toast; it must NOT archive. Archiving is an
+ // explicit user action (the X button / "Keep paused"). Otherwise every
+ // toast that simply times out would silently fill the History view.
+ const timer = setTimeout(onExpire, 5000);
return () => clearTimeout(timer);
- }, [notif.id, dismiss, isAgentPaused]);
+ }, [notif.id, onExpire, isAgentPaused]);
return (
[n.id, n] as const));
const active = toastIds
.map((id) => byId.get(id))
- .filter((n): n is Notification => !!n && !n.read)
+ .filter((n): n is Notification => !!n && !n.read && !n.archived)
.slice(0, 3);
return (
@@ -342,7 +345,11 @@ export function NotificationToasts() {
role="region"
>
{active.map((n) => (
-
+ setToastIds((prev) => prev.filter((id) => id !== n.id))}
+ />
))}
);
diff --git a/desktop/src/components/ServiceIcon.test.tsx b/desktop/src/components/ServiceIcon.test.tsx
new file mode 100644
index 000000000..c7e43901b
--- /dev/null
+++ b/desktop/src/components/ServiceIcon.test.tsx
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+const mockOnClick = vi.fn();
+
+const baseService = {
+ app_id: "test-app",
+ display_name: "Test App",
+ icon: "https://example.com/icon.png",
+ url: "https://example.com",
+ category: "productivity",
+ backend: "docker",
+ status: "running" as const,
+};
+
+import { ServiceIcon } from "./ServiceIcon";
+
+describe("ServiceIcon", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders the display name as visible text", () => {
+ render( );
+ expect(screen.getByText("Test App")).toBeInTheDocument();
+ });
+
+ it("renders a button with an accessible label containing the display name", () => {
+ render( );
+ const button = screen.getByRole("button", { name: /open test app/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it("calls onClick when the button is clicked", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /open test app/i }));
+ expect(mockOnClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the service icon image when icon is provided", () => {
+ render( );
+ const img = screen.getByRole("img", { name: /test app/i });
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute("src", "https://example.com/icon.png");
+ });
+
+ it("falls back to the generic grid icon when icon is null", () => {
+ const noIconService = { ...baseService, icon: null };
+ const { container } = render(
+
+ );
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ });
+
+ it("falls back to the generic grid icon when the image fails to load", () => {
+ const { container } = render(
+
+ );
+ const img = screen.getByRole("img", { name: /test app/i });
+ fireEvent.error(img);
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ expect(container.querySelector("svg")).toBeInTheDocument();
+ });
+
+ it("truncates long display names with ellipsis", () => {
+ const longNameService = {
+ ...baseService,
+ display_name: "A Very Long Service Name That Exceeds The Max Width",
+ };
+ render( );
+ const span = screen.getByText(
+ "A Very Long Service Name That Exceeds The Max Width"
+ );
+ expect(span).toHaveClass("truncate");
+ });
+});
diff --git a/desktop/src/components/SetupChecklist.test.tsx b/desktop/src/components/SetupChecklist.test.tsx
new file mode 100644
index 000000000..7ff42f629
--- /dev/null
+++ b/desktop/src/components/SetupChecklist.test.tsx
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { SetupChecklist } from "./SetupChecklist";
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: (sel: (s: Record) => unknown) =>
+ sel({
+ openWindow: vi.fn(),
+ }),
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getApp: (id: string) => ({
+ id,
+ name: id,
+ icon: "app",
+ category: "platform",
+ defaultSize: { w: 800, h: 600 },
+ minSize: { w: 400, h: 300 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 1,
+ }),
+}));
+
+const baseStatus = {
+ account: false,
+ has_provider: false,
+ taos_model_set: false,
+ has_agent: false,
+ memory_enabled: false,
+ dismissed: false,
+ complete: false,
+};
+
+function mockFetchStatus(status: Record) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string) => {
+ if (url === "/api/setup/status") {
+ return Promise.resolve(
+ new Response(JSON.stringify(status), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ }
+ if (url === "/api/setup/dismiss") {
+ return Promise.resolve(new Response("{}", { status: 200 }));
+ }
+ return Promise.reject(new Error("unexpected fetch: " + url));
+ }) as unknown as typeof fetch,
+ );
+}
+
+describe("SetupChecklist", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("renders nothing when status is still loading (null)", () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ () =>
+ new Promise(() => {
+ /* never resolves */
+ }),
+ ) as unknown as typeof fetch,
+ );
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing when status.dismissed is true", () => {
+ mockFetchStatus({ ...baseStatus, dismissed: true });
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders nothing when status.complete is true", () => {
+ mockFetchStatus({ ...baseStatus, complete: true });
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the header with Get started and step count", async () => {
+ mockFetchStatus(baseStatus);
+ render( );
+ expect(await screen.findByText("Get started")).toBeInTheDocument();
+ expect(screen.getByText("0/5")).toBeInTheDocument();
+ });
+
+ it("renders all five step labels", async () => {
+ mockFetchStatus(baseStatus);
+ render( );
+ expect(await screen.findByText("Create your account")).toBeInTheDocument();
+ expect(screen.getByText("Add a provider")).toBeInTheDocument();
+ expect(screen.getByText("Choose a model for the taOS agent")).toBeInTheDocument();
+ expect(screen.getByText("Deploy your first agent")).toBeInTheDocument();
+ expect(screen.getByText("Set up memory")).toBeInTheDocument();
+ });
+
+ it("renders detail text for incomplete steps", async () => {
+ mockFetchStatus(baseStatus);
+ render( );
+ expect(await screen.findByText("Connect a cloud API key or local model server")).toBeInTheDocument();
+ expect(screen.getByText("Pick the model your taOS agent will use")).toBeInTheDocument();
+ expect(screen.getByText("Deploy an AI agent (Hermes recommended)")).toBeInTheDocument();
+ expect(screen.getByText("taOSmd memory is recommended and on by default")).toBeInTheDocument();
+ });
+
+ it("shows the correct done count when some steps are complete", async () => {
+ mockFetchStatus({
+ ...baseStatus,
+ account: true,
+ has_provider: true,
+ });
+ render( );
+ expect(await screen.findByText("2/5")).toBeInTheDocument();
+ });
+
+ it("does not show detail text for completed steps", async () => {
+ mockFetchStatus({
+ ...baseStatus,
+ account: true,
+ });
+ render( );
+ expect(await screen.findByText("Create your account")).toBeInTheDocument();
+ // "Done at sign-up" is the detail for account, which should not render when done
+ expect(screen.queryByText("Done at sign-up")).toBeNull();
+ });
+
+ it("renders a dismiss button with accessible label", async () => {
+ mockFetchStatus(baseStatus);
+ render( );
+ const dismissBtn = await screen.findByRole("button", {
+ name: "Dismiss setup checklist",
+ });
+ expect(dismissBtn).toBeInTheDocument();
+ });
+
+ it("calls onDismissed after clicking dismiss", async () => {
+ mockFetchStatus(baseStatus);
+ const onDismissed = vi.fn();
+ render( );
+ const dismissBtn = await screen.findByRole("button", {
+ name: "Dismiss setup checklist",
+ });
+ fireEvent.click(dismissBtn);
+ await waitFor(() => {
+ expect(onDismissed).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("renders accessible aria-labels for completed and pending steps", async () => {
+ mockFetchStatus({
+ ...baseStatus,
+ account: true,
+ has_provider: false,
+ });
+ render( );
+ const doneStep = await screen.findByRole("button", {
+ name: "Create your account — complete",
+ });
+ expect(doneStep).toBeInTheDocument();
+ const pendingStep = screen.getByRole("button", {
+ name: "Add a provider",
+ });
+ expect(pendingStep).toBeInTheDocument();
+ });
+
+ it("renders a list with role=list for the steps", async () => {
+ mockFetchStatus(baseStatus);
+ render( );
+ expect(await screen.findByRole("list")).toBeInTheDocument();
+ });
+});
diff --git a/desktop/src/components/StatusIndicators.test.tsx b/desktop/src/components/StatusIndicators.test.tsx
new file mode 100644
index 000000000..687ec0623
--- /dev/null
+++ b/desktop/src/components/StatusIndicators.test.tsx
@@ -0,0 +1,189 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const openWindowMock = vi.fn();
+
+vi.mock("@/stores/process-store", () => ({
+ useProcessStore: (sel: (s: { openWindow: () => void }) => unknown) =>
+ sel({ openWindow: openWindowMock }),
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ getApp: (id: string) =>
+ id === "dashboard"
+ ? { id: "dashboard", name: "Activity", defaultSize: { w: 1100, h: 720 } }
+ : undefined,
+}));
+
+vi.mock("lucide-react", () => {
+ const mk = (name: string) =>
+ function MockIcon({ size }: { size?: number }) {
+ return ;
+ };
+ return {
+ Cpu: mk("cpu"),
+ MemoryStick: mk("memory-stick"),
+ Zap: mk("zap"),
+ CircuitBoard: mk("circuit-board"),
+ };
+});
+
+import { StatusIndicators } from "./StatusIndicators";
+
+let originalFetch: typeof globalThis.fetch;
+
+function mockFetch(json: Record) {
+ globalThis.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: new Map([["content-type", "application/json"]]) as unknown as Headers,
+ json: () => Promise.resolve(json),
+ }) as unknown as typeof fetch;
+}
+
+describe("StatusIndicators", () => {
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ openWindowMock.mockClear();
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ vi.restoreAllMocks();
+ });
+
+ it("renders nothing while data is loading", () => {
+ globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as unknown as typeof fetch;
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders CPU and RAM indicators after fetch resolves", async () => {
+ mockFetch({
+ resources: { cpu_percent: 42, ram_percent: 65 },
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /open dashboard/i })).toBeInTheDocument();
+ });
+
+ expect(screen.getByTitle("CPU: 42%")).toBeInTheDocument();
+ expect(screen.getByTitle("RAM: 65%")).toBeInTheDocument();
+ });
+
+ it("shows the correct accessible labels for CPU and RAM with values", async () => {
+ mockFetch({
+ resources: { cpu_percent: 42, ram_percent: 65 },
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ const cpuEl = screen.getByTitle("CPU: 42%");
+ const ramEl = screen.getByTitle("RAM: 65%");
+
+ expect(cpuEl).toHaveAttribute("aria-label", "CPU usage 42 percent");
+ expect(ramEl).toHaveAttribute("aria-label", "RAM usage 65 percent");
+ });
+
+ it("shows VRAM indicator when GPU is present", async () => {
+ mockFetch({
+ resources: { cpu_percent: 30, ram_percent: 50, vram_percent: 75 },
+ hardware: { gpu: { type: "nvidia", vram_mb: 8192 }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ expect(screen.getByTitle("CPU: 30%")).toBeInTheDocument();
+ expect(screen.getByTitle("RAM: 50%")).toBeInTheDocument();
+ expect(screen.getByTitle("VRAM: 75%")).toBeInTheDocument();
+ });
+
+ it("shows NPU indicator when NPU is present", async () => {
+ mockFetch({
+ resources: { cpu_percent: 20, ram_percent: 40, npu_pct: 15 },
+ hardware: { gpu: { type: "none" }, npu: { type: "apple" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ expect(screen.getByTitle("CPU: 20%")).toBeInTheDocument();
+ expect(screen.getByTitle("RAM: 40%")).toBeInTheDocument();
+ expect(screen.getByTitle("NPU: 15%")).toBeInTheDocument();
+ expect(screen.queryByTitle(/VRAM/)).toBeNull();
+ });
+
+ it("shows both VRAM and NPU when GPU and NPU are present", async () => {
+ mockFetch({
+ resources: { cpu_percent: 55, ram_pct: 70, vram_pct: 80, npu_pct: 25 },
+ hardware: { gpu: { type: "nvidia", vram_mb: 16384 }, npu: { type: "qualcomm" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ expect(screen.getByTitle("CPU: 55%")).toBeInTheDocument();
+ expect(screen.getByTitle("RAM: 70%")).toBeInTheDocument();
+ expect(screen.getByTitle("VRAM: 80%")).toBeInTheDocument();
+ expect(screen.getByTitle("NPU: 25%")).toBeInTheDocument();
+ });
+
+ it("shows unknown usage when values are null", async () => {
+ mockFetch({
+ resources: {},
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ const cpuEl = screen.getByTitle("CPU: \u2014");
+ const ramEl = screen.getByTitle("RAM: \u2014");
+
+ expect(cpuEl).toHaveAttribute("aria-label", "CPU usage unknown");
+ expect(ramEl).toHaveAttribute("aria-label", "RAM usage unknown");
+ });
+
+ it("calls openWindow when the dashboard button is clicked", async () => {
+ mockFetch({
+ resources: { cpu_percent: 10, ram_percent: 20 },
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ fireEvent.click(screen.getByRole("button", { name: /open dashboard/i }));
+ expect(openWindowMock).toHaveBeenCalledWith("dashboard", { w: 1100, h: 720 });
+ });
+
+ it("applies compact class when compact prop is true", async () => {
+ mockFetch({
+ resources: { cpu_percent: 10, ram_percent: 20 },
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ const btn = screen.getByRole("button", { name: /open dashboard/i });
+ expect(btn.className).not.toContain("px-1");
+ });
+
+ it("applies non-compact padding class when compact is false", async () => {
+ mockFetch({
+ resources: { cpu_percent: 10, ram_percent: 20 },
+ hardware: { gpu: { type: "none" }, npu: { type: "none" } },
+ });
+
+ render( );
+ await waitFor(() => screen.getByRole("button", { name: /open dashboard/i }));
+
+ const btn = screen.getByRole("button", { name: /open dashboard/i });
+ expect(btn.className).toContain("px-1");
+ });
+});
diff --git a/desktop/src/components/TaosAssistantSettings.test.tsx b/desktop/src/components/TaosAssistantSettings.test.tsx
new file mode 100644
index 000000000..7e66ec413
--- /dev/null
+++ b/desktop/src/components/TaosAssistantSettings.test.tsx
@@ -0,0 +1,152 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { TaosAssistantSettings } from "./TaosAssistantSettings";
+import { useTaosAgentStore } from "@/stores/taos-agent-store";
+
+vi.mock("@/lib/models", () => ({
+ fetchClusterWorkers: vi.fn(),
+ fetchCloudProviders: vi.fn(),
+ workersToAggregated: vi.fn(),
+ cloudProvidersToAggregated: vi.fn(),
+ localProvidersToAggregated: vi.fn(),
+}));
+
+import {
+ fetchClusterWorkers,
+ fetchCloudProviders,
+ workersToAggregated,
+ cloudProvidersToAggregated,
+ localProvidersToAggregated,
+} from "@/lib/models";
+
+const mockFetch = vi.fn();
+
+function setupFetch() {
+ mockFetch.mockImplementation((url: string) => {
+ if (url === "/api/models") {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({ models: [] }),
+ });
+ }
+ if (url === "/api/taos-agent/settings") {
+ return Promise.resolve({ ok: true });
+ }
+ return Promise.resolve({ ok: true, json: async () => ({}) });
+ });
+ vi.stubGlobal("fetch", mockFetch);
+}
+
+function resetStore() {
+ useTaosAgentStore.setState({
+ isOpen: false,
+ messages: [],
+ model: null,
+ streaming: false,
+ settingsOpen: false,
+ });
+}
+
+describe("TaosAssistantSettings", () => {
+ beforeEach(() => {
+ resetStore();
+ setupFetch();
+ vi.mocked(fetchClusterWorkers).mockResolvedValue([]);
+ vi.mocked(fetchCloudProviders).mockResolvedValue([]);
+ vi.mocked(workersToAggregated).mockReturnValue([]);
+ vi.mocked(cloudProvidersToAggregated).mockReturnValue([]);
+ vi.mocked(localProvidersToAggregated).mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it("renders nothing when open is false", () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("renders the dialog with title when open is true", () => {
+ render( );
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("taOS agent — Settings")).toBeInTheDocument();
+ });
+
+ it("shows the current model name in the header when set", () => {
+ useTaosAgentStore.setState({ model: "gpt-4o" });
+ render( );
+ expect(screen.getByText("gpt-4o")).toBeInTheDocument();
+ });
+
+ it("does not show a model name span when no model is set", () => {
+ useTaosAgentStore.setState({ model: null });
+ render( );
+ expect(screen.getByText("taOS agent — Settings")).toBeInTheDocument();
+ expect(screen.queryByText("gpt-4o")).not.toBeInTheDocument();
+ });
+
+ it("calls onClose when the close button is clicked", () => {
+ const onClose = vi.fn();
+ render( );
+ const closeBtn = screen.getByRole("button", { name: /close settings/i });
+ fireEvent.click(closeBtn);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose when the backdrop is clicked", () => {
+ const onClose = vi.fn();
+ render( );
+ const backdrop = screen.getByRole("dialog");
+ fireEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("fetches models on open and passes loaded state to ModelPickerFlow", async () => {
+ vi.mocked(fetchClusterWorkers).mockResolvedValue([]);
+ vi.mocked(fetchCloudProviders).mockResolvedValue([]);
+ render( );
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith("/api/models");
+ });
+ expect(fetchClusterWorkers).toHaveBeenCalledTimes(1);
+ expect(fetchCloudProviders).toHaveBeenCalledTimes(1);
+ });
+
+ it("sends PATCH to /api/taos-agent/settings with selected model and closes", async () => {
+ const onClose = vi.fn();
+ useTaosAgentStore.setState({ model: "old-model" });
+
+ vi.mocked(localProvidersToAggregated).mockReturnValue([
+ { id: "llama-3", name: "Llama 3", host: "controller", hostKind: "controller" },
+ ]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Llama 3")).toBeInTheDocument();
+ });
+
+ const modelBtn = screen.getByText("Llama 3");
+ fireEvent.click(modelBtn);
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledWith(
+ "/api/taos-agent/settings",
+ expect.objectContaining({
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "llama-3" }),
+ })
+ );
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(useTaosAgentStore.getState().model).toBe("llama-3");
+ });
+});
diff --git a/desktop/src/components/TopBar.tsx b/desktop/src/components/TopBar.tsx
index 998879faf..7de32c892 100644
--- a/desktop/src/components/TopBar.tsx
+++ b/desktop/src/components/TopBar.tsx
@@ -99,7 +99,7 @@ function PowerMenu() {
export function TopBar({ onSearchOpen, onAssistantOpen }: Props) {
const clock = useClock();
const { showWidgets, toggleWidgets } = useWidgetStore();
- const unreadCount = useNotificationStore((s) => s.notifications.filter((n) => !n.read).length);
+ const unreadCount = useNotificationStore((s) => s.notifications.filter((n) => !n.read && !n.archived).length);
const toggleCentre = useNotificationStore((s) => s.toggleCentre);
return (
diff --git a/desktop/src/components/UpdateAvailableToast.test.tsx b/desktop/src/components/UpdateAvailableToast.test.tsx
new file mode 100644
index 000000000..9bb8e7529
--- /dev/null
+++ b/desktop/src/components/UpdateAvailableToast.test.tsx
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { UpdateAvailableToast } from "./UpdateAvailableToast";
+
+const mockAddNotification = vi.fn();
+const mockCurrentVersion: { value: string | null } = { value: null };
+
+vi.mock("@/contexts/BackendStatusContext", () => ({
+ useBackendStatus: () => ({
+ currentVersion: mockCurrentVersion.value,
+ }),
+}));
+
+vi.mock("@/stores/notification-store", () => ({
+ useNotificationStore: (selector: (state: { addNotification: typeof mockAddNotification }) => unknown) =>
+ selector({ addNotification: mockAddNotification }),
+}));
+
+describe("UpdateAvailableToast", () => {
+ beforeEach(() => {
+ mockAddNotification.mockClear();
+ mockCurrentVersion.value = null;
+ });
+
+ it("renders nothing", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("does not fire notification when currentVersion is null", () => {
+ mockCurrentVersion.value = null;
+ render( );
+ expect(mockAddNotification).not.toHaveBeenCalled();
+ });
+
+ it("does not fire notification when versions match", () => {
+ mockCurrentVersion.value = "1.0.0";
+ render( );
+ expect(mockAddNotification).not.toHaveBeenCalled();
+ });
+
+ it("does not fire notification for dev build version", () => {
+ mockCurrentVersion.value = "1.0.1";
+ render( );
+ expect(mockAddNotification).not.toHaveBeenCalled();
+ });
+
+ it("does not fire notification for 0.0.0 build version", () => {
+ mockCurrentVersion.value = "1.0.1";
+ render( );
+ expect(mockAddNotification).not.toHaveBeenCalled();
+ });
+
+ it("fires notification when backend version differs from build version", () => {
+ mockCurrentVersion.value = "1.0.1";
+ render( );
+ expect(mockAddNotification).toHaveBeenCalledTimes(1);
+ expect(mockAddNotification).toHaveBeenCalledWith({
+ source: "system",
+ level: "info",
+ title: "New taOS version available",
+ body: "Reload to upgrade from 1.0.0 to 1.0.1.",
+ });
+ });
+
+ it("strips build metadata from versions before comparing", () => {
+ mockCurrentVersion.value = "1.0.0+a3bd632";
+ render( );
+ expect(mockAddNotification).not.toHaveBeenCalled();
+ });
+
+ it("does not re-fire notification for the same version on re-render", () => {
+ mockCurrentVersion.value = "1.0.1";
+ const { rerender } = render( );
+ expect(mockAddNotification).toHaveBeenCalledTimes(1);
+ rerender( );
+ expect(mockAddNotification).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/desktop/src/components/WallpaperPicker.test.tsx b/desktop/src/components/WallpaperPicker.test.tsx
new file mode 100644
index 000000000..6a7294896
--- /dev/null
+++ b/desktop/src/components/WallpaperPicker.test.tsx
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { WallpaperPicker } from "./WallpaperPicker";
+import { useThemeStore } from "@/stores/theme-store";
+
+describe("WallpaperPicker", () => {
+ beforeEach(() => {
+ useThemeStore.setState({
+ wallpaperId: "graphite",
+ wallpaperKind: "image",
+ wallpaperOverlayText: null,
+ showOverlayText: true,
+ wallpaperParams: { density: 200, speed: 0.5, glow: 6 },
+ });
+ });
+
+ it("renders nothing when open is false", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the dialog with title when open is true", () => {
+ render( );
+ expect(screen.getByRole("dialog", { name: /change wallpaper/i })).toBeInTheDocument();
+ expect(screen.getByText("Change Wallpaper")).toBeInTheDocument();
+ });
+
+ it("renders all wallpaper buttons with labels", () => {
+ render( );
+ expect(screen.getByRole("button", { name: /graphite/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /neural \(live\)/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /classic/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /aurora/i })).toBeInTheDocument();
+ });
+
+ it("marks the active wallpaper with aria-pressed and a check indicator", () => {
+ useThemeStore.setState({ wallpaperId: "aurora" });
+ render( );
+ const activeBtn = screen.getByRole("button", { name: /aurora/i });
+ expect(activeBtn).toHaveAttribute("aria-pressed", "true");
+ const inactiveBtn = screen.getByRole("button", { name: /graphite/i });
+ expect(inactiveBtn).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("updates wallpaperId when a wallpaper button is clicked", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /midnight blue/i }));
+ expect(useThemeStore.getState().wallpaperId).toBe("midnight");
+ });
+
+ it("calls onClose when the close button is clicked", () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: /close/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows animated wallpaper sliders when wallpaperKind is animated", () => {
+ useThemeStore.setState({ wallpaperKind: "animated" });
+ render( );
+ expect(screen.getByLabelText("Density")).toBeInTheDocument();
+ expect(screen.getByLabelText("Speed")).toBeInTheDocument();
+ expect(screen.getByLabelText("Glow")).toBeInTheDocument();
+ });
+
+ it("does not show animated wallpaper sliders when wallpaperKind is image", () => {
+ useThemeStore.setState({ wallpaperKind: "image" });
+ render( );
+ expect(screen.queryByLabelText("Density")).toBeNull();
+ expect(screen.queryByLabelText("Speed")).toBeNull();
+ expect(screen.queryByLabelText("Glow")).toBeNull();
+ });
+
+ it("shows the slogan toggle when wallpaperOverlayText is set", () => {
+ useThemeStore.setState({ wallpaperOverlayText: "taOS" });
+ render( );
+ const toggle = screen.getByRole("switch", { name: /show slogan/i });
+ expect(toggle).toBeInTheDocument();
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("does not show the slogan toggle when wallpaperOverlayText is null", () => {
+ useThemeStore.setState({ wallpaperOverlayText: null });
+ render( );
+ expect(screen.queryByRole("switch", { name: /show slogan/i })).toBeNull();
+ });
+
+ it("reflects showOverlayText=false on the slogan toggle", () => {
+ useThemeStore.setState({ wallpaperOverlayText: "taOS", showOverlayText: false });
+ render( );
+ const toggle = screen.getByRole("switch", { name: /show slogan/i });
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("calls toggleOverlayText when the slogan switch is clicked", () => {
+ useThemeStore.setState({ wallpaperOverlayText: "taOS", showOverlayText: true });
+ render( );
+ fireEvent.click(screen.getByRole("switch", { name: /show slogan/i }));
+ expect(useThemeStore.getState().showOverlayText).toBe(false);
+ });
+
+ it("displays current wallpaperParams values on the sliders", () => {
+ useThemeStore.setState({
+ wallpaperKind: "animated",
+ wallpaperParams: { density: 150, speed: 1.2, glow: 8 },
+ });
+ render( );
+ expect(screen.getByText("150")).toBeInTheDocument();
+ expect(screen.getByText("1.2")).toBeInTheDocument();
+ expect(screen.getByText("8")).toBeInTheDocument();
+ });
+
+ it("calls setWallpaperParam when a slider is changed", () => {
+ useThemeStore.setState({ wallpaperKind: "animated" });
+ render( );
+ const densitySlider = screen.getByLabelText("Density");
+ fireEvent.change(densitySlider, { target: { value: "300" } });
+ expect(useThemeStore.getState().wallpaperParams.density).toBe(300);
+ });
+});
diff --git a/desktop/src/hooks/use-installed-optional-apps.test.ts b/desktop/src/hooks/use-installed-optional-apps.test.ts
new file mode 100644
index 000000000..0f70162bb
--- /dev/null
+++ b/desktop/src/hooks/use-installed-optional-apps.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useInstalledOptionalApps } from "./use-installed-optional-apps";
+import { onAppEvent, emitAppEvent, APP_OPTIONAL_CHANGED } from "@/lib/app-event-bus";
+
+describe("useInstalledOptionalApps", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("returns an empty set before the fetch resolves", () => {
+ let resolveJson: (value: { installed: string[] }) => void;
+ (fetch as ReturnType).mockReturnValueOnce(
+ new Promise(() => { /* pending */ }),
+ );
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+ expect(result.current).toBeInstanceOf(Set);
+ expect(result.current.size).toBe(0);
+ });
+
+ it("fetches installed optional apps and returns them as a Set", async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ installed: ["reddit", "youtube"] }),
+ });
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+
+ await waitFor(() => expect(result.current.size).toBe(2));
+ expect(result.current.has("reddit")).toBe(true);
+ expect(result.current.has("youtube")).toBe(true);
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/apps/optional/installed",
+ expect.objectContaining({ headers: { Accept: "application/json" } }),
+ );
+ });
+
+ it("returns an empty set when the response is not ok", async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalled();
+ });
+ expect(result.current).toBeInstanceOf(Set);
+ expect(result.current.size).toBe(0);
+ });
+
+ it("returns an empty set when fetch rejects", async () => {
+ (fetch as ReturnType).mockRejectedValueOnce(
+ new Error("network failure"),
+ );
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalled();
+ });
+ expect(result.current).toBeInstanceOf(Set);
+ expect(result.current.size).toBe(0);
+ });
+
+ it("re-fetches when APP_OPTIONAL_CHANGED event fires", async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ installed: ["reddit"] }),
+ });
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+
+ await waitFor(() => expect(result.current.has("reddit")).toBe(true));
+
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ installed: ["reddit", "github", "x"] }),
+ });
+
+ emitAppEvent(APP_OPTIONAL_CHANGED, "github");
+
+ await waitFor(() => expect(result.current.size).toBe(3));
+ expect(result.current.has("github")).toBe(true);
+ expect(result.current.has("x")).toBe(true);
+ });
+
+ it("returns an empty set when the response has no installed field", async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({}),
+ });
+
+ const { result } = renderHook(() => useInstalledOptionalApps());
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalled();
+ });
+ expect(result.current).toBeInstanceOf(Set);
+ expect(result.current.size).toBe(0);
+ });
+});
diff --git a/desktop/src/hooks/use-installed-services.test.ts b/desktop/src/hooks/use-installed-services.test.ts
new file mode 100644
index 000000000..53dd88749
--- /dev/null
+++ b/desktop/src/hooks/use-installed-services.test.ts
@@ -0,0 +1,137 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { useInstalledServices } from "./use-installed-services";
+import { onAppEvent } from "@/lib/app-event-bus";
+
+vi.mock("@/lib/app-event-bus", () => ({
+ onAppEvent: vi.fn().mockReturnValue(() => {}),
+ APP_INSTALLED: "app.installed",
+}));
+
+describe("useInstalledServices", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("returns an empty array before the fetch resolves", () => {
+ (fetch as ReturnType).mockReturnValue(new Promise(() => {}));
+ const { result } = renderHook(() => useInstalledServices());
+ expect(result.current).toEqual([]);
+ });
+
+ it("fetches /api/apps/installed on mount and populates services", async () => {
+ const mockServices = [
+ {
+ app_id: "firefox",
+ display_name: "Firefox",
+ icon: null,
+ url: "http://localhost:3000",
+ category: "browser",
+ backend: "docker",
+ status: "running" as const,
+ },
+ {
+ app_id: "code-server",
+ display_name: "Code Server",
+ icon: null,
+ url: "http://localhost:8080",
+ category: "development",
+ backend: "docker",
+ status: "stopped" as const,
+ },
+ ];
+
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockServices,
+ });
+
+ const { result } = renderHook(() => useInstalledServices());
+
+ await waitFor(() => expect(result.current).toHaveLength(2));
+ expect(fetch).toHaveBeenCalledWith("/api/apps/installed");
+ expect(result.current).toEqual(mockServices);
+ });
+
+ it("returns an empty array when the response is not ok", async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+
+ const { result } = renderHook(() => useInstalledServices());
+
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(result.current).toEqual([]);
+ });
+
+ it("returns an empty array when fetch rejects", async () => {
+ (fetch as ReturnType).mockRejectedValueOnce(
+ new Error("Network error"),
+ );
+
+ const { result } = renderHook(() => useInstalledServices());
+
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(result.current).toEqual([]);
+ });
+
+ it("re-fetches when an app.installed event fires", async () => {
+ const firstBatch = [
+ {
+ app_id: "firefox",
+ display_name: "Firefox",
+ icon: null,
+ url: "http://localhost:3000",
+ category: "browser",
+ backend: "docker",
+ status: "running" as const,
+ },
+ ];
+
+ const secondBatch = [
+ ...firstBatch,
+ {
+ app_id: "code-server",
+ display_name: "Code Server",
+ icon: null,
+ url: "http://localhost:8080",
+ category: "development",
+ backend: "docker",
+ status: "running" as const,
+ },
+ ];
+
+ (fetch as ReturnType)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => firstBatch,
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => secondBatch,
+ });
+
+ let appInstalledCallback: (() => void) | undefined;
+ (onAppEvent as ReturnType).mockImplementation(
+ (_name: string, cb: () => void) => {
+ appInstalledCallback = cb;
+ return () => {};
+ },
+ );
+
+ const { result } = renderHook(() => useInstalledServices());
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(fetch).toHaveBeenCalledTimes(1);
+
+ appInstalledCallback!();
+
+ await waitFor(() => expect(result.current).toHaveLength(2));
+ expect(fetch).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/desktop/src/hooks/use-installed-userspace-apps.test.ts b/desktop/src/hooks/use-installed-userspace-apps.test.ts
new file mode 100644
index 000000000..45189d976
--- /dev/null
+++ b/desktop/src/hooks/use-installed-userspace-apps.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import { useInstalledUserspaceApps } from "./use-installed-userspace-apps";
+
+vi.mock("@/lib/userspace-apps", () => ({
+ fetchUserspaceApps: vi.fn(),
+ USERSPACE_APPS_CHANGED: "taos:userspace-apps-changed",
+}));
+
+vi.mock("@/registry/app-registry", () => ({
+ syncUserspaceApps: vi.fn(),
+}));
+
+vi.mock("@/lib/app-event-bus", () => ({
+ onAppEvent: vi.fn().mockReturnValue(() => {}),
+}));
+
+import { fetchUserspaceApps } from "@/lib/userspace-apps";
+import { syncUserspaceApps } from "@/registry/app-registry";
+
+const mockedFetchUserspaceApps = vi.mocked(fetchUserspaceApps);
+const mockedSyncUserspaceApps = vi.mocked(syncUserspaceApps);
+
+describe("useInstalledUserspaceApps", () => {
+ beforeEach(() => {
+ mockedFetchUserspaceApps.mockClear();
+ mockedSyncUserspaceApps.mockClear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns an empty array on initial render", () => {
+ mockedFetchUserspaceApps.mockResolvedValueOnce([]);
+ const { result } = renderHook(() => useInstalledUserspaceApps());
+ expect(result.current).toEqual([]);
+ });
+
+ it("populates apps after a successful fetch", async () => {
+ const manifests = [
+ {
+ id: "userspace:app-1",
+ name: "App One",
+ icon: "layout-grid",
+ category: "userspace" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 360, h: 280 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 100,
+ },
+ {
+ id: "userspace:app-2",
+ name: "App Two",
+ icon: "layout-grid",
+ category: "userspace" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 360, h: 280 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 100,
+ },
+ ];
+ mockedFetchUserspaceApps.mockResolvedValueOnce(manifests);
+
+ const { result } = renderHook(() => useInstalledUserspaceApps());
+
+ await waitFor(() => {
+ expect(result.current).toHaveLength(2);
+ });
+
+ expect(mockedSyncUserspaceApps).toHaveBeenCalledWith(manifests);
+ expect(result.current.map((m) => m.id)).toEqual([
+ "userspace:app-1",
+ "userspace:app-2",
+ ]);
+ });
+
+ it("returns an empty array when fetch rejects", async () => {
+ mockedFetchUserspaceApps.mockRejectedValueOnce(new Error("network"));
+
+ const { result } = renderHook(() => useInstalledUserspaceApps());
+
+ await waitFor(() => {
+ expect(mockedFetchUserspaceApps).toHaveBeenCalled();
+ });
+
+ expect(result.current).toEqual([]);
+ });
+
+ it("re-fetches when USERSPACE_APPS_CHANGED event fires", async () => {
+ const firstBatch = [
+ {
+ id: "userspace:app-1",
+ name: "App One",
+ icon: "layout-grid",
+ category: "userspace" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 360, h: 280 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 100,
+ },
+ ];
+ const secondBatch = [
+ {
+ id: "userspace:app-1",
+ name: "App One",
+ icon: "layout-grid",
+ category: "userspace" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 360, h: 280 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 100,
+ },
+ {
+ id: "userspace:app-3",
+ name: "App Three",
+ icon: "layout-grid",
+ category: "userspace" as const,
+ component: () => Promise.resolve({ default: () => null }),
+ defaultSize: { w: 900, h: 600 },
+ minSize: { w: 360, h: 280 },
+ singleton: true,
+ pinned: false,
+ launchpadOrder: 100,
+ },
+ ];
+
+ mockedFetchUserspaceApps.mockResolvedValueOnce(firstBatch);
+
+ let eventHandler: (() => void) | undefined;
+ const { onAppEvent } = await import("@/lib/app-event-bus");
+ (onAppEvent as ReturnType).mockImplementation(
+ (_name: string, handler: () => void) => {
+ eventHandler = handler;
+ return () => {};
+ },
+ );
+
+ const { result } = renderHook(() => useInstalledUserspaceApps());
+
+ await waitFor(() => {
+ expect(result.current).toHaveLength(1);
+ });
+
+ mockedFetchUserspaceApps.mockResolvedValueOnce(secondBatch);
+
+ eventHandler!();
+
+ await waitFor(() => {
+ expect(result.current).toHaveLength(2);
+ });
+
+ expect(mockedSyncUserspaceApps).toHaveBeenCalledTimes(2);
+ expect(result.current.map((m) => m.id)).toEqual([
+ "userspace:app-1",
+ "userspace:app-3",
+ ]);
+ });
+});
diff --git a/desktop/src/hooks/use-shortcut-registry.test.tsx b/desktop/src/hooks/use-shortcut-registry.test.tsx
new file mode 100644
index 000000000..bf3ab88db
--- /dev/null
+++ b/desktop/src/hooks/use-shortcut-registry.test.tsx
@@ -0,0 +1,206 @@
+import { render, screen } from "@testing-library/react";
+import { renderHook, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ ShortcutProvider,
+ useShortcuts,
+ useShortcut,
+ parseCombo,
+ matchesEvent,
+} from "./use-shortcut-registry";
+import type { ReactNode } from "react";
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children} ;
+}
+
+function makeFakeEvent(key: string, opts: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {}) {
+ return {
+ key,
+ ctrlKey: opts.ctrl ?? false,
+ shiftKey: opts.shift ?? false,
+ altKey: opts.alt ?? false,
+ metaKey: opts.meta ?? false,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ } as unknown as KeyboardEvent;
+}
+
+function ShortcutRegistrar({ combo, action, label, scope }: { combo: string; action: () => void; label: string; scope?: "system" | "app" | "overlay" }) {
+ useShortcut(combo, action, label, scope);
+ return null;
+}
+
+describe("parseCombo", () => {
+ it("parses a simple key", () => {
+ expect(parseCombo("k")).toEqual({ ctrl: false, shift: false, alt: false, key: "k" });
+ });
+
+ it("parses ctrl+key combo", () => {
+ expect(parseCombo("Ctrl+K")).toEqual({ ctrl: true, shift: false, alt: false, key: "k" });
+ });
+
+ it("parses ctrl+shift+key combo", () => {
+ expect(parseCombo("Ctrl+Shift+S")).toEqual({ ctrl: true, shift: true, alt: false, key: "s" });
+ });
+
+ it("parses ctrl+alt+key combo", () => {
+ const result = parseCombo("ctrl+alt+delete");
+ expect(result.ctrl).toBe(true);
+ expect(result.alt).toBe(true);
+ expect(result.key).toBe("delete");
+ });
+});
+
+describe("matchesEvent", () => {
+ it("matches a plain key press", () => {
+ expect(matchesEvent(parseCombo("k"), makeFakeEvent("k"))).toBe(true);
+ });
+
+ it("does not match a different key", () => {
+ expect(matchesEvent(parseCombo("k"), makeFakeEvent("j"))).toBe(false);
+ });
+
+ it("matches ctrl+key combo", () => {
+ expect(matchesEvent(parseCombo("Ctrl+S"), makeFakeEvent("s", { ctrl: true }))).toBe(true);
+ });
+
+ it("does not match when ctrl is missing", () => {
+ expect(matchesEvent(parseCombo("Ctrl+S"), makeFakeEvent("s"))).toBe(false);
+ });
+
+ it("uses metaKey as ctrl", () => {
+ expect(matchesEvent(parseCombo("Ctrl+K"), makeFakeEvent("k", { meta: true }))).toBe(true);
+ });
+
+ it("requires shift when specified", () => {
+ expect(matchesEvent(parseCombo("Ctrl+Shift+A"), makeFakeEvent("a", { ctrl: true }))).toBe(false);
+ });
+
+ it("does not match when extra modifier is pressed", () => {
+ expect(matchesEvent(parseCombo("Ctrl+A"), makeFakeEvent("a", { ctrl: true, shift: true }))).toBe(false);
+ });
+});
+
+describe("useShortcuts outside provider", () => {
+ it("returns a no-op getAll and keyboardLockActive=false", () => {
+ const { result } = renderHook(() => useShortcuts());
+ expect(result.current.getAll()).toEqual([]);
+ expect(result.current.keyboardLockActive).toBe(false);
+ });
+});
+
+describe("useShortcuts inside provider", () => {
+ it("returns empty list initially", () => {
+ const { result } = renderHook(() => useShortcuts(), { wrapper });
+ expect(result.current.getAll()).toEqual([]);
+ expect(result.current.keyboardLockActive).toBe(false);
+ });
+
+ it("keyboardLockActive starts as false", () => {
+ const { result } = renderHook(() => useShortcuts(), { wrapper });
+ expect(result.current.keyboardLockActive).toBe(false);
+ });
+});
+
+describe("shortcut registration", () => {
+ it("registers a shortcut and getAll reflects it", () => {
+ const action = vi.fn();
+ let shortcutsRef: ReturnType | null = null;
+
+ function CaptureShortcuts() {
+ shortcutsRef = useShortcuts();
+ return null;
+ }
+
+ act(() => {
+ render(
+
+
+
+
+ );
+ });
+
+ expect(shortcutsRef).not.toBeNull();
+ const all = shortcutsRef!.getAll();
+ expect(all).toHaveLength(1);
+ expect(all[0].combo).toBe("Ctrl+K");
+ expect(all[0].scope).toBe("app");
+ });
+
+ it("unregisters a shortcut on unmount", () => {
+ const action = vi.fn();
+ let shortcutsRef: ReturnType | null = null;
+
+ function CaptureShortcuts() {
+ shortcutsRef = useShortcuts();
+ return null;
+ }
+
+ const { rerender } = render(
+
+
+
+
+ );
+
+ expect(shortcutsRef!.getAll()).toHaveLength(1);
+
+ act(() => {
+ rerender(
+
+
+
+ );
+ });
+
+ expect(shortcutsRef!.getAll()).toHaveLength(0);
+ });
+
+ it("registers multiple shortcuts", () => {
+ let shortcutsRef: ReturnType | null = null;
+
+ function CaptureShortcuts() {
+ shortcutsRef = useShortcuts();
+ return null;
+ }
+
+ act(() => {
+ render(
+
+
+
+
+
+ );
+ });
+
+ const all = shortcutsRef!.getAll();
+ expect(all).toHaveLength(2);
+ const scopes = all.map((s: { scope: string }) => s.scope).sort();
+ expect(scopes).toEqual(["app", "system"]);
+ });
+
+ it("defaults scope to system when not specified", () => {
+ let shortcutsRef: ReturnType | null = null;
+
+ function CaptureShortcuts() {
+ shortcutsRef = useShortcuts();
+ return null;
+ }
+
+ act(() => {
+ render(
+
+
+
+
+ );
+ });
+
+ const all = shortcutsRef!.getAll();
+ expect(all).toHaveLength(1);
+ expect(all[0].scope).toBe("system");
+ });
+});
diff --git a/desktop/src/hooks/use-snap-zones.test.ts b/desktop/src/hooks/use-snap-zones.test.ts
new file mode 100644
index 000000000..6b141b5ef
--- /dev/null
+++ b/desktop/src/hooks/use-snap-zones.test.ts
@@ -0,0 +1,190 @@
+import { describe, it, expect } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import {
+ useSnapZones,
+ detectSnapZone,
+ getSnapBounds,
+} from "./use-snap-zones";
+import type { SnapPosition } from "@/stores/process-store";
+
+const VP = { width: 1200, height: 800, topBarH: 40, dockH: 60 };
+
+describe("detectSnapZone", () => {
+ it("returns null when cursor is in the middle of the viewport", () => {
+ expect(detectSnapZone(600, 400, VP)).toBeNull();
+ });
+
+ it("returns 'left' when cursor is near the left edge", () => {
+ expect(detectSnapZone(5, 400, VP)).toBe("left");
+ });
+
+ it("returns 'right' when cursor is near the right edge", () => {
+ expect(detectSnapZone(1195, 400, VP)).toBe("right");
+ });
+
+ it("returns 'top-left' when cursor is near the top-left corner", () => {
+ expect(detectSnapZone(5, 50, VP)).toBe("top-left");
+ });
+
+ it("returns 'top-right' when cursor is near the top-right corner", () => {
+ expect(detectSnapZone(1195, 50, VP)).toBe("top-right");
+ });
+
+ it("returns 'bottom-left' when cursor is near the bottom-left corner", () => {
+ expect(detectSnapZone(5, 730, VP)).toBe("bottom-left");
+ });
+
+ it("returns 'bottom-right' when cursor is near the bottom-right corner", () => {
+ expect(detectSnapZone(1195, 730, VP)).toBe("bottom-right");
+ });
+});
+
+describe("getSnapBounds", () => {
+ it("returns null for a null snap position", () => {
+ expect(getSnapBounds(null, VP)).toBeNull();
+ });
+
+ it("returns half-width left bounds for 'left'", () => {
+ expect(getSnapBounds("left", VP)).toEqual({
+ x: 0,
+ y: 0,
+ w: 600,
+ h: 700,
+ });
+ });
+
+ it("returns half-width right bounds for 'right'", () => {
+ expect(getSnapBounds("right", VP)).toEqual({
+ x: 600,
+ y: 0,
+ w: 600,
+ h: 700,
+ });
+ });
+
+ it("returns quarter bounds for 'top-left'", () => {
+ expect(getSnapBounds("top-left", VP)).toEqual({
+ x: 0,
+ y: 0,
+ w: 600,
+ h: 350,
+ });
+ });
+
+ it("returns quarter bounds for 'top-right'", () => {
+ expect(getSnapBounds("top-right", VP)).toEqual({
+ x: 600,
+ y: 0,
+ w: 600,
+ h: 350,
+ });
+ });
+
+ it("returns quarter bounds for 'bottom-left'", () => {
+ expect(getSnapBounds("bottom-left", VP)).toEqual({
+ x: 0,
+ y: 350,
+ w: 600,
+ h: 350,
+ });
+ });
+
+ it("returns quarter bounds for 'bottom-right'", () => {
+ expect(getSnapBounds("bottom-right", VP)).toEqual({
+ x: 600,
+ y: 350,
+ w: 600,
+ h: 350,
+ });
+ });
+});
+
+describe("useSnapZones", () => {
+ it("starts with null preview and null previewBounds", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ expect(result.current.preview).toBeNull();
+ expect(result.current.previewBounds).toBeNull();
+ });
+
+ it("updates preview and previewBounds when onDrag detects a zone", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ act(() => {
+ result.current.onDrag(5, 400);
+ });
+
+ expect(result.current.preview).toBe("left");
+ expect(result.current.previewBounds).toEqual({
+ x: 0,
+ y: 0,
+ w: 600,
+ h: 700,
+ });
+ });
+
+ it("updates preview to a different zone when onDrag moves to a new zone", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ act(() => {
+ result.current.onDrag(5, 400);
+ });
+ expect(result.current.preview).toBe("left");
+
+ act(() => {
+ result.current.onDrag(1195, 400);
+ });
+ expect(result.current.preview).toBe("right");
+ expect(result.current.previewBounds).toEqual({
+ x: 600,
+ y: 0,
+ w: 600,
+ h: 700,
+ });
+ });
+
+ it("sets preview back to null when onDrag moves out of any zone", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ act(() => {
+ result.current.onDrag(5, 400);
+ });
+ expect(result.current.preview).toBe("left");
+
+ act(() => {
+ result.current.onDrag(600, 400);
+ });
+ expect(result.current.preview).toBeNull();
+ expect(result.current.previewBounds).toBeNull();
+ });
+
+ it("onDragStop returns the current zone and resets preview to null", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ act(() => {
+ result.current.onDrag(5, 400);
+ });
+ expect(result.current.preview).toBe("left");
+
+ let stoppedZone: SnapPosition;
+ act(() => {
+ stoppedZone = result.current.onDragStop();
+ });
+
+ expect(stoppedZone!).toBe("left");
+ expect(result.current.preview).toBeNull();
+ expect(result.current.previewBounds).toBeNull();
+ });
+
+ it("onDragStop returns null when no zone is active", () => {
+ const { result } = renderHook(() => useSnapZones(VP));
+
+ let stoppedZone: SnapPosition;
+ act(() => {
+ stoppedZone = result.current.onDragStop();
+ });
+
+ expect(stoppedZone!).toBeNull();
+ expect(result.current.preview).toBeNull();
+ });
+});
diff --git a/desktop/src/registry/app-registry.ts b/desktop/src/registry/app-registry.ts
index 79aa05a35..6f3c2820f 100644
--- a/desktop/src/registry/app-registry.ts
+++ b/desktop/src/registry/app-registry.ts
@@ -66,8 +66,9 @@ const apps: AppManifest[] = [
{ id: "calculator", name: "Calculator", icon: "calculator", category: "os", component: () => import("@/apps/CalculatorApp").then((m) => ({ default: m.CalculatorApp })), defaultSize: { w: 320, h: 480 }, minSize: { w: 280, h: 400 }, singleton: true, pinned: false, launchpadOrder: 20 },
{ id: "calendar", name: "Calendar", icon: "calendar", category: "os", component: () => import("@/apps/CalendarApp").then((m) => ({ default: m.CalendarApp })), defaultSize: { w: 900, h: 600 }, minSize: { w: 600, h: 400 }, singleton: true, pinned: false, launchpadOrder: 21 },
{ id: "contacts", name: "Contacts", icon: "contact", category: "os", component: () => import("@/apps/ContactsApp").then((m) => ({ default: m.ContactsApp })), defaultSize: { w: 700, h: 500 }, minSize: { w: 400, h: 300 }, singleton: true, pinned: false, launchpadOrder: 22 },
+ // Single Browser app. The proxy vs real-Neko-streamed engine is a per-tab
+ // toggle inside it (BrowserModeToggle / LiveBrowserView), not a separate app.
{ id: "browser", name: "Browser", icon: "globe", category: "os", component: () => import("@/apps/BrowserApp").then((m) => ({ default: m.BrowserApp })), defaultSize: { w: 1024, h: 700 }, minSize: { w: 600, h: 400 }, singleton: false, pinned: false, launchpadOrder: 23 },
- { id: "streamed-browser", name: "Browser (Streamed)", icon: "monitor-play", category: "os", component: () => import("@/apps/StreamedBrowserApp").then((m) => ({ default: m.StreamedBrowserApp })), defaultSize: { w: 1280, h: 800 }, minSize: { w: 800, h: 500 }, singleton: false, pinned: false, launchpadOrder: 23.5 },
{ id: "media-player", name: "Media Player", icon: "play-circle", category: "os", component: () => import("@/apps/MediaPlayerApp").then((m) => ({ default: m.MediaPlayerApp })), defaultSize: { w: 800, h: 500 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 24 },
{ id: "text-editor", name: "Text Editor", icon: "file-text", category: "os", component: () => import("@/apps/TextEditorApp").then((m) => ({ default: m.TextEditorApp })), defaultSize: { w: 800, h: 550 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 25 },
{ id: "image-viewer", name: "Image Viewer", icon: "eye", category: "os", component: () => import("@/apps/ImageViewerApp").then((m) => ({ default: m.ImageViewerApp })), defaultSize: { w: 800, h: 600 }, minSize: { w: 400, h: 300 }, singleton: false, pinned: false, launchpadOrder: 26 },
diff --git a/desktop/src/registry/optional-apps.ts b/desktop/src/registry/optional-apps.ts
index a17b565f9..a96edc5a2 100644
--- a/desktop/src/registry/optional-apps.ts
+++ b/desktop/src/registry/optional-apps.ts
@@ -1,8 +1,13 @@
/**
- * Presentation metadata for the optional, Store-installable frontend apps
- * (Reddit / YouTube / GitHub / X). The registry (app-registry.ts) owns the
- * runtime manifest (component, sizing); this owns how they look in the Store's
- * "taOS Apps" section. ids must match the registry entries marked `optional`.
+ * Presentation metadata for optional, Store-installable frontend apps. The
+ * registry (app-registry.ts) owns the runtime manifest (component, sizing);
+ * this owns how they look in the Store's "taOS Apps" section. ids must match
+ * the registry entries marked `optional`.
+ *
+ * The platform social apps (Reddit / YouTube / GitHub / X) were DE-SEEDED from
+ * the default Store: they are unfinished and now live as the operator's private
+ * App Studio drafts, to be finished + published on stream rather than offered to
+ * every user. Their registry manifests + components remain for that reseed.
*/
export interface OptionalAppMeta {
@@ -18,33 +23,5 @@ export interface OptionalAppMeta {
cover: string;
}
-export const OPTIONAL_APPS: OptionalAppMeta[] = [
- {
- id: "reddit",
- name: "Reddit",
- icon: "scroll-text",
- tagline: "Browse subreddits, posts, and comments inside taOS.",
- cover: "linear-gradient(135deg, #ff4500 0%, #cc3700 100%)",
- },
- {
- id: "youtube-library",
- name: "YouTube",
- icon: "play-circle",
- tagline: "A focused video library and watch surface.",
- cover: "linear-gradient(135deg, #ff0033 0%, #b3001f 100%)",
- },
- {
- id: "github-browser",
- name: "GitHub",
- icon: "github",
- tagline: "Track repos, issues, and pull requests at a glance.",
- cover: "linear-gradient(135deg, #2b3137 0%, #14161a 100%)",
- },
- {
- id: "x-monitor",
- name: "X",
- icon: "at-sign",
- tagline: "Monitor timelines and posts from X.",
- cover: "linear-gradient(135deg, #1a1a1a 0%, #000000 100%)",
- },
-];
+// Empty after the social-app de-seed. New optional Store apps get added here.
+export const OPTIONAL_APPS: OptionalAppMeta[] = [];
diff --git a/desktop/src/stores/dock-store.test.ts b/desktop/src/stores/dock-store.test.ts
new file mode 100644
index 000000000..f1d318180
--- /dev/null
+++ b/desktop/src/stores/dock-store.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { useDockStore } from "./dock-store";
+
+const DEFAULT_PINNED = ["messages", "agents", "files", "store", "settings"];
+
+beforeEach(() => {
+ useDockStore.setState({ pinned: [...DEFAULT_PINNED] });
+});
+
+describe("dock-store — defaults", () => {
+ it("starts with the default pinned list", () => {
+ expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED);
+ });
+});
+
+describe("dock-store — pin", () => {
+ it("appends a new app id to the pinned list", () => {
+ useDockStore.getState().pin("terminal");
+ const pinned = useDockStore.getState().pinned;
+ expect(pinned).toContain("terminal");
+ expect(pinned).toEqual([...DEFAULT_PINNED, "terminal"]);
+ });
+
+ it("does not duplicate an already-pinned app id", () => {
+ useDockStore.getState().pin("messages");
+ const pinned = useDockStore.getState().pinned;
+ expect(pinned).toEqual(DEFAULT_PINNED);
+ expect(pinned.filter((id) => id === "messages")).toHaveLength(1);
+ });
+});
+
+describe("dock-store — unpin", () => {
+ it("removes an app id from the pinned list", () => {
+ useDockStore.getState().unpin("agents");
+ const pinned = useDockStore.getState().pinned;
+ expect(pinned).not.toContain("agents");
+ expect(pinned).toEqual(["messages", "files", "store", "settings"]);
+ });
+
+ it("does nothing when unpinning an id that is not pinned", () => {
+ useDockStore.getState().unpin("nonexistent");
+ expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED);
+ });
+});
+
+describe("dock-store — reorder", () => {
+ it("replaces the pinned list with the provided order", () => {
+ const reordered = ["settings", "store", "files", "agents", "messages"];
+ useDockStore.getState().reorder(reordered);
+ expect(useDockStore.getState().pinned).toEqual(reordered);
+ });
+
+ it("accepts an empty list", () => {
+ useDockStore.getState().reorder([]);
+ expect(useDockStore.getState().pinned).toEqual([]);
+ });
+});
+
+describe("dock-store — reset to defaults", () => {
+ it("restores the default pinned list after mutations", () => {
+ const store = useDockStore.getState();
+ store.pin("terminal");
+ store.unpin("messages");
+ store.reorder(["agents", "files"]);
+
+ useDockStore.setState({ pinned: [...DEFAULT_PINNED] });
+ expect(useDockStore.getState().pinned).toEqual(DEFAULT_PINNED);
+ });
+});
diff --git a/desktop/src/stores/notification-store.test.ts b/desktop/src/stores/notification-store.test.ts
index b8631fa3f..060e9645a 100644
--- a/desktop/src/stores/notification-store.test.ts
+++ b/desktop/src/stores/notification-store.test.ts
@@ -73,11 +73,17 @@ describe("mergeServerNotifications", () => {
expect(useNotificationStore.getState().notifications.map((n) => n.id)).toContain("srv-901");
store.dismiss("srv-901");
- expect(useNotificationStore.getState().notifications.map((n) => n.id)).not.toContain("srv-901");
+ // Dismissed item is archived, not removed from the store.
+ const all = useNotificationStore.getState().notifications;
+ const dismissed = all.find((n) => n.id === "srv-901");
+ expect(dismissed).toBeDefined();
+ expect(dismissed?.archived).toBe(true);
- // Backend still reports it (no server-side dismiss); it must stay hidden.
+ // Backend still reports it (no server-side dismiss); it must stay hidden from active.
store.mergeServerNotifications([srv(901, 100)]);
- expect(useNotificationStore.getState().notifications.map((n) => n.id)).not.toContain("srv-901");
+ const afterPoll = useNotificationStore.getState().notifications;
+ const stillArchived = afterPoll.find((n) => n.id === "srv-901");
+ expect(stillArchived?.archived).toBe(true);
});
it("caps the merged list at 100 items", () => {
@@ -87,3 +93,75 @@ describe("mergeServerNotifications", () => {
expect(useNotificationStore.getState().notifications).toHaveLength(100);
});
});
+
+describe("dismiss archives instead of removing", () => {
+ it("sets archived flag on dismiss", () => {
+ const store = useNotificationStore.getState();
+ const id = store.addNotification({ source: "system", title: "test", body: "body", level: "info" });
+
+ store.dismiss(id);
+
+ const all = useNotificationStore.getState().notifications;
+ expect(all).toHaveLength(1);
+ expect(all[0].archived).toBe(true);
+ expect(all[0].id).toBe(id);
+ });
+
+ it("excludes archived items from active notifications", () => {
+ const store = useNotificationStore.getState();
+ const id1 = store.addNotification({ source: "system", title: "keep", body: "active", level: "info" });
+ const id2 = store.addNotification({ source: "system", title: "archive", body: "gone", level: "info" });
+
+ store.dismiss(id2);
+
+ const active = useNotificationStore.getState().notifications.filter((n) => !n.archived);
+ expect(active).toHaveLength(1);
+ expect(active[0].id).toBe(id1);
+ });
+
+ it("archivedNotifications returns archived items newest-first", () => {
+ const store = useNotificationStore.getState();
+ store.addNotification({ source: "system", title: "old", body: "b", level: "info" });
+ const id2 = store.addNotification({ source: "system", title: "new", body: "b", level: "info" });
+
+ store.dismiss(id2);
+
+ const archived = store.archivedNotifications();
+ expect(archived).toHaveLength(1);
+ expect(archived[0].id).toBe(id2);
+ expect(archived[0].archived).toBe(true);
+ });
+
+ it("clearAll archives all notifications", () => {
+ const store = useNotificationStore.getState();
+ store.addNotification({ source: "system", title: "a", body: "b", level: "info" });
+ store.addNotification({ source: "system", title: "c", body: "d", level: "info" });
+
+ store.clearAll();
+
+ const all = useNotificationStore.getState().notifications;
+ expect(all).toHaveLength(2);
+ expect(all.every((n) => n.archived)).toBe(true);
+ });
+
+ it("clearArchived removes archived items permanently", () => {
+ const store = useNotificationStore.getState();
+ const id = store.addNotification({ source: "system", title: "test", body: "b", level: "info" });
+ store.dismiss(id);
+
+ store.clearArchived();
+
+ expect(useNotificationStore.getState().notifications).toHaveLength(0);
+ });
+
+ it("unreadCount excludes archived items", () => {
+ const store = useNotificationStore.getState();
+ const id1 = store.addNotification({ source: "system", title: "unread", body: "b", level: "info" });
+ store.addNotification({ source: "system", title: "to-archive", body: "b", level: "info" });
+
+ expect(store.unreadCount()).toBe(2);
+
+ store.dismiss(id1);
+ expect(store.unreadCount()).toBe(1);
+ });
+});
diff --git a/desktop/src/stores/notification-store.ts b/desktop/src/stores/notification-store.ts
index b67fdcc94..206bc34f1 100644
--- a/desktop/src/stores/notification-store.ts
+++ b/desktop/src/stores/notification-store.ts
@@ -12,6 +12,8 @@ export interface Notification {
timestamp: number;
/** Extra typed payload for structured notifications like agent.paused. */
meta?: Record;
+ /** When true the notification has been dismissed/archived. */
+ archived?: boolean;
}
interface NotificationStore {
@@ -27,6 +29,8 @@ interface NotificationStore {
toggleCentre: () => void;
closeCentre: () => void;
unreadCount: () => number;
+ archivedNotifications: () => Notification[];
+ clearArchived: () => void;
}
let counter = 0;
@@ -64,10 +68,13 @@ export const useNotificationStore = create((set, get) => ({
const merged = items
.filter((n) => !dismissedServerIds.has(n.id))
.map((n) => (priorRead.get(n.id) ? { ...n, read: true } : n));
- // Keep every client-origin item ("notif-N") untouched, drop the old
- // server items (replaced by the fresh list), de-dupe, sort newest-first.
- const client = s.notifications.filter((n) => !n.id.startsWith("srv-"));
- const combined = [...merged, ...client];
+ // Keep every client-origin item ("notif-N") untouched, plus any archived
+ // server items (they must survive polls). Drop the old unarchived server
+ // items (replaced by the fresh list), de-dupe, sort newest-first.
+ const kept = s.notifications.filter(
+ (n) => !n.id.startsWith("srv-") || n.archived,
+ );
+ const combined = [...merged, ...kept];
combined.sort((a, b) => b.timestamp - a.timestamp);
return { notifications: combined.slice(0, 100) };
});
@@ -85,7 +92,11 @@ export const useNotificationStore = create((set, get) => ({
dismiss(id) {
if (id.startsWith("srv-")) dismissedServerIds.add(id);
- set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) }));
+ set((s) => ({
+ notifications: s.notifications.map((n) =>
+ n.id === id ? { ...n, archived: true } : n,
+ ),
+ }));
},
clearAll() {
@@ -93,7 +104,9 @@ export const useNotificationStore = create((set, get) => ({
for (const n of s.notifications) {
if (n.id.startsWith("srv-")) dismissedServerIds.add(n.id);
}
- return { notifications: [] };
+ return {
+ notifications: s.notifications.map((n) => ({ ...n, archived: true })),
+ };
});
},
@@ -106,6 +119,18 @@ export const useNotificationStore = create((set, get) => ({
},
unreadCount() {
- return get().notifications.filter((n) => !n.read).length;
+ return get().notifications.filter((n) => !n.read && !n.archived).length;
+ },
+
+ archivedNotifications() {
+ return get().notifications
+ .filter((n) => n.archived)
+ .sort((a, b) => b.timestamp - a.timestamp);
+ },
+
+ clearArchived() {
+ set((s) => ({
+ notifications: s.notifications.filter((n) => !n.archived),
+ }));
},
}));
diff --git a/desktop/src/stores/theme-store.test.ts b/desktop/src/stores/theme-store.test.ts
new file mode 100644
index 000000000..b45905a15
--- /dev/null
+++ b/desktop/src/stores/theme-store.test.ts
@@ -0,0 +1,119 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { useThemeStore } from "./theme-store";
+
+const reset = () => {
+ useThemeStore.setState({
+ wallpaperId: "graphite",
+ wallpaperImage: "url('/static/wallpaper-graphite.png')",
+ wallpaperMobileImage: "url('/static/wallpaper-graphite-mobile.png')",
+ wallpaperFallback: "#141415",
+ wallpaperLightImage: "url('/static/wallpaper-graphite-light.png')",
+ wallpaperLightMobileImage: "url('/static/wallpaper-graphite-light-mobile.png')",
+ wallpaperLightFallback: "#eef0f3",
+ wallpaperKind: "image",
+ wallpaperComponent: null,
+ wallpaperOverlayText: null,
+ showOverlayText: true,
+ wallpaperParams: { density: 200, speed: 0.5, glow: 6 },
+ showDesktopIcons: true,
+ reduceEffects: false,
+ structure: {},
+ effects: [],
+ activeThemeId: "default",
+ wallpaperByTheme: {},
+ themeDefaultWallpaper: {},
+ wallpaperIdByTheme: {},
+ });
+};
+
+describe("theme-store", () => {
+ beforeEach(() => {
+ reset();
+ localStorage.clear();
+ });
+
+ it("setWallpaper updates wallpaper fields for a known wallpaper id", () => {
+ useThemeStore.getState().setWallpaper("neural-live");
+ const s = useThemeStore.getState();
+ expect(s.wallpaperId).toBe("neural-live");
+ expect(s.wallpaperImage).toBe("");
+ expect(s.wallpaperKind).toBe("animated");
+ expect(s.wallpaperComponent).toBe("particles");
+ expect(s.wallpaperOverlayText).toBe("taOS");
+ expect(s.wallpaperFallback).toBe("#141415");
+ });
+
+ it("setWallpaper records the choice under the active theme for later restore", () => {
+ useThemeStore.setState({ activeThemeId: "custom-a" });
+ useThemeStore.getState().setWallpaper("aurora");
+ expect(useThemeStore.getState().wallpaperIdByTheme["custom-a"]).toBe("aurora");
+ });
+
+ it("setWallpaper ignores an unknown wallpaper id and leaves state untouched", () => {
+ const before = useThemeStore.getState();
+ useThemeStore.getState().setWallpaper("does-not-exist");
+ const after = useThemeStore.getState();
+ expect(after.wallpaperId).toBe(before.wallpaperId);
+ expect(after.wallpaperImage).toBe(before.wallpaperImage);
+ expect(after.wallpaperFallback).toBe(before.wallpaperFallback);
+ });
+
+ it("toggleOverlayText flips the flag and persists the pref", () => {
+ const initial = useThemeStore.getState().showOverlayText;
+ useThemeStore.getState().toggleOverlayText();
+ expect(useThemeStore.getState().showOverlayText).toBe(!initial);
+ expect(localStorage.getItem("taos-wallpaper-slogan")).toBe(initial ? "off" : "on");
+
+ useThemeStore.getState().toggleOverlayText();
+ expect(useThemeStore.getState().showOverlayText).toBe(initial);
+ expect(localStorage.getItem("taos-wallpaper-slogan")).toBe(initial ? "on" : "off");
+ });
+
+ it("setWallpaperParam updates only the requested key and persists all params", () => {
+ useThemeStore.getState().setWallpaperParam("density", 50);
+ const params = useThemeStore.getState().wallpaperParams;
+ expect(params.density).toBe(50);
+ expect(params.speed).toBe(0.5);
+ expect(params.glow).toBe(6);
+ expect(JSON.parse(localStorage.getItem("taos-wallpaper-params")!)).toEqual({
+ density: 50,
+ speed: 0.5,
+ glow: 6,
+ });
+ });
+
+ it("toggleDesktopIcons flips the boolean", () => {
+ const before = useThemeStore.getState().showDesktopIcons;
+ useThemeStore.getState().toggleDesktopIcons();
+ expect(useThemeStore.getState().showDesktopIcons).toBe(!before);
+ useThemeStore.getState().toggleDesktopIcons();
+ expect(useThemeStore.getState().showDesktopIcons).toBe(before);
+ });
+
+ it("setReduceEffects sets the flag and persists the pref", () => {
+ useThemeStore.getState().setReduceEffects(true);
+ expect(useThemeStore.getState().reduceEffects).toBe(true);
+ expect(localStorage.getItem("taos-reduce-effects")).toBe("on");
+
+ useThemeStore.getState().setReduceEffects(false);
+ expect(useThemeStore.getState().reduceEffects).toBe(false);
+ expect(localStorage.getItem("taos-reduce-effects")).toBe("off");
+ });
+
+ it("getWallpapers returns the full wallpaper catalogue with stable ids", () => {
+ const list = useThemeStore.getState().getWallpapers();
+ expect(list.length).toBeGreaterThan(0);
+ const ids = list.map((w) => w.id);
+ expect(ids).toContain("graphite");
+ expect(ids).toContain("neural-live");
+ expect(ids).toContain("default");
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("reset: showDesktopIcons defaults to true after explicit false then reset", () => {
+ useThemeStore.getState().toggleDesktopIcons();
+ expect(useThemeStore.getState().showDesktopIcons).toBe(false);
+ reset();
+ expect(useThemeStore.getState().showDesktopIcons).toBe(true);
+ });
+});
diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts
index 3bd97ad8f..dec85d8ab 100644
--- a/desktop/vite.config.ts
+++ b/desktop/vite.config.ts
@@ -27,7 +27,6 @@ export default defineConfig({
"src/apps/BrowserApp/AddressBar.test.tsx",
"src/apps/BrowserApp/keyboard.test.ts",
"src/apps/BrowserApp/ProfileSwitcher.test.tsx",
- "src/apps/StreamedBrowserApp/StreamedBrowserApp.test.tsx",
// Order-dependent: passes in isolation, fails under the full suite (#114):
"src/components/__tests__/EmojiPicker.test.tsx",
],
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 8113b1798..ce717105e 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,5 +1,17 @@
SINGLE SOURCE OF TRUTH for cross-agent handoff.
-Last updated: 2026-06-20 ~20:35 UTC, @taOS-dev (POST-RESET BURST. Jay's two priorities BOTH merged to dev: #1227 Browser app redesign (collapsible sidebar via browser-ui-store zustand, design-bar tokens, empty/connecting placeholders, LiveBrowserView untouched) [#66], and #1228 the STREAMED-BROWSER WHITE-SCREEN FIX (#73): neko_url is now host-aware (rewritten from X-Forwarded-Host/Host on return so over Tailscale the iframe loads the Tailscale IP not the unreachable LAN IP) + NEKO_WEBRTC_NAT1TO1 includes BOTH LAN + Tailscale IPs (tailscale ip -4 / tailscale0 detection, no-op without tailscale). NEEDS LIVE PI VERIFY: pull Pi to dev, open Browser over Tailscale, iframe host should = tailscale IP + video should paint. Root cause was confirmed: neko_url hardcoded the LAN IP (Jay reaches taOS as a PWA over Tailscale) -> pure white. Also merged: Code Studio slice2 #1225 (apply-blocks fallback -- taos-agent has NO tool-calling loop so real live edits need an agent execution engine, a later slice), store submission state machine #1226. THREE background-security-review fixes merged (08faf70b): IDOR guard on get_submission (403 unless owner/admin/published), apply-blocks symlink-TOCTOU (re-resolve parent in-root + O_NOFOLLOW per write), install_registry admin gate on record/set-version/delete (global AuthMiddleware covers authn; this adds authz). #1226 500->400 on invalid kind/mode fixed. IN FLIGHT: #124 fix-forward agent (async tailscale detect via to_thread + wire the connecting empty-state + sidebar keyboard a11y). neko-cdp image is BUILT+PUBLIC+multi-arch (Pi pulls it); #1223 sandbox hardening merged+replied. BOARD: store-pipeline foundations all merged (submission store, install registry, sharing grants tsk-jrwild, TUI manifest, verification runner tsk-qgn5en if it landed); next store-pipeline = gitaos repo/bundle publish + App Studio publish UI + verification wiring (App Studio app-project model per #81). Crons + resume pair re-armed (next pair b665fe4b 02:13 / 7fd78b20 02:32 BST). five_hour 10% / seven_day 27%. PRIOR ENTRY BELOW.)
+Last updated: 2026-06-21 ~midday, @taOS-dev (EPIC SLICES ROUND 2+3 ALL MERGED to dev (Jay: "keep going"). Eight epic PRs landed today across the three locked epics, every one green + bot-reviewed: ROUND 1 (integration on the foundations): #1276 cluster capability-map endpoints, #1277 board audit-log wiring + per-task history, #1278 coding tool-calling dispatch. ROUND 2/3 (next slices): #1279 cluster capability map auto-populates from worker REGISTRATION (worker hardware dict already carries cpu/ram/gpu/npu; best-effort, preserves draining + carries forward omitted hardware on re-register), #1280 coding model-agnostic tool-calling LOOP engine (run_tool_loop drives any model_step; iteration guard + soft-error feed-back + safe stop on garbage), #1281 audit PROJECT-SCOPED activity feed (added project_id + JSON detail columns via a guarded _post_init ALTER -- additive, no destructive migration; GET /api/projects/{id}/audit, scoped so no cross-project leak), #1282 cluster STALE-NODE offline sweep (mark_stale_offline = non-destructive prune; POST .../capability/sweep), #1283 coding litellm-backed MODEL_STEP (to_openai_tools + transcript<->messages + parse_completion; completion_fn injectable, lazy litellm import). NET: CODING STUDIO BACKEND IS A COMPLETE VERTICAL on dev -- fs-tools -> jailed dispatch -> loop engine -> model driver; a real model can now drive workspace edits end to end (only the route-to-UI wiring + a concrete agent-model binding remain). CLUSTER: map + endpoints + self-population + soft stale-offline (next = frontend Cluster view). AUDIT: foundation + per-transition recording + per-task history + project feed + detail column (next = richer event coverage + UI). BOT-REVIEW LOOP WORKED: the gated auto-merge watchers HELD every feature PR that picked up a gitar/coderabbit Bug/Edge-Case; I fixed all SIX this session before merge -- toast-archive (#1258), capability heartbeat-clobbers-draining (#1276), audit close TOCTOU (#1277), dispatch UnicodeDecodeError+size (#1278), reg-clobbers-hardware (#1279), loop non-dict step + malformed call (#1280), parse_completion empty-choices (#1283). One test-file merge conflict (#1279 vs #1282 both appended capability route tests) resolved keep-both. Guardrails respected throughout: no new auth, no destructive migrations, no installer/boot. five_hour ~49% / seven_day 6% -- ample budget. NEXT (Jay's call): coding route+UI wiring (run the loop against an agent's configured model from a workspace), cluster frontend, richer audit events. PRIOR ENTRY BELOW.)
+
+==================================================================
+STATE 2026-06-21 ~morning, @taOS-dev (MORNING EPIC-BUILD PASS, Jay authorised the Claude-budget spend ("yes you do it please, do everything listed"). CLEARED THE OVERNIGHT REVIEW QUEUE: merged the 3 held epic FOUNDATIONS to dev (#1239 capability map, #1274 board audit log, #1275 coding fs-tools) + the 3 manual dependabot majors (#1253 fetch-metadata 2->3, #1254 checkout 4->7 [the lone lagging workflow; rest of the repo was already v7], #1255 litellm patch). FIXED #1258 TOAST-ARCHIVE BUG: the 5s auto-expire timer called dismiss() which set archived=true, so every toast that timed out was silently filed into the notification History; auto-expiry now only hides the toast (removes it from the visible set), archiving stays an explicit user action (X / Keep paused); added a fake-timer regression test (pushed to exec/tsk-dafnla, CI running). GHCR: Jay set taos-neko-rk3588 PUBLIC (manual TODO cleared). THEN BUILT THE 3 EPIC-INTEGRATION SLICES on the foundations, each its own green PR HELD for Jay: #1276 cluster capability-map endpoints (heartbeat[worker-HMAC]/list/set-status/prune, 16 tests), #1277 board audit-log wiring (ProjectTaskStore records every create/claim/release/close/reopen transition best-effort + GET .../tasks/{id}/audit history endpoint; reopen already did undo-of-close; 9 tests), #1278 coding tool-calling dispatch (agent_tools/coding_tools.py TOOL_SCHEMAS + dispatch() over the jailed fs-tools, GET /api/coding/tools + POST .../workspaces/{id}/tool execute step; 11 tests). All three reuse existing auth/jail (no new auth), no DB migrations, no installer/boot. Branch hygiene note: the audit work was briefly committed onto the capability branch then rebase --onto origin/dev split cleanly; PR #1276 was never polluted. five_hour was ~14% at build time, seven_day 2% -- ample budget. NEXT: merge #1276/#1277/#1278 + #1258 once CI greens (fold any gitar findings first). PRIOR ENTRY BELOW.)
+
+==================================================================
+STATE 2026-06-21 ~00:45 UTC, @taOS-dev (OVERNIGHT QUEUE-FILL + #125 DONE. RK3588 HW ENCODE (#125/#624) COMPLETE: from-source MPP + gstreamer-rockchip (BoxCloud fork) builds on a GitHub arm64 runner (build-neko-rk3588-image.yml, never on the Pi), publishes ghcr.io/jaylfc/taos-neko-rk3588; live-validated on the Pi -- mpph264enc encodes 720p on the VPU in 0.18s/60frames with /dev/mpp_service+/dev/dri+/dev/rga. Resolver flipped (#1236, merged); CDP url now also exposed for the rk3588 image (it is built FROM the CDP image). MANUAL TODO FOR JAY: toggle the taos-neko-rk3588 GHCR package to PUBLIC (REST API cannot set container visibility). CHANGELOG backfilled beta.4 + beta.4.1. OWL LANE: re-fed after a 4h starvation -- ~34 conflict-free cards posted tonight (route/module/hook test waves, 2 bugs #841+#888, agent-coordination doc #96, 4 epic FOUNDATIONS [cluster capability map, append-only board audit log #105, relationships+permissions tests, coding fs-tool primitives], 4 features [searx JSON #969, notif history #62, settings-notif #65, chat select+edit #835/#834]). DISPATCHER POLICY CHANGE (Jay): dispatch_loop.sh now auto-merges ONLY test/doc PRs; every feature/bug/foundation PR is HELD for @taOS-dev review (it greps the PR file list). GROK DISABLED: ~/.taos-team/GROK_DISABLED marker + a guard in dispatcher.sh; remove to re-enable. GUARDRAILS on feature cards: no auth/security, no DB migrations, no installer/systemd/boot, no public copy. EPIC DIRECTIONS LOCKED (Jay, all four): cluster=capability-map first; append-only=board audit log first; social=relationships+permissions foundation; coding-studio=minimal tool-calling loop in taos-agent (fs-tools card is its first slice). OWL QUALITY WATCH: gitar flagged #1237 tests assert-nothing-unless-200 (vacuous) -- tighten future test-card specs to require meaningful assertions regardless of status. PRIOR ENTRY BELOW.)
+
+==================================================================
+STATE 2026-06-20 ~22:20 UTC, @taOS-dev (BETA.5 SHIPPED: promoted dev->master (#1234, merge 5c988638), tagged v1.0.0-beta.5, GitHub Release cut (release trigger publishes the desktop bundle). This promotion = 47 commits since beta.4.1. KEY LANDINGS: #1230 the real STREAMED-BROWSER FIX -- single connecting-host NAT1TO1 (the comma-separated multi-IP mapping from #1228 was breaking pion WebRTC with 'invalid 1:1 NAT IP mapping'; validated LIVE, video paints over Tailscale; this is the actual white-screen root cause, not the host-rewrite). #1229 the #124 nits + gitar must-fixes: connecting-overlay now has a 15s timeout fallback so it cannot hang over a live session; sidebar close affordance is keyboard-focusable; the async-detect nit reconciled with #1230 -- build_neko_run_args reverted to a direct call (it is pure post-#1230) and the real blocking gethostbyname is now offloaded via asyncio.to_thread at the route. DEPENDABOT: #1213/#1214/#1215 version groups merged (lucide icons verified all-present in 0.577), #1232 dompurify 3.4.11 (the one genuinely-outstanding advisory); the 6 critical/high alerts were STALE (dev already had litellm 1.89.2 / fastapi-sso 0.21.0) and clear now master is promoted. NEW: #1231 dependabot auto-merge workflow (npm+actions patch/minor + security patch/minor on green; majors+python stay manual) + dev BRANCH PROTECTION (required checks test 3.12/3.13 + spa-build + lint; strict off; admins NOT enforced so admin-merge still works). VERSION DECISION (Jay): beta.4.2 is NOT PEP 440-valid so pyproject could not hold it (that is why beta.4/4.1 left pyproject at beta.3); moved to beta.5 -- valid in semver AND PEP 440 -- across all 3 files + both lockfiles. DOC GAP: CHANGELOG jumps beta.3 -> beta.5 (beta.4/4.1 never got changelog entries, only GitHub Releases); offered Jay a backfill, not done. EXTERNAL: @simonlpaige (external) left an expert SSRF/DNS-rebinding hardening suggestion on #971 (cached IP->hostname map + httpx client-factory middleware, or AsyncHTTPTransport with SNI hint via server_hostname); replied with Jay's go-ahead, plan tracked in #971 (prototype client-factory middleware first). NEXT: #125 RK3588 hardware video encode for neko (#624). five_hour 30% / seven_day 29%. PRIOR ENTRY BELOW.)
+
+==================================================================
+STATE 2026-06-20 ~20:35 UTC, @taOS-dev (POST-RESET BURST. Jay's two priorities BOTH merged to dev: #1227 Browser app redesign (collapsible sidebar via browser-ui-store zustand, design-bar tokens, empty/connecting placeholders, LiveBrowserView untouched) [#66], and #1228 the STREAMED-BROWSER WHITE-SCREEN FIX (#73): neko_url is now host-aware (rewritten from X-Forwarded-Host/Host on return so over Tailscale the iframe loads the Tailscale IP not the unreachable LAN IP) + NEKO_WEBRTC_NAT1TO1 includes BOTH LAN + Tailscale IPs (tailscale ip -4 / tailscale0 detection, no-op without tailscale). NEEDS LIVE PI VERIFY: pull Pi to dev, open Browser over Tailscale, iframe host should = tailscale IP + video should paint. Root cause was confirmed: neko_url hardcoded the LAN IP (Jay reaches taOS as a PWA over Tailscale) -> pure white. Also merged: Code Studio slice2 #1225 (apply-blocks fallback -- taos-agent has NO tool-calling loop so real live edits need an agent execution engine, a later slice), store submission state machine #1226. THREE background-security-review fixes merged (08faf70b): IDOR guard on get_submission (403 unless owner/admin/published), apply-blocks symlink-TOCTOU (re-resolve parent in-root + O_NOFOLLOW per write), install_registry admin gate on record/set-version/delete (global AuthMiddleware covers authn; this adds authz). #1226 500->400 on invalid kind/mode fixed. IN FLIGHT: #124 fix-forward agent (async tailscale detect via to_thread + wire the connecting empty-state + sidebar keyboard a11y). neko-cdp image is BUILT+PUBLIC+multi-arch (Pi pulls it); #1223 sandbox hardening merged+replied. BOARD: store-pipeline foundations all merged (submission store, install registry, sharing grants tsk-jrwild, TUI manifest, verification runner tsk-qgn5en if it landed); next store-pipeline = gitaos repo/bundle publish + App Studio publish UI + verification wiring (App Studio app-project model per #81). Crons + resume pair re-armed (next pair b665fe4b 02:13 / 7fd78b20 02:32 BST). five_hour 10% / seven_day 27%. PRIOR ENTRY BELOW.)
==================================================================
STATE 2026-06-20 ~19:18 UTC, @taOS-dev (WIND-DOWN at five_hour 95% (Jay: push to ~97). NEKO #1223 DONE: built+fixed the taos-neko-cdp image (Dockerfile.cdp had a multi-line RUN parse error that failed all 4 prior builds -> single-line; now built, PUBLIC, multi-arch arm64+amd64, Pi pulls it). Live-tested on the Pi: neko v3 auth is URL query params (?pwd=/?usr=) into the WebSocket handshake, NO cookie/hash/localStorage on the connect path, so removing allow-same-origin is SAFE. Merged #1223 to master (01d4f043) + back-merged to dev; reply posted to @tomaioo. CODE STUDIO: slice1 #1220 merged; slice2 = apply-blocks FALLBACK (#1225, branch feat/coding-studio-slice2) because /api/taos-agent/chat delegates to OpenCodeAdapter with NO tool-calling loop -> real live agent file-edits need an agent execution engine (next slice). #1225 has the gitar .git-write-guard fix pushed; GATE+MERGE #1225 pending. WHITE-SCREEN (Jay's streamed Browser app): NOT #1223 (his served bundle built 14:43 predates it, still has allow-same-origin). Session genuinely runs (container taos-neko-... up port 8801, neko_url set, node=host, NAT1TO1=Pi LAN ip correct, EPR 59010-59019 published) but the stream does not paint -> pre-existing render bug. NEED FROM JAY: does the white area show any neko UI vs pure white, and is taOS loaded over http or https (pure white + https => mixed-content block of the http neko_url). NEXT WORK (Jay asked, post-reset): (1) redesign Browser app to the design bar with COLLAPSIBLE SIDEBAR etc (#66, in_progress), (2) fix the streamed-browser render / Pi browser-node (#71). BOARD wave-2 (store pipeline): tsk-kknsro submission state machine (PR #1226), tsk-qgn5en verification check runner, tsk-jrwild sharing-grants -- owl-dispatch gates+merges green lane PRs; catch gitar must-fix via :23 repo-watch. Resume pair 986713ac (21:13 BST) / 9821573c (21:32 BST) for the 20:10 UTC reset. five_hour 95% / seven_day 26%. PRIOR ENTRY BELOW.)
diff --git a/neko-lan-test.png b/neko-lan-test.png
new file mode 100644
index 000000000..56345f2b5
Binary files /dev/null and b/neko-lan-test.png differ
diff --git a/neko-single-test.png b/neko-single-test.png
new file mode 100644
index 000000000..9064ca82f
Binary files /dev/null and b/neko-single-test.png differ
diff --git a/neko-tcpmux-test.png b/neko-tcpmux-test.png
new file mode 100644
index 000000000..56345f2b5
Binary files /dev/null and b/neko-tcpmux-test.png differ
diff --git a/pyproject.toml b/pyproject.toml
index dc41b8272..bef941c64 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tinyagentos"
-version = "1.0.0-beta.5"
+version = "1.0.0-beta.6"
description = "Self-hosted AI agent memory system for low-power hardware"
license = { file = "LICENSE" }
requires-python = ">=3.11"
@@ -46,7 +46,7 @@ dependencies = [
[project.optional-dependencies]
worker = ["pystray>=0.19.0", "Pillow>=10.0"]
-proxy = ["litellm[proxy]>=1.89.2", "prisma>=0.11.0"]
+proxy = ["litellm[proxy]>=1.89.3", "prisma>=0.11.0"]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=0.23.0",
diff --git a/scripts/rollback.sh b/scripts/rollback.sh
new file mode 100755
index 000000000..eef38f3d9
--- /dev/null
+++ b/scripts/rollback.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# taOS rollback -- restore the branch + commit the last update left, then
+# restart. Pure git + service restart on purpose: it must work when an update
+# has broken the Python app or the dashboard is unreachable.
+#
+# bash scripts/rollback.sh # undo the last update (branch + version)
+# bash scripts/rollback.sh [ # roll back to a specific tag/branch/sha
+#
+# The updater records the pre-update state in ]/.taos-rollback before it
+# touches anything, so even a clean fast-forward has a restore point.
+set -euo pipefail
+
+# --- locate the install (this script lives in /scripts) ---
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+INSTALL_DIR="${TAOS_INSTALL_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
+cd "$INSTALL_DIR"
+
+if [[ ! -d .git ]]; then
+ echo "taos rollback: $INSTALL_DIR is not a git checkout; cannot roll back" >&2
+ exit 1
+fi
+
+log(){ echo "[rollback] $*"; }
+
+target_ref="${1:-}"
+
+if [[ -n "$target_ref" ]]; then
+ # Explicit target: a tag, branch, or sha the user named.
+ prev_branch=""
+ prev_sha="$target_ref"
+ log "explicit target: $target_ref"
+elif [[ -f .taos-rollback ]]; then
+ # shellcheck disable=SC1091
+ source .taos-rollback
+ prev_branch="${prev_branch:-}"
+ prev_sha="${prev_sha:-}"
+ log "recorded target: branch='${prev_branch}' commit='${prev_sha:0:12}'"
+else
+ # Fallback for installs predating the recorded file: newest recovery tag.
+ prev_sha="$(git tag --list 'taos-pre-update-*' --sort=-creatordate | head -1)"
+ prev_branch=""
+ if [[ -z "$prev_sha" ]]; then
+ echo "taos rollback: no recorded rollback target and no taos-pre-update-* tag found" >&2
+ exit 1
+ fi
+ log "no record file; using newest recovery tag: $prev_sha"
+fi
+
+# Best-effort fetch so an explicit branch/tag that only exists on the remote
+# can still be resolved; never fatal (offline recovery must still work).
+git fetch origin --tags --quiet 2>/dev/null || log "fetch skipped (offline?)"
+
+# An explicit target may name a branch that only exists on the remote
+# (origin/). Resolve it to the remote ref and recreate it locally so both
+# branch and version are restored, not just a detached commit.
+if [[ -n "$target_ref" ]] && ! git rev-parse --verify --quiet "${prev_sha}^{commit}" >/dev/null; then
+ if git rev-parse --verify --quiet "origin/${target_ref}^{commit}" >/dev/null; then
+ prev_branch="$target_ref"
+ prev_sha="origin/${target_ref}"
+ log "explicit target resolved to remote branch origin/${target_ref}"
+ fi
+fi
+
+if ! git rev-parse --verify --quiet "${prev_sha}^{commit}" >/dev/null; then
+ echo "taos rollback: cannot resolve '$prev_sha' to a commit" >&2
+ exit 1
+fi
+
+# A broken update often leaves a dirty tree, and a plain checkout would fail
+# under set -e and defeat the recovery. Stash local changes (including
+# untracked) first so the checkout always lands; getting back to a working
+# version beats preserving in-place edits, and the stash keeps them retrievable.
+if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
+ if git stash push --include-untracked -m "taos-rollback-$(git rev-parse --short HEAD 2>/dev/null)" >/dev/null 2>&1; then
+ log "stashed local changes before rollback (recover with: git stash list)"
+ else
+ log "could not stash local changes; will force the checkout"
+ fi
+fi
+
+# Restore BOTH branch and version: move/create the recorded branch onto the
+# recorded commit and check it out. With no recorded branch (explicit ref or
+# tag fallback) we land in detached HEAD at that commit, which is still a valid
+# running state. Fall back to --force so a stubborn tree can never block recovery.
+if [[ -n "$prev_branch" && "$prev_branch" != "HEAD" ]]; then
+ log "restoring branch '$prev_branch' at ${prev_sha:0:12}"
+ git checkout -B "$prev_branch" "$prev_sha" 2>/dev/null || git checkout --force -B "$prev_branch" "$prev_sha"
+else
+ log "checking out $prev_sha (detached)"
+ git checkout --quiet --detach "$prev_sha" 2>/dev/null || git checkout --quiet --force --detach "$prev_sha"
+fi
+
+# --- restart the service (first method that applies wins) ---
+restarted=""
+if command -v systemctl >/dev/null 2>&1; then
+ if systemctl is-enabled tinyagentos >/dev/null 2>&1 || systemctl status tinyagentos >/dev/null 2>&1; then
+ sudo systemctl restart tinyagentos 2>/dev/null && restarted="systemd" || true
+ fi
+ if [[ -z "$restarted" ]] && systemctl --user status tinyagentos >/dev/null 2>&1; then
+ systemctl --user restart tinyagentos 2>/dev/null && restarted="systemd --user" || true
+ fi
+fi
+if [[ -z "$restarted" ]] && command -v launchctl >/dev/null 2>&1; then
+ plist="$HOME/Library/LaunchAgents/com.tinyagentos.controller.plist"
+ if [[ -f "$plist" ]]; then
+ launchctl unload "$plist" 2>/dev/null || true
+ launchctl load "$plist" 2>/dev/null && restarted="launchd" || true
+ fi
+fi
+if [[ -z "$restarted" && -x "$INSTALL_DIR/scripts/taos-run.sh" ]]; then
+ # Stop the running controller, but never signal THIS rollback process or its
+ # ancestors: the invoking `taos` CLI also has "tinyagentos" in its command
+ # line, so a bare `pkill -f tinyagentos` would kill the process doing the
+ # rollback before the new controller starts.
+ skip=" $$ "
+ _p=$$
+ while _p="$(ps -o ppid= -p "$_p" 2>/dev/null | tr -d ' ')"; do
+ [[ -z "$_p" || "$_p" == "0" || "$_p" == "1" ]] && break
+ skip="$skip$_p "
+ done
+ for _pid in $(pgrep -f "taos-run.sh|tinyagentos" 2>/dev/null || true); do
+ case "$skip" in *" $_pid "*) continue ;; esac
+ kill "$_pid" 2>/dev/null || true
+ done
+ nohup "$INSTALL_DIR/scripts/taos-run.sh" >/dev/null 2>&1 &
+ restarted="nohup"
+fi
+
+now_sha="$(git rev-parse --short HEAD)"
+now_ref="$(git rev-parse --abbrev-ref HEAD)"
+log "rolled back to ${now_ref} @ ${now_sha}"
+if [[ -n "$restarted" ]]; then
+ log "service restarted via ${restarted}. Give it a few seconds, then reload taOS."
+else
+ log "could not auto-restart the service; restart taOS manually to finish."
+fi
+log "note: if the web UI looks stale, the frontend bundle may need a rebuild (re-run the installer)."
diff --git a/tests/conftest.py b/tests/conftest.py
index 4c2d90881..282525420 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -301,6 +301,10 @@ async def client(app, tmp_data_dir):
if project_store._db is not None:
await project_store.close()
await project_store.init()
+ board_audit = app.state.board_audit
+ if board_audit._db is not None:
+ await board_audit.close()
+ await board_audit.init()
project_task_store = app.state.project_task_store
if project_task_store._db is not None:
await project_task_store.close()
@@ -357,6 +361,7 @@ async def client(app, tmp_data_dir):
yield c
await canvas_store.close()
await project_task_store.close()
+ await board_audit.close()
await project_store.close()
await chat_channels.close()
await chat_messages.close()
@@ -501,6 +506,10 @@ async def client_with_qmd(app_with_qmd):
if project_store._db is not None:
await project_store.close()
await project_store.init()
+ board_audit = app_with_qmd.state.board_audit
+ if board_audit._db is not None:
+ await board_audit.close()
+ await board_audit.init()
project_task_store = app_with_qmd.state.project_task_store
if project_task_store._db is not None:
await project_task_store.close()
@@ -524,6 +533,7 @@ async def client_with_qmd(app_with_qmd):
yield c
await canvas_store.close()
await project_task_store.close()
+ await board_audit.close()
await project_store.close()
await chat_channels.close()
await chat_messages.close()
diff --git a/tests/test_agent_registry_store.py b/tests/test_agent_registry_store.py
new file mode 100644
index 000000000..7b8ec79aa
--- /dev/null
+++ b/tests/test_agent_registry_store.py
@@ -0,0 +1,813 @@
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+import pytest
+import pytest_asyncio
+
+from tinyagentos.agent_registry_store import (
+ AgentRegistryStore,
+ _assert_valid_transition,
+ _b64url_decode,
+ _b64url_encode,
+ _row_to_dict,
+ _slugify,
+ load_or_create_signing_keypair,
+ mint_canonical_id,
+ mint_registry_token,
+ verify_registry_token,
+ VALID_STATUSES,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ """Fresh AgentRegistryStore backed by a temp sqlite file."""
+ s = AgentRegistryStore(tmp_path / "agent_registry.db")
+ await s.init()
+ yield s
+ await s.close()
+
+
+@pytest.fixture
+def signing_keypair(tmp_path):
+ """Generate an Ed25519 keypair via the store helper."""
+ priv, pub = load_or_create_signing_keypair(tmp_path / "keys")
+ return priv, pub
+
+
+# ---------------------------------------------------------------------------
+# Module-level pure-function tests
+# ---------------------------------------------------------------------------
+
+
+class TestSlugify:
+ def test_basic(self):
+ assert _slugify("My Agent") == "my-agent"
+
+ def test_mixed_case(self):
+ assert _slugify("TaOS Agent") == "taos-agent"
+
+ def test_special_chars(self):
+ assert _slugify("agent@v2.0!") == "agent-v2-0"
+
+ def test_empty_string(self):
+ assert _slugify("") == "agent"
+
+ def test_only_special_chars(self):
+ assert _slugify("!@#$%") == "agent"
+
+ def test_leading_trailing_dashes(self):
+ assert _slugify(" hello ") == "hello"
+
+ def test_multiple_spaces(self):
+ assert _slugify("a b c") == "a-b-c"
+
+
+class TestMintCanonicalId:
+ def test_format(self):
+ ts = datetime(2026, 3, 15, 14, 30, 45, tzinfo=timezone.utc)
+ result = mint_canonical_id("my-agent", ts)
+ assert result == "my-agent-20260315-143045"
+
+ def test_different_slug(self):
+ ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
+ assert mint_canonical_id("bot", ts) == "bot-20260101-000000"
+
+
+class TestB64url:
+ def test_encode_roundtrip(self):
+ raw = b'{"alg":"EdDSA","typ":"JWT"}'
+ encoded = _b64url_encode(raw)
+ assert _b64url_decode(encoded) == raw
+
+ def test_no_padding(self):
+ encoded = _b64url_encode(b"test")
+ assert "=" not in encoded
+
+ def test_empty(self):
+ assert _b64url_encode(b"") == ""
+ assert _b64url_decode("") == b""
+
+
+class TestAssertValidTransition:
+ def test_pending_to_active(self):
+ _assert_valid_transition("pending", "active")
+
+ def test_pending_to_rejected(self):
+ _assert_valid_transition("pending", "rejected")
+
+ def test_active_to_suspended(self):
+ _assert_valid_transition("active", "suspended")
+
+ def test_suspended_to_active(self):
+ _assert_valid_transition("suspended", "active")
+
+ def test_active_to_revoked(self):
+ _assert_valid_transition("active", "revoked")
+
+ def test_suspended_to_revoked(self):
+ _assert_valid_transition("suspended", "revoked")
+
+ def test_pending_to_revoked(self):
+ _assert_valid_transition("pending", "revoked")
+
+ def test_rejected_to_revoked(self):
+ _assert_valid_transition("rejected", "revoked")
+
+ def test_rejected_to_pending(self):
+ _assert_valid_transition("rejected", "pending")
+
+ def test_rejected_to_active(self):
+ _assert_valid_transition("rejected", "active")
+
+ def test_invalid_status_raises(self):
+ with pytest.raises(ValueError, match="unknown status"):
+ _assert_valid_transition("active", "nonexistent")
+
+ def test_invalid_transition_raises(self):
+ with pytest.raises(ValueError, match="invalid lifecycle transition"):
+ _assert_valid_transition("active", "pending")
+
+ def test_revoked_is_terminal(self):
+ with pytest.raises(ValueError, match="invalid lifecycle transition"):
+ _assert_valid_transition("revoked", "active")
+
+ def test_valid_statuses_frozen(self):
+ assert "active" in VALID_STATUSES
+ assert "pending" in VALID_STATUSES
+ assert "suspended" in VALID_STATUSES
+ assert "revoked" in VALID_STATUSES
+ assert "rejected" in VALID_STATUSES
+
+
+class TestRowToDict:
+ def _make_row(self, data):
+ """Create a minimal row-like object with dict-access and .keys()."""
+ return _FakeRow(data)
+
+ def test_basic_conversion(self):
+ row = self._make_row({"id": 1, "canonical_id": "test-1", "capabilities": '["a","b"]'})
+ result = _row_to_dict(row)
+ assert result["capabilities"] == ["a", "b"]
+
+ def test_empty_capabilities(self):
+ row = self._make_row({"id": 1, "capabilities": "[]"})
+ result = _row_to_dict(row)
+ assert result["capabilities"] == []
+
+ def test_null_capabilities(self):
+ row = self._make_row({"id": 1, "capabilities": None})
+ result = _row_to_dict(row)
+ assert result["capabilities"] == []
+
+ def test_invalid_json_capabilities(self):
+ row = self._make_row({"id": 1, "capabilities": "not-json"})
+ result = _row_to_dict(row)
+ assert result["capabilities"] == []
+
+ def test_preserves_other_fields(self):
+ row = self._make_row({"id": 1, "display_name": "My Agent", "capabilities": "[]"})
+ result = _row_to_dict(row)
+ assert result["display_name"] == "My Agent"
+
+
+class _FakeRow:
+ """Minimal stand-in for aiosqlite.Row."""
+ def __init__(self, data):
+ self._data = data
+
+ def keys(self):
+ return self._data.keys()
+
+ def __getitem__(self, key):
+ return self._data[key]
+
+
+# ---------------------------------------------------------------------------
+# Token minting / verification
+# ---------------------------------------------------------------------------
+
+
+class TestTokenMinting:
+ def test_mint_returns_three_parts(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-001", priv)
+ parts = token.split(".")
+ assert len(parts) == 3
+
+ def test_mint_and_verify_roundtrip(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-002", priv, user_id="user-1", framework="openclaw")
+ payload = verify_registry_token(token, pub)
+ assert payload["sub"] == "agent-002"
+ assert payload["iss"] == "taos-registry"
+ assert payload["user_id"] == "user-1"
+ assert payload["framework"] == "openclaw"
+
+ def test_mint_with_project_id(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-003", priv, project_id="proj-99")
+ payload = verify_registry_token(token, pub)
+ assert payload["project_id"] == "proj-99"
+
+ def test_mint_without_project_id_omits_claim(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-004", priv)
+ payload = verify_registry_token(token, pub)
+ assert "project_id" not in payload
+
+ def test_verify_bad_signature_raises(self, signing_keypair, tmp_path):
+ priv, _ = signing_keypair
+ # Generate a different keypair for verification
+ _, wrong_pub = load_or_create_signing_keypair(tmp_path / "other_keys")
+ token = mint_registry_token("agent-005", priv)
+ with pytest.raises(ValueError, match="signature verification failed"):
+ verify_registry_token(token, wrong_pub)
+
+ def test_verify_malformed_token_raises(self, signing_keypair):
+ _, pub = signing_keypair
+ with pytest.raises(ValueError, match="three dot-separated parts"):
+ verify_registry_token("only.two", pub)
+
+ def test_verify_truncated_token_raises(self, signing_keypair):
+ _, pub = signing_keypair
+ with pytest.raises(ValueError):
+ verify_registry_token("one", pub)
+
+ def test_token_has_jti(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-006", priv)
+ payload = verify_registry_token(token, pub)
+ assert "jti" in payload
+ assert len(payload["jti"]) == 32 # uuid4 hex
+
+ def test_token_has_iat(self, signing_keypair):
+ priv, pub = signing_keypair
+ token = mint_registry_token("agent-007", priv)
+ payload = verify_registry_token(token, pub)
+ assert "iat" in payload
+ assert isinstance(payload["iat"], int)
+
+
+# ---------------------------------------------------------------------------
+# Signing keypair persistence
+# ---------------------------------------------------------------------------
+
+
+class TestSigningKeypair:
+ def test_creates_keypair(self, tmp_path):
+ d = tmp_path / "keys"
+ priv, pub = load_or_create_signing_keypair(d)
+ assert b"PRIVATE" in priv
+ assert b"PUBLIC" in pub
+
+ def test_idempotent(self, tmp_path):
+ d = tmp_path / "keys"
+ priv1, pub1 = load_or_create_signing_keypair(d)
+ priv2, pub2 = load_or_create_signing_keypair(d)
+ assert priv1 == priv2
+ assert pub1 == pub2
+
+ def test_pem_file_created(self, tmp_path):
+ d = tmp_path / "keys"
+ load_or_create_signing_keypair(d)
+ pem_file = d / "agent_registry_signing.pem"
+ assert pem_file.exists()
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: registration
+# ---------------------------------------------------------------------------
+
+
+class TestRegister:
+ @pytest.mark.asyncio
+ async def test_basic_registration(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="My Agent",
+ user_id="user-1",
+ )
+ assert row["framework"] == "openclaw"
+ assert row["display_name"] == "My Agent"
+ assert row["user_id"] == "user-1"
+ assert row["status"] == "active"
+ assert row["canonical_id"].startswith("my-agent-")
+ assert row["capabilities"] == []
+
+ @pytest.mark.asyncio
+ async def test_registration_with_capabilities(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Cap Agent",
+ capabilities=["read", "write"],
+ )
+ assert row["capabilities"] == ["read", "write"]
+
+ @pytest.mark.asyncio
+ async def test_registration_with_handle_and_role(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Handled",
+ handle="@handler",
+ role="worker",
+ )
+ assert row["handle"] == "@handler"
+ assert row["role"] == "worker"
+
+ @pytest.mark.asyncio
+ async def test_external_selfjoin_is_pending(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Ext",
+ origin="external-selfjoin",
+ )
+ assert row["status"] == "pending"
+
+ @pytest.mark.asyncio
+ async def test_default_origin_is_active(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Default",
+ )
+ assert row["status"] == "active"
+
+ @pytest.mark.asyncio
+ async def test_empty_display_name_uses_framework_slug(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="",
+ )
+ assert row["canonical_id"].startswith("openclaw-")
+
+ @pytest.mark.asyncio
+ async def test_canonical_id_is_unique(self, store):
+ r1 = await store.register(framework="openclaw", display_name="Same")
+ r2 = await store.register(framework="openclaw", display_name="Same")
+ assert r1["canonical_id"] != r2["canonical_id"]
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.register(framework="openclaw")
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: get
+# ---------------------------------------------------------------------------
+
+
+class TestGet:
+ @pytest.mark.asyncio
+ async def test_get_existing(self, store):
+ registered = await store.register(framework="openclaw", display_name="Get Me")
+ fetched = await store.get(registered["canonical_id"])
+ assert fetched is not None
+ assert fetched["canonical_id"] == registered["canonical_id"]
+ assert fetched["display_name"] == "Get Me"
+
+ @pytest.mark.asyncio
+ async def test_get_nonexistent(self, store):
+ result = await store.get("does-not-exist-20260101-000000")
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.get("anything")
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: list_all
+# ---------------------------------------------------------------------------
+
+
+class TestListAll:
+ @pytest.mark.asyncio
+ async def test_empty(self, store):
+ assert await store.list_all() == []
+
+ @pytest.mark.asyncio
+ async def test_lists_all(self, store):
+ await store.register(framework="openclaw", display_name="A")
+ await store.register(framework="openclaw", display_name="B")
+ rows = await store.list_all()
+ assert len(rows) == 2
+
+ @pytest.mark.asyncio
+ async def test_filter_by_status(self, store):
+ await store.register(framework="openclaw", display_name="Active One")
+ r2 = await store.register(
+ framework="openclaw",
+ display_name="Pending One",
+ origin="external-selfjoin",
+ )
+ active_rows = await store.list_all(status="active")
+ pending_rows = await store.list_all(status="pending")
+ assert len(active_rows) == 1
+ assert len(pending_rows) == 1
+ assert pending_rows[0]["canonical_id"] == r2["canonical_id"]
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.list_all()
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: list_for_user
+# ---------------------------------------------------------------------------
+
+
+class TestListForUser:
+ @pytest.mark.asyncio
+ async def test_filters_by_user(self, store):
+ await store.register(framework="openclaw", display_name="U1", user_id="user-1")
+ await store.register(framework="openclaw", display_name="U2", user_id="user-2")
+ await store.register(framework="openclaw", display_name="U3", user_id="user-1")
+ rows = await store.list_for_user("user-1")
+ assert len(rows) == 2
+ assert all(r["user_id"] == "user-1" for r in rows)
+
+ @pytest.mark.asyncio
+ async def test_user_no_agents(self, store):
+ await store.register(framework="openclaw", display_name="Other", user_id="user-1")
+ rows = await store.list_for_user("user-empty")
+ assert rows == []
+
+ @pytest.mark.asyncio
+ async def test_filter_by_user_and_status(self, store):
+ await store.register(framework="openclaw", display_name="UA", user_id="user-a")
+ r2 = await store.register(
+ framework="openclaw",
+ display_name="UP",
+ user_id="user-a",
+ origin="external-selfjoin",
+ )
+ pending = await store.list_for_user("user-a", status="pending")
+ assert len(pending) == 1
+ assert pending[0]["canonical_id"] == r2["canonical_id"]
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.list_for_user("anyone")
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: list_revoked
+# ---------------------------------------------------------------------------
+
+
+class TestListRevoked:
+ @pytest.mark.asyncio
+ async def test_empty_when_none_revoked(self, store):
+ await store.register(framework="openclaw", display_name="Active")
+ assert await store.list_revoked() == []
+
+ @pytest.mark.asyncio
+ async def test_lists_revoked(self, store):
+ r1 = await store.register(framework="openclaw", display_name="To Revoke")
+ await store.revoke(r1["canonical_id"])
+ revoked = await store.list_revoked()
+ assert len(revoked) == 1
+ assert revoked[0]["canonical_id"] == r1["canonical_id"]
+ assert revoked[0]["revoked_at"] is not None
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.list_revoked()
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: list_inactive
+# ---------------------------------------------------------------------------
+
+
+class TestListInactive:
+ @pytest.mark.asyncio
+ async def test_empty_when_all_active(self, store):
+ await store.register(framework="openclaw", display_name="Active")
+ assert await store.list_inactive() == []
+
+ @pytest.mark.asyncio
+ async def test_lists_non_active(self, store):
+ r1 = await store.register(
+ framework="openclaw",
+ display_name="Pending",
+ origin="external-selfjoin",
+ )
+ inactive = await store.list_inactive()
+ assert len(inactive) == 1
+ assert inactive[0]["canonical_id"] == r1["canonical_id"]
+ assert inactive[0]["status"] == "pending"
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.list_inactive()
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: set_status (lifecycle transitions)
+# ---------------------------------------------------------------------------
+
+
+class TestSetStatus:
+ @pytest.mark.asyncio
+ async def test_pending_to_active(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Promote",
+ origin="external-selfjoin",
+ )
+ assert row["status"] == "pending"
+ updated = await store.set_status(row["canonical_id"], "active")
+ assert updated["status"] == "active"
+
+ @pytest.mark.asyncio
+ async def test_pending_to_rejected(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Reject Me",
+ origin="external-selfjoin",
+ )
+ updated = await store.set_status(row["canonical_id"], "rejected")
+ assert updated["status"] == "rejected"
+
+ @pytest.mark.asyncio
+ async def test_active_to_suspended(self, store):
+ row = await store.register(framework="openclaw", display_name="Suspend Me")
+ updated = await store.set_status(row["canonical_id"], "suspended")
+ assert updated["status"] == "suspended"
+
+ @pytest.mark.asyncio
+ async def test_suspended_to_active(self, store):
+ row = await store.register(framework="openclaw", display_name="Reactivate")
+ await store.set_status(row["canonical_id"], "suspended")
+ updated = await store.set_status(row["canonical_id"], "active")
+ assert updated["status"] == "active"
+
+ @pytest.mark.asyncio
+ async def test_active_to_revoked(self, store):
+ row = await store.register(framework="openclaw", display_name="Revoke Me")
+ updated = await store.set_status(row["canonical_id"], "revoked")
+ assert updated["status"] == "revoked"
+ assert updated["revoked_at"] is not None
+
+ @pytest.mark.asyncio
+ async def test_rejected_to_pending(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Reopen",
+ origin="external-selfjoin",
+ )
+ await store.set_status(row["canonical_id"], "rejected")
+ updated = await store.set_status(row["canonical_id"], "pending")
+ assert updated["status"] == "pending"
+
+ @pytest.mark.asyncio
+ async def test_rejected_to_active(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Direct Approve",
+ origin="external-selfjoin",
+ )
+ await store.set_status(row["canonical_id"], "rejected")
+ updated = await store.set_status(row["canonical_id"], "active")
+ assert updated["status"] == "active"
+
+ @pytest.mark.asyncio
+ async def test_nonexistent_raises_key_error(self, store):
+ with pytest.raises(KeyError):
+ await store.set_status("no-such-id-20260101-000000", "active")
+
+ @pytest.mark.asyncio
+ async def test_invalid_status_raises_value_error(self, store):
+ row = await store.register(framework="openclaw", display_name="Bad Status")
+ with pytest.raises(ValueError, match="unknown status"):
+ await store.set_status(row["canonical_id"], "garbage")
+
+ @pytest.mark.asyncio
+ async def test_invalid_transition_raises_value_error(self, store):
+ row = await store.register(framework="openclaw", display_name="Bad Trans")
+ with pytest.raises(ValueError, match="invalid lifecycle transition"):
+ await store.set_status(row["canonical_id"], "pending")
+
+ @pytest.mark.asyncio
+ async def test_revoked_is_terminal(self, store):
+ row = await store.register(framework="openclaw", display_name="Terminal")
+ await store.set_status(row["canonical_id"], "revoked")
+ with pytest.raises(ValueError, match="invalid lifecycle transition"):
+ await store.set_status(row["canonical_id"], "active")
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.set_status("anything", "active")
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: update
+# ---------------------------------------------------------------------------
+
+
+class TestUpdate:
+ @pytest.mark.asyncio
+ async def test_update_display_name(self, store):
+ row = await store.register(framework="openclaw", display_name="Old Name")
+ updated = await store.update(row["canonical_id"], display_name="New Name")
+ assert updated["display_name"] == "New Name"
+
+ @pytest.mark.asyncio
+ async def test_update_handle(self, store):
+ row = await store.register(framework="openclaw", display_name="Handled")
+ updated = await store.update(row["canonical_id"], handle="@newhandle")
+ assert updated["handle"] == "@newhandle"
+
+ @pytest.mark.asyncio
+ async def test_update_role(self, store):
+ row = await store.register(framework="openclaw", display_name="Roled")
+ updated = await store.update(row["canonical_id"], role="manager")
+ assert updated["role"] == "manager"
+
+ @pytest.mark.asyncio
+ async def test_update_capabilities(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Capped",
+ capabilities=["read"],
+ )
+ updated = await store.update(row["canonical_id"], capabilities=["read", "write"])
+ assert updated["capabilities"] == ["read", "write"]
+
+ @pytest.mark.asyncio
+ async def test_update_multiple_fields(self, store):
+ row = await store.register(framework="openclaw", display_name="Multi")
+ updated = await store.update(
+ row["canonical_id"],
+ display_name="Multi Updated",
+ handle="@multi",
+ capabilities=["admin"],
+ )
+ assert updated["display_name"] == "Multi Updated"
+ assert updated["handle"] == "@multi"
+ assert updated["capabilities"] == ["admin"]
+
+ @pytest.mark.asyncio
+ async def test_update_nonexistent_returns_none(self, store):
+ result = await store.update("no-such-20260101-000000", display_name="X")
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_update_no_fields_returns_unchanged(self, store):
+ row = await store.register(framework="openclaw", display_name="Noop")
+ updated = await store.update(row["canonical_id"])
+ assert updated["display_name"] == "Noop"
+
+ @pytest.mark.asyncio
+ async def test_immutable_fields_not_changed(self, store):
+ row = await store.register(
+ framework="openclaw",
+ display_name="Immutable",
+ user_id="user-1",
+ )
+ updated = await store.update(row["canonical_id"], display_name="Changed")
+ assert updated["user_id"] == "user-1"
+ assert updated["framework"] == "openclaw"
+ assert updated["canonical_id"] == row["canonical_id"]
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.update("anything", display_name="X")
+
+
+# ---------------------------------------------------------------------------
+# AgentRegistryStore: revoke
+# ---------------------------------------------------------------------------
+
+
+class TestRevoke:
+ @pytest.mark.asyncio
+ async def test_revoke_sets_revoked_at(self, store):
+ row = await store.register(framework="openclaw", display_name="Revoke Me")
+ assert row["revoked_at"] is None
+ updated = await store.revoke(row["canonical_id"])
+ assert updated["revoked_at"] is not None
+ assert updated["status"] == "revoked"
+
+ @pytest.mark.asyncio
+ async def test_revoke_nonexistent_returns_none(self, store):
+ result = await store.revoke("no-such-20260101-000000")
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_revoke_idempotent(self, store):
+ row = await store.register(framework="openclaw", display_name="Idem")
+ first = await store.revoke(row["canonical_id"])
+ second = await store.revoke(row["canonical_id"])
+ assert first["revoked_at"] == second["revoked_at"]
+ assert first["status"] == "revoked"
+ assert second["status"] == "revoked"
+
+ @pytest.mark.asyncio
+ async def test_not_initialized_raises(self, tmp_path):
+ s = AgentRegistryStore(tmp_path / "not_init.db")
+ with pytest.raises(RuntimeError, match="not initialised"):
+ await s.revoke("anything")
+
+
+# ---------------------------------------------------------------------------
+# Full lifecycle round-trip
+# ---------------------------------------------------------------------------
+
+
+class TestFullLifecycle:
+ @pytest.mark.asyncio
+ async def test_register_get_update_revoke(self, store):
+ registered = await store.register(
+ framework="openclaw",
+ display_name="Lifecycle Agent",
+ user_id="user-lc",
+ capabilities=["read"],
+ )
+ cid = registered["canonical_id"]
+ assert registered["status"] == "active"
+
+ fetched = await store.get(cid)
+ assert fetched["display_name"] == "Lifecycle Agent"
+
+ updated = await store.update(cid, capabilities=["read", "write"])
+ assert updated["capabilities"] == ["read", "write"]
+
+ revoked = await store.revoke(cid)
+ assert revoked["status"] == "revoked"
+ assert revoked["revoked_at"] is not None
+
+ @pytest.mark.asyncio
+ async def test_external_selfjoin_full_flow(self, store):
+ registered = await store.register(
+ framework="openclaw",
+ display_name="Ext Flow",
+ origin="external-selfjoin",
+ )
+ cid = registered["canonical_id"]
+ assert registered["status"] == "pending"
+
+ approved = await store.set_status(cid, "active")
+ assert approved["status"] == "active"
+
+ suspended = await store.set_status(cid, "suspended")
+ assert suspended["status"] == "suspended"
+
+ reactivated = await store.set_status(cid, "active")
+ assert reactivated["status"] == "active"
+
+ revoked = await store.set_status(cid, "revoked")
+ assert revoked["status"] == "revoked"
+ assert revoked["revoked_at"] is not None
+
+ @pytest.mark.asyncio
+ async def test_list_filters_after_transitions(self, store):
+ r1 = await store.register(framework="openclaw", display_name="A1")
+ r2 = await store.register(
+ framework="openclaw",
+ display_name="P1",
+ origin="external-selfjoin",
+ )
+ r3 = await store.register(framework="openclaw", display_name="S1")
+
+ # Suspend r3
+ await store.set_status(r3["canonical_id"], "suspended")
+
+ # r2 stays pending
+ active = await store.list_all(status="active")
+ pending = await store.list_all(status="pending")
+ suspended = await store.list_all(status="suspended")
+
+ active_ids = {r["canonical_id"] for r in active}
+ assert r1["canonical_id"] in active_ids
+ assert r2["canonical_id"] not in active_ids
+ assert r3["canonical_id"] not in active_ids
+
+ assert len(pending) == 1
+ assert pending[0]["canonical_id"] == r2["canonical_id"]
+
+ assert len(suspended) == 1
+ assert suspended[0]["canonical_id"] == r3["canonical_id"]
diff --git a/tests/test_apps_installed.py b/tests/test_apps_installed.py
index 6770666e5..4f084f966 100644
--- a/tests/test_apps_installed.py
+++ b/tests/test_apps_installed.py
@@ -175,24 +175,24 @@ async def test_install_then_listed(self, apps_client):
assert resp.json() == {"installed": []}
# Install an allowlisted optional app.
- resp = await client.post("/api/apps/optional/reddit/install")
+ resp = await client.post("/api/apps/optional/coding-studio/install")
assert resp.status_code == 200
- assert resp.json()["app_id"] == "reddit"
+ assert resp.json()["app_id"] == "coding-studio"
resp = await client.get("/api/apps/optional/installed")
- assert resp.json()["installed"] == ["reddit"]
+ assert resp.json()["installed"] == ["coding-studio"]
@pytest.mark.asyncio
async def test_uninstall_removes(self, apps_client):
client, _ = apps_client
- await client.post("/api/apps/optional/x-monitor/install")
+ await client.post("/api/apps/optional/coding-studio/install")
resp = await client.get("/api/apps/optional/installed")
- assert "x-monitor" in resp.json()["installed"]
+ assert "coding-studio" in resp.json()["installed"]
- resp = await client.post("/api/apps/optional/x-monitor/uninstall")
+ resp = await client.post("/api/apps/optional/coding-studio/uninstall")
assert resp.status_code == 200
resp = await client.get("/api/apps/optional/installed")
- assert "x-monitor" not in resp.json()["installed"]
+ assert "coding-studio" not in resp.json()["installed"]
@pytest.mark.asyncio
async def test_unknown_app_rejected(self, apps_client):
@@ -207,10 +207,10 @@ async def test_optional_apps_excluded_from_services_list(self, apps_client):
"""Installed optional frontend apps must NOT show as proxy services
(they have no runtime location)."""
client, _ = apps_client
- await client.post("/api/apps/optional/github-browser/install")
+ await client.post("/api/apps/optional/coding-studio/install")
resp = await client.get("/api/apps/installed")
ids = {i["app_id"] for i in resp.json()}
- assert "github-browser" not in ids
+ assert "coding-studio" not in ids
class TestOptionalAppCatalog:
@@ -237,20 +237,20 @@ async def test_install_records_app_versions_version(self, apps_client):
"""Installing an app should record the APP_VERSIONS version in the DB."""
from tinyagentos.routes.apps import APP_VERSIONS
client, store = apps_client
- resp = await client.post("/api/apps/optional/reddit/install")
+ resp = await client.post("/api/apps/optional/coding-studio/install")
assert resp.status_code == 200
rows = await store.list_installed()
- row = next((r for r in rows if r["app_id"] == "reddit"), None)
+ row = next((r for r in rows if r["app_id"] == "coding-studio"), None)
assert row is not None
- assert row["version"] == APP_VERSIONS["reddit"]
+ assert row["version"] == APP_VERSIONS["coding-studio"]
@pytest.mark.asyncio
async def test_update_available_false_for_fresh_install(self, apps_client):
"""A freshly installed app records the current version, so update_available=false."""
client, _ = apps_client
- await client.post("/api/apps/optional/youtube-library/install")
+ await client.post("/api/apps/optional/coding-studio/install")
resp = await client.get("/api/apps/optional/catalog")
- app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio")
assert app["installed"] is True
assert app["update_available"] is False
@@ -263,15 +263,15 @@ async def test_update_available_true_when_recorded_version_is_older(self, apps_c
# Seed the DB with an older version directly.
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"})),
+ ("coding-studio", 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")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio")
assert app["installed"] is True
- # APP_VERSIONS["x-monitor"] is "1.0.0" which is > "0.9.0"
+ # APP_VERSIONS["coding-studio"] is "1.0.0" which is > "0.9.0"
assert app["update_available"] is True
- assert app["version"] == APP_VERSIONS["x-monitor"]
+ assert app["version"] == APP_VERSIONS["coding-studio"]
@pytest.mark.asyncio
async def test_catalog_does_not_leak_unknown_ids(self, apps_client):
diff --git a/tests/test_board_audit.py b/tests/test_board_audit.py
new file mode 100644
index 000000000..df4ca54f0
--- /dev/null
+++ b/tests/test_board_audit.py
@@ -0,0 +1,131 @@
+import pytest
+
+from tinyagentos.board_audit import BoardAuditLog
+
+
+async def _log(tmp_path):
+ s = BoardAuditLog(tmp_path / "audit.db")
+ await s.init()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_record_and_history_ordered(tmp_path):
+ s = await _log(tmp_path)
+ e1 = await s.record("tsk-1", "claimed", actor="@kilo", from_status="open", to_status="claimed", ts="2026-01-01T00:00:00+00:00")
+ e2 = await s.record("tsk-1", "merged", actor="@taOS-dev", from_status="claimed", to_status="done", ts="2026-01-01T00:00:00+00:00")
+ hist = await s.history("tsk-1")
+ assert [h["id"] for h in hist] == [e1, e2] # insertion order, stable even on equal ts
+ assert hist[0]["event"] == "claimed" and hist[0]["actor"] == "@kilo"
+ assert hist[1]["to_status"] == "done"
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_append_only_no_mutate_or_delete(tmp_path):
+ s = await _log(tmp_path)
+ await s.record("tsk-1", "open")
+ # The store exposes no update/delete API: recording the same task again keeps both.
+ await s.record("tsk-1", "open")
+ assert len(await s.history("tsk-1")) == 2
+ assert not hasattr(s, "delete")
+ assert not hasattr(s, "update")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_history_scoped_per_task(tmp_path):
+ s = await _log(tmp_path)
+ await s.record("tsk-1", "open")
+ await s.record("tsk-2", "open")
+ assert len(await s.history("tsk-1")) == 1
+ assert await s.history("tsk-missing") == []
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_all_since_filters_by_ts(tmp_path):
+ s = await _log(tmp_path)
+ await s.record("tsk-1", "old", ts="2026-01-01T00:00:00+00:00")
+ await s.record("tsk-1", "new", ts="2026-06-01T00:00:00+00:00")
+ recent = await s.all_since("2026-03-01T00:00:00+00:00")
+ assert [r["event"] for r in recent] == ["new"]
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_record_requires_task_and_event(tmp_path):
+ s = await _log(tmp_path)
+ with pytest.raises(ValueError):
+ await s.record("", "open")
+ with pytest.raises(ValueError):
+ await s.record("tsk-1", "")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_returns_event_or_none(tmp_path):
+ s = await _log(tmp_path)
+ eid = await s.record("tsk-1", "open")
+ assert (await s.get(eid))["event"] == "open"
+ assert await s.get("ba-missing") is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_recent_for_project_scoped_and_newest_first(tmp_path):
+ s = await _log(tmp_path)
+ await s.record("tsk-a", "task.created", project_id="prj-1", to_status="open")
+ await s.record("tsk-b", "task.created", project_id="prj-2", to_status="open")
+ await s.record("tsk-a", "task.closed", project_id="prj-1", to_status="closed")
+ feed = await s.recent_for_project("prj-1")
+ # Only prj-1 events, newest first.
+ assert [e["event"] for e in feed] == ["task.closed", "task.created"]
+ assert all(e["project_id"] == "prj-1" for e in feed)
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_recent_for_project_respects_limit(tmp_path):
+ s = await _log(tmp_path)
+ for i in range(5):
+ await s.record(f"tsk-{i}", "task.created", project_id="prj-1", to_status="open")
+ assert len(await s.recent_for_project("prj-1", limit=2)) == 2
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_record_stores_detail_json(tmp_path):
+ s = await _log(tmp_path)
+ eid = await s.record("tsk-1", "task.created", project_id="prj-1", detail={"title": "Ship it"})
+ ev = await s.get(eid)
+ assert ev["detail"] == {"title": "Ship it"}
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_post_init_migrates_old_schema(tmp_path):
+ """An existing board_audit table without project_id/detail is upgraded in place."""
+ import aiosqlite
+
+ db = tmp_path / "old.db"
+ async with aiosqlite.connect(str(db)) as conn:
+ await conn.execute(
+ "CREATE TABLE board_audit (id TEXT PRIMARY KEY, task_id TEXT NOT NULL, "
+ "event TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', from_status TEXT, "
+ "to_status TEXT, ts TEXT NOT NULL)"
+ )
+ await conn.execute(
+ "INSERT INTO board_audit (id, task_id, event, ts) VALUES ('ba-old', 'tsk-z', 'task.created', '2026-01-01T00:00:00+00:00')"
+ )
+ await conn.commit()
+
+ s = BoardAuditLog(db)
+ await s.init() # _post_init should ALTER in the new columns
+ ev = await s.get("ba-old")
+ assert ev["project_id"] == ""
+ assert ev["detail"] == {}
+ # New writes carry project_id and are queryable by the feed.
+ await s.record("tsk-new", "task.created", project_id="prj-9", to_status="open")
+ assert len(await s.recent_for_project("prj-9")) == 1
+ await s.close()
diff --git a/tests/test_board_audit_wiring.py b/tests/test_board_audit_wiring.py
new file mode 100644
index 000000000..f62e159d0
--- /dev/null
+++ b/tests/test_board_audit_wiring.py
@@ -0,0 +1,97 @@
+"""Tests for board audit-log wiring into the project task lifecycle (#105).
+
+Every status transition (create -> claim -> release -> close -> reopen) must
+append an append-only audit event, readable via the task audit endpoint, in
+insertion order with accurate from/to status.
+"""
+import pytest
+
+
+async def _make_task(client):
+ pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"]
+ tid = (await client.post(f"/api/projects/{pid}/tasks", json={"title": "T1"})).json()["id"]
+ return pid, tid
+
+
+@pytest.mark.asyncio
+async def test_create_records_open_event(client):
+ pid, tid = await _make_task(client)
+ resp = await client.get(f"/api/projects/{pid}/tasks/{tid}/audit")
+ assert resp.status_code == 200
+ events = resp.json()["events"]
+ assert len(events) == 1
+ assert events[0]["event"] == "task.created"
+ assert events[0]["from_status"] is None
+ assert events[0]["to_status"] == "open"
+
+
+@pytest.mark.asyncio
+async def test_full_lifecycle_is_audited_in_order(client):
+ pid, tid = await _make_task(client)
+ await client.post(f"/api/projects/{pid}/tasks/{tid}/claim", json={"claimer_id": "agent-1"})
+ await client.post(f"/api/projects/{pid}/tasks/{tid}/release", json={"releaser_id": "agent-1"})
+ await client.post(f"/api/projects/{pid}/tasks/{tid}/claim", json={"claimer_id": "agent-2"})
+ await client.post(
+ f"/api/projects/{pid}/tasks/{tid}/close",
+ json={"closed_by": "agent-2", "reason": "done"},
+ )
+ await client.post(f"/api/projects/{pid}/tasks/{tid}/reopen", json={"reopened_by": "user"})
+
+ events = (await client.get(f"/api/projects/{pid}/tasks/{tid}/audit")).json()["events"]
+ transitions = [(e["event"], e["from_status"], e["to_status"]) for e in events]
+ assert transitions == [
+ ("task.created", None, "open"),
+ ("task.claimed", "open", "claimed"),
+ ("task.released", "claimed", "open"),
+ ("task.claimed", "open", "claimed"),
+ ("task.closed", "claimed", "closed"),
+ ("task.reopened", "closed", "open"),
+ ]
+ # The closing actor is captured.
+ closed = next(e for e in events if e["event"] == "task.closed")
+ assert closed["actor"] == "agent-2"
+
+
+@pytest.mark.asyncio
+async def test_audit_endpoint_404s_unknown_task(client):
+ pid = (await client.post("/api/projects", json={"name": "A", "slug": "a"})).json()["id"]
+ resp = await client.get(f"/api/projects/{pid}/tasks/tsk-nope/audit")
+ assert resp.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_failed_transition_is_not_audited(client):
+ """A no-op transition (reopen on a non-closed task) records nothing."""
+ pid, tid = await _make_task(client)
+ resp = await client.post(
+ f"/api/projects/{pid}/tasks/{tid}/reopen", json={"reopened_by": "user"}
+ )
+ assert resp.status_code == 409
+ events = (await client.get(f"/api/projects/{pid}/tasks/{tid}/audit")).json()["events"]
+ # Only the creation event; the failed reopen left no trace.
+ assert [e["event"] for e in events] == ["task.created"]
+
+
+@pytest.mark.asyncio
+async def test_project_audit_feed_is_scoped(client):
+ """The project-wide feed returns only that project's events, newest first."""
+ p1 = (await client.post("/api/projects", json={"name": "P1", "slug": "p1"})).json()["id"]
+ p2 = (await client.post("/api/projects", json={"name": "P2", "slug": "p2"})).json()["id"]
+ t1 = (await client.post(f"/api/projects/{p1}/tasks", json={"title": "A"})).json()["id"]
+ await client.post(f"/api/projects/{p2}/tasks", json={"title": "B"})
+ await client.post(f"/api/projects/{p1}/tasks/{t1}/claim", json={"claimer_id": "agent-1"})
+
+ feed = (await client.get(f"/api/projects/{p1}/audit")).json()["events"]
+ events = [e["event"] for e in feed]
+ # Newest first: claim then create; nothing from p2.
+ assert events == ["task.claimed", "task.created"]
+ assert all(e["project_id"] == p1 for e in feed)
+
+
+@pytest.mark.asyncio
+async def test_project_audit_feed_limit_clamped(client):
+ p1 = (await client.post("/api/projects", json={"name": "P1", "slug": "p1"})).json()["id"]
+ await client.post(f"/api/projects/{p1}/tasks", json={"title": "A"})
+ await client.post(f"/api/projects/{p1}/tasks", json={"title": "B"})
+ feed = (await client.get(f"/api/projects/{p1}/audit", params={"limit": 1})).json()["events"]
+ assert len(feed) == 1
diff --git a/tests/test_browser_container.py b/tests/test_browser_container.py
index cef92ebfa..681ffd4f7 100644
--- a/tests/test_browser_container.py
+++ b/tests/test_browser_container.py
@@ -173,9 +173,10 @@ async def test_runner_start_desktop_mock_returns_details():
# CDP image (option C foundation)
# ---------------------------------------------------------------------------
-def test_rk3588_image_is_cdp_image():
- """RK3588 must resolve to the CDP-enabled custom image."""
- assert DEFAULT_NEKO_RK3588_IMAGE == DEFAULT_NEKO_CDP_IMAGE
+def test_rk3588_uses_dedicated_hwencode_image():
+ """RK3588 resolves to the dedicated rkmpp HW-encode image, not the plain CDP image."""
+ assert DEFAULT_NEKO_RK3588_IMAGE == "ghcr.io/jaylfc/taos-neko-rk3588:latest"
+ assert DEFAULT_NEKO_RK3588_IMAGE != DEFAULT_NEKO_CDP_IMAGE
@pytest.mark.asyncio
diff --git a/tests/test_browser_proxy_origin.py b/tests/test_browser_proxy_origin.py
new file mode 100644
index 000000000..635dd1383
--- /dev/null
+++ b/tests/test_browser_proxy_origin.py
@@ -0,0 +1,266 @@
+"""Tests for tinyagentos.browser_proxy_origin pure functions."""
+from __future__ import annotations
+
+import os
+import sys
+import time
+
+import pytest
+
+# Ensure the project root is on sys.path so the editable-installed
+# tinyagentos package is importable when pytest bypasses the .pth hook.
+_PROJECT_ROOT = os.path.join(os.path.dirname(__file__), "..")
+if _PROJECT_ROOT not in sys.path:
+ sys.path.insert(0, _PROJECT_ROOT)
+
+
+class _FakeState:
+ """Minimal stand-in for app.state with configurable attributes."""
+
+ def __init__(self, attrs: dict | None = None):
+ if attrs:
+ for k, v in attrs.items():
+ setattr(self, k, v)
+
+
+class _FakeAuthMgr:
+ """Minimal auth manager that returns users by id."""
+
+ def __init__(self, users: dict | None = None):
+ self._users = users or {}
+
+ def get_user_by_id(self, user_id: str):
+ return self._users.get(user_id)
+
+
+# ---------------------------------------------------------------------------
+# _is_safe_next
+# ---------------------------------------------------------------------------
+
+
+class TestIsSafeNext:
+ """_is_safe_next validates the redirect target in the redeem flow."""
+
+ def test_valid_proxy_path_is_safe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("/api/desktop/browser/proxy") is True
+
+ def test_valid_proxy_path_with_query_is_safe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("/api/desktop/browser/proxy?url=https%3A%2F%2Fexample.com") is True
+
+ def test_absolute_url_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("https://evil.com/steal") is False
+
+ def test_scheme_relative_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("//evil.com/steal") is False
+
+ def test_root_path_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("/") is False
+
+ def test_other_path_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("/__taos/redeem") is False
+
+ def test_empty_string_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("") is False
+
+ def test_double_slash_is_unsafe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ assert _is_safe_next("/api/desktop/browser/proxy/extra") is False
+
+ def test_path_with_fragment_is_safe(self):
+ from tinyagentos.browser_proxy_origin import _is_safe_next
+
+ # Fragment is stripped by urlsplit; path still matches proxy endpoint.
+ # The function guards against off-origin redirects, not fragments.
+ assert _is_safe_next("/api/desktop/browser/proxy#fragment") is True
+
+
+# ---------------------------------------------------------------------------
+# _session_store
+# ---------------------------------------------------------------------------
+
+
+class TestSessionStore:
+ """_session_store lazily initializes and returns the session dict."""
+
+ def test_creates_store_when_missing(self):
+ from tinyagentos.browser_proxy_origin import _session_store
+
+ state = _FakeState()
+ store = _session_store(state)
+
+ assert store is not None
+ assert isinstance(store, dict)
+ assert store is state.browser_proxy_sessions
+
+ def test_returns_existing_store(self):
+ from tinyagentos.browser_proxy_origin import _session_store
+
+ existing: dict = {}
+ state = _FakeState({"browser_proxy_sessions": existing})
+ store = _session_store(state)
+
+ assert store is existing
+
+
+# ---------------------------------------------------------------------------
+# _new_browser_session
+# ---------------------------------------------------------------------------
+
+
+class TestNewBrowserSession:
+ """_new_browser_session creates a session and returns its id."""
+
+ def test_creates_session_with_user_and_expiry(self):
+ from tinyagentos.browser_proxy_origin import _new_browser_session
+
+ state = _FakeState()
+ session_id = _new_browser_session(state, "user-42")
+
+ assert isinstance(session_id, str)
+ assert len(session_id) > 0
+
+ store = state.browser_proxy_sessions
+ assert session_id in store
+ assert store[session_id]["user_id"] == "user-42"
+ assert store[session_id]["expires_at"] > time.monotonic()
+
+ def test_each_call_returns_unique_id(self):
+ from tinyagentos.browser_proxy_origin import _new_browser_session
+
+ state = _FakeState()
+ id1 = _new_browser_session(state, "user-1")
+ id2 = _new_browser_session(state, "user-1")
+
+ assert id1 != id2
+ assert len(state.browser_proxy_sessions) == 2
+
+
+# ---------------------------------------------------------------------------
+# _resolve_browser_session
+# ---------------------------------------------------------------------------
+
+
+class TestResolveBrowserSession:
+ """_resolve_browser_session validates and resolves a session id."""
+
+ def test_valid_session_returns_user_id(self):
+ from tinyagentos.browser_proxy_origin import (
+ _new_browser_session,
+ _resolve_browser_session,
+ )
+
+ state = _FakeState()
+ session_id = _new_browser_session(state, "user-99")
+
+ result = _resolve_browser_session(state, session_id)
+
+ assert result == "user-99"
+
+ def test_unknown_session_returns_none(self):
+ from tinyagentos.browser_proxy_origin import _resolve_browser_session
+
+ state = _FakeState()
+
+ assert _resolve_browser_session(state, "nonexistent-id") is None
+
+ def test_expired_session_returns_none_and_removes_entry(self):
+ from tinyagentos.browser_proxy_origin import (
+ _new_browser_session,
+ _resolve_browser_session,
+ )
+
+ state = _FakeState()
+ session_id = _new_browser_session(state, "user-ttl")
+ entry = state.browser_proxy_sessions[session_id]
+ entry["expires_at"] = time.monotonic() - 1
+
+ result = _resolve_browser_session(state, session_id)
+
+ assert result is None
+ assert session_id not in state.browser_proxy_sessions
+
+ def test_valid_session_refreshes_expiry(self):
+ from tinyagentos.browser_proxy_origin import (
+ _new_browser_session,
+ _resolve_browser_session,
+ )
+
+ state = _FakeState()
+ session_id = _new_browser_session(state, "user-refresh")
+ original_expiry = state.browser_proxy_sessions[session_id]["expires_at"]
+
+ time.sleep(0.01)
+ _resolve_browser_session(state, session_id)
+
+ assert state.browser_proxy_sessions[session_id]["expires_at"] > original_expiry
+
+
+# ---------------------------------------------------------------------------
+# _SharedState
+# ---------------------------------------------------------------------------
+
+
+class TestSharedState:
+ """_SharedState delegates allowlisted attrs and isolates local ones."""
+
+ def test_allowlisted_attribute_delegates_to_shared(self):
+ from tinyagentos.browser_proxy_origin import _SharedState
+
+ shared = _FakeState({"auth": _FakeAuthMgr(), "main_port": 6969})
+ proxy = _SharedState(shared)
+
+ assert proxy.auth is shared.auth
+ assert proxy.main_port == 6969
+
+ def test_non_allowlisted_attribute_raises(self):
+ from tinyagentos.browser_proxy_origin import _SharedState
+
+ shared = _FakeState({"auth": _FakeAuthMgr()})
+ proxy = _SharedState(shared)
+
+ with pytest.raises(AttributeError, match="not in the proxy-origin allowlist"):
+ proxy.secrets
+
+ def test_local_setattr_does_not_leak_to_shared(self):
+ from tinyagentos.browser_proxy_origin import _SharedState
+
+ shared = _FakeState({"auth": _FakeAuthMgr()})
+ proxy = _SharedState(shared)
+
+ proxy.browser_proxy_sessions = {"abc": 123}
+
+ assert proxy.browser_proxy_sessions == {"abc": 123}
+ assert not hasattr(shared, "browser_proxy_sessions")
+
+ def test_local_getattr_takes_priority_over_shared(self):
+ from tinyagentos.browser_proxy_origin import _SharedState
+
+ shared = _FakeState({"auth": _FakeAuthMgr()})
+ proxy = _SharedState(shared)
+
+ proxy.auth = "local-override"
+
+ assert proxy.auth == "local-override"
+
+ def test_init_rejects_extra_kwargs(self):
+ from tinyagentos.browser_proxy_origin import _SharedState
+
+ shared = _FakeState()
+
+ with pytest.raises(TypeError):
+ _SharedState(shared, bad_kwarg=1)
diff --git a/tests/test_capability_map.py b/tests/test_capability_map.py
new file mode 100644
index 000000000..4d3909dd5
--- /dev/null
+++ b/tests/test_capability_map.py
@@ -0,0 +1,138 @@
+import time
+
+import pytest
+
+from tinyagentos.cluster.capability_map import CapabilityMap
+
+
+async def _store(tmp_path):
+ s = CapabilityMap(tmp_path / "cap.db")
+ await s.init()
+ return s
+
+
+def _node(node_id="n1", status="online", last_seen=1000):
+ return {
+ "node_id": node_id,
+ "hostname": f"host-{node_id}",
+ "cpu": {"arch": "aarch64", "cores": 8, "soc": "rk3588"},
+ "ram_mb": 16384,
+ "gpu": {"type": "mali", "vram_mb": 0, "cuda": False, "vulkan": True},
+ "npu": {"type": "rknpu", "tops": 6},
+ "status": status,
+ "last_seen": last_seen,
+ }
+
+
+@pytest.mark.asyncio
+async def test_upsert_get_roundtrip(tmp_path):
+ s = await _store(tmp_path)
+ out = await s.upsert(_node())
+ assert out["node_id"] == "n1"
+ assert out["cpu"]["soc"] == "rk3588"
+ got = await s.get("n1")
+ assert got["gpu"]["vulkan"] is True
+ assert got["npu"]["tops"] == 6
+ assert got["ram_mb"] == 16384
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_upsert_updates_not_duplicates(tmp_path):
+ s = await _store(tmp_path)
+ await s.upsert(_node(status="online"))
+ await s.upsert(_node(status="draining"))
+ assert (await s.get("n1"))["status"] == "draining"
+ assert len(await s.list()) == 1
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_list_status_filter(tmp_path):
+ s = await _store(tmp_path)
+ await s.upsert(_node("n1", "online"))
+ await s.upsert(_node("n2", "offline"))
+ assert {n["node_id"] for n in await s.list(status="online")} == {"n1"}
+ assert len(await s.list()) == 2
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_status(tmp_path):
+ s = await _store(tmp_path)
+ await s.upsert(_node())
+ out = await s.set_status("n1", "draining")
+ assert out["status"] == "draining"
+ assert await s.set_status("missing", "online") is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_set_status_rejects_invalid(tmp_path):
+ s = await _store(tmp_path)
+ await s.upsert(_node())
+ with pytest.raises(ValueError):
+ await s.set_status("n1", "bogus")
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_upsert_rejects_invalid_status(tmp_path):
+ s = await _store(tmp_path)
+ with pytest.raises(ValueError):
+ await s.upsert(_node(status="bogus"))
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_get_unknown_returns_none(tmp_path):
+ s = await _store(tmp_path)
+ assert await s.get("nope") is None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_prune_stale(tmp_path):
+ s = await _store(tmp_path)
+ await s.upsert(_node("old", "online", last_seen=1000))
+ await s.upsert(_node("fresh", "online", last_seen=int(time.time())))
+ pruned = await s.prune_stale(older_than_s=3600)
+ assert pruned == 1
+ assert await s.get("old") is None
+ assert await s.get("fresh") is not None
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_upsert_preserves_explicit_zero_last_seen(tmp_path):
+ s = await _store(tmp_path)
+ out = await s.upsert(_node(last_seen=0))
+ assert out["last_seen"] == 0
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_upsert_requires_node_id(tmp_path):
+ s = await _store(tmp_path)
+ with pytest.raises(ValueError):
+ await s.upsert({"hostname": "x"})
+ await s.close()
+
+
+@pytest.mark.asyncio
+async def test_mark_stale_offline_preserves_row(tmp_path):
+ s = CapabilityMap(tmp_path / "cap.db")
+ await s.init()
+ # Fresh online node (recent last_seen) + a stale online one + a draining one.
+ await s.upsert({"node_id": "fresh", "status": "online"})
+ await s.upsert({"node_id": "stale", "status": "online", "last_seen": 1})
+ await s.upsert({"node_id": "drain", "status": "draining", "last_seen": 1})
+ flipped = await s.mark_stale_offline(older_than_s=60)
+ assert flipped == 1
+ assert (await s.get("stale"))["status"] == "offline"
+ assert (await s.get("fresh"))["status"] == "online"
+ # draining is an admin intent; a stale sweep must not touch it.
+ assert (await s.get("drain"))["status"] == "draining"
+ # The row is preserved, not deleted.
+ assert await s.get("stale") is not None
+ await s.close()
diff --git a/tests/test_coding_loop.py b/tests/test_coding_loop.py
new file mode 100644
index 000000000..abdffabf7
--- /dev/null
+++ b/tests/test_coding_loop.py
@@ -0,0 +1,142 @@
+"""Tests for the Coding Studio tool-calling loop engine (#86).
+
+The loop is model-agnostic: a scripted ``model_step`` stands in for the real
+model glue, so we can exercise the drive/dispatch/guard logic deterministically.
+"""
+import pytest
+
+from tinyagentos.agent_tools import coding_loop
+
+
+def _scripted(steps):
+ """Return a model_step that yields the given steps in order, then finals."""
+ seq = list(steps)
+ last_transcript = {}
+
+ async def model_step(transcript):
+ last_transcript["value"] = transcript
+ if seq:
+ return seq.pop(0)
+ return {"type": "final", "text": "done"}
+
+ model_step.last = last_transcript
+ return model_step
+
+
+@pytest.mark.asyncio
+async def test_loop_writes_then_reads_then_finishes(tmp_path):
+ """A two-tool plan runs against the workspace and the loop returns final."""
+ steps = [
+ {
+ "type": "tool_calls",
+ "calls": [
+ {"id": "c1", "name": "write_file", "arguments": {"path": "a.txt", "content": "hi"}},
+ ],
+ },
+ {
+ "type": "tool_calls",
+ "calls": [{"id": "c2", "name": "read_file", "arguments": {"path": "a.txt"}}],
+ },
+ {"type": "final", "text": "the file says hi"},
+ ]
+ out = await coding_loop.run_tool_loop(tmp_path, _scripted(steps))
+ assert out["stopped"] == "final"
+ assert out["final"] == "the file says hi"
+ assert out["iterations"] == 3
+ assert (tmp_path / "a.txt").read_text() == "hi"
+ # The read result is in the transcript for the model to have consumed.
+ tool_msgs = [m for m in out["transcript"] if m["role"] == "tool"]
+ assert tool_msgs[-1]["result"] == {"ok": True, "result": "hi"}
+
+
+@pytest.mark.asyncio
+async def test_loop_immediate_final(tmp_path):
+ out = await coding_loop.run_tool_loop(tmp_path, _scripted([{"type": "final", "text": "nothing to do"}]))
+ assert out["iterations"] == 1
+ assert out["final"] == "nothing to do"
+
+
+@pytest.mark.asyncio
+async def test_loop_tool_error_is_fed_back_not_raised(tmp_path):
+ """A failing tool call surfaces as a soft error in the transcript; loop continues."""
+ steps = [
+ {
+ "type": "tool_calls",
+ "calls": [{"id": "c1", "name": "read_file", "arguments": {"path": "../escape"}}],
+ },
+ {"type": "final", "text": "recovered"},
+ ]
+ out = await coding_loop.run_tool_loop(tmp_path, _scripted(steps))
+ assert out["final"] == "recovered"
+ tool_msg = next(m for m in out["transcript"] if m["role"] == "tool")
+ assert tool_msg["result"]["ok"] is False
+
+
+@pytest.mark.asyncio
+async def test_loop_respects_max_iterations(tmp_path):
+ """A model that never finishes is stopped by the iteration guard."""
+ # Always asks for a (harmless) list_dir, never finals.
+ async def never_done(transcript):
+ return {"type": "tool_calls", "calls": [{"id": "x", "name": "list_dir", "arguments": {}}]}
+
+ out = await coding_loop.run_tool_loop(tmp_path, never_done, max_iterations=3)
+ assert out["stopped"] == "max_iterations"
+ assert out["final"] is None
+ assert out["iterations"] == 3
+
+
+@pytest.mark.asyncio
+async def test_loop_unknown_step_shape_stops_safely(tmp_path):
+ async def garbage(transcript):
+ return {"type": "???"}
+
+ out = await coding_loop.run_tool_loop(tmp_path, garbage)
+ assert out["stopped"] == "final"
+ assert out["final"] is None
+ assert out["iterations"] == 1
+
+
+@pytest.mark.asyncio
+async def test_loop_passes_growing_transcript_to_model(tmp_path):
+ steps = [
+ {"type": "tool_calls", "calls": [{"id": "c1", "name": "list_dir", "arguments": {}}]},
+ {"type": "final", "text": "ok"},
+ ]
+ ms = _scripted(steps)
+ out = await coding_loop.run_tool_loop(tmp_path, ms, initial_transcript=[{"role": "user", "content": "go"}])
+ # By the final step the model saw the user msg, the assistant tool_calls, and the tool result.
+ seen_roles = [m["role"] for m in ms.last["value"]]
+ assert seen_roles[0] == "user"
+ assert "tool" in seen_roles
+ assert out["final"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_loop_non_dict_step_stops_safely(tmp_path):
+ """model_step returning None / a list must not crash the loop."""
+ async def returns_none(transcript):
+ return None
+
+ out = await coding_loop.run_tool_loop(tmp_path, returns_none)
+ assert out["stopped"] == "final"
+ assert out["final"] is None
+ assert out["iterations"] == 1
+
+
+@pytest.mark.asyncio
+async def test_loop_malformed_call_entry_is_soft_error(tmp_path):
+ """A non-dict entry in calls surfaces as a soft error, loop continues."""
+ steps = [
+ {"type": "tool_calls", "calls": ["not-a-dict", None]},
+ {"type": "final", "text": "kept going"},
+ ]
+ seq = list(steps)
+
+ async def model_step(transcript):
+ return seq.pop(0) if seq else {"type": "final", "text": "done"}
+
+ out = await coding_loop.run_tool_loop(tmp_path, model_step)
+ assert out["final"] == "kept going"
+ tool_msgs = [m for m in out["transcript"] if m["role"] == "tool"]
+ assert all(m["result"]["ok"] is False for m in tool_msgs)
+ assert tool_msgs[0]["result"]["error"] == "malformed tool call"
diff --git a/tests/test_coding_model.py b/tests/test_coding_model.py
new file mode 100644
index 000000000..b97537292
--- /dev/null
+++ b/tests/test_coding_model.py
@@ -0,0 +1,126 @@
+"""Tests for the litellm-backed model_step adapter (#86).
+
+A fake completion_fn stands in for litellm so the conversion + parsing logic is
+exercised without a live model or network.
+"""
+import json
+
+import pytest
+
+from tinyagentos.agent_tools import coding_loop, coding_model
+
+
+def test_to_openai_tools_shape():
+ tools = coding_model.to_openai_tools()
+ names = {t["function"]["name"] for t in tools}
+ assert names == {"read_file", "write_file", "file_exists", "list_dir"}
+ assert all(t["type"] == "function" for t in tools)
+ # parameters carry the input_schema verbatim.
+ read = next(t for t in tools if t["function"]["name"] == "read_file")
+ assert read["function"]["parameters"]["required"] == ["path"]
+
+
+def test_transcript_to_messages_maps_shapes():
+ transcript = [
+ {"role": "user", "content": "go"},
+ {"role": "assistant", "tool_calls": [{"id": "c1", "name": "list_dir", "arguments": {"path": "."}}]},
+ {"role": "tool", "tool_call_id": "c1", "name": "list_dir", "result": {"ok": True, "result": ["a"]}},
+ ]
+ msgs = coding_model.transcript_to_messages(transcript, system="be helpful")
+ assert msgs[0] == {"role": "system", "content": "be helpful"}
+ assert msgs[1]["content"] == "go"
+ # assistant tool_calls become OpenAI function tool_calls with stringified args.
+ tc = msgs[2]["tool_calls"][0]
+ assert tc["function"]["name"] == "list_dir"
+ assert json.loads(tc["function"]["arguments"]) == {"path": "."}
+ # tool result is JSON-encoded into content.
+ assert json.loads(msgs[3]["content"]) == {"ok": True, "result": ["a"]}
+
+
+class _Obj:
+ """Minimal object-style stand-in for a litellm response."""
+
+ def __init__(self, d):
+ self.__dict__.update(d)
+
+
+def test_parse_completion_object_style_tool_call():
+ resp = _Obj(
+ {
+ "choices": [
+ _Obj(
+ {
+ "message": _Obj(
+ {
+ "content": None,
+ "tool_calls": [
+ _Obj(
+ {
+ "id": "c1",
+ "function": _Obj(
+ {"name": "write_file", "arguments": '{"path":"a.txt","content":"hi"}'}
+ ),
+ }
+ )
+ ],
+ }
+ )
+ }
+ )
+ ]
+ }
+ )
+ step = coding_model.parse_completion(resp)
+ assert step["type"] == "tool_calls"
+ assert step["calls"][0] == {"id": "c1", "name": "write_file", "arguments": {"path": "a.txt", "content": "hi"}}
+
+
+def test_parse_completion_dict_style_final():
+ resp = {"choices": [{"message": {"content": "all done", "tool_calls": None}}]}
+ assert coding_model.parse_completion(resp) == {"type": "final", "text": "all done"}
+
+
+def test_parse_completion_tolerates_bad_arguments():
+ resp = {"choices": [{"message": {"tool_calls": [{"id": "c1", "function": {"name": "list_dir", "arguments": "not-json"}}]}}]}
+ step = coding_model.parse_completion(resp)
+ assert step["calls"][0]["arguments"] == {}
+
+
+@pytest.mark.asyncio
+async def test_model_step_drives_loop_end_to_end(tmp_path):
+ """A scripted completion_fn + the real loop writes then reads a file."""
+ # Two model turns: first a write tool call, then a final answer.
+ scripted = [
+ {"choices": [{"message": {"tool_calls": [
+ {"id": "c1", "function": {"name": "write_file", "arguments": '{"path":"hello.py","content":"print(1)"}'}}
+ ]}}]},
+ {"choices": [{"message": {"content": "wrote hello.py", "tool_calls": None}}]},
+ ]
+ seen = {"models": [], "tools": None}
+
+ async def fake_completion(model, messages, tools):
+ seen["models"].append(model)
+ seen["tools"] = tools
+ return scripted.pop(0)
+
+ step = coding_model.make_litellm_model_step("openai/gpt-x", completion_fn=fake_completion)
+ out = await coding_loop.run_tool_loop(tmp_path, step)
+
+ assert out["stopped"] == "final"
+ assert out["final"] == "wrote hello.py"
+ assert (tmp_path / "hello.py").read_text() == "print(1)"
+ # The model was actually called with the workspace tools.
+ assert seen["models"] == ["openai/gpt-x", "openai/gpt-x"]
+ assert {t["function"]["name"] for t in seen["tools"]} == {"read_file", "write_file", "file_exists", "list_dir"}
+
+
+def test_parse_completion_empty_choices_is_safe_final():
+ assert coding_model.parse_completion({"choices": []}) == {"type": "final", "text": ""}
+
+
+def test_parse_completion_missing_choices_is_safe_final():
+ assert coding_model.parse_completion({}) == {"type": "final", "text": ""}
+
+
+def test_parse_completion_null_message_is_safe_final():
+ assert coding_model.parse_completion({"choices": [{"message": None}]}) == {"type": "final", "text": ""}
diff --git a/tests/test_coding_tool_loop.py b/tests/test_coding_tool_loop.py
new file mode 100644
index 000000000..162689643
--- /dev/null
+++ b/tests/test_coding_tool_loop.py
@@ -0,0 +1,135 @@
+"""Tests for the Coding Studio agent tool-calling loop (#86).
+
+Covers the dispatch substrate (unit) and the two HTTP endpoints that drive it.
+"""
+import pytest
+import pytest_asyncio
+
+from tinyagentos.agent_tools import coding_tools
+
+
+# ---------------------------------------------------------------------------
+# dispatch() unit tests (no HTTP)
+# ---------------------------------------------------------------------------
+
+def test_dispatch_write_then_read_roundtrip(tmp_path):
+ r = coding_tools.dispatch(tmp_path, "write_file", {"path": "a/b.txt", "content": "hi"})
+ assert r["ok"] is True
+ r = coding_tools.dispatch(tmp_path, "read_file", {"path": "a/b.txt"})
+ assert r == {"ok": True, "result": "hi"}
+
+
+def test_dispatch_unknown_tool(tmp_path):
+ r = coding_tools.dispatch(tmp_path, "delete_everything", {})
+ assert r["ok"] is False
+ assert "unknown tool" in r["error"]
+
+
+def test_dispatch_missing_argument(tmp_path):
+ r = coding_tools.dispatch(tmp_path, "write_file", {"path": "x.txt"})
+ assert r["ok"] is False
+ assert "missing argument" in r["error"]
+
+
+def test_dispatch_jail_violation_is_caught(tmp_path):
+ r = coding_tools.dispatch(tmp_path, "read_file", {"path": "../escape.txt"})
+ assert r["ok"] is False
+ assert "refused" in r["error"]
+
+
+def test_dispatch_read_missing_file(tmp_path):
+ r = coding_tools.dispatch(tmp_path, "read_file", {"path": "nope.txt"})
+ assert r["ok"] is False
+ assert r["error"] == "file not found"
+
+
+def test_dispatch_read_binary_is_soft_error(tmp_path):
+ (tmp_path / "blob.bin").write_bytes(b"\xff\xfe\x00\x01\x80")
+ r = coding_tools.dispatch(tmp_path, "read_file", {"path": "blob.bin"})
+ assert r["ok"] is False
+ assert "UTF-8" in r["error"]
+
+
+def test_dispatch_read_too_large_is_soft_error(tmp_path):
+ (tmp_path / "big.txt").write_text("a" * (coding_tools.MAX_READ_BYTES + 1))
+ r = coding_tools.dispatch(tmp_path, "read_file", {"path": "big.txt"})
+ assert r["ok"] is False
+ assert "limit" in r["error"]
+
+
+def test_dispatch_list_dir_defaults_to_root(tmp_path):
+ coding_tools.dispatch(tmp_path, "write_file", {"path": "one.txt", "content": "1"})
+ r = coding_tools.dispatch(tmp_path, "list_dir", {})
+ assert r["ok"] is True
+ assert "one.txt" in r["result"]
+
+
+def test_dispatch_file_exists(tmp_path):
+ coding_tools.dispatch(tmp_path, "write_file", {"path": "here.txt", "content": "x"})
+ assert coding_tools.dispatch(tmp_path, "file_exists", {"path": "here.txt"})["result"] is True
+ assert coding_tools.dispatch(tmp_path, "file_exists", {"path": "gone.txt"})["result"] is False
+
+
+# ---------------------------------------------------------------------------
+# HTTP endpoints
+# ---------------------------------------------------------------------------
+
+@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_tools_endpoint_lists_schemas(coding_client):
+ client, _app = coding_client
+ r = await client.get("/api/coding/tools")
+ assert r.status_code == 200
+ names = {t["name"] for t in r.json()["tools"]}
+ assert names == {"read_file", "write_file", "file_exists", "list_dir"}
+
+
+@pytest.mark.asyncio
+async def test_tool_endpoint_executes_against_workspace(coding_client):
+ client, _app = coding_client
+ ws = (await client.post("/api/coding/workspaces", json={"name": "loop"})).json()
+ wid = ws["id"]
+
+ r = await client.post(
+ f"/api/coding/workspaces/{wid}/tool",
+ json={"name": "write_file", "arguments": {"path": "main.py", "content": "print(1)"}},
+ )
+ assert r.status_code == 200
+ assert r.json()["ok"] is True
+
+ r = await client.post(
+ f"/api/coding/workspaces/{wid}/tool",
+ json={"name": "read_file", "arguments": {"path": "main.py"}},
+ )
+ assert r.json() == {"ok": True, "result": "print(1)"}
+
+
+@pytest.mark.asyncio
+async def test_tool_endpoint_jail_violation_is_soft_error(coding_client):
+ client, _app = coding_client
+ ws = (await client.post("/api/coding/workspaces", json={"name": "jail"})).json()
+ r = await client.post(
+ f"/api/coding/workspaces/{ws['id']}/tool",
+ json={"name": "read_file", "arguments": {"path": "../../etc/passwd"}},
+ )
+ # Soft error (HTTP 200, ok=false) so the loop can recover.
+ assert r.status_code == 200
+ assert r.json()["ok"] is False
+
+
+@pytest.mark.asyncio
+async def test_tool_endpoint_unknown_workspace_404(coding_client):
+ client, _app = coding_client
+ r = await client.post(
+ "/api/coding/workspaces/ws-nope/tool",
+ json={"name": "list_dir", "arguments": {}},
+ )
+ assert r.status_code == 404
diff --git a/tests/test_download_manager.py b/tests/test_download_manager.py
new file mode 100644
index 000000000..07c04dacc
--- /dev/null
+++ b/tests/test_download_manager.py
@@ -0,0 +1,478 @@
+import asyncio
+import hashlib
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import pytest_asyncio
+
+from tinyagentos.download_manager import DownloadManager, DownloadTask
+
+
+# ---------------------------------------------------------------------------
+# DownloadTask dataclass
+# ---------------------------------------------------------------------------
+
+class TestDownloadTask:
+ def test_defaults(self):
+ task = DownloadTask(id="t1", url="http://example.com/model.bin", dest=Path("/tmp/model.bin"))
+ assert task.id == "t1"
+ assert task.url == "http://example.com/model.bin"
+ assert task.dest == Path("/tmp/model.bin")
+ assert task.total_bytes == 0
+ assert task.downloaded_bytes == 0
+ assert task.status == "pending"
+ assert task.error == ""
+ assert task.started_at == 0
+ assert task.completed_at == 0
+
+ def test_custom_fields(self):
+ task = DownloadTask(
+ id="t2",
+ url="http://example.com/x",
+ dest=Path("/tmp/x"),
+ total_bytes=100,
+ status="downloading",
+ )
+ assert task.total_bytes == 100
+ assert task.status == "downloading"
+
+
+# ---------------------------------------------------------------------------
+# DownloadManager: construction and torrent-settings wiring
+# ---------------------------------------------------------------------------
+
+class TestDownloadManagerInit:
+ def test_no_arg_constructor(self):
+ dm = DownloadManager()
+ assert dm._tasks == {}
+ assert dm._running == {}
+ assert dm._torrent_settings_store is None
+ assert dm._torrent is None
+
+ def test_with_torrent_settings_store(self):
+ store = MagicMock()
+ dm = DownloadManager(torrent_settings_store=store)
+ assert dm._torrent_settings_store is store
+
+
+class TestApplyTorrentSettings:
+ def test_no_op_when_torrent_not_instantiated(self):
+ dm = DownloadManager()
+ dm.apply_torrent_settings(MagicMock())
+
+ def test_delegates_to_torrent(self):
+ dm = DownloadManager()
+ fake_torrent = MagicMock()
+ dm._torrent = fake_torrent
+ settings = MagicMock()
+ dm.apply_torrent_settings(settings)
+ fake_torrent.apply_settings.assert_called_once_with(settings)
+
+
+class TestGetTorrentDownloader:
+ def test_returns_cached_instance(self):
+ dm = DownloadManager()
+ cached = MagicMock()
+ dm._torrent = cached
+ assert dm._get_torrent_downloader() is cached
+
+ def test_returns_none_when_import_raises(self):
+ dm = DownloadManager()
+ with patch("tinyagentos.torrent_downloader.TorrentDownloader", side_effect=ImportError("no libtorrent")):
+ result = dm._get_torrent_downloader()
+ assert result is None
+
+ def test_returns_none_when_torrent_raises(self):
+ dm = DownloadManager()
+ with patch("tinyagentos.torrent_downloader.TorrentDownloader", side_effect=RuntimeError("nope")):
+ result = dm._get_torrent_downloader()
+ assert result is None
+
+ def test_creates_with_settings_from_store(self):
+ dm = DownloadManager()
+ fake_settings = MagicMock()
+ fake_store = MagicMock()
+ fake_store.load.return_value = fake_settings
+ dm._torrent_settings_store = fake_store
+
+ mock_torrent_cls = MagicMock()
+ with patch("tinyagentos.torrent_downloader.TorrentDownloader", mock_torrent_cls):
+ result = dm._get_torrent_downloader()
+ mock_torrent_cls.assert_called_once_with(settings=fake_settings)
+ assert result is mock_torrent_cls.return_value
+
+ def test_creates_without_settings_when_no_store(self):
+ dm = DownloadManager()
+ mock_torrent_cls = MagicMock()
+ with patch("tinyagentos.torrent_downloader.TorrentDownloader", mock_torrent_cls):
+ result = dm._get_torrent_downloader()
+ mock_torrent_cls.assert_called_once_with(settings=None)
+
+
+# ---------------------------------------------------------------------------
+# start_download
+# ---------------------------------------------------------------------------
+
+class TestStartDownload:
+ @pytest.mark.asyncio
+ async def test_creates_task_and_stores_it(self, tmp_path):
+ dm = DownloadManager()
+ dest = tmp_path / "model.bin"
+ task = dm.start_download("dl-1", "http://example.com/model.bin", dest)
+ assert task.id == "dl-1"
+ assert task.url == "http://example.com/model.bin"
+ assert task.dest == dest
+ assert task.status == "pending"
+ assert "dl-1" in dm._tasks
+ assert "dl-1" in dm._running
+ dm._running["dl-1"].cancel()
+ try:
+ await dm._running["dl-1"]
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ @pytest.mark.asyncio
+ async def test_returns_same_task_as_stored(self, tmp_path):
+ dm = DownloadManager()
+ dest = tmp_path / "model.bin"
+ task = dm.start_download("dl-2", "http://example.com/m.bin", dest)
+ assert dm.get_progress("dl-2") is task
+ dm._running["dl-2"].cancel()
+ try:
+ await dm._running["dl-2"]
+ except (asyncio.CancelledError, Exception):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# get_progress / list_active / list_all
+# ---------------------------------------------------------------------------
+
+class TestProgressAndListing:
+ def test_get_progress_returns_none_for_unknown(self):
+ dm = DownloadManager()
+ assert dm.get_progress("nonexistent") is None
+
+ def test_get_progress_returns_task(self):
+ dm = DownloadManager()
+ task = DownloadTask(id="x", url="http://x", dest=Path("/tmp/x"))
+ dm._tasks["x"] = task
+ assert dm.get_progress("x") is task
+
+ def test_list_active_filters_completed_and_error(self):
+ dm = DownloadManager()
+ dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="pending")
+ dm._tasks["b"] = DownloadTask(id="b", url="http://b", dest=Path("/tmp/b"), status="downloading")
+ dm._tasks["c"] = DownloadTask(id="c", url="http://c", dest=Path("/tmp/c"), status="complete")
+ dm._tasks["d"] = DownloadTask(id="d", url="http://d", dest=Path("/tmp/d"), status="error")
+ active = dm.list_active()
+ ids = {t.id for t in active}
+ assert ids == {"a", "b"}
+
+ def test_list_active_empty_when_all_complete(self):
+ dm = DownloadManager()
+ dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="complete")
+ assert dm.list_active() == []
+
+ def test_list_all_returns_everything(self):
+ dm = DownloadManager()
+ dm._tasks["a"] = DownloadTask(id="a", url="http://a", dest=Path("/tmp/a"), status="pending")
+ dm._tasks["b"] = DownloadTask(id="b", url="http://b", dest=Path("/tmp/b"), status="complete")
+ all_tasks = dm.list_all()
+ assert len(all_tasks) == 2
+
+ def test_list_all_empty(self):
+ dm = DownloadManager()
+ assert dm.list_all() == []
+
+
+# ---------------------------------------------------------------------------
+# _download (HTTP path) -- mocked httpx
+# ---------------------------------------------------------------------------
+
+class TestDownloadHttp:
+ @pytest_asyncio.fixture
+ def dm(self):
+ return DownloadManager()
+
+ def _make_async_context_manager_mock(self, content: bytes, content_length: int | None = None):
+ """Build a mock that works as both `async with client.stream(...)` and
+ `async with client` (the outer AsyncClient context manager).
+
+ The code does::
+ async with httpx.AsyncClient(...) as client:
+ async with client.stream("GET", url) as resp:
+ ...
+ """
+ mock_resp = MagicMock()
+ mock_resp.raise_for_status = MagicMock()
+ if content_length is not None:
+ mock_resp.headers = {"content-length": str(content_length)}
+ else:
+ mock_resp.headers = {}
+
+ async def _aiter_bytes(chunk_size=65536):
+ for i in range(0, len(content), chunk_size):
+ yield content[i:i + chunk_size]
+
+ mock_resp.aiter_bytes = _aiter_bytes
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
+ return mock_resp
+
+ def _make_mock_client(self, mock_resp):
+ """Build a mock httpx.AsyncClient that works as an async context manager
+ whose `.stream()` method also returns an async context manager."""
+ mock_client = MagicMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.stream = MagicMock(return_value=mock_resp)
+ return mock_client
+
+ @pytest.mark.asyncio
+ async def test_successful_download(self, dm, tmp_path):
+ data = b"hello world" * 100
+ dest = tmp_path / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+ mock_resp = self._make_async_context_manager_mock(data, len(data))
+ mock_client = self._make_mock_client(mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256=None)
+
+ assert task.status == "complete"
+ assert task.downloaded_bytes == len(data)
+ assert task.total_bytes == len(data)
+ assert task.completed_at > 0
+ assert task.error == ""
+ assert dest.read_bytes() == data
+
+ @pytest.mark.asyncio
+ async def test_download_with_correct_sha256(self, dm, tmp_path):
+ data = b"test data"
+ expected_hash = hashlib.sha256(data).hexdigest()
+ dest = tmp_path / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+ mock_resp = self._make_async_context_manager_mock(data, len(data))
+ mock_client = self._make_mock_client(mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256=expected_hash)
+
+ assert task.status == "complete"
+ assert dest.exists()
+
+ @pytest.mark.asyncio
+ async def test_download_sha256_mismatch(self, dm, tmp_path):
+ data = b"test data"
+ dest = tmp_path / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+ mock_resp = self._make_async_context_manager_mock(data, len(data))
+ mock_client = self._make_mock_client(mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256="wronghash")
+
+ assert task.status == "error"
+ assert task.error == "SHA256 mismatch"
+ assert not dest.exists()
+
+ @pytest.mark.asyncio
+ async def test_download_http_error(self, dm, tmp_path):
+ dest = tmp_path / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+
+ mock_resp = MagicMock()
+ mock_resp.raise_for_status = MagicMock(side_effect=Exception("404 Not Found"))
+ mock_resp.headers = {}
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
+
+ mock_client = MagicMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.stream = MagicMock(return_value=mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256=None)
+
+ assert task.status == "error"
+ assert task.error == "404 Not Found"
+
+ @pytest.mark.asyncio
+ async def test_download_creates_parent_dirs(self, dm, tmp_path):
+ data = b"x"
+ dest = tmp_path / "sub" / "dir" / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+ mock_resp = self._make_async_context_manager_mock(data, 1)
+ mock_client = self._make_mock_client(mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256=None)
+
+ assert dest.exists()
+ assert dest.read_bytes() == data
+
+ @pytest.mark.asyncio
+ async def test_download_no_content_length(self, dm, tmp_path):
+ data = b"no content length"
+ dest = tmp_path / "out.bin"
+ task = DownloadTask(id="dl", url="http://example.com/f.bin", dest=dest)
+ mock_resp = self._make_async_context_manager_mock(data, None)
+ mock_client = self._make_mock_client(mock_resp)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download(task, expected_sha256=None)
+
+ assert task.status == "complete"
+ assert task.total_bytes == 0
+ assert task.downloaded_bytes == len(data)
+
+
+# ---------------------------------------------------------------------------
+# _download_with_fallback: torrent-first and fallback logic
+# ---------------------------------------------------------------------------
+
+class TestDownloadWithFallback:
+ @pytest_asyncio.fixture
+ def dm(self):
+ return DownloadManager()
+
+ def _make_http_mock(self, data: bytes):
+ mock_resp = MagicMock()
+ mock_resp.raise_for_status = MagicMock()
+ mock_resp.headers = {"content-length": str(len(data))}
+
+ async def _aiter_bytes(chunk_size=65536):
+ yield data
+
+ mock_resp.aiter_bytes = _aiter_bytes
+ mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
+ mock_resp.__aexit__ = AsyncMock(return_value=False)
+
+ mock_client = MagicMock()
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=False)
+ mock_client.stream = MagicMock(return_value=mock_resp)
+ return mock_client
+
+ @pytest.mark.asyncio
+ async def test_falls_through_to_http_when_no_magnet(self, dm, tmp_path):
+ data = b"fallback data"
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+ mock_client = self._make_http_mock(data)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download_with_fallback(task, expected_sha256=None, magnet=None)
+
+ assert task.status == "complete"
+ assert dest.read_bytes() == data
+
+ @pytest.mark.asyncio
+ async def test_falls_through_when_license_disallows(self, dm, tmp_path):
+ data = b"http only"
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+ mock_client = self._make_http_mock(data)
+
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download_with_fallback(
+ task,
+ expected_sha256=None,
+ magnet="magnet:?xt=urn:btih:abc",
+ license_allows_redistribution=False,
+ )
+
+ assert task.status == "complete"
+
+ @pytest.mark.asyncio
+ async def test_torrent_success_path(self, dm, tmp_path):
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+
+ fake_torrent = AsyncMock()
+ fake_progress_task = MagicMock()
+ fake_progress_task.total_bytes = 999
+ fake_progress_task.downloaded_bytes = 999
+
+ async def mock_download(task_id, magnet_or_torrent, dest, expected_sha256, progress_cb):
+ progress_cb(fake_progress_task)
+
+ fake_torrent.download = mock_download
+
+ with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent):
+ await dm._download_with_fallback(
+ task,
+ expected_sha256=None,
+ magnet="magnet:?xt=urn:btih:abc",
+ license_allows_redistribution=True,
+ )
+
+ assert task.status == "complete"
+ assert task.total_bytes == 999
+ assert task.downloaded_bytes == 999
+ assert task.completed_at > 0
+
+ @pytest.mark.asyncio
+ async def test_torrent_failure_falls_back_to_http(self, dm, tmp_path):
+ data = b"http fallback after torrent error"
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+
+ fake_torrent = AsyncMock()
+ fake_torrent.download = AsyncMock(side_effect=Exception("no peers"))
+ mock_client = self._make_http_mock(data)
+
+ with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent):
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download_with_fallback(
+ task,
+ expected_sha256=None,
+ magnet="magnet:?xt=urn:btih:abc",
+ license_allows_redistribution=True,
+ )
+
+ assert task.status == "complete"
+ assert dest.read_bytes() == data
+
+ @pytest.mark.asyncio
+ async def test_torrent_unavailable_falls_back_to_http(self, dm, tmp_path):
+ data = b"http because no torrent"
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+ mock_client = self._make_http_mock(data)
+
+ with patch.object(dm, "_get_torrent_downloader", return_value=None):
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download_with_fallback(
+ task,
+ expected_sha256=None,
+ magnet="magnet:?xt=urn:btih:abc",
+ license_allows_redistribution=True,
+ )
+
+ assert task.status == "complete"
+
+ @pytest.mark.asyncio
+ async def test_torrent_resets_state_before_http_fallback(self, dm, tmp_path):
+ data = b"clean slate"
+ dest = tmp_path / "model.bin"
+ task = DownloadTask(id="dl", url="http://example.com/m.bin", dest=dest)
+
+ fake_torrent = AsyncMock()
+ fake_torrent.download = AsyncMock(side_effect=Exception("torrent broke"))
+ mock_client = self._make_http_mock(data)
+
+ with patch.object(dm, "_get_torrent_downloader", return_value=fake_torrent):
+ with patch("tinyagentos.download_manager.httpx.AsyncClient", return_value=mock_client):
+ await dm._download_with_fallback(
+ task,
+ expected_sha256=None,
+ magnet="magnet:?xt=urn:btih:abc",
+ license_allows_redistribution=True,
+ )
+
+ assert task.status == "complete"
+ assert task.error == ""
diff --git a/tests/test_feedback_store.py b/tests/test_feedback_store.py
new file mode 100644
index 000000000..5fff495f5
--- /dev/null
+++ b/tests/test_feedback_store.py
@@ -0,0 +1,173 @@
+import pytest
+import pytest_asyncio
+
+from tinyagentos.feedback_store import MAX_BODY_LEN, MAX_SCREENSHOT_LEN, FeedbackStore
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ store = FeedbackStore(tmp_path / "feedback.db")
+ await store.init()
+ yield store
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_create_returns_all_fields(store):
+ row = await store.create(
+ user_id="user-1",
+ type="bug",
+ title="Something broke",
+ body="Details here",
+ app="myapp",
+ )
+ assert row["user_id"] == "user-1"
+ assert row["type"] == "bug"
+ assert row["title"] == "Something broke"
+ assert row["body"] == "Details here"
+ assert row["app"] == "myapp"
+ assert row["screenshot"] == ""
+ assert "id" in row
+ assert "created_at" in row
+
+
+@pytest.mark.asyncio
+async def test_create_with_screenshot(store):
+ row = await store.create(
+ user_id="user-1",
+ type="bug",
+ title="Screenshot test",
+ body="body",
+ screenshot="data:image/png;base64,AAAA",
+ )
+ assert row["screenshot"] == "data:image/png;base64,AAAA"
+
+
+@pytest.mark.asyncio
+async def test_create_generates_unique_ids(store):
+ row1 = await store.create(user_id="u", type="bug", title="t1", body="b1")
+ row2 = await store.create(user_id="u", type="bug", title="t2", body="b2")
+ assert row1["id"] != row2["id"]
+
+
+@pytest.mark.asyncio
+async def test_get_by_id_returns_full_row(store):
+ created = await store.create(
+ user_id="user-1",
+ type="feature",
+ title="Add dark mode",
+ body="please",
+ screenshot="data:image/png;base64,BBBB",
+ app="settings",
+ )
+ fetched = await store.get_by_id(created["id"], "user-1")
+ assert fetched is not None
+ assert fetched["id"] == created["id"]
+ assert fetched["user_id"] == "user-1"
+ assert fetched["type"] == "feature"
+ assert fetched["title"] == "Add dark mode"
+ assert fetched["body"] == "please"
+ assert fetched["screenshot"] == "data:image/png;base64,BBBB"
+ assert fetched["app"] == "settings"
+ assert fetched["has_screenshot"] is True
+
+
+@pytest.mark.asyncio
+async def test_get_by_id_wrong_user_returns_none(store):
+ created = await store.create(user_id="user-1", type="bug", title="t", body="b")
+ result = await store.get_by_id(created["id"], "user-2")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_get_by_id_nonexistent_returns_none(store):
+ result = await store.get_by_id("nonexistent-id", "user-1")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_get_by_id_has_screenshot_false_when_empty(store):
+ created = await store.create(user_id="u", type="bug", title="t", body="b", screenshot="")
+ fetched = await store.get_by_id(created["id"], "u")
+ assert fetched["has_screenshot"] is False
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_returns_items_most_recent_first(store):
+ r1 = await store.create(user_id="user-1", type="bug", title="old", body="b1")
+ r2 = await store.create(user_id="user-1", type="bug", title="new", body="b2")
+ rows = await store.list_for_user("user-1")
+ assert len(rows) == 2
+ assert rows[0]["id"] == r2["id"]
+ assert rows[0]["title"] == "new"
+ assert rows[1]["id"] == r1["id"]
+ assert rows[1]["title"] == "old"
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_excludes_screenshot_blob(store):
+ await store.create(
+ user_id="user-1",
+ type="bug",
+ title="t",
+ body="b",
+ screenshot="data:image/png;base64,SECRET",
+ )
+ rows = await store.list_for_user("user-1")
+ assert len(rows) == 1
+ assert "screenshot" not in rows[0]
+ assert rows[0]["has_screenshot"] is True
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_scoped_to_user(store):
+ await store.create(user_id="user-1", type="bug", title="u1-item", body="b")
+ await store.create(user_id="user-2", type="bug", title="u2-item", body="b")
+ rows = await store.list_for_user("user-1")
+ assert len(rows) == 1
+ assert rows[0]["user_id"] == "user-1"
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_empty_when_no_feedback(store):
+ rows = await store.list_for_user("nobody")
+ assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_has_screenshot_false(store):
+ await store.create(user_id="u", type="bug", title="t", body="b", screenshot="")
+ rows = await store.list_for_user("u")
+ assert rows[0]["has_screenshot"] is False
+
+
+@pytest.mark.asyncio
+async def test_list_for_user_returns_expected_keys(store):
+ await store.create(user_id="u", type="bug", title="t", body="b")
+ rows = await store.list_for_user("u")
+ assert len(rows) == 1
+ expected_keys = {"id", "user_id", "type", "title", "body", "app", "created_at", "has_screenshot"}
+ assert set(rows[0].keys()) == expected_keys
+
+
+@pytest.mark.asyncio
+async def test_create_default_app(store):
+ row = await store.create(user_id="u", type="bug", title="t", body="")
+ assert row["app"] == ""
+
+
+@pytest.mark.asyncio
+async def test_multiple_types(store):
+ await store.create(user_id="u", type="bug", title="bug report", body="b")
+ await store.create(user_id="u", type="feature", title="feature req", body="f")
+ await store.create(user_id="u", type="question", title="question", body="q")
+ rows = await store.list_for_user("u")
+ assert len(rows) == 3
+ types = {r["type"] for r in rows}
+ assert types == {"bug", "feature", "question"}
+
+
+@pytest.mark.asyncio
+async def test_constants_are_sane():
+ assert MAX_SCREENSHOT_LEN == 4_000_000
+ assert MAX_BODY_LEN == 20_000
diff --git a/tests/test_fs_tools.py b/tests/test_fs_tools.py
new file mode 100644
index 000000000..9385c428d
--- /dev/null
+++ b/tests/test_fs_tools.py
@@ -0,0 +1,59 @@
+import os
+
+import pytest
+
+from tinyagentos.agent_tools.fs_tools import (
+ read_file, write_file, file_exists, list_dir, JailViolation,
+)
+
+
+def _ws(tmp_path):
+ root = tmp_path / "workspace"
+ root.mkdir()
+ return root
+
+
+def test_write_then_read_roundtrip(tmp_path):
+ root = _ws(tmp_path)
+ n = write_file(root, "sub/dir/file.txt", "hello")
+ assert n == 5
+ assert read_file(root, "sub/dir/file.txt") == "hello"
+ assert file_exists(root, "sub/dir/file.txt") is True
+ assert file_exists(root, "nope.txt") is False
+
+
+def test_escape_via_dotdot_is_refused(tmp_path):
+ root = _ws(tmp_path)
+ (tmp_path / "secret.txt").write_text("top secret")
+ with pytest.raises(JailViolation):
+ read_file(root, "../secret.txt")
+ with pytest.raises(JailViolation):
+ write_file(root, "../escaped.txt", "x")
+ assert file_exists(root, "../secret.txt") is False
+
+
+def test_git_path_is_refused(tmp_path):
+ root = _ws(tmp_path)
+ with pytest.raises(JailViolation):
+ write_file(root, ".git/hooks/pre-commit", "#!/bin/sh\necho pwned")
+ with pytest.raises(JailViolation):
+ read_file(root, ".git/config")
+
+
+def test_symlink_escape_is_refused(tmp_path):
+ root = _ws(tmp_path)
+ (tmp_path / "outside.txt").write_text("secret")
+ os.symlink(tmp_path / "outside.txt", root / "link.txt")
+ with pytest.raises(JailViolation):
+ read_file(root, "link.txt")
+
+
+def test_list_dir(tmp_path):
+ root = _ws(tmp_path)
+ write_file(root, "a.txt", "1")
+ write_file(root, "b.txt", "2")
+ write_file(root, "sub/c.txt", "3")
+ assert list_dir(root) == ["a.txt", "b.txt", "sub"]
+ assert list_dir(root, "sub") == ["c.txt"]
+ with pytest.raises(JailViolation):
+ list_dir(root, "../")
diff --git a/tests/test_installed_apps.py b/tests/test_installed_apps.py
new file mode 100644
index 000000000..68349bbc0
--- /dev/null
+++ b/tests/test_installed_apps.py
@@ -0,0 +1,162 @@
+import pytest
+import pytest_asyncio
+
+from tinyagentos.installed_apps import InstalledAppsStore
+
+
+@pytest_asyncio.fixture
+async def store(tmp_path):
+ store = InstalledAppsStore(tmp_path / "installed_apps.db")
+ await store.init()
+ yield store
+ await store.close()
+
+
+@pytest.mark.asyncio
+async def test_install_and_is_installed(store):
+ assert not await store.is_installed("myapp")
+ await store.install("myapp", version="1.0.0")
+ assert await store.is_installed("myapp")
+
+
+@pytest.mark.asyncio
+async def test_install_default_version_and_metadata(store):
+ await store.install("myapp")
+ rows = await store.list_installed()
+ assert len(rows) == 1
+ assert rows[0]["app_id"] == "myapp"
+ assert rows[0]["version"] == ""
+ assert rows[0]["metadata"] == {}
+
+
+@pytest.mark.asyncio
+async def test_install_with_metadata(store):
+ await store.install("myapp", version="2.0.0", metadata={"author": "test"})
+ rows = await store.list_installed()
+ assert rows[0]["version"] == "2.0.0"
+ assert rows[0]["metadata"] == {"author": "test"}
+
+
+@pytest.mark.asyncio
+async def test_install_replace_existing(store):
+ await store.install("myapp", version="1.0.0", metadata={"old": "data"})
+ await store.install("myapp", version="2.0.0", metadata={"new": "data"})
+ rows = await store.list_installed()
+ assert len(rows) == 1
+ assert rows[0]["version"] == "2.0.0"
+ assert rows[0]["metadata"] == {"new": "data"}
+
+
+@pytest.mark.asyncio
+async def test_list_installed_order(store):
+ await store.install("app-a", version="1.0")
+ await store.install("app-b", version="1.0")
+ await store.install("app-c", version="1.0")
+ rows = await store.list_installed()
+ assert [r["app_id"] for r in rows] == ["app-c", "app-b", "app-a"]
+
+
+@pytest.mark.asyncio
+async def test_list_installed_empty(store):
+ rows = await store.list_installed()
+ assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_uninstall_returns_true_when_exists(store):
+ await store.install("myapp")
+ assert await store.uninstall("myapp") is True
+ assert not await store.is_installed("myapp")
+
+
+@pytest.mark.asyncio
+async def test_uninstall_returns_false_when_missing(store):
+ assert await store.uninstall("nonexistent") is False
+
+
+@pytest.mark.asyncio
+async def test_update_and_get_runtime_location(store):
+ await store.update_runtime_location("myapp", "localhost", 8080, backend="rkllama", ui_path="/ui")
+ loc = await store.get_runtime_location("myapp")
+ assert loc is not None
+ assert loc["runtime_host"] == "localhost"
+ assert loc["runtime_port"] == 8080
+ assert loc["backend"] == "rkllama"
+ assert loc["ui_path"] == "/ui"
+
+
+@pytest.mark.asyncio
+async def test_get_runtime_location_returns_none_when_missing(store):
+ assert await store.get_runtime_location("nonexistent") is None
+
+
+@pytest.mark.asyncio
+async def test_update_runtime_location_defaults(store):
+ await store.update_runtime_location("myapp", "host", 9000)
+ loc = await store.get_runtime_location("myapp")
+ assert loc["backend"] == ""
+ assert loc["ui_path"] == "/"
+
+
+@pytest.mark.asyncio
+async def test_update_runtime_location_overwrite(store):
+ await store.update_runtime_location("myapp", "host1", 1000)
+ await store.update_runtime_location("myapp", "host2", 2000, backend="b2", ui_path="/p")
+ loc = await store.get_runtime_location("myapp")
+ assert loc["runtime_host"] == "host2"
+ assert loc["runtime_port"] == 2000
+ assert loc["backend"] == "b2"
+ assert loc["ui_path"] == "/p"
+
+
+@pytest.mark.asyncio
+async def test_remove_runtime_location(store):
+ await store.update_runtime_location("myapp", "host", 8080)
+ await store.remove_runtime_location("myapp")
+ assert await store.get_runtime_location("myapp") is None
+
+
+@pytest.mark.asyncio
+async def test_remove_runtime_location_when_missing(store):
+ await store.remove_runtime_location("nonexistent")
+
+
+@pytest.mark.asyncio
+async def test_full_round_trip(store):
+ await store.install("myapp", version="1.0.0", metadata={"key": "val"})
+ assert await store.is_installed("myapp")
+
+ rows = await store.list_installed()
+ assert len(rows) == 1
+ assert rows[0]["app_id"] == "myapp"
+
+ await store.update_runtime_location("myapp", "127.0.0.1", 3000)
+ loc = await store.get_runtime_location("myapp")
+ assert loc["runtime_host"] == "127.0.0.1"
+ assert loc["runtime_port"] == 3000
+
+ assert await store.uninstall("myapp") is True
+ assert not await store.is_installed("myapp")
+ assert await store.get_runtime_location("myapp") is not None
+
+
+@pytest.mark.asyncio
+async def test_multiple_apps(store):
+ await store.install("app-1", version="1.0")
+ await store.install("app-2", version="2.0")
+ await store.install("app-3", version="3.0")
+
+ rows = await store.list_installed()
+ assert len(rows) == 3
+
+ assert await store.is_installed("app-1")
+ assert await store.is_installed("app-2")
+ assert await store.is_installed("app-3")
+
+ await store.uninstall("app-2")
+ assert not await store.is_installed("app-2")
+ assert await store.is_installed("app-1")
+ assert await store.is_installed("app-3")
+
+ rows = await store.list_installed()
+ assert len(rows) == 2
diff --git a/tests/test_office_docs.py b/tests/test_office_docs.py
new file mode 100644
index 000000000..6e707fd97
--- /dev/null
+++ b/tests/test_office_docs.py
@@ -0,0 +1,173 @@
+import pytest
+import pytest_asyncio
+
+from tinyagentos.office_docs import VALID_KINDS, _new_doc_id, OfficeDocStore
+
+
+@pytest_asyncio.fixture
+async def office_doc_store(tmp_path):
+ store = OfficeDocStore(tmp_path / "office_docs.db")
+ await store.init()
+ yield store
+ await store.close()
+
+
+class TestNewDocId:
+ def test_format(self):
+ doc_id = _new_doc_id()
+ assert doc_id.startswith("doc-")
+ suffix = doc_id[4:]
+ assert len(suffix) == 8
+ alphabet = "abcdefghijklmnopqrstuvwxyz234567"
+ for ch in suffix:
+ assert ch in alphabet
+
+ def test_uniqueness(self):
+ ids = {_new_doc_id() for _ in range(100)}
+ assert len(ids) == 100
+
+
+class TestValidKinds:
+ def test_expected_kinds(self):
+ assert VALID_KINDS == {"write", "calc", "db", "slides"}
+
+
+@pytest.mark.asyncio
+async def test_create_happy_path(office_doc_store):
+ row = await office_doc_store.create(kind="write", title="My Doc", content="Hello")
+ assert row["kind"] == "write"
+ assert row["title"] == "My Doc"
+ assert row["content"] == "Hello"
+ assert row["id"].startswith("doc-")
+ assert row["created_at"] == row["updated_at"]
+ assert isinstance(row["created_at"], int)
+
+
+@pytest.mark.asyncio
+async def test_create_all_kinds(office_doc_store):
+ for kind in VALID_KINDS:
+ row = await office_doc_store.create(kind=kind, title=kind, content="x")
+ assert row["kind"] == kind
+
+
+@pytest.mark.asyncio
+async def test_create_invalid_kind(office_doc_store):
+ with pytest.raises(ValueError, match="kind must be one of"):
+ await office_doc_store.create(kind="invalid", title="T", content="C")
+
+
+@pytest.mark.asyncio
+async def test_get_existing(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="T", content="C")
+ fetched = await office_doc_store.get(created["id"])
+ assert fetched is not None
+ assert fetched["id"] == created["id"]
+ assert fetched["kind"] == "write"
+ assert fetched["title"] == "T"
+ assert fetched["content"] == "C"
+ assert "created_at" in fetched
+ assert "updated_at" in fetched
+
+
+@pytest.mark.asyncio
+async def test_get_nonexistent(office_doc_store):
+ result = await office_doc_store.get("doc-nonexistent")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_list_empty(office_doc_store):
+ rows = await office_doc_store.list()
+ assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_list_returns_all(office_doc_store):
+ await office_doc_store.create(kind="write", title="A", content="a")
+ await office_doc_store.create(kind="calc", title="B", content="b")
+ rows = await office_doc_store.list()
+ assert len(rows) == 2
+
+
+@pytest.mark.asyncio
+async def test_list_order_desc_updated_at(office_doc_store):
+ import asyncio
+ r1 = await office_doc_store.create(kind="write", title="First", content="1")
+ await asyncio.sleep(1.1)
+ r2 = await office_doc_store.create(kind="write", title="Second", content="2")
+ rows = await office_doc_store.list()
+ assert rows[0]["id"] == r2["id"]
+ assert rows[1]["id"] == r1["id"]
+
+
+@pytest.mark.asyncio
+async def test_list_excludes_content(office_doc_store):
+ await office_doc_store.create(kind="write", title="T", content="secret")
+ rows = await office_doc_store.list()
+ assert len(rows) == 1
+ assert "content" not in rows[0]
+
+
+@pytest.mark.asyncio
+async def test_update_title_and_content(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="Old", content="old")
+ updated = await office_doc_store.update(created["id"], title="New", content="new")
+ assert updated is not None
+ assert updated["title"] == "New"
+ assert updated["content"] == "new"
+ assert updated["kind"] == "write"
+ assert updated["updated_at"] >= created["updated_at"]
+
+
+@pytest.mark.asyncio
+async def test_update_with_kind_change(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="T", content="C")
+ updated = await office_doc_store.update(created["id"], title="T", content="C", kind="calc")
+ assert updated["kind"] == "calc"
+
+
+@pytest.mark.asyncio
+async def test_update_invalid_kind(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="T", content="C")
+ with pytest.raises(ValueError, match="kind must be one of"):
+ await office_doc_store.update(created["id"], title="T", content="C", kind="bogus")
+
+
+@pytest.mark.asyncio
+async def test_update_nonexistent(office_doc_store):
+ result = await office_doc_store.update("doc-noexist", title="X", content="Y")
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_delete_existing(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="T", content="C")
+ deleted = await office_doc_store.delete(created["id"])
+ assert deleted is True
+ assert await office_doc_store.get(created["id"]) is None
+
+
+@pytest.mark.asyncio
+async def test_delete_nonexistent(office_doc_store):
+ result = await office_doc_store.delete("doc-noexist")
+ assert result is False
+
+
+@pytest.mark.asyncio
+async def test_create_get_update_delete_roundtrip(office_doc_store):
+ created = await office_doc_store.create(kind="write", title="Start", content="orig")
+ doc_id = created["id"]
+
+ fetched = await office_doc_store.get(doc_id)
+ assert fetched["title"] == "Start"
+
+ updated = await office_doc_store.update(doc_id, title="Edited", content="changed", kind="calc")
+ assert updated["title"] == "Edited"
+ assert updated["kind"] == "calc"
+
+ rows = await office_doc_store.list()
+ assert len(rows) == 1
+
+ assert await office_doc_store.delete(doc_id) is True
+ assert await office_doc_store.get(doc_id) is None
+ assert await office_doc_store.list() == []
diff --git a/tests/test_rollback.py b/tests/test_rollback.py
new file mode 100644
index 000000000..424f7f260
--- /dev/null
+++ b/tests/test_rollback.py
@@ -0,0 +1,68 @@
+import subprocess
+
+import pytest
+
+from tinyagentos.rollback import ROLLBACK_FILE, read_rollback_target, record_pre_update
+
+
+def test_record_then_read_roundtrip(tmp_path):
+ record_pre_update(tmp_path, branch="dev", sha="abc123def", ts=1700000000)
+ target = read_rollback_target(tmp_path)
+ assert target == {"branch": "dev", "sha": "abc123def", "ts": "1700000000"}
+
+
+def test_record_overwrites(tmp_path):
+ record_pre_update(tmp_path, branch="dev", sha="aaa", ts=1)
+ record_pre_update(tmp_path, branch="feat/x", sha="bbb", ts=2)
+ assert read_rollback_target(tmp_path) == {"branch": "feat/x", "sha": "bbb", "ts": "2"}
+
+
+def test_read_none_when_absent(tmp_path):
+ assert read_rollback_target(tmp_path) is None
+
+
+def test_file_is_shell_sourceable(tmp_path):
+ """scripts/rollback.sh sources this file, so bash must read the same values."""
+ record_pre_update(tmp_path, branch="feat/odd-name", sha="deadbeef", ts=42)
+ out = subprocess.check_output(
+ ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && echo \"$prev_branch|$prev_sha|$prev_ts\""],
+ text=True,
+ ).strip()
+ assert out == "feat/odd-name|deadbeef|42"
+
+
+def test_quote_injection_is_safe(tmp_path):
+ # A branch name with a quote must not break the sourceable file.
+ record_pre_update(tmp_path, branch="a'b", sha="c", ts=1)
+ assert read_rollback_target(tmp_path)["branch"] == "a'b"
+ out = subprocess.check_output(
+ ["bash", "-c", f"source '{tmp_path / ROLLBACK_FILE}' && printf '%s' \"$prev_branch\""],
+ text=True,
+ )
+ assert out == "a'b"
+
+
+@pytest.mark.asyncio
+async def test_update_records_rollback_target(tmp_path, monkeypatch):
+ """update_to_master records the pre-update branch + sha before mutating."""
+ import tinyagentos.update_runner as ur
+
+ calls = {"n": 0}
+
+ async def fake_run(args, cwd):
+ # Simulate: fetch ok, on branch 'dev', HEAD sha, clean tree, ff-merge ok.
+ joined = " ".join(args)
+ if "rev-parse --abbrev-ref" in joined:
+ return (0, "dev\n")
+ if "rev-parse HEAD" in joined:
+ return (0, "abc1234567\n")
+ if "status --porcelain" in joined:
+ return (0, "") # clean
+ return (0, "")
+
+ monkeypatch.setattr(ur, "_run", fake_run)
+ await ur.update_to_master(tmp_path)
+ target = read_rollback_target(tmp_path)
+ assert target is not None
+ assert target["branch"] == "dev"
+ assert target["sha"] == "abc1234567"
diff --git a/tests/test_routes_agent_deploy.py b/tests/test_routes_agent_deploy.py
new file mode 100644
index 000000000..8d9dbd9e3
--- /dev/null
+++ b/tests/test_routes_agent_deploy.py
@@ -0,0 +1,403 @@
+"""Endpoint-level tests for the agent deploy route, exercising the helpers
+in tinyagentos/routes/agent_deploy.py through the FastAPI test client.
+
+The full /api/agents/deploy endpoint requires live infrastructure
+(container runtime, taosmd, LLM proxy, etc.) and is NOT tested end-to-end.
+Instead we exercise the validation and routing helpers that the endpoint
+calls, which are reachable through the endpoint with appropriate mocking.
+"""
+
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, Mock, patch
+
+from tinyagentos.cluster.model_resolver import ModelLocation
+
+
+def _app(client):
+ return client._transport.app
+
+
+# ---------------------------------------------------------------------------
+# validate_framework_and_ram
+# ---------------------------------------------------------------------------
+
+
+class TestValidateFrameworkAndRam:
+ """Tests for agent_deploy.validate_framework_and_ram via the deploy endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_unknown_framework_returns_400(self, client):
+ """A framework not in the registry catalog must return 400."""
+ mock_manifest = Mock()
+ mock_manifest.id = "some-framework"
+ mock_manifest.type = "agent-framework"
+
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[mock_manifest])
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = None
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "nonexistent-fw"},
+ )
+ assert r.status_code == 400
+ body = r.json()
+ assert "error" in body
+ assert "nonexistent-fw" in body["error"]
+
+ @pytest.mark.asyncio
+ async def test_unknown_framework_lists_available(self, client):
+ """The 400 error for an unknown framework lists available frameworks."""
+ mock_m1 = Mock()
+ mock_m1.id = "openclaw"
+ mock_m1.type = "agent-framework"
+ mock_m2 = Mock()
+ mock_m2.id = "smolagents"
+ mock_m2.type = "agent-framework"
+
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[mock_m1, mock_m2])
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = None
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "bogus"},
+ )
+ assert r.status_code == 400
+ body = r.json()
+ assert "openclaw" in body["error"]
+ assert "smolagents" in body["error"]
+
+ @pytest.mark.asyncio
+ async def test_low_ram_returns_400(self, client):
+ """A framework that needs more RAM than available must return 400."""
+ mock_manifest = Mock()
+ mock_manifest.id = "openclaw"
+ mock_manifest.type = "agent-framework"
+ mock_manifest.requires = {"ram_mb": 2048}
+
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[mock_manifest])
+ mock_registry.get = Mock(return_value=mock_manifest)
+
+ mock_hw = Mock()
+ mock_hw.ram_mb = 2048 # 2 GB -- not enough for 2048 + 500 + 2048
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = mock_hw
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "openclaw"},
+ )
+ assert r.status_code == 400
+ body = r.json()
+ assert "error" in body
+ assert "ram_mb" in body
+ assert "min_ram_mb" in body
+ assert body["framework"] == "openclaw"
+
+ @pytest.mark.asyncio
+ async def test_sufficient_ram_passes_validation(self, client):
+ """With enough RAM, framework validation passes (endpoint proceeds past it)."""
+ mock_manifest = Mock()
+ mock_manifest.id = "openclaw"
+ mock_manifest.type = "agent-framework"
+ mock_manifest.requires = {"ram_mb": 512}
+
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[mock_manifest])
+ mock_registry.get = Mock(return_value=mock_manifest)
+
+ mock_hw = Mock()
+ mock_hw.ram_mb = 16384 # 16 GB -- plenty
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = mock_hw
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "openclaw"},
+ )
+ # Should NOT get a 400 from framework validation.
+ assert r.status_code != 400 or "framework" not in r.json().get("error", "").lower()
+
+ @pytest.mark.asyncio
+ async def test_framework_none_skips_validation(self, client):
+ """framework='none' skips both catalog lookup and RAM check."""
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[])
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = None
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "none"},
+ )
+ # Should not get a framework-related 400.
+ if r.status_code == 400:
+ assert "framework" not in r.json().get("error", "").lower()
+
+ @pytest.mark.asyncio
+ async def test_no_hardware_profile_skips_ram_check(self, client):
+ """When hardware_profile is None, RAM check is skipped."""
+ mock_manifest = Mock()
+ mock_manifest.id = "openclaw"
+ mock_manifest.type = "agent-framework"
+ mock_manifest.requires = {"ram_mb": 99999}
+
+ mock_registry = Mock()
+ mock_registry.list_available = Mock(return_value=[mock_manifest])
+ mock_registry.get = Mock(return_value=mock_manifest)
+
+ app = _app(client)
+ app.state.registry = mock_registry
+ app.state.hardware_profile = None
+
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "framework": "openclaw"},
+ )
+ # Should not get a RAM-related 400 since hw profile is None.
+ if r.status_code == 400:
+ assert "ram" not in r.json().get("error", "").lower()
+
+
+# ---------------------------------------------------------------------------
+# resolve_deploy_routing
+# ---------------------------------------------------------------------------
+
+
+class TestResolveDeployRouting:
+ """Tests for agent_deploy.resolve_deploy_routing via the deploy endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_model_not_found_returns_404(self, client):
+ """A model that resolves to not_found must return 404."""
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="not_found"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent", "model": "nonexistent-model"},
+ )
+ assert r.status_code == 404
+ body = r.json()
+ assert "error" in body
+ assert "nonexistent-model" in body["error"]
+
+ @pytest.mark.asyncio
+ async def test_model_routed_to_worker_returns_202(self, client):
+ """A model on a worker (no pin) must return 202 with routing info."""
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(
+ kind="worker",
+ hosts=["worker-a", "worker-b"],
+ canonical_host="worker-a",
+ ),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={
+ "name": "test-agent",
+ "model": "qwen2.5-7b",
+ },
+ )
+ assert r.status_code == 202
+ body = r.json()
+ assert body["status"] == "routed"
+ assert body["worker"] == "worker-a"
+ assert "worker-a" in body["available_on"]
+ assert "worker-b" in body["available_on"]
+
+ @pytest.mark.asyncio
+ async def test_model_routed_to_pinned_worker_returns_202(self, client):
+ """A model on a worker with a valid pin returns 202."""
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(
+ kind="worker",
+ hosts=["worker-a", "worker-b"],
+ canonical_host="worker-a",
+ ),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={
+ "name": "test-agent",
+ "model": "qwen2.5-7b",
+ "target_worker": "worker-b",
+ },
+ )
+ assert r.status_code == 202
+ body = r.json()
+ assert body["status"] == "routed"
+ assert body["worker"] == "worker-b"
+
+ @pytest.mark.asyncio
+ async def test_pinned_worker_without_model_returns_409(self, client):
+ """A pinned worker that does NOT have the model must return 409."""
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(
+ kind="worker",
+ hosts=["worker-a"],
+ canonical_host="worker-a",
+ ),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={
+ "name": "test-agent",
+ "model": "qwen2.5-7b",
+ "target_worker": "worker-b",
+ },
+ )
+ assert r.status_code == 409
+ body = r.json()
+ assert "error" in body
+ assert "worker-b" in body["error"]
+ assert body["pinned_worker"] == "worker-b"
+ assert body["model"] == "qwen2.5-7b"
+ assert "worker-a" in body["available_on"]
+
+ @pytest.mark.asyncio
+ async def test_no_model_skips_routing(self, client):
+ """When no model is specified, routing is skipped entirely."""
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent"},
+ )
+ # Should not get a 404/409 from routing.
+ assert r.status_code not in (404, 409)
+
+ @pytest.mark.asyncio
+ async def test_cloud_model_falls_through(self, client):
+ """A cloud model resolves to 'cloud' and falls through to local deploy."""
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="cloud"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={
+ "name": "test-agent",
+ "model": "gpt-4o",
+ },
+ )
+ # Cloud models fall through; NOT a routing error.
+ assert r.status_code not in (404, 409)
+
+
+# ---------------------------------------------------------------------------
+# archive_smoke_check
+# ---------------------------------------------------------------------------
+
+# NOTE: archive_smoke_check is exercised during the POST /api/agents/deploy
+# response construction (after the agent record is saved and the background
+# task is spawned). We test it here with controller-local model resolution so
+# the deploy path actually completes, and we inspect archive_smoke_ok in the
+# response. The tests below verify that the smoke-check flag reflects archive
+# health. End-to-end archive correctness is tested in tests/routes/archive.
+
+
+class TestArchiveSmokeCheck:
+ """Tests for agent_deploy.archive_smoke_check via the deploy endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_archive_smoke_ok_true(self, client):
+ """When archive.record and query succeed, archive_smoke_ok is True."""
+ mock_archive = AsyncMock()
+ mock_archive.record = AsyncMock()
+ mock_archive.query = AsyncMock(return_value=[{"id": 1}])
+
+ app = _app(client)
+ app.state.archive = mock_archive
+
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="controller"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent"},
+ )
+ if r.status_code == 200:
+ body = r.json()
+ assert body.get("archive_smoke_ok") is True
+
+ @pytest.mark.asyncio
+ async def test_archive_smoke_ok_false_on_record_failure(self, client):
+ """When archive.record raises, archive_smoke_ok is False."""
+ mock_archive = AsyncMock()
+ mock_archive.record = AsyncMock(side_effect=Exception("disk full"))
+ mock_archive.query = AsyncMock(return_value=[])
+
+ app = _app(client)
+ app.state.archive = mock_archive
+
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="controller"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent"},
+ )
+ if r.status_code == 200:
+ body = r.json()
+ assert body.get("archive_smoke_ok") is False
+
+ @pytest.mark.asyncio
+ async def test_archive_smoke_ok_false_when_no_archive(self, client):
+ """When archive is None on app.state, archive_smoke_ok is False."""
+ app = _app(client)
+ app.state.archive = None
+
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="controller"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent"},
+ )
+ if r.status_code == 200:
+ body = r.json()
+ assert body.get("archive_smoke_ok") is False
+
+ @pytest.mark.asyncio
+ async def test_archive_smoke_ok_false_on_empty_query(self, client):
+ """When archive.query returns empty list, archive_smoke_ok is False."""
+ mock_archive = AsyncMock()
+ mock_archive.record = AsyncMock()
+ mock_archive.query = AsyncMock(return_value=[])
+
+ app = _app(client)
+ app.state.archive = mock_archive
+
+ with patch(
+ "tinyagentos.cluster.model_resolver.resolve_model_location",
+ return_value=ModelLocation(kind="controller"),
+ ):
+ r = await client.post(
+ "/api/agents/deploy",
+ json={"name": "test-agent"},
+ )
+ if r.status_code == 200:
+ body = r.json()
+ assert body.get("archive_smoke_ok") is False
diff --git a/tests/test_routes_apps.py b/tests/test_routes_apps.py
index 3e9b77014..2e02f005d 100644
--- a/tests/test_routes_apps.py
+++ b/tests/test_routes_apps.py
@@ -108,21 +108,21 @@ async def test_empty_returns_empty_list(self, client):
@pytest.mark.asyncio
async def test_install_then_listed(self, client):
- resp = await client.post("/api/apps/optional/reddit/install")
+ resp = await client.post("/api/apps/optional/coding-studio/install")
assert resp.status_code == 200
- assert resp.json()["app_id"] == "reddit"
+ assert resp.json()["app_id"] == "coding-studio"
resp = await client.get("/api/apps/optional/installed")
- assert resp.json()["installed"] == ["reddit"]
+ assert resp.json()["installed"] == ["coding-studio"]
@pytest.mark.asyncio
async def test_uninstall_removes(self, client):
- await client.post("/api/apps/optional/x-monitor/install")
+ await client.post("/api/apps/optional/coding-studio/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 "coding-studio" in resp.json()["installed"]
+ resp = await client.post("/api/apps/optional/coding-studio/uninstall")
assert resp.status_code == 200
resp = await client.get("/api/apps/optional/installed")
- assert "x-monitor" not in resp.json()["installed"]
+ assert "coding-studio" not in resp.json()["installed"]
@pytest.mark.asyncio
async def test_unknown_app_rejected_install(self, client):
@@ -136,10 +136,10 @@ async def test_unknown_app_rejected_uninstall(self, client):
@pytest.mark.asyncio
async def test_optional_app_not_in_installed_services(self, client):
- await client.post("/api/apps/optional/github-browser/install")
+ await client.post("/api/apps/optional/coding-studio/install")
resp = await client.get("/api/apps/installed")
ids = {i["app_id"] for i in resp.json()}
- assert "github-browser" not in ids
+ assert "coding-studio" not in ids
class TestOptionalCatalog:
@@ -166,18 +166,18 @@ async def test_catalog_item_shape(self, client):
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")
+ resp = await client.post("/api/apps/optional/coding-studio/install")
assert resp.status_code == 200
rows = await store.list_installed()
- row = next((r for r in rows if r["app_id"] == "reddit"), None)
+ row = next((r for r in rows if r["app_id"] == "coding-studio"), None)
assert row is not None
- assert row["version"] == APP_VERSIONS["reddit"]
+ assert row["version"] == APP_VERSIONS["coding-studio"]
@pytest.mark.asyncio
async def test_update_available_false_for_fresh_install(self, client):
- await client.post("/api/apps/optional/youtube-library/install")
+ await client.post("/api/apps/optional/coding-studio/install")
resp = await client.get("/api/apps/optional/catalog")
- app = next(a for a in resp.json()["apps"] if a["id"] == "youtube-library")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio")
assert app["installed"] is True
assert app["update_available"] is False
@@ -187,14 +187,14 @@ async def test_update_available_true_when_stored_version_older(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 (?, ?, ?, ?)",
- ("x-monitor", 1000.0, "0.9.0", json.dumps({"kind": "frontend-app"})),
+ ("coding-studio", 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")
+ app = next(a for a in resp.json()["apps"] if a["id"] == "coding-studio")
assert app["installed"] is True
assert app["update_available"] is True
- assert app["version"] == APP_VERSIONS["x-monitor"]
+ assert app["version"] == APP_VERSIONS["coding-studio"]
@pytest.mark.asyncio
async def test_catalog_does_not_leak_unknown_ids(self, client):
@@ -208,3 +208,23 @@ async def test_catalog_does_not_leak_unknown_ids(self, client):
returned_ids = {a["id"] for a in resp.json()["apps"]}
assert "unknown-app" not in returned_ids
assert returned_ids == OPTIONAL_FRONTEND_APPS
+
+
+class TestSocialAppsDeseeded:
+ """The platform social apps were removed from the default store."""
+
+ def test_social_apps_not_in_allowlist(self):
+ for app_id in ("reddit", "x-monitor", "github-browser", "youtube-library"):
+ assert app_id not in OPTIONAL_FRONTEND_APPS
+
+ @pytest.mark.asyncio
+ async def test_social_app_install_404(self, client):
+ for app_id in ("reddit", "x-monitor", "github-browser", "youtube-library"):
+ resp = await client.post(f"/api/apps/optional/{app_id}/install")
+ assert resp.status_code == 404, app_id
+
+ @pytest.mark.asyncio
+ async def test_social_apps_absent_from_catalog(self, client):
+ resp = await client.get("/api/apps/optional/catalog")
+ ids = {a["id"] for a in resp.json()["apps"]}
+ assert ids.isdisjoint({"reddit", "x-monitor", "github-browser", "youtube-library"})
diff --git a/tests/test_routes_browser_sessions.py b/tests/test_routes_browser_sessions.py
index 1e4737141..d45684d7b 100644
--- a/tests/test_routes_browser_sessions.py
+++ b/tests/test_routes_browser_sessions.py
@@ -81,3 +81,64 @@ async def test_get_session_not_found(self, client, monkeypatch):
assert resp.status_code == 404
body = resp.json()
assert body["error"] == "not_found"
+
+
+class TestCreateSessionHostPlacement:
+ """A RAM-capable controller host serves a streamed session itself, even with
+ no Tier-2 browser workers (the 'Pi isn't capable' regression)."""
+
+ @pytest.mark.asyncio
+ async def test_create_places_on_capable_host(self, client):
+ app = client._transport.app # noqa: SLF001
+ # Capable host (16GB), no browser workers.
+ app.state._state["host_hardware"] = {"ram_mb": 16000} # noqa: SLF001
+ runner = AsyncMock()
+ app.state._state["browser_container_runner"] = runner # noqa: SLF001
+
+ mgr = AsyncMock()
+ mgr.create_session = AsyncMock(return_value={"id": "sess-1"})
+ mgr.start_on_host = AsyncMock(
+ return_value={
+ "id": "sess-1", "owner_type": "user", "owner_id": "admin",
+ "status": "running", "node": "host", "neko_url": "http://host:8801",
+ "container_id": "c1", "cdp_url": None, "is_mobile": False,
+ "profile_name": "default", "url": "http://example.com",
+ }
+ )
+ app.state._state["browser_sessions"] = mgr # noqa: SLF001
+
+ resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"})
+ assert resp.status_code == 201, resp.text
+ assert resp.json()["node"] == "host"
+ mgr.start_on_host.assert_awaited_once()
+ mgr.start_on_worker.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_create_409_when_no_host_and_no_workers(self, client):
+ app = client._transport.app # noqa: SLF001
+ # Non-capable host (4GB), no workers -> nowhere to place.
+ app.state._state["host_hardware"] = {"ram_mb": 4096} # noqa: SLF001
+ mgr = AsyncMock()
+ mgr.create_session = AsyncMock(return_value={"id": "sess-2"})
+ app.state._state["browser_sessions"] = mgr # noqa: SLF001
+ resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"})
+ assert resp.status_code == 409
+ assert resp.json()["error"] == "no_capable_node"
+
+
+@pytest.mark.asyncio
+async def test_create_worker_lookup_failure_leaves_no_orphan(client, monkeypatch):
+ """If the chosen worker can't be resolved, 409 without creating a session row."""
+ import tinyagentos.routes.browser_sessions as bs
+
+ # Force placement onto a 'worker' that the cluster can't resolve.
+ monkeypatch.setattr(bs, "resolve_browser_target", lambda *a, **k: ("worker", "ghost"))
+ app = client._transport.app # noqa: SLF001
+ app.state.cluster_manager.get_worker = lambda name: None # noqa: SLF001
+ mgr = AsyncMock()
+ app.state._state["browser_sessions"] = mgr # noqa: SLF001
+
+ resp = await client.post("/api/browser/sessions", json={"url": "http://example.com"})
+ assert resp.status_code == 409
+ assert resp.json()["error"] == "no_capable_node"
+ mgr.create_session.assert_not_awaited()
diff --git a/tests/test_routes_cluster_capability.py b/tests/test_routes_cluster_capability.py
new file mode 100644
index 000000000..17b87d1ea
--- /dev/null
+++ b/tests/test_routes_cluster_capability.py
@@ -0,0 +1,272 @@
+"""Tests for the cluster capability-map endpoints.
+
+Coverage:
+- heartbeat requires worker HMAC; unsigned -> 401
+- heartbeat node_id must match the authenticated worker name -> 403 on mismatch
+- a signed heartbeat upserts and the row is readable via admin list
+- list filters by status
+- set-status validates the value and 404s an unknown node
+- prune drops stale rows
+- reads/mutations require an admin session
+"""
+from __future__ import annotations
+
+import pytest
+
+from test_routes_cluster_pairing import pair_worker, sign_worker_request
+
+
+async def _signed_heartbeat(client, key, node):
+ import json
+
+ path = "/api/cluster/capability/heartbeat"
+ body = json.dumps(node).encode()
+ headers = sign_worker_request(key, node["node_id"], "POST", path, body)
+ headers["Content-Type"] = "application/json"
+ return await client.post(path, content=body, headers=headers)
+
+
+@pytest.mark.asyncio
+async def test_heartbeat_requires_hmac(client, app):
+ """An unsigned heartbeat is rejected by the worker HMAC gate."""
+ await app.state.capability_map.init()
+ resp = await client.post(
+ "/api/cluster/capability/heartbeat",
+ json={"node_id": "node-a"},
+ )
+ assert resp.status_code == 401
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_heartbeat_name_mismatch_rejected(client, app):
+ """The signed worker name must equal the heartbeat node_id."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ # Sign as node-a but claim node_id node-b in the body.
+ import json
+
+ path = "/api/cluster/capability/heartbeat"
+ body = json.dumps({"node_id": "node-b"}).encode()
+ headers = sign_worker_request(key, "node-a", "POST", path, body)
+ headers["Content-Type"] = "application/json"
+ resp = await client.post(path, content=body, headers=headers)
+ assert resp.status_code == 403
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_heartbeat_upserts_and_lists(client, app):
+ """A signed heartbeat upserts a row visible to the admin list."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ resp = await _signed_heartbeat(
+ client,
+ key,
+ {
+ "node_id": "node-a",
+ "hostname": "pi5",
+ "ram_mb": 16000,
+ "gpu": {"name": "mali"},
+ "npu": {"tops": 6},
+ "status": "online",
+ },
+ )
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["node_id"] == "node-a"
+ assert resp.json()["gpu"] == {"name": "mali"}
+
+ resp = await client.get("/api/cluster/capability")
+ assert resp.status_code == 200
+ nodes = resp.json()["nodes"]
+ assert any(n["node_id"] == "node-a" and n["ram_mb"] == 16000 for n in nodes)
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_list_filters_by_status(client, app):
+ """?status= filters the returned rows."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key_a = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ key_b = await pair_worker(client, app, "node-b", "http://10.0.0.6:9000")
+ await _signed_heartbeat(client, key_a, {"node_id": "node-a", "status": "online"})
+ await _signed_heartbeat(client, key_b, {"node_id": "node-b", "status": "draining"})
+
+ resp = await client.get("/api/cluster/capability", params={"status": "draining"})
+ assert resp.status_code == 200
+ nodes = resp.json()["nodes"]
+ assert [n["node_id"] for n in nodes] == ["node-b"]
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_set_status_validates_and_404s(client, app):
+ """set-status rejects bad values and unknown nodes."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"})
+
+ resp = await client.post(
+ "/api/cluster/capability/node-a/status", json={"status": "bogus"}
+ )
+ assert resp.status_code == 400
+
+ resp = await client.post(
+ "/api/cluster/capability/node-a/status", json={"status": "draining"}
+ )
+ assert resp.status_code == 200
+ assert resp.json()["status"] == "draining"
+
+ resp = await client.post(
+ "/api/cluster/capability/ghost/status", json={"status": "online"}
+ )
+ assert resp.status_code == 404
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_heartbeat_preserves_admin_draining(client, app):
+ """A routine heartbeat must not clear an admin-set 'draining' status."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"})
+ r = await client.post(
+ "/api/cluster/capability/node-a/status", json={"status": "draining"}
+ )
+ assert r.json()["status"] == "draining"
+ # A subsequent heartbeat (defaults status=online) must keep it draining.
+ r = await _signed_heartbeat(client, key, {"node_id": "node-a", "status": "online"})
+ assert r.json()["status"] == "draining"
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_prune_drops_stale(client, app):
+ """prune removes rows older than the cutoff."""
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "node-a", "http://10.0.0.5:9000")
+ # last_seen far in the past so any positive cutoff prunes it.
+ await _signed_heartbeat(
+ client, key, {"node_id": "node-a", "status": "online"}
+ )
+ # Force the row stale directly via the store, then prune.
+ await app.state.capability_map.upsert(
+ {"node_id": "node-a", "status": "online", "last_seen": 1}
+ )
+ resp = await client.post(
+ "/api/cluster/capability/prune", json={"older_than_s": 60}
+ )
+ assert resp.status_code == 200
+ assert resp.json()["pruned"] == 1
+ resp = await client.get("/api/cluster/capability")
+ assert all(n["node_id"] != "node-a" for n in resp.json()["nodes"])
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_worker_registration_populates_capability_map(client, app):
+ """A paired worker's HMAC registration records its hardware into the map."""
+ import json
+
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "rig-1", "http://10.2.0.1:9000")
+ reg = json.dumps(
+ {
+ "name": "rig-1",
+ "url": "http://10.2.0.1:9000",
+ "platform": "linux",
+ "host_lan_ip": "10.2.0.9",
+ "hardware": {
+ "cpu": {"cores": 8},
+ "ram_mb": 16000,
+ "gpu": {"name": "rtx3060"},
+ "npu": {},
+ },
+ }
+ ).encode()
+ headers = sign_worker_request(key, "rig-1", "POST", "/api/cluster/workers", reg)
+ headers["Content-Type"] = "application/json"
+ resp = await client.post("/api/cluster/workers", content=reg, headers=headers)
+ assert resp.status_code == 200, resp.text
+
+ nodes = (await client.get("/api/cluster/capability")).json()["nodes"]
+ node = next((n for n in nodes if n["node_id"] == "rig-1"), None)
+ assert node is not None
+ assert node["status"] == "online"
+ assert node["ram_mb"] == 16000
+ assert node["gpu"] == {"name": "rtx3060"}
+ assert node["hostname"] == "10.2.0.9"
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_registration_does_not_revive_drained_node(client, app):
+ """If an admin drained a node, re-registration keeps it draining."""
+ import json
+
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "rig-2", "http://10.2.0.2:9000")
+ await app.state.capability_map.upsert({"node_id": "rig-2", "status": "draining"})
+
+ reg = json.dumps({"name": "rig-2", "url": "http://10.2.0.2:9000", "platform": "linux"}).encode()
+ headers = sign_worker_request(key, "rig-2", "POST", "/api/cluster/workers", reg)
+ headers["Content-Type"] = "application/json"
+ resp = await client.post("/api/cluster/workers", content=reg, headers=headers)
+ assert resp.status_code == 200
+
+ node = await app.state.capability_map.get("rig-2")
+ assert node["status"] == "draining"
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_reregistration_without_hardware_preserves_stored(client, app):
+ """A re-register with empty hardware must not wipe previously-detected fields."""
+ import json
+
+ await app.state.capability_map.init()
+ await app.state.cluster_pairing.init()
+ key = await pair_worker(client, app, "rig-3", "http://10.3.0.1:9000")
+ # First register with full hardware.
+ reg1 = json.dumps({
+ "name": "rig-3", "url": "http://10.3.0.1:9000", "platform": "linux",
+ "hardware": {"cpu": {"cores": 12}, "ram_mb": 32000, "gpu": {"name": "rtx4090"}, "npu": {}},
+ }).encode()
+ h1 = sign_worker_request(key, "rig-3", "POST", "/api/cluster/workers", reg1)
+ h1["Content-Type"] = "application/json"
+ assert (await client.post("/api/cluster/workers", content=reg1, headers=h1)).status_code == 200
+
+ # Re-register with NO hardware (legacy/flat-mode worker).
+ reg2 = json.dumps({"name": "rig-3", "url": "http://10.3.0.1:9000", "platform": "linux"}).encode()
+ h2 = sign_worker_request(key, "rig-3", "POST", "/api/cluster/workers", reg2)
+ h2["Content-Type"] = "application/json"
+ assert (await client.post("/api/cluster/workers", content=reg2, headers=h2)).status_code == 200
+
+ node = await app.state.capability_map.get("rig-3")
+ assert node["ram_mb"] == 32000 # preserved
+ assert node["gpu"] == {"name": "rtx4090"} # preserved
+ assert node["status"] == "online"
+ await app.state.capability_map.close()
+
+
+@pytest.mark.asyncio
+async def test_sweep_offlines_stale_online_nodes(client, app):
+ """The sweep endpoint flips stale online nodes offline, keeping the row."""
+ await app.state.capability_map.init()
+ await app.state.capability_map.upsert({"node_id": "n-live", "status": "online"})
+ await app.state.capability_map.upsert({"node_id": "n-gone", "status": "online", "last_seen": 1})
+ resp = await client.post("/api/cluster/capability/sweep", json={"older_than_s": 60})
+ assert resp.status_code == 200
+ assert resp.json()["offlined"] == 1
+ nodes = {n["node_id"]: n["status"] for n in (await client.get("/api/cluster/capability")).json()["nodes"]}
+ assert nodes["n-gone"] == "offline"
+ assert nodes["n-live"] == "online"
+ await app.state.capability_map.close()
diff --git a/tests/test_routes_taos_agent.py b/tests/test_routes_taos_agent.py
new file mode 100644
index 000000000..25e54bd67
--- /dev/null
+++ b/tests/test_routes_taos_agent.py
@@ -0,0 +1,334 @@
+"""Endpoint tests for tinyagentos/routes/taos_agent.py.
+
+Covers the endpoints that are testable in-process with the FastAPI
+test client (no live LLM / opencode / container needed):
+
+ GET /api/taos-agent/config
+ PUT /api/taos-agent/permitted-models
+ PUT /api/taos-agent/persona
+ PATCH /api/taos-agent/settings (validation)
+ POST /api/taos-agent/chat (guard responses)
+
+Endpoints exercised in separate modules:
+ - GET/PATCH settings, attachments upload/serve: test_taos_agent_route.py
+ - POST /api/taos-agent/chat streaming happy path: test_taos_agent_chat.py
+"""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+import yaml
+from httpx import ASGITransport, AsyncClient
+
+from tinyagentos.app import create_app
+import tinyagentos.cluster.model_resolver as _model_resolver_mod
+from tinyagentos.cluster.model_resolver import ModelLocation
+
+
+# ---------------------------------------------------------------------------
+# Fixtures (same pattern as tests/test_taos_agent_route.py)
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def tmp_data_dir(tmp_path):
+ config = {
+ "server": {"host": "0.0.0.0", "port": 6969},
+ "backends": [
+ {"name": "test-backend", "type": "rkllama", "url": "http://localhost:8080", "priority": 1}
+ ],
+ "qmd": {"url": "http://localhost:7832"},
+ "agents": [],
+ "metrics": {"poll_interval": 30, "retention_days": 30},
+ }
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(yaml.dump(config))
+ (tmp_path / ".setup_complete").touch()
+ return tmp_path
+
+
+@pytest.fixture
+def app(tmp_data_dir):
+ return create_app(data_dir=tmp_data_dir)
+
+
+@pytest_asyncio.fixture
+async def client(app):
+ ds = app.state.desktop_settings
+ if ds._db is not None:
+ await ds.close()
+ await ds.init()
+ app.state.auth.setup_user("admin", "Test Admin", "", "testpass")
+ record = app.state.auth.find_user("admin")
+ uid = record["id"] if record else ""
+ token = app.state.auth.create_session(user_id=uid, long_lived=True)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(
+ transport=transport,
+ base_url="http://test",
+ cookies={"taos_session": token},
+ ) as c:
+ yield c
+ await ds.close()
+ await app.state.http_client.aclose()
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _fake_proxy(running: bool = True) -> MagicMock:
+ proxy = MagicMock()
+ proxy.is_running.return_value = running
+ return proxy
+
+
+# ---------------------------------------------------------------------------
+# GET /api/taos-agent/config
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_config_returns_full_payload(client, app):
+ """GET /config returns model, permitted_models, persona, key_masked,
+ framework, and system."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+ resp = await client.get("/api/taos-agent/config")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert set(data.keys()) == {
+ "model", "permitted_models", "persona",
+ "key_masked", "framework", "system",
+ }
+ assert data["model"] == "gpt-4o"
+ assert data["framework"] == "opencode"
+ assert isinstance(data["permitted_models"], list)
+ assert isinstance(data["persona"], str)
+ assert data["system"] is True
+
+
+@pytest.mark.asyncio
+async def test_config_key_masked_none_when_no_key(client, app):
+ """When taos_opencode_key is absent, key_masked is None."""
+ resp = await client.get("/api/taos-agent/config")
+ assert resp.status_code == 200
+ assert resp.json()["key_masked"] is None
+
+
+@pytest.mark.asyncio
+async def test_config_key_masked_scrubs_long_key(client, app):
+ """A real-looking key is masked (first 6 + ellipsis + last 4)."""
+ app.state.taos_opencode_key = "sk-1234567890abcdef"
+ resp = await client.get("/api/taos-agent/config")
+ assert resp.status_code == 200
+ masked = resp.json()["key_masked"]
+ assert masked is not None
+ assert masked.startswith("sk-123")
+ assert masked.endswith("cdef")
+
+
+@pytest.mark.asyncio
+async def test_config_key_masked_short_key_returns_ellipsis(client, app):
+ """A key shorter than 12 chars is replaced with the ellipsis sentinel."""
+ app.state.taos_opencode_key = "short"
+ resp = await client.get("/api/taos-agent/config")
+ assert resp.status_code == 200
+ assert resp.json()["key_masked"] == "…"
+
+
+# ---------------------------------------------------------------------------
+# PUT /api/taos-agent/permitted-models
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_put_permitted_models_happy_path(client, app, monkeypatch):
+ """Setting permitted models with a reachable model returns the new set."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+
+ monkeypatch.setattr(
+ _model_resolver_mod, "resolve_model_location",
+ lambda request, model_id: ModelLocation(kind="cloud"),
+ )
+
+ resp = await client.put(
+ "/api/taos-agent/permitted-models",
+ json={"models": ["gpt-4o", "claude-4"]},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "gpt-4o" in data["permitted_models"]
+ assert "claude-4" in data["permitted_models"]
+
+
+@pytest.mark.asyncio
+async def test_put_permitted_models_empty_list_returns_400(client):
+ """An empty models list must be rejected with 400."""
+ resp = await client.put(
+ "/api/taos-agent/permitted-models",
+ json={"models": []},
+ )
+ assert resp.status_code == 400
+ assert "empty" in resp.json()["error"].lower()
+
+
+@pytest.mark.asyncio
+async def test_put_permitted_models_unreachable_returns_409(client, app, monkeypatch):
+ """A model that resolves to not_found returns 409."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+
+ monkeypatch.setattr(
+ _model_resolver_mod, "resolve_model_location",
+ lambda request, model_id: ModelLocation(kind="not_found"),
+ )
+
+ resp = await client.put(
+ "/api/taos-agent/permitted-models",
+ json={"models": ["does-not-exist"]},
+ )
+ assert resp.status_code == 409
+ error = resp.json()["error"].lower()
+ assert "not reachable" in error or "not_found" in error
+
+
+@pytest.mark.asyncio
+async def test_put_permitted_models_prepends_current_model(client, app, monkeypatch):
+ """The current primary model is always included even if not in the list."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+
+ monkeypatch.setattr(
+ _model_resolver_mod, "resolve_model_location",
+ lambda request, model_id: ModelLocation(kind="cloud"),
+ )
+
+ resp = await client.put(
+ "/api/taos-agent/permitted-models",
+ json={"models": ["claude-4"]},
+ )
+ assert resp.status_code == 200
+ permitted = resp.json()["permitted_models"]
+ assert permitted[0] == "gpt-4o"
+ assert "claude-4" in permitted
+
+
+@pytest.mark.asyncio
+async def test_put_permitted_models_re_scopes_key(client, app, monkeypatch):
+ """When proxy + key are present, key_rescoped reflects proxy.update_agent_key."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+ app.state.llm_proxy = _fake_proxy(running=True)
+ app.state.taos_opencode_key = "sk-1234567890abcdef"
+
+ monkeypatch.setattr(
+ _model_resolver_mod, "resolve_model_location",
+ lambda request, model_id: ModelLocation(kind="cloud"),
+ )
+
+ proxy = app.state.llm_proxy
+ proxy.update_agent_key = AsyncMock(return_value=True)
+
+ resp = await client.put(
+ "/api/taos-agent/permitted-models",
+ json={"models": ["gpt-4o"]},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["key_rescoped"] is True
+ proxy.update_agent_key.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# PUT /api/taos-agent/persona
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_put_persona_happy_path(client):
+ """Setting a persona returns it back."""
+ resp = await client.put(
+ "/api/taos-agent/persona",
+ json={"persona": "You are a helpful pirate."},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["persona"] == "You are a helpful pirate."
+
+
+@pytest.mark.asyncio
+async def test_put_persona_persists_across_get_config(client, app):
+ """After PUT persona, GET /config reflects the saved persona."""
+ await client.put(
+ "/api/taos-agent/persona",
+ json={"persona": "Be concise."},
+ )
+ resp = await client.get("/api/taos-agent/config")
+ assert resp.status_code == 200
+ assert resp.json()["persona"] == "Be concise."
+
+
+@pytest.mark.asyncio
+async def test_put_persona_empty_string_accepted(client):
+ """An empty persona string is accepted (it clears the override)."""
+ resp = await client.put(
+ "/api/taos-agent/persona",
+ json={"persona": ""},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["persona"] == ""
+
+
+# ---------------------------------------------------------------------------
+# PATCH /api/taos-agent/settings validation
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_patch_settings_missing_model_returns_422(client):
+ """Omitting the required `model` field returns 422."""
+ resp = await client.patch("/api/taos-agent/settings", json={})
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_patch_settings_non_string_model_returns_422(client):
+ """A non-string model value returns 422."""
+ resp = await client.patch(
+ "/api/taos-agent/settings",
+ json={"model": 123},
+ )
+ assert resp.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# POST /api/taos-agent/chat guards
+# ---------------------------------------------------------------------------
+
+@pytest.mark.asyncio
+async def test_chat_no_model_returns_400(client):
+ """POST /chat with no model configured returns 400."""
+ resp = await client.post(
+ "/api/taos-agent/chat",
+ json={"messages": [{"role": "user", "content": "hi"}]},
+ )
+ assert resp.status_code == 400
+ assert "model" in resp.json()["error"].lower()
+
+
+@pytest.mark.asyncio
+async def test_chat_proxy_not_running_returns_503(client, app):
+ """POST /chat when proxy is not running returns 503."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+ app.state.llm_proxy = _fake_proxy(running=False)
+
+ resp = await client.post(
+ "/api/taos-agent/chat",
+ json={"messages": [{"role": "user", "content": "hi"}]},
+ )
+ assert resp.status_code == 503
+ error = resp.json()["error"].lower()
+ assert "proxy" in error or "lite" in error
+
+
+@pytest.mark.asyncio
+async def test_chat_missing_messages_field_returns_422(client):
+ """POST /chat with a body missing `messages` returns 422."""
+ await client.patch("/api/taos-agent/settings", json={"model": "gpt-4o"})
+ resp = await client.post(
+ "/api/taos-agent/chat",
+ json={},
+ )
+ assert resp.status_code == 422
diff --git a/tests/test_torrent_settings.py b/tests/test_torrent_settings.py
new file mode 100644
index 000000000..d8ef06d7b
--- /dev/null
+++ b/tests/test_torrent_settings.py
@@ -0,0 +1,140 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from tinyagentos.torrent_settings import TorrentSettings, TorrentSettingsStore
+
+
+class TestTorrentSettingsDataclass:
+ def test_defaults(self):
+ s = TorrentSettings()
+ assert s.seed_enabled is True
+ assert s.upload_rate_limit_kbps == 5000
+ assert s.max_active_seeds == 20
+
+ def test_custom_values(self):
+ s = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=1000, max_active_seeds=5)
+ assert s.seed_enabled is False
+ assert s.upload_rate_limit_kbps == 1000
+ assert s.max_active_seeds == 5
+
+ def test_to_dict(self):
+ s = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=2048, max_active_seeds=10)
+ d = s.to_dict()
+ assert d == {
+ "seed_enabled": False,
+ "upload_rate_limit_kbps": 2048,
+ "max_active_seeds": 10,
+ }
+
+ def test_to_dict_roundtrip(self):
+ original = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=1234, max_active_seeds=7)
+ restored = TorrentSettings(**original.to_dict())
+ assert restored == original
+
+
+class TestTorrentSettingsStoreLoad:
+ def test_load_missing_file_returns_defaults(self, tmp_path):
+ store = TorrentSettingsStore(tmp_path / "nonexistent" / "settings.json")
+ s = store.load()
+ assert s == TorrentSettings()
+
+ def test_load_returns_defaults_when_file_empty(self, tmp_path):
+ p = tmp_path / "settings.json"
+ p.write_text("")
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s == TorrentSettings()
+
+ def test_load_returns_defaults_when_file_invalid_json(self, tmp_path):
+ p = tmp_path / "settings.json"
+ p.write_text("{not valid json")
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s == TorrentSettings()
+
+ def test_load_discard_unknown_keys(self, tmp_path):
+ p = tmp_path / "settings.json"
+ p.write_text(json.dumps({"seed_enabled": True, "unknown_field": 42}))
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s.seed_enabled is True
+ assert s.upload_rate_limit_kbps == 5000
+ assert s.max_active_seeds == 20
+
+ def test_load_partial_values_use_defaults_for_rest(self, tmp_path):
+ p = tmp_path / "settings.json"
+ p.write_text(json.dumps({"seed_enabled": False}))
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s.seed_enabled is False
+ assert s.upload_rate_limit_kbps == 5000
+ assert s.max_active_seeds == 20
+
+ def test_load_full_values(self, tmp_path):
+ p = tmp_path / "settings.json"
+ payload = {"seed_enabled": False, "upload_rate_limit_kbps": 8000, "max_active_seeds": 50}
+ p.write_text(json.dumps(payload))
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s.seed_enabled is False
+ assert s.upload_rate_limit_kbps == 8000
+ assert s.max_active_seeds == 50
+
+ def test_load_coerces_string_to_bool_and_int(self, tmp_path):
+ p = tmp_path / "settings.json"
+ payload = {"seed_enabled": "yes", "upload_rate_limit_kbps": "3000", "max_active_seeds": "15"}
+ p.write_text(json.dumps(payload))
+ store = TorrentSettingsStore(p)
+ s = store.load()
+ assert s.seed_enabled is True
+ assert s.upload_rate_limit_kbps == 3000
+ assert s.max_active_seeds == 15
+
+
+class TestTorrentSettingsStoreSave:
+ def test_save_creates_parent_directories(self, tmp_path):
+ p = tmp_path / "deep" / "nested" / "settings.json"
+ store = TorrentSettingsStore(p)
+ settings = TorrentSettings(seed_enabled=False)
+ store.save(settings)
+ assert p.exists()
+
+ def test_save_writes_indented_json(self, tmp_path):
+ p = tmp_path / "settings.json"
+ store = TorrentSettingsStore(p)
+ settings = TorrentSettings(upload_rate_limit_kbps=9999)
+ store.save(settings)
+ raw = json.loads(p.read_text())
+ assert raw["upload_rate_limit_kbps"] == 9999
+ assert raw["seed_enabled"] is True
+ assert raw["max_active_seeds"] == 20
+
+ def test_save_then_load_roundtrip(self, tmp_path):
+ p = tmp_path / "settings.json"
+ store = TorrentSettingsStore(p)
+ original = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=3000, max_active_seeds=5)
+ store.save(original)
+ loaded = store.load()
+ assert loaded == original
+
+
+class TestTorrentSettingsStoreSaveThenReloadReturnTrip:
+ def test_separate_instances_share_file(self, tmp_path):
+ p = tmp_path / "settings.json"
+ store_a = TorrentSettingsStore(p)
+ store_b = TorrentSettingsStore(p)
+ settings = TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=7777, max_active_seeds=3)
+ store_a.save(settings)
+ loaded = store_b.load()
+ assert loaded == settings
+
+ def test_overwrite_existing(self, tmp_path):
+ p = tmp_path / "settings.json"
+ store = TorrentSettingsStore(p)
+ store.save(TorrentSettings(seed_enabled=True))
+ store.save(TorrentSettings(seed_enabled=False, upload_rate_limit_kbps=100))
+ loaded = store.load()
+ assert loaded.seed_enabled is False
+ assert loaded.upload_rate_limit_kbps == 100
diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py
index 957c26a8e..1fa92a3d0 100644
--- a/tinyagentos/__init__.py
+++ b/tinyagentos/__init__.py
@@ -1 +1 @@
-__version__ = "1.0.0-beta.5"
+__version__ = "1.0.0-beta.6"
diff --git a/tinyagentos/agent_tools/__init__.py b/tinyagentos/agent_tools/__init__.py
new file mode 100644
index 000000000..d306712d6
--- /dev/null
+++ b/tinyagentos/agent_tools/__init__.py
@@ -0,0 +1 @@
+"""Agent execution primitives (workspace-jailed file tools, etc.)."""
diff --git a/tinyagentos/agent_tools/coding_loop.py b/tinyagentos/agent_tools/coding_loop.py
new file mode 100644
index 000000000..1ac1d4f27
--- /dev/null
+++ b/tinyagentos/agent_tools/coding_loop.py
@@ -0,0 +1,116 @@
+"""The Coding Studio agent tool-calling loop (#86).
+
+This is the loop the coding epic is named for. It sits on top of the dispatch
+step (coding_tools.dispatch) and is deliberately model-agnostic: it drives any
+``model_step`` callable that, given the running transcript, either asks for tool
+calls or returns a final answer. The model glue (Anthropic / litellm / a local
+model) provides ``model_step`` in a later slice; the loop, the tool execution,
+and the iteration guard live here so they are testable without a live model.
+
+Flow per turn:
+ model_step(transcript) -> {"type": "tool_calls", "calls": [...]} or
+ {"type": "final", "text": "..."}
+ each call -> coding_tools.dispatch(workspace_root, name, arguments)
+ -> appended to the transcript as a tool_result
+ repeat until the model returns a final answer or max_iterations is reached.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Awaitable, Callable
+
+from tinyagentos.agent_tools import coding_tools
+
+# A model_step is an async callable: (transcript) -> step dict.
+ModelStep = Callable[[list[dict]], Awaitable[dict]]
+
+
+async def run_tool_loop(
+ workspace_root,
+ model_step: ModelStep,
+ *,
+ initial_transcript: list[dict] | None = None,
+ max_iterations: int = 8,
+) -> dict[str, Any]:
+ """Drive a tool-calling loop to completion against one workspace.
+
+ Returns:
+ {
+ "final": , # the model's final answer, or None if the
+ # iteration guard tripped first
+ "iterations": , # model_step turns taken
+ "stopped": <"final"|"max_iterations">,
+ "transcript": [...], # full message list, including tool results
+ }
+
+ The transcript grows with two message shapes the model glue produces/reads:
+ {"role": "assistant", "tool_calls": [{"id","name","arguments"}, ...]}
+ {"role": "tool", "tool_call_id": , "name": , "result": }
+ A final answer is recorded as {"role": "assistant", "content": }.
+ """
+ transcript: list[dict] = list(initial_transcript or [])
+ iterations = 0
+
+ while iterations < max_iterations:
+ iterations += 1
+ step = await model_step(transcript)
+ # A misbehaving model_step that returns a non-mapping must not crash the
+ # loop (its whole contract is to stay resilient); treat it as a safe stop.
+ kind = step.get("type") if isinstance(step, dict) else None
+
+ if kind == "final":
+ text = step.get("text", "")
+ transcript.append({"role": "assistant", "content": text})
+ return {
+ "final": text,
+ "iterations": iterations,
+ "stopped": "final",
+ "transcript": transcript,
+ }
+
+ if kind == "tool_calls":
+ calls = step.get("calls") or []
+ transcript.append({"role": "assistant", "tool_calls": calls})
+ for call in calls:
+ # A non-dict call entry (bare string, None) must surface as a soft
+ # error, not crash the loop via .get on a non-mapping.
+ if not isinstance(call, dict):
+ transcript.append(
+ {
+ "role": "tool",
+ "tool_call_id": None,
+ "name": None,
+ "result": {"ok": False, "error": "malformed tool call"},
+ }
+ )
+ continue
+ result = coding_tools.dispatch(
+ workspace_root, call.get("name"), call.get("arguments")
+ )
+ transcript.append(
+ {
+ "role": "tool",
+ "tool_call_id": call.get("id"),
+ "name": call.get("name"),
+ "result": result,
+ }
+ )
+ continue
+
+ # An unrecognised step shape is treated as a final, empty answer rather
+ # than looping forever on a misbehaving model_step.
+ transcript.append({"role": "assistant", "content": ""})
+ return {
+ "final": None,
+ "iterations": iterations,
+ "stopped": "final",
+ "transcript": transcript,
+ }
+
+ # Iteration guard tripped: the model never produced a final answer.
+ return {
+ "final": None,
+ "iterations": iterations,
+ "stopped": "max_iterations",
+ "transcript": transcript,
+ }
diff --git a/tinyagentos/agent_tools/coding_model.py b/tinyagentos/agent_tools/coding_model.py
new file mode 100644
index 000000000..4201e8590
--- /dev/null
+++ b/tinyagentos/agent_tools/coding_model.py
@@ -0,0 +1,157 @@
+"""A concrete ``model_step`` for the coding tool-calling loop (#86).
+
+The loop engine (coding_loop.run_tool_loop) is model-agnostic; this module
+supplies the real driver: it converts the loop transcript to OpenAI-style chat
+messages, hands the model the workspace tools, calls the completion, and parses
+the reply back into the loop's step shape ({"type": "tool_calls"|"final"}).
+
+The completion call is injectable (``completion_fn``) so this is testable
+without a live model or a network round trip; the default lazily imports
+litellm so importing this module never requires litellm to be installed.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from tinyagentos.agent_tools.coding_tools import TOOL_SCHEMAS
+
+
+def to_openai_tools(schemas: list[dict[str, Any]] | None = None) -> list[dict]:
+ """Convert the Anthropic-style tool schemas to OpenAI function-tool format."""
+ out = []
+ for s in schemas if schemas is not None else TOOL_SCHEMAS:
+ out.append(
+ {
+ "type": "function",
+ "function": {
+ "name": s["name"],
+ "description": s.get("description", ""),
+ "parameters": s.get("input_schema", {"type": "object", "properties": {}}),
+ },
+ }
+ )
+ return out
+
+
+def transcript_to_messages(transcript: list[dict], system: str | None = None) -> list[dict]:
+ """Render the loop transcript as OpenAI chat messages.
+
+ Maps the loop's internal message shapes:
+ {"role": "user"|"assistant", "content": ...} -> passthrough
+ {"role": "assistant", "tool_calls": [{id,name,arguments}]} -> assistant w/ tool_calls
+ {"role": "tool", "tool_call_id", "name", "result": {...}} -> tool message (JSON result)
+ """
+ messages: list[dict] = []
+ if system:
+ messages.append({"role": "system", "content": system})
+ for m in transcript:
+ role = m.get("role")
+ if role == "assistant" and "tool_calls" in m:
+ messages.append(
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": c.get("id"),
+ "type": "function",
+ "function": {
+ "name": c.get("name"),
+ "arguments": json.dumps(c.get("arguments") or {}),
+ },
+ }
+ for c in m["tool_calls"]
+ ],
+ }
+ )
+ elif role == "tool":
+ messages.append(
+ {
+ "role": "tool",
+ "tool_call_id": m.get("tool_call_id"),
+ "name": m.get("name"),
+ "content": json.dumps(m.get("result")),
+ }
+ )
+ else:
+ messages.append({"role": role, "content": m.get("content", "")})
+ return messages
+
+
+def _parse_arguments(raw) -> dict:
+ """Tool-call arguments arrive as a JSON string from the model; tolerate dicts."""
+ if isinstance(raw, dict):
+ return raw
+ if not raw:
+ return {}
+ try:
+ parsed = json.loads(raw)
+ return parsed if isinstance(parsed, dict) else {}
+ except (json.JSONDecodeError, TypeError):
+ return {}
+
+
+def _message_field(message, key):
+ """Read a field off either an object-style or dict-style completion message."""
+ if isinstance(message, dict):
+ return message.get(key)
+ return getattr(message, key, None)
+
+
+def parse_completion(response) -> dict:
+ """Turn a chat-completion response into the loop's step shape.
+
+ A content-filtered or error-shaped response can come back with no choices or
+ no message; fall back to an empty final answer rather than raising, so the
+ loop's never-raise contract holds.
+ """
+ choices = (response.get("choices") if isinstance(response, dict) else getattr(response, "choices", None)) or []
+ if not choices:
+ return {"type": "final", "text": ""}
+ first = choices[0]
+ message = (first.get("message") if isinstance(first, dict) else getattr(first, "message", None))
+ if message is None:
+ return {"type": "final", "text": ""}
+ tool_calls = _message_field(message, "tool_calls")
+ if tool_calls:
+ calls = []
+ for tc in tool_calls:
+ fn = tc["function"] if isinstance(tc, dict) else tc.function
+ calls.append(
+ {
+ "id": (tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)),
+ "name": (fn["name"] if isinstance(fn, dict) else fn.name),
+ "arguments": _parse_arguments(
+ fn["arguments"] if isinstance(fn, dict) else fn.arguments
+ ),
+ }
+ )
+ return {"type": "tool_calls", "calls": calls}
+ return {"type": "final", "text": _message_field(message, "content") or ""}
+
+
+async def _default_completion(model: str, messages: list[dict], tools: list[dict]):
+ # Lazy import so this module never hard-requires litellm at import time.
+ import litellm
+
+ return await litellm.acompletion(model=model, messages=messages, tools=tools)
+
+
+def make_litellm_model_step(model: str, *, system: str | None = None, completion_fn=None):
+ """Build a model_step for run_tool_loop backed by a chat-completions model.
+
+ completion_fn(model, messages, tools) -> response is injectable (defaults to
+ litellm.acompletion). The returned async callable takes the loop transcript
+ and returns the next step.
+ """
+ call = completion_fn or _default_completion
+ tools = to_openai_tools()
+
+ async def model_step(transcript: list[dict]) -> dict:
+ messages = transcript_to_messages(transcript, system=system)
+ response = await call(model, messages, tools)
+ return parse_completion(response)
+
+ return model_step
diff --git a/tinyagentos/agent_tools/coding_tools.py b/tinyagentos/agent_tools/coding_tools.py
new file mode 100644
index 000000000..7d0667e14
--- /dev/null
+++ b/tinyagentos/agent_tools/coding_tools.py
@@ -0,0 +1,137 @@
+"""Tool dispatch for the Coding Studio agent loop (#86).
+
+This is the substrate the tool-calling loop sits on: a registry of the
+workspace file primitives (read/write/exists/list) described as JSON tool
+schemas an LLM can be handed, plus a single ``dispatch`` step that validates a
+proposed tool call and executes it against a jailed workspace root.
+
+Keeping execution here (rather than in the model glue) means the loop is the
+same whether driven by a real model, a test, or a replayed transcript: propose
+a call -> dispatch -> feed the result back.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from tinyagentos.agent_tools import fs_tools
+from tinyagentos.agent_tools.fs_tools import JailViolation
+
+# Cap how much a single read pulls into memory, matching the HTTP read route's
+# guard so the agent loop cannot fault the controller by reading a huge file.
+MAX_READ_BYTES = 2_000_000
+
+
+class _ReadTooLarge(ValueError):
+ """A read_file target exceeds MAX_READ_BYTES."""
+
+# Anthropic/OpenAI-style function schemas. Handed to the model so it knows the
+# available tools and their arguments; the names map 1:1 to _HANDLERS below.
+TOOL_SCHEMAS: list[dict[str, Any]] = [
+ {
+ "name": "read_file",
+ "description": "Read a UTF-8 text file inside the workspace.",
+ "input_schema": {
+ "type": "object",
+ "properties": {"path": {"type": "string", "description": "Workspace-relative path."}},
+ "required": ["path"],
+ },
+ },
+ {
+ "name": "write_file",
+ "description": "Create or overwrite a UTF-8 text file inside the workspace.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string", "description": "Workspace-relative path."},
+ "content": {"type": "string", "description": "Full file contents to write."},
+ },
+ "required": ["path", "content"],
+ },
+ },
+ {
+ "name": "file_exists",
+ "description": "Check whether a workspace-relative path is an existing file.",
+ "input_schema": {
+ "type": "object",
+ "properties": {"path": {"type": "string"}},
+ "required": ["path"],
+ },
+ },
+ {
+ "name": "list_dir",
+ "description": "List the entries of a workspace directory (defaults to the root).",
+ "input_schema": {
+ "type": "object",
+ "properties": {"path": {"type": "string", "description": "Workspace-relative dir; '.' for root."}},
+ "required": [],
+ },
+ },
+]
+
+TOOL_NAMES = {t["name"] for t in TOOL_SCHEMAS}
+
+
+def _h_read_file(root, args):
+ # Resolve through the same jail fs_tools uses, then size-check before reading.
+ target = fs_tools._resolve(root, args["path"])
+ if target.stat().st_size > MAX_READ_BYTES:
+ raise _ReadTooLarge(f"file exceeds {MAX_READ_BYTES} byte read limit")
+ return target.read_text()
+
+
+def _h_write_file(root, args):
+ return fs_tools.write_file(root, args["path"], args["content"])
+
+
+def _h_file_exists(root, args):
+ return fs_tools.file_exists(root, args["path"])
+
+
+def _h_list_dir(root, args):
+ return fs_tools.list_dir(root, args.get("path", "."))
+
+
+_HANDLERS = {
+ "read_file": (_h_read_file, ("path",)),
+ "write_file": (_h_write_file, ("path", "content")),
+ "file_exists": (_h_file_exists, ("path",)),
+ "list_dir": (_h_list_dir, ()),
+}
+
+
+def dispatch(workspace_root, name: str, arguments: dict | None) -> dict:
+ """Execute one tool call against the workspace.
+
+ Returns a structured, always-JSON-serialisable result:
+ {"ok": True, "result": } on success
+ {"ok": False, "error": } on an unknown tool, missing argument,
+ jail violation, or filesystem error.
+
+ Never raises: the loop feeds the error string back to the model so it can
+ recover, rather than crashing the turn.
+ """
+ handler = _HANDLERS.get(name)
+ if handler is None:
+ return {"ok": False, "error": f"unknown tool: {name!r}"}
+ fn, required = handler
+ args = arguments or {}
+ if not isinstance(args, dict):
+ return {"ok": False, "error": "arguments must be an object"}
+ missing = [k for k in required if k not in args]
+ if missing:
+ return {"ok": False, "error": f"missing argument(s): {', '.join(missing)}"}
+ try:
+ return {"ok": True, "result": fn(workspace_root, args)}
+ except JailViolation as exc:
+ return {"ok": False, "error": str(exc)}
+ except _ReadTooLarge as exc:
+ return {"ok": False, "error": str(exc)}
+ except UnicodeDecodeError:
+ # read_file decodes as UTF-8; a binary/non-UTF-8 file must come back as a
+ # soft error, not crash the loop turn.
+ return {"ok": False, "error": "binary or non-UTF-8 file"}
+ except FileNotFoundError:
+ return {"ok": False, "error": "file not found"}
+ except OSError as exc:
+ return {"ok": False, "error": f"filesystem error: {exc.strerror or exc}"}
diff --git a/tinyagentos/agent_tools/fs_tools.py b/tinyagentos/agent_tools/fs_tools.py
new file mode 100644
index 000000000..ba8926133
--- /dev/null
+++ b/tinyagentos/agent_tools/fs_tools.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from tinyagentos.routes.coding import _resolve_jailed
+
+
+class JailViolation(ValueError):
+ """Raised when a path escapes the workspace, targets .git, or is otherwise refused.
+
+ These are the file primitives the Coding Studio agent calls for live edits.
+ Every path is resolved through the same workspace jail the coding routes use
+ (_resolve_jailed), so an escape via ``..``, an absolute path, a symlink that
+ resolves outside the workspace, or anything under ``.git`` is refused.
+ """
+
+
+def _resolve(workspace_root, rel_path: str, *, allow_root: bool = False) -> Path:
+ root = Path(workspace_root).resolve()
+ target = _resolve_jailed(root, rel_path, allow_root=allow_root)
+ if target is None:
+ raise JailViolation(f"path refused (escapes workspace or targets .git): {rel_path!r}")
+ return target
+
+
+def read_file(workspace_root, rel_path: str) -> str:
+ return _resolve(workspace_root, rel_path).read_text()
+
+
+def write_file(workspace_root, rel_path: str, content: str) -> int:
+ target = _resolve(workspace_root, rel_path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ return target.write_text(content)
+
+
+def file_exists(workspace_root, rel_path: str) -> bool:
+ try:
+ return _resolve(workspace_root, rel_path).is_file()
+ except JailViolation:
+ return False
+
+
+def list_dir(workspace_root, rel_path: str = ".") -> list[str]:
+ target = _resolve(workspace_root, rel_path, allow_root=True)
+ if not target.is_dir():
+ raise JailViolation(f"not a directory: {rel_path!r}")
+ return sorted(p.name for p in target.iterdir())
diff --git a/tinyagentos/app.py b/tinyagentos/app.py
index 6158f63b7..8560803e5 100644
--- a/tinyagentos/app.py
+++ b/tinyagentos/app.py
@@ -258,6 +258,8 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) ->
agent_grants_store = AgentGrantsStore(data_dir / "agent_grants.db")
from tinyagentos.cluster.pairing_store import ClusterPairingStore
cluster_pairing_store = ClusterPairingStore(data_dir / "cluster_pairing.db")
+ from tinyagentos.cluster.capability_map import CapabilityMap
+ capability_map_store = CapabilityMap(data_dir / "capability_map.db")
metrics_store = MetricsStore(data_dir / "metrics.db")
notif_store = NotificationStore(data_dir / "notifications.db")
@@ -336,7 +338,11 @@ async def _probe_backend(backend: dict) -> dict:
project_event_broker = ProjectEventBroker()
from tinyagentos.desktop_control import DesktopCommandBroker
desktop_command_broker = DesktopCommandBroker()
- project_task_store = ProjectTaskStore(data_dir / "projects.db", broker=project_event_broker)
+ from tinyagentos.board_audit import BoardAuditLog
+ board_audit_store = BoardAuditLog(data_dir / "board_audit.db")
+ project_task_store = ProjectTaskStore(
+ data_dir / "projects.db", broker=project_event_broker, audit=board_audit_store
+ )
project_canvas_store = ProjectCanvasStoreImpl(data_dir / "projects.db", broker=project_event_broker)
projects_root = data_dir / "projects"
chat_hub = ChatHub()
@@ -411,6 +417,8 @@ async def lifespan(app: FastAPI):
app.state.agent_grants = agent_grants_store
await cluster_pairing_store.init()
app.state.cluster_pairing = cluster_pairing_store
+ await capability_map_store.init()
+ app.state.capability_map = capability_map_store
await metrics_store.init()
await notif_store.init()
await qmd_client.init()
@@ -430,6 +438,8 @@ async def lifespan(app: FastAPI):
await chat_messages.init()
await chat_channels.init()
await project_store.init()
+ await board_audit_store.init()
+ app.state.board_audit = board_audit_store
await project_task_store.init()
await project_canvas_store.init()
projects_root.mkdir(parents=True, exist_ok=True)
@@ -1315,6 +1325,7 @@ async def dispatch(self, request, call_next):
app.state.chat_messages = chat_messages
app.state.chat_channels = chat_channels
app.state.project_store = project_store
+ app.state.board_audit = board_audit_store
app.state.project_task_store = project_task_store
app.state.project_event_broker = project_event_broker
app.state.desktop_command_broker = desktop_command_broker
@@ -1370,6 +1381,7 @@ async def dispatch(self, request, call_next):
app.state.auth_requests = auth_requests_store
app.state.agent_grants = agent_grants_store
app.state.cluster_pairing = cluster_pairing_store
+ app.state.capability_map = capability_map_store
# Detect and set container runtime (eager, so tests work without lifespan)
try:
@@ -1401,6 +1413,15 @@ async def dispatch(self, request, call_next):
def main():
+ import sys
+ # `taos rollback [ref]` -- undo the last update (restore branch + version) and
+ # restart. Delegates to the pure-shell recovery script so it behaves the same
+ # whether the app is healthy or a bad update left it broken.
+ if len(sys.argv) > 1 and sys.argv[1] == "rollback":
+ import subprocess
+ script = PROJECT_DIR / "scripts" / "rollback.sh"
+ raise SystemExit(subprocess.call(["bash", str(script), *sys.argv[2:]]))
+
import uvicorn
config = load_config(PROJECT_DIR / "data" / "config.yaml")
app = create_app()
diff --git a/tinyagentos/board_audit.py b/tinyagentos/board_audit.py
new file mode 100644
index 000000000..35b6b4f27
--- /dev/null
+++ b/tinyagentos/board_audit.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+import datetime
+import json
+import secrets
+
+from tinyagentos.base_store import BaseStore
+
+_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
+
+BOARD_AUDIT_SCHEMA = """
+CREATE TABLE IF NOT EXISTS board_audit (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL,
+ project_id TEXT NOT NULL DEFAULT '',
+ event TEXT NOT NULL,
+ actor TEXT NOT NULL DEFAULT '',
+ from_status TEXT,
+ to_status TEXT,
+ detail TEXT NOT NULL DEFAULT '{}',
+ ts TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_board_audit_task ON board_audit(task_id);
+CREATE INDEX IF NOT EXISTS idx_board_audit_ts ON board_audit(ts);
+"""
+# The project_id index is created in _post_init (not SCHEMA): on an existing
+# board_audit table that predates the column, running it here would fail before
+# the guarded ALTER had a chance to add the column.
+
+_COLS = "id, task_id, project_id, event, actor, from_status, to_status, detail, ts"
+
+
+def _now_iso() -> str:
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+
+def _new_id() -> str:
+ return "ba-" + "".join(secrets.choice(_ALPHABET) for _ in range(8))
+
+
+def _row(r) -> dict:
+ return {
+ "id": r[0], "task_id": r[1], "project_id": r[2], "event": r[3], "actor": r[4],
+ "from_status": r[5], "to_status": r[6], "detail": json.loads(r[7] or "{}"), "ts": r[8],
+ }
+
+
+class BoardAuditLog(BaseStore):
+ """Append-only audit trail of board task state changes (#105).
+
+ The seed of the nothing-is-ever-deleted / Time Machine story: every status
+ change is recorded and never updated or deleted. There is deliberately no
+ public mutate or delete method. History is returned in insertion order
+ (SQLite rowid) so it is stable even when two events share a timestamp.
+
+ Each row carries the owning project_id (so a project-scoped activity feed
+ never leaks another project's events) and a free-form JSON ``detail`` blob
+ for event-specific context that does not fit the status columns.
+ """
+
+ SCHEMA = BOARD_AUDIT_SCHEMA
+
+ async def _post_init(self) -> None:
+ # project_id + detail were added after the initial board_audit ship.
+ # Guarded ALTER so existing databases gain them without a destructive
+ # migration (SQLite lacks ADD COLUMN IF NOT EXISTS before 3.37).
+ cols = {row[1] for row in await (await self._db.execute("PRAGMA table_info(board_audit)")).fetchall()}
+ if "project_id" not in cols:
+ await self._db.execute(
+ "ALTER TABLE board_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''"
+ )
+ if "detail" not in cols:
+ await self._db.execute(
+ "ALTER TABLE board_audit ADD COLUMN detail TEXT NOT NULL DEFAULT '{}'"
+ )
+ # The column is guaranteed present now (fresh via SCHEMA or just ALTERed),
+ # so the project_id index can be created idempotently for both paths.
+ await self._db.execute(
+ "CREATE INDEX IF NOT EXISTS idx_board_audit_project ON board_audit(project_id)"
+ )
+ await self._db.commit()
+
+ async def record(
+ self,
+ task_id: str,
+ event: str,
+ actor: str = "",
+ from_status: str | None = None,
+ to_status: str | None = None,
+ ts: str | None = None,
+ project_id: str = "",
+ detail: dict | None = None,
+ ) -> str:
+ if not task_id:
+ raise ValueError("task_id is required")
+ if not event:
+ raise ValueError("event is required")
+ eid = _new_id()
+ when = ts or _now_iso()
+ await self._db.execute(
+ f"INSERT INTO board_audit ({_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (eid, task_id, project_id, event, actor, from_status, to_status,
+ json.dumps(detail or {}), when),
+ )
+ await self._db.commit()
+ return eid
+
+ async def history(self, task_id: str) -> list[dict]:
+ async with self._db.execute(
+ f"SELECT {_COLS} FROM board_audit WHERE task_id = ? ORDER BY rowid ASC",
+ (task_id,),
+ ) as cur:
+ rows = await cur.fetchall()
+ return [_row(r) for r in rows]
+
+ async def recent_for_project(self, project_id: str, limit: int = 100) -> list[dict]:
+ """Newest-first activity feed for one project (capped)."""
+ async with self._db.execute(
+ f"SELECT {_COLS} FROM board_audit WHERE project_id = ? ORDER BY rowid DESC LIMIT ?",
+ (project_id, limit),
+ ) as cur:
+ rows = await cur.fetchall()
+ return [_row(r) for r in rows]
+
+ async def all_since(self, ts: str) -> list[dict]:
+ async with self._db.execute(
+ f"SELECT {_COLS} FROM board_audit WHERE ts >= ? ORDER BY rowid ASC", (ts,)
+ ) as cur:
+ rows = await cur.fetchall()
+ return [_row(r) for r in rows]
+
+ async def get(self, event_id: str) -> dict | None:
+ async with self._db.execute(
+ f"SELECT {_COLS} FROM board_audit WHERE id = ?", (event_id,)
+ ) as cur:
+ r = await cur.fetchone()
+ return _row(r) if r else None
diff --git a/tinyagentos/cluster/capability_map.py b/tinyagentos/cluster/capability_map.py
new file mode 100644
index 000000000..ac2a77ac3
--- /dev/null
+++ b/tinyagentos/cluster/capability_map.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import json
+import time
+
+from tinyagentos.base_store import BaseStore
+
+CAPABILITY_MAP_SCHEMA = """
+CREATE TABLE IF NOT EXISTS capability_map (
+ node_id TEXT PRIMARY KEY,
+ hostname TEXT NOT NULL DEFAULT '',
+ cpu TEXT NOT NULL DEFAULT '{}',
+ ram_mb INTEGER NOT NULL DEFAULT 0,
+ gpu TEXT NOT NULL DEFAULT '{}',
+ npu TEXT NOT NULL DEFAULT '{}',
+ status TEXT NOT NULL DEFAULT 'offline',
+ last_seen INTEGER NOT NULL DEFAULT 0
+);
+"""
+
+VALID_STATUS = {"online", "offline", "draining"}
+_COLS = "node_id, hostname, cpu, ram_mb, gpu, npu, status, last_seen"
+
+
+def _row(r) -> dict:
+ return {
+ "node_id": r[0],
+ "hostname": r[1],
+ "cpu": json.loads(r[2] or "{}"),
+ "ram_mb": r[3],
+ "gpu": json.loads(r[4] or "{}"),
+ "npu": json.loads(r[5] or "{}"),
+ "status": r[6],
+ "last_seen": r[7],
+ }
+
+
+class CapabilityMap(BaseStore):
+ """Per-node hardware capability map for the cluster.
+
+ Records each node's CPU/GPU/NPU/RAM plus a live status (online/offline/
+ draining) and last_seen heartbeat. The foundation the scheduler and
+ placement logic read from. CPU/GPU/NPU are stored as JSON columns so the
+ shapes match HardwareProfile without a rigid schema.
+ """
+
+ SCHEMA = CAPABILITY_MAP_SCHEMA
+
+ async def upsert(self, node: dict) -> dict:
+ node_id = node.get("node_id")
+ if not node_id:
+ raise ValueError("node requires a non-empty node_id")
+ status = node.get("status", "offline")
+ if status not in VALID_STATUS:
+ raise ValueError(f"invalid status: {status!r}")
+ ls = node.get("last_seen")
+ last_seen = int(ls) if ls is not None else int(time.time())
+ await self._db.execute(
+ f"""INSERT INTO capability_map ({_COLS})
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(node_id) DO UPDATE SET
+ hostname=excluded.hostname, cpu=excluded.cpu,
+ ram_mb=excluded.ram_mb, gpu=excluded.gpu, npu=excluded.npu,
+ status=excluded.status, last_seen=excluded.last_seen""",
+ (
+ node_id, node.get("hostname", ""), json.dumps(node.get("cpu", {})),
+ int(node.get("ram_mb", 0)), json.dumps(node.get("gpu", {})),
+ json.dumps(node.get("npu", {})), status, last_seen,
+ ),
+ )
+ await self._db.commit()
+ return await self.get(node_id)
+
+ async def get(self, node_id: str) -> dict | None:
+ async with self._db.execute(
+ f"SELECT {_COLS} FROM capability_map WHERE node_id = ?", (node_id,)
+ ) as cur:
+ r = await cur.fetchone()
+ return _row(r) if r else None
+
+ async def list(self, status: str | None = None) -> list[dict]:
+ sql = f"SELECT {_COLS} FROM capability_map"
+ params: list = []
+ if status is not None:
+ sql += " WHERE status = ?"
+ params.append(status)
+ sql += " ORDER BY node_id ASC"
+ async with self._db.execute(sql, params) as cur:
+ rows = await cur.fetchall()
+ return [_row(r) for r in rows]
+
+ async def set_status(self, node_id: str, status: str) -> dict | None:
+ if status not in VALID_STATUS:
+ raise ValueError(f"invalid status: {status!r}")
+ if await self.get(node_id) is None:
+ return None
+ await self._db.execute(
+ "UPDATE capability_map SET status = ? WHERE node_id = ?", (status, node_id)
+ )
+ await self._db.commit()
+ return await self.get(node_id)
+
+ async def prune_stale(self, older_than_s: int) -> int:
+ cutoff = int(time.time()) - older_than_s
+ async with self._db.execute(
+ "DELETE FROM capability_map WHERE last_seen < ?", (cutoff,)
+ ) as cur:
+ count = cur.rowcount
+ await self._db.commit()
+ return count
+
+ async def mark_stale_offline(self, older_than_s: int) -> int:
+ """Mark nodes that stopped heartbeating as offline (row preserved).
+
+ The non-destructive counterpart to prune_stale: an 'online' node whose
+ last_seen is older than the cutoff is flipped to 'offline' so the
+ scheduler stops placing work on it, but the row (and its hardware) stays
+ for history and for when it comes back. 'draining' is an admin intent
+ and is left untouched; only 'online' nodes go stale-offline. Returns the
+ number of nodes flipped.
+ """
+ cutoff = int(time.time()) - older_than_s
+ async with self._db.execute(
+ "UPDATE capability_map SET status = 'offline' "
+ "WHERE status = 'online' AND last_seen < ?",
+ (cutoff,),
+ ) as cur:
+ count = cur.rowcount
+ await self._db.commit()
+ return count
diff --git a/tinyagentos/projects/task_store.py b/tinyagentos/projects/task_store.py
index 1b159714d..e7f7ac0f3 100644
--- a/tinyagentos/projects/task_store.py
+++ b/tinyagentos/projects/task_store.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
+import logging
import time
from typing import TYPE_CHECKING
@@ -9,8 +10,11 @@
from tinyagentos.projects.ids import new_id
if TYPE_CHECKING:
+ from tinyagentos.board_audit import BoardAuditLog
from tinyagentos.projects.events import ProjectEventBroker
+logger = logging.getLogger(__name__)
+
TASK_SCHEMA = """
CREATE TABLE IF NOT EXISTS project_tasks (
id TEXT PRIMARY KEY,
@@ -86,15 +90,51 @@ def _row_to_task(row, description) -> dict:
class ProjectTaskStore(BaseStore):
SCHEMA = TASK_SCHEMA
- def __init__(self, db_path, *, broker: "ProjectEventBroker | None" = None) -> None:
+ def __init__(
+ self,
+ db_path,
+ *,
+ broker: "ProjectEventBroker | None" = None,
+ audit: "BoardAuditLog | None" = None,
+ ) -> None:
super().__init__(db_path)
self._broker = broker
+ self._audit = audit
async def _publish(self, project_id: str, kind: str, payload: dict) -> None:
if self._broker is not None:
from tinyagentos.projects.events import ProjectEvent
await self._broker.publish(project_id, ProjectEvent(kind=kind, payload=payload))
+ async def _record_audit(
+ self,
+ task_id: str,
+ event: str,
+ actor: str,
+ from_status: str | None,
+ to_status: str | None,
+ project_id: str = "",
+ ) -> None:
+ """Append a status transition to the board audit log (best effort).
+
+ The audit log lives in its own store; a failure to record must never
+ roll back or break the task mutation that already committed. project_id
+ is recorded so the project-scoped activity feed never crosses projects.
+ """
+ if self._audit is None:
+ return
+ try:
+ await self._audit.record(
+ task_id=task_id,
+ event=event,
+ actor=actor,
+ from_status=from_status,
+ to_status=to_status,
+ project_id=project_id,
+ )
+ except Exception:
+ logger.warning("board audit record failed for task %s", task_id, exc_info=True)
+
async def create_task(
self,
project_id: str,
@@ -121,6 +161,7 @@ async def create_task(
await self._db.commit()
new_task = await self.get_task(tid)
await self._publish(project_id, "task.created", {"id": new_task["id"], "task": new_task})
+ await self._record_audit(tid, "task.created", created_by, None, "open", project_id=project_id)
return new_task
async def get_task(self, task_id: str) -> dict | None:
@@ -204,6 +245,10 @@ async def claim_task(self, task_id: str, claimer_id: str) -> bool:
existing = await self.get_task(task_id)
if existing is not None:
await self._publish(existing["project_id"], "task.claimed", {"id": task_id, "claimed_by": claimer_id})
+ await self._record_audit(
+ task_id, "task.claimed", claimer_id, "open", "claimed",
+ project_id=existing["project_id"] if existing else "",
+ )
return changed
async def release_task(self, task_id: str, releaser_id: str) -> bool:
@@ -224,6 +269,10 @@ async def release_task(self, task_id: str, releaser_id: str) -> bool:
"task.released",
{"id": task_id, "releaser_id": releaser_id},
)
+ await self._record_audit(
+ task_id, "task.released", releaser_id, "claimed", "open",
+ project_id=existing["project_id"] if existing else "",
+ )
return changed
async def close_task(
@@ -245,6 +294,14 @@ async def close_task(
existing = await self.get_task(task_id)
if existing is not None:
await self._publish(existing["project_id"], "task.closed", {"id": task_id, "closed_by": closed_by})
+ # Derive the pre-close status race-free from the committed row rather
+ # than a separate pre-read (which would have a TOCTOU gap). close does
+ # not clear claimed_by, so a set claimer means it was 'claimed'.
+ from_status = "claimed" if existing and existing.get("claimed_by") else "open"
+ await self._record_audit(
+ task_id, "task.closed", closed_by, from_status, "closed",
+ project_id=existing["project_id"] if existing else "",
+ )
return changed
async def reopen_task(self, task_id: str, reopened_by: str) -> bool:
@@ -265,6 +322,10 @@ async def reopen_task(self, task_id: str, reopened_by: str) -> bool:
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})
+ await self._record_audit(
+ task_id, "task.reopened", reopened_by, "closed", "open",
+ project_id=existing["project_id"] if existing else "",
+ )
return changed
async def add_relationship(
diff --git a/tinyagentos/rollback.py b/tinyagentos/rollback.py
new file mode 100644
index 000000000..899fdbaff
--- /dev/null
+++ b/tinyagentos/rollback.py
@@ -0,0 +1,63 @@
+"""Update rollback state for taOS.
+
+Before every update, the updater records the exact branch + commit it is leaving
+so a later ``taos rollback`` can restore BOTH (the previous version and the
+previous branch, even if both changed). The record is written as a tiny
+shell-sourceable file so ``scripts/rollback.sh`` can read it with no Python and
+no dashboard, which is the whole point: rollback must work when an update has
+broken the app.
+
+File: ``/.taos-rollback`` (single record, overwritten each update).
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+ROLLBACK_FILE = ".taos-rollback"
+
+
+def _shq(value: str) -> str:
+ """Single-quote a value so the file stays safe to `source` in bash."""
+ return "'" + str(value).replace("'", "'\\''") + "'"
+
+
+def record_pre_update(project_dir, *, branch: str, sha: str, ts: int) -> Path:
+ """Write the pre-update branch + commit so a rollback can restore both.
+
+ Overwrites any prior record: rollback targets the state immediately before
+ the most recent update, which is the one a user would want to undo.
+ """
+ path = Path(project_dir) / ROLLBACK_FILE
+ path.write_text(
+ "# taOS rollback target -- the branch + commit the last update left.\n"
+ "# Shell-sourceable on purpose so scripts/rollback.sh needs no Python.\n"
+ f"prev_branch={_shq(branch)}\n"
+ f"prev_sha={_shq(sha)}\n"
+ f"prev_ts={_shq(ts)}\n"
+ )
+ return path
+
+
+def read_rollback_target(project_dir) -> dict | None:
+ """Read the recorded rollback target, or None if there is no record.
+
+ Returns ``{"branch": str, "sha": str, "ts": str}``. Parses the simple
+ ``key='value'`` lines without sourcing (so it is safe to call on any input).
+ """
+ path = Path(project_dir) / ROLLBACK_FILE
+ if not path.is_file():
+ return None
+ out: dict[str, str] = {}
+ for line in path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, raw = line.partition("=")
+ val = raw.strip()
+ if len(val) >= 2 and val[0] == val[-1] == "'":
+ val = val[1:-1].replace("'\\''", "'")
+ out[key.strip()] = val
+ if "prev_branch" not in out or "prev_sha" not in out:
+ return None
+ return {"branch": out["prev_branch"], "sha": out["prev_sha"], "ts": out.get("prev_ts", "")}
diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py
index 4257a133c..baa8ddfd9 100644
--- a/tinyagentos/routes/__init__.py
+++ b/tinyagentos/routes/__init__.py
@@ -111,6 +111,9 @@ def register_all_routers(app):
from tinyagentos.routes.cluster_migrate import router as cluster_migrate_router
app.include_router(cluster_migrate_router)
+ from tinyagentos.routes.cluster_capability import router as cluster_capability_router
+ app.include_router(cluster_capability_router)
+
from tinyagentos.routes.training import router as training_router
app.include_router(training_router)
diff --git a/tinyagentos/routes/apps.py b/tinyagentos/routes/apps.py
index 61a92280b..ef53498c8 100644
--- a/tinyagentos/routes/apps.py
+++ b/tinyagentos/routes/apps.py
@@ -26,10 +26,13 @@
# runtime location). The frontend owns name/icon/cover; the backend only tracks
# which ids are installed, gated to this allowlist so the endpoint can't be used
# to write arbitrary install rows.
+# The platform social apps (reddit, youtube-library, github-browser, x-monitor)
+# were DE-SEEDED from the default store: they are unfinished and now live as the
+# operator's private App Studio drafts to be finished + published on stream, not
+# offered to every user. The Creative Studios remain installable optional apps.
OPTIONAL_FRONTEND_APPS = {
- "reddit", "youtube-library", "github-browser", "x-monitor",
- # Creative Studios install the same way: a frontend-only optional app whose
- # install row just flips the launcher visibility, no service spawned.
+ # Creative Studios: a frontend-only optional app whose install row just
+ # flips the launcher visibility, no service spawned.
"coding-studio", "design-studio", "music-studio", "app-studio", "office-suite",
}
_FRONTEND_APP_KIND = "frontend-app"
@@ -37,10 +40,6 @@
# In-core version for each optional app. When an app becomes a real .taosapp
# package, the package version will win instead of this value.
APP_VERSIONS: dict[str, str] = {
- "reddit": "1.0.0",
- "youtube-library": "1.0.0",
- "github-browser": "1.0.0",
- "x-monitor": "1.0.0",
"coding-studio": "1.0.0",
"design-studio": "1.0.0",
"music-studio": "1.0.0",
@@ -50,10 +49,6 @@
# Trust level for each optional app (all current optional apps are first-party).
APP_TRUST: dict[str, str] = {
- "reddit": "first-party",
- "youtube-library": "first-party",
- "github-browser": "first-party",
- "x-monitor": "first-party",
"coding-studio": "first-party",
"design-studio": "first-party",
"music-studio": "first-party",
diff --git a/tinyagentos/routes/browser_sessions.py b/tinyagentos/routes/browser_sessions.py
index 8fde6b4b3..c30649aa8 100644
--- a/tinyagentos/routes/browser_sessions.py
+++ b/tinyagentos/routes/browser_sessions.py
@@ -116,33 +116,47 @@ async def create_session(
return JSONResponse({"error": "session has no user id"}, status_code=401)
cluster = request.app.state.cluster_manager
- if body.node is not None:
- capable_names = {n["name"] for n in list_browser_nodes(cluster)}
- if body.node not in capable_names:
- return JSONResponse({"error": "no_capable_node"}, status_code=409)
- node = body.node
- else:
- node = pick_browser_node(cluster)
- if node is None:
- return JSONResponse({"error": "no_capable_node"}, status_code=409)
-
- worker = cluster.get_worker(node)
- if worker is None:
+ # Placement: explicit worker -> host (if RAM-capable) -> best worker. The
+ # host branch is what lets a capable controller (e.g. a 16GB Pi) serve a
+ # streamed session itself; the previous worker-only path returned
+ # no_capable_node on a host with no Tier-2 browser workers.
+ host_hw = getattr(request.app.state, "host_hardware", None)
+ target = resolve_browser_target(cluster, host_hw, explicit_node=body.node)
+ if target is None:
return JSONResponse({"error": "no_capable_node"}, status_code=409)
+ kind, node = target
+
+ # Resolve the worker BEFORE creating the session row, so a failed lookup
+ # returns 409 without leaving an orphaned session record in the store.
+ worker = None
+ if kind != "host":
+ worker = cluster.get_worker(node)
+ if worker is None:
+ return JSONResponse({"error": "no_capable_node"}, status_code=409)
mgr = request.app.state.browser_sessions
session = await mgr.create_session(
"user", user_id, body.url, body.profile or "default"
)
+ vol = f"taos-browser-{session['id']}"
auth_token = getattr(request.app.state, "browser_worker_auth_token", None)
try:
- session = await mgr.start_on_worker(
- session["id"],
- node=node,
- worker_url=worker.url,
- profile_volume=f"taos-browser-{session['id']}",
- auth_token=auth_token,
- )
+ if kind == "host":
+ runner = request.app.state.browser_container_runner
+ # _connecting_host_ip does a blocking DNS lookup; offload it so the
+ # event loop is not stalled while the host is resolved.
+ nat1to1_ip = await asyncio.to_thread(_connecting_host_ip, request)
+ session = await mgr.start_on_host(
+ session["id"], profile_volume=vol, runner=runner, nat1to1_ip=nat1to1_ip
+ )
+ else:
+ session = await mgr.start_on_worker(
+ session["id"],
+ node=node,
+ worker_url=worker.url,
+ profile_volume=vol,
+ auth_token=auth_token,
+ )
except BrowserWorkerError:
return JSONResponse({"error": "worker_start_failed"}, status_code=502)
return JSONResponse(session, status_code=201)
diff --git a/tinyagentos/routes/cluster.py b/tinyagentos/routes/cluster.py
index 315babfa1..3b31f0700 100644
--- a/tinyagentos/routes/cluster.py
+++ b/tinyagentos/routes/cluster.py
@@ -267,11 +267,51 @@ async def register_worker(request: Request, body: WorkerRegister):
signing_key=signing_key,
)
await cluster.register_worker(info)
+ await _record_worker_capability(request.app, body.name, body.host_lan_ip, body.hardware)
if body.pending_storage_backup:
await _surface_storage_backup(request.app, body.name, body.pending_storage_backup)
return {"status": "registered", "name": body.name}
+async def _record_worker_capability(app, name: str, host_lan_ip: str, hardware: dict) -> None:
+ """Populate the capability map from a registering worker (best effort).
+
+ The worker's detected hardware dict already carries cpu/ram_mb/gpu/npu, the
+ same shape the capability map stores, so registration doubles as a heartbeat
+ that marks the node online. A failure here must never fail registration; an
+ explicit admin set-status still owns 'draining'.
+ """
+ store = getattr(app.state, "capability_map", None)
+ if store is None:
+ return
+ hw = hardware or {}
+ try:
+ current = await store.get(name)
+ status = "draining" if current is not None and current["status"] == "draining" else "online"
+ # The store does a full-row overwrite, so a legacy/flat-mode worker that
+ # re-registers without hardware would wipe previously-detected fields.
+ # Carry forward each field the incoming hardware omits.
+ prev = current or {}
+
+ def _keep(key, default):
+ val = hw.get(key)
+ return val if val else prev.get(key, default)
+
+ await store.upsert(
+ {
+ "node_id": name,
+ "hostname": host_lan_ip or prev.get("hostname") or name,
+ "cpu": _keep("cpu", {}),
+ "ram_mb": _keep("ram_mb", 0),
+ "gpu": _keep("gpu", {}),
+ "npu": _keep("npu", {}),
+ "status": status,
+ }
+ )
+ except Exception: # noqa: BLE001
+ logger.warning("capability-map upsert on worker registration failed for %s", name)
+
+
async def _surface_storage_backup(app, worker_name: str, marker: dict) -> None:
"""Materialise an install-worker storage-backup marker as both a
notification and a workspace text file so the user finds out about
diff --git a/tinyagentos/routes/cluster_capability.py b/tinyagentos/routes/cluster_capability.py
new file mode 100644
index 000000000..30f16a369
--- /dev/null
+++ b/tinyagentos/routes/cluster_capability.py
@@ -0,0 +1,134 @@
+"""Cluster capability-map endpoints.
+
+Thin HTTP surface over CapabilityMap (tinyagentos/cluster/capability_map.py):
+workers push a hardware/status heartbeat; admins read the live map and adjust
+node status. The scheduler and placement logic read the same store directly.
+
+Auth mirrors the existing cluster routes: heartbeats are gated by the worker
+HMAC (a node may only write its own row), reads and admin mutations require an
+admin session. No new auth machinery is introduced here.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+from tinyagentos.cluster.worker_auth import _HMACError, require_worker_hmac
+from tinyagentos.routes.auth import _require_admin
+
+router = APIRouter()
+
+# Heartbeats older than this are considered stale and pruned on demand.
+DEFAULT_STALE_S = 900
+
+
+class CapabilityHeartbeat(BaseModel):
+ node_id: str
+ hostname: str = ""
+ cpu: dict = Field(default_factory=dict)
+ ram_mb: int = 0
+ gpu: dict = Field(default_factory=dict)
+ npu: dict = Field(default_factory=dict)
+ status: str = "online"
+
+
+class StatusUpdate(BaseModel):
+ status: str
+
+
+class PruneRequest(BaseModel):
+ older_than_s: int = DEFAULT_STALE_S
+
+
+def _store(request: Request):
+ return getattr(request.app.state, "capability_map", None)
+
+
+@router.post("/api/cluster/capability/heartbeat")
+async def capability_heartbeat(request: Request, body: CapabilityHeartbeat):
+ """Worker HMAC — a node upserts its own capability row + liveness."""
+ try:
+ await require_worker_hmac(request)
+ except _HMACError as exc:
+ return exc.response
+ # A node may only write its own row: the authenticated worker name must
+ # match the heartbeat's node_id (same rule as worker registration).
+ if getattr(request.state, "hmac_worker_name", None) != body.node_id:
+ return JSONResponse(
+ {"error": "Worker name in header does not match node_id"},
+ status_code=403,
+ )
+ store = _store(request)
+ if store is None:
+ return JSONResponse({"error": "capability map unavailable"}, status_code=503)
+ payload = body.model_dump()
+ # "draining" is an admin decision to stop scheduling new work on a node that
+ # is still alive and heartbeating. A routine heartbeat (status defaults to
+ # "online") must not silently clear that intent; only an explicit admin
+ # set-status call clears draining. Preserve it across heartbeats.
+ current = await store.get(body.node_id)
+ if current is not None and current["status"] == "draining":
+ payload["status"] = "draining"
+ try:
+ node = await store.upsert(payload)
+ except ValueError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+ return node
+
+
+@router.get("/api/cluster/capability")
+async def capability_list(request: Request, status: str | None = None):
+ """Admin — list capability rows, optionally filtered by status."""
+ ok, err = _require_admin(request)
+ if not ok:
+ return err
+ store = _store(request)
+ if store is None:
+ return JSONResponse({"error": "capability map unavailable"}, status_code=503)
+ return {"nodes": await store.list(status)}
+
+
+@router.post("/api/cluster/capability/{node_id}/status")
+async def capability_set_status(request: Request, node_id: str, body: StatusUpdate):
+ """Admin — set a node's status (online/offline/draining)."""
+ ok, err = _require_admin(request)
+ if not ok:
+ return err
+ store = _store(request)
+ if store is None:
+ return JSONResponse({"error": "capability map unavailable"}, status_code=503)
+ try:
+ node = await store.set_status(node_id, body.status)
+ except ValueError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+ if node is None:
+ return JSONResponse({"error": "unknown node"}, status_code=404)
+ return node
+
+
+@router.post("/api/cluster/capability/prune")
+async def capability_prune(request: Request, body: PruneRequest):
+ """Admin — drop rows whose last heartbeat is older than older_than_s."""
+ ok, err = _require_admin(request)
+ if not ok:
+ return err
+ store = _store(request)
+ if store is None:
+ return JSONResponse({"error": "capability map unavailable"}, status_code=503)
+ removed = await store.prune_stale(body.older_than_s)
+ return {"pruned": removed}
+
+
+@router.post("/api/cluster/capability/sweep")
+async def capability_sweep(request: Request, body: PruneRequest):
+ """Admin — flip online nodes with no recent heartbeat to offline (row kept)."""
+ ok, err = _require_admin(request)
+ if not ok:
+ return err
+ store = _store(request)
+ if store is None:
+ return JSONResponse({"error": "capability map unavailable"}, status_code=503)
+ flipped = await store.mark_stale_offline(body.older_than_s)
+ return {"offlined": flipped}
diff --git a/tinyagentos/routes/coding.py b/tinyagentos/routes/coding.py
index 7551702a1..2ff9be205 100644
--- a/tinyagentos/routes/coding.py
+++ b/tinyagentos/routes/coding.py
@@ -35,6 +35,11 @@ class ApplyBlocksBody(BaseModel):
blocks: list[ApplyBlock]
+class ToolCallBody(BaseModel):
+ name: str
+ arguments: dict | None = None
+
+
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
@@ -450,3 +455,32 @@ async def apply_blocks(request: Request, workspace_id: str, body: ApplyBlocksBod
applied.append(rel)
return {"applied": applied}
+
+
+# ---------------------------------------------------------------------------
+# Agent tool-calling loop (#86)
+# ---------------------------------------------------------------------------
+
+@router.get("/api/coding/tools")
+async def list_coding_tools(request: Request):
+ """The workspace tool schemas the agent loop hands to the model."""
+ from tinyagentos.agent_tools.coding_tools import TOOL_SCHEMAS
+
+ return {"tools": TOOL_SCHEMAS}
+
+
+@router.post("/api/coding/workspaces/{workspace_id}/tool")
+async def run_coding_tool(request: Request, workspace_id: str, body: ToolCallBody):
+ """Execute one agent tool call against a jailed workspace.
+
+ The execution half of the tool-calling loop: the model proposes a call, the
+ controller runs it here and feeds the structured result back. Errors are
+ returned as {"ok": false, "error": ...} (HTTP 200) so the loop can recover;
+ only an unknown workspace is a hard 404.
+ """
+ from tinyagentos.agent_tools.coding_tools import dispatch
+
+ root, err = await _workspace_root(request, workspace_id)
+ if err is not None:
+ return err
+ return dispatch(root, body.name, body.arguments)
diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py
index 68863773a..b90c01236 100644
--- a/tinyagentos/routes/projects.py
+++ b/tinyagentos/routes/projects.py
@@ -654,6 +654,49 @@ async def reopen_task(
return await store.get_task(task_id)
+@router.get("/api/projects/{project_id}/audit")
+async def project_audit_feed(
+ project_id: str,
+ request: Request,
+ user: CurrentUser = Depends(current_user),
+ limit: int = 100,
+):
+ """Project-wide board activity feed, newest first (owner-gated, #105).
+
+ Scoped to the project so it never surfaces another project's events. limit
+ is clamped to a sane ceiling to keep the response bounded.
+ """
+ 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
+ audit = getattr(request.app.state, "board_audit", None)
+ capped = max(1, min(limit, 500))
+ events = await audit.recent_for_project(project_id, capped) if audit is not None else []
+ return {"project_id": project_id, "events": events}
+
+
+@router.get("/api/projects/{project_id}/tasks/{task_id}/audit")
+async def task_audit_history(
+ project_id: str,
+ task_id: str,
+ request: Request,
+ user: CurrentUser = Depends(current_user),
+):
+ """Append-only audit trail for a task: every status transition in order (#105)."""
+ 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)
+ audit = getattr(request.app.state, "board_audit", None)
+ events = await audit.history(task_id) if audit is not None else []
+ return {"task_id": task_id, "events": events}
+
+
@router.post("/api/projects/{project_id}/tasks/{task_id}/relationships")
async def add_relationship(
project_id: str,
diff --git a/tinyagentos/update_runner.py b/tinyagentos/update_runner.py
index b0fb49d83..6961fe7f7 100644
--- a/tinyagentos/update_runner.py
+++ b/tinyagentos/update_runner.py
@@ -36,6 +36,21 @@ class UpdateResult:
ok: bool = True # False when a step failed and no (or partial) switch happened
+def _record_rollback_target(project_dir, branch: str, sha: str, ts: int) -> None:
+ """Persist the pre-update branch + commit for `taos rollback` (best effort).
+
+ Always called before an update mutates the tree, so even a clean
+ fast-forward (no recovery tag) leaves a restore point.
+ """
+ if not branch or not sha:
+ return
+ try:
+ from tinyagentos.rollback import record_pre_update
+ record_pre_update(project_dir, branch=branch, sha=sha, ts=ts)
+ except Exception: # noqa: BLE001
+ logger.warning("update_runner: failed to record rollback target", exc_info=True)
+
+
async def _run(args: list[str], cwd: Path) -> tuple[int, str]:
"""Run a subprocess safely (no shell) and return (returncode, output)."""
proc = await asyncio.create_subprocess_exec(
@@ -83,6 +98,10 @@ async def update_to_master(project_dir: Path) -> UpdateResult:
)
dirty = bool(status_out.strip())
+ # Record the rollback target AFTER the dirty probe so the (gitignored) state
+ # file never counts as a dirty working tree and triggers a spurious stash.
+ _record_rollback_target(project_dir, branch, current_sha, ts)
+
result = UpdateResult(previous_sha=current_sha, new_sha=current_sha)
# 3. Switch to master if on another branch
@@ -184,6 +203,8 @@ async def switch_to_branch(branch: str, project_dir: Path) -> UpdateResult:
return UpdateResult(previous_sha="", new_sha="", ok=False,
message=f"Fetch failed — no changes applied. ({out.strip()[:200]})")
+ _, cur_branch_out = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], project_dir)
+ cur_branch = cur_branch_out.strip()
_, sha_out = await _run(["git", "rev-parse", "HEAD"], project_dir)
current_sha = sha_out.strip()
short_sha = current_sha[:7]
@@ -191,6 +212,10 @@ async def switch_to_branch(branch: str, project_dir: Path) -> UpdateResult:
_, status_out = await _run(["git", "status", "--porcelain", "-u"], project_dir)
dirty = bool(status_out.strip())
+ # After the dirty probe (see update_to_master) so the gitignored state file
+ # never triggers a spurious stash.
+ _record_rollback_target(project_dir, cur_branch, current_sha, ts)
+
result = UpdateResult(previous_sha=current_sha, new_sha=current_sha)
recovery_tag = f"taos-pre-switch-{short_sha}-{ts}"
diff --git a/tinyagentos/worker/browser_container.py b/tinyagentos/worker/browser_container.py
index 4c7afaef8..48fe112fd 100644
--- a/tinyagentos/worker/browser_container.py
+++ b/tinyagentos/worker/browser_container.py
@@ -90,12 +90,13 @@ def build_nat1to1(node_ip: str, connecting_host_ip: str | None = None) -> str:
# Requires --shm-size=4g at container run time.
# Built for arm64 (RK3588 primary) + amd64.
DEFAULT_NEKO_CDP_IMAGE = "ghcr.io/jaylfc/taos-neko-cdp:latest"
-# RK3588 uses the CDP image so agent automation is available out of the box
-# on the Pi. The rkmpp HW-encode layer (#624) will extend this image once
-# the GStreamer Rockchip packages are validated; for now software encode is
-# the fallback (RK3588 hardware decode of page content still works via the
-# kernel driver even without GStreamer rkmpp).
-DEFAULT_NEKO_RK3588_IMAGE = DEFAULT_NEKO_CDP_IMAGE
+# RK3588 uses a dedicated image built on top of the CDP image (so it keeps
+# Chromium >=148 + CDP) plus the Rockchip MPP + gstreamer-rockchip mpph264enc
+# plugin for VPU H.264 encode (#624). Validated live on the Pi: mpph264enc
+# encodes 720p on the VPU when /dev/mpp_service, /dev/dri and /dev/rga are
+# passed (resolve_neko_image supplies all three). rk3588 hosts always resolve
+# to this image; there is no automatic resolver-level fallback to the CDP image.
+DEFAULT_NEKO_RK3588_IMAGE = "ghcr.io/jaylfc/taos-neko-rk3588:latest"
NEKO_SCREEN = "1280x720@30"
NEKO_SCREEN_MOBILE = "800x1600@30"
NEKO_PROFILE_MOUNT = "/home/neko"
@@ -339,9 +340,10 @@ async def start(self, *, session_id: str, profile_volume: str, mobile: bool = Fa
# The port is bound to 127.0.0.1 inside the container; the launcher
# accesses it via `docker exec` / a loopback port binding on the host.
# None for images that don't expose CDP (stock Neko, GPU image).
+ # The RK3588 image is built FROM the CDP image, so it also exposes CDP.
cdp_url = (
"http://127.0.0.1:9222"
- if spec.image == DEFAULT_NEKO_CDP_IMAGE
+ if spec.image in (DEFAULT_NEKO_CDP_IMAGE, DEFAULT_NEKO_RK3588_IMAGE)
else None
)
diff --git a/uv.lock b/uv.lock
index adc9ee095..9e0be5cf0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1442,7 +1442,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.89.2"
+version = "1.89.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -1458,9 +1458,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/29/865d38325f9c424daf0bcc7ab61908cf51a87862b3a0dfebd059b60dac97/litellm-1.89.2.tar.gz", hash = "sha256:b2534d69568eed026310f4e006407db2d46494eb629bd1e71eb9603ec146540d", size = 14079449, upload-time = "2026-06-18T02:26:03.64Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/f1/f7cfead063f2ab1877c8fb465d0d7fe300b75f081bcb73525f6d550aeb1c/litellm-1.89.3.tar.gz", hash = "sha256:8fcdb2b7a0ef3381d41adf164443842e31ef9f0cd5bcda6fc3c0bd8bc2959510", size = 14080611, upload-time = "2026-06-20T22:42:26.997Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/32/c8/51c93e8d017e7af02600171b5b6cc3805b07507acb2b94de7235ce764015/litellm-1.89.2-py3-none-any.whl", hash = "sha256:07e8e43b1a70fe919021376742897d18ffe7577ccfbb84632c949670f9abdc03", size = 15492797, upload-time = "2026-06-18T02:25:59.863Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/f1/34d174ff1d84e459b30f971606ac9cb7078ad24cd7661e9786b25adf7def/litellm-1.89.3-py3-none-any.whl", hash = "sha256:414ef5aee504b2b3eb1b219d39f1c11902db399cbdbc06e5fb550c15d731abeb", size = 15495226, upload-time = "2026-06-20T22:42:23.156Z" },
]
[package.optional-dependencies]
@@ -3574,7 +3574,7 @@ wheels = [
[[package]]
name = "tinyagentos"
-version = "1.0.0b5"
+version = "1.0.0b6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -3629,7 +3629,7 @@ requires-dist = [
{ name = "httpx", marker = "extra == 'dev'" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "libtorrent", specifier = ">=2.0.9" },
- { name = "litellm", extras = ["proxy"], marker = "extra == 'proxy'", specifier = ">=1.89.2" },
+ { name = "litellm", extras = ["proxy"], marker = "extra == 'proxy'", specifier = ">=1.89.3" },
{ name = "lxml", specifier = ">=5.0.0" },
{ name = "pillow", specifier = ">=10.0" },
{ name = "pillow", marker = "extra == 'worker'", specifier = ">=10.0" },