diff --git a/.agent/EXECPLAN.md b/.agent/EXECPLAN.md deleted file mode 100644 index f079bc62..00000000 --- a/.agent/EXECPLAN.md +++ /dev/null @@ -1,375 +0,0 @@ -# True Multi-Agent Projects (Local Folders + Git Init + Per-Tile Agent IDs) - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, the web UI can create a “project” by name and the server will create a real folder in your home directory (for example `~/example-project`), initialize it as a Git repository, and create a basic `.gitignore` that prevents committing `.env` files. - -Inside a project, the UI can create multiple “agent tiles”. Each tile will be a true, isolated Clawdbot agent by using a unique `agentId` in the session key (for example `agent:proj-example-project-coder-a1b2c3:main`). This gives each tile its own Clawdbot state directory subtree (`~/.clawdbot/agents//...`) and its own default workspace (`~/clawd-`), so sessions, auth profiles, and transcripts do not collide between tiles. - -You can see it working by: - -1. Starting the Clawdbot gateway and this Next.js dev server. -2. Creating a project named `example-project` in the UI. -3. Verifying the folder `~/example-project` exists, contains a `.git/` directory, and `.gitignore` contains `.env`. -4. Creating two tiles in that project (for example “Coder” and “Research” roles) and sending each a message. -5. Verifying transcripts land in different directories: - - `~/.clawdbot/agents//sessions/*.jsonl` - - `~/.clawdbot/agents//sessions/*.jsonl` - -## Progress - -- [x] (2026-01-25 18:30Z) Read `.agent/PLANS.md` and audited current app structure (projects store, tiles, gateway client). -- [x] (2026-01-25 18:55Z) Implemented project creation side effects (slugify, mkdir, `git init`, `.gitignore`) and updated the UI to accept only a project name. -- [x] (2026-01-25 18:55Z) Implemented per-tile `agentId`/`role` storage with a v1→v2 store migration and v2 session key derivation. -- [x] (2026-01-25 19:31Z) Added server-side tile creation endpoint with workspace bootstrap + auth profile copy and optional tile deletion endpoint. -- [x] (2026-01-25 19:31Z) Wired UI tile creation/deletion to server endpoints and updated client API helpers. -- [ ] (2026-01-25 19:38Z) Validate end-to-end (completed: `npm run lint`, `npm run build`; remaining: manual project/tile creation + gateway transcript verification). - -## Surprises & Discoveries - -- Observation: (none yet) - Evidence: (none yet) - -## Decision Log - -- Decision: Do not patch Clawdbot’s `clawdbot.json` to add `agents.list` entries for every tile. - Rationale: The gateway’s `config.patch`/`config.apply` schedules a restart, which is too disruptive for a UI that creates tiles frequently. For WebChat (`chat.send`) the gateway resolves `agentId` directly from the `sessionKey` prefix; using `agent::...` is sufficient to get isolated `agentDir` + session transcripts without touching config. - Date/Author: 2026-01-25 / Codex - -- Decision: Never delete project folders or agent directories from disk as part of “delete” actions. - Rationale: Directory deletion is destructive and explicitly discouraged by the repo’s agent guidelines; the UI should only remove entries from its own store and leave cleanup as a separate, explicit operation. - Date/Author: 2026-01-25 / Codex - -- Decision: Generate client-side tile `agentId`s as `proj--` until server-side tile provisioning lands. - Rationale: The UI still creates tiles locally during Milestone 2; this keeps per-tile session keys unique without blocking on the new tile endpoint. - Date/Author: 2026-01-25 / Codex - -## Outcomes & Retrospective - -(Fill in once milestones land.) - -## Context and Orientation - -This repository is a local-only Next.js app that talks directly to a running Clawdbot Gateway over a WebSocket. - -Key concepts (define these because names are overloaded): - -- “Project”: In this app, a project is a record in a JSON store plus a real folder on disk in your home directory. After this change, creating a project also creates `~/` and initializes a Git repo there. -- “Tile”: A draggable UI panel representing an interactive chat session. Today, tiles are “agents” only in the UI sense; they all use `agent:main:...` session keys. -- “Agent” (Clawdbot agent): In Clawdbot, the agent is encoded in the session key prefix: `agent::`. The `` is used to choose the agent’s state directory (`~/.clawdbot/agents//agent`) and session transcript directory (`~/.clawdbot/agents//sessions`). When we say “true multi-agent” in this plan, we mean “each tile uses a different `` so Clawdbot’s state and transcripts are isolated per tile”. -- “Gateway”: The Clawdbot process that exposes a WebSocket RPC interface. This UI uses it via `src/lib/gateway/GatewayClient.ts` and calls methods like `chat.send` and `sessions.patch`. - -Current code layout (important files you must read before editing): - -- Project store (server side): - - `app/api/projects/store.ts` writes `~/.clawdbot/agent-canvas/projects.json` - - `app/api/projects/route.ts` implements `GET/POST/PUT /api/projects` - - `app/api/projects/[projectId]/route.ts` implements `DELETE /api/projects/:projectId` -- Client-side state: - - `src/state/store.tsx` holds `ProjectsStore` in React state and persists it back to the server via `PUT /api/projects` -- UI + gateway wiring: - - `app/page.tsx` sends chat via `client.call("chat.send", { sessionKey, ... })` - - `src/lib/gateway/GatewayClient.ts` is the WebSocket RPC client - -Important current behavior that must change: - -- Tile session keys are currently hard-coded under the main agent: - - `src/state/store.tsx` builds keys as `agent:main:proj-${projectId}-${tileId}` - - This causes all tiles to share the same agentId (“main”), which is not true multi-agent isolation. - -## Milestones - -### Milestone 1: Projects create real folders and Git repos - -At the end of this milestone, creating a project from the UI (by name) will create a new directory in `~/` (based on a slugified name), run `git init` inside it, and ensure `.gitignore` ignores `.env` files. The project record stored in `projects.json` will point at that directory via `repoPath`. - -Define “slugified name” for this repo so it is deterministic and testable: - -- Trim whitespace. -- Lowercase. -- Replace any run of characters that are not `a-z` or `0-9` with a single `-`. -- Trim leading/trailing `-`. -- If the result is empty, reject the request with HTTP 400 (“Project name produced an empty folder name”). - -Define collision behavior explicitly so the endpoint is safe to retry and safe around existing folders: - -- Preferred: if `~/` already exists, choose the first available suffix `~/-2`, `~/-3`, etc, and return a warning in the response that indicates the final path chosen. -- Never delete or overwrite existing directories as part of “create project”. - -Define the exact `.gitignore` rules this milestone must guarantee (append missing lines if the file exists; do not remove user lines): - - .env - .env.* - !.env.example - -You can prove it works by creating a project named `example-project` and running `ls -la ~/example-project` to see `.git/` and `.gitignore`, and `cat ~/example-project/.gitignore` to confirm `.env` is present. - -### Milestone 2: Persisted tile schema supports per-tile agent IDs - -At the end of this milestone, the persisted store schema moves from `ProjectsStore.version = 1` to `version = 2`. Each tile now has a required `agentId` and `role`, and the `sessionKey` becomes a derived value that always uses the agent id prefix form `agent::main`. - -Migration rules from v1 must be written down and implemented exactly: - -- If the stored file is missing or has `version: 1`, treat it as v1. -- For each v1 tile: - - Set `tile.agentId` by parsing `tile.sessionKey`: - - If `tile.sessionKey` matches `agent::`, then `agentId = `. - - Otherwise set `agentId = "main"`. - - Set `tile.role = "coding"` (legacy default). - - Keep `tile.sessionKey` as-is for legacy tiles so existing sessions/transcripts still match. -- For any new tile created under v2 rules: - - Always set `tile.sessionKey = agent::main`. - -You can prove it works by loading an existing v1 store and seeing it automatically hydrate tiles with `agentId = "main"` (legacy) while new tiles get unique `agentId`s. - -### Milestone 3: Creating a tile provisions a real Clawdbot agent workspace and auth copy - -At the end of this milestone, creating a tile is no longer a client-only operation. The client will call a new server endpoint, and the server will both update the `projects.json` store and run filesystem side effects that make the new tile usable as an independent Clawdbot agent. - -Add a new API route: - -- `POST /api/projects/:projectId/tiles` - -Request JSON: - - { "name": "Coder", "role": "coding" } - -Response JSON: - - { "store": , "tile": , "warnings": ["..."] } - -Server-side behavior for `POST /api/projects/:projectId/tiles`: - -- Load the current store via `loadStore()` and find the project by id. -- Generate: - - `tile.id = crypto.randomUUID()` - - `tile.agentId` using a deterministic, safe id generator (specified in the Interfaces section) based on: - - project folder slug (from `project.repoPath` basename) - - role - - a short random suffix (for uniqueness) -- Set `tile.sessionKey = agent::main`. -- Add the tile to the project, save the store, and then run side effects: - - Create the agent workspace folder at `~/clawd-` (create directories if missing). - - Attempt to create a symlink inside that workspace: `~/clawd-/repo -> `. - - If the symlink already exists, do nothing. - - If the symlink creation fails, do not create an alternate symlink/fallback; the bootstrap files below already contain the absolute repo path. - - Create these bootstrap files in the workspace if they do not exist: - - BOOTSTRAP.md - AGENTS.md - SOUL.md - - The minimum required content (write exactly this structure; customize role + repo path dynamically): - - # BOOTSTRAP.md - - Project repo: - Role: - - You are operating inside this project. Prefer working in ./repo (symlink) when it exists. - If ./repo does not exist, operate directly in: - - First action: run "ls" in the repo to confirm access. - -- Copy auth profiles from the default agent into the new agent if the new agent has no auth file yet: - - Resolve `stateDir = process.env.CLAWDBOT_STATE_DIR ?? "~/.clawdbot"`. - - Resolve `sourceAgentId = process.env.CLAWDBOT_DEFAULT_AGENT_ID ?? "main"`. - - Source: `/agents//agent/auth-profiles.json` - - Destination: `/agents//agent/auth-profiles.json` - - Never overwrite an existing destination file. - - If the source does not exist, return a warning like: `No auth profiles found at ; agent may need login`. - -You can prove it works by creating a tile and checking that `~/clawd-/BOOTSTRAP.md` exists and `~/.clawdbot/agents//agent/auth-profiles.json` exists (assuming the source exists). - -### Milestone 4: Chat uses per-tile session keys and produces isolated transcripts - -At the end of this milestone, sending a message from a tile uses that tile’s per-tile session key (`agent::main`). This must result in distinct transcript files under each agent’s session directory. - -You can prove it works by sending one message in two different tiles and then checking: - - ls ~/.clawdbot/agents//sessions - ls ~/.clawdbot/agents//sessions - -Both should contain JSONL transcript files and the files should differ. - -## Plan of Work - -Implement this as a sequence of small, verifiable changes. Avoid modifying the Clawdbot repo; all changes are contained within this repository. - -First, make project creation authoritative on the server: update `app/api/projects/route.ts` `POST` so it accepts a project name and performs filesystem creation (`~/`, `git init`, `.gitignore`). Update the UI form in `app/page.tsx` so the user provides only a project name; do not ask for a repo path in the UI anymore, since the server determines it. - -Second, introduce a v2 store schema that supports per-tile `agentId` and `role`. Implement the migration in `app/api/projects/store.ts` inside `loadStore()`. Stop silently resetting the store on parse errors; instead, throw an error that returns HTTP 500 with a message pointing at the store path so the user can fix or delete it intentionally. - -Third, add a dedicated server endpoint for creating tiles so you can run filesystem side effects at creation time. Implement: - -- `app/api/projects/[projectId]/tiles/route.ts` with `POST` as defined in Milestone 3. -- Optionally (recommended), implement `DELETE app/api/projects/[projectId]/tiles/[tileId]/route.ts` so tile deletion can also be server-authoritative (while still not deleting directories on disk). - -This endpoint will generate the tile id, compute a safe `agentId`, create the workspace and bootstrap files, and copy auth profiles if possible. It must return the updated store (and the created tile) so the client can update state without racing the debounced `PUT /api/projects` persistence. - -Fourth, update the client to use the per-tile `sessionKey` derived from `agentId` when calling `chat.send` and `sessions.patch`, and update the lookup helpers that match incoming `chat` events by `sessionKey`. - -This requires edits in: - -- `src/state/store.tsx` to remove client-only `createTile()` and replace it with a function that calls the new tile endpoint and dispatches `loadStore` with the returned store. -- `app/page.tsx` to ensure any “new agent” action calls the updated store function, and that any message send uses the tile’s current `sessionKey`. - -Finally, validate with a hands-on scenario and run `npm run lint` and `npm run build`. - -## Concrete Steps - -All commands in this section are run from the repository root unless otherwise stated. - -1. Install deps: - - npm install - -2. Start the Clawdbot gateway in a separate terminal (this plan assumes it is already installed and configured on your machine): - - clawdbot gateway run --bind loopback --port 18789 --force - -3. Start the Next.js app: - - npm run dev - -4. Open the UI: - - http://localhost:3000 - -5. Connect to the gateway (Connection panel): - - Gateway URL: ws://127.0.0.1:18789 - Token: (enter if your gateway requires it) - -6. Create a project named `example-project` and verify on disk: - - ls -la ~/example-project - cat ~/example-project/.gitignore - -7. Create two tiles (roles: “coding” and “research”), send one message from each, and verify on disk: - - ls ~/.clawdbot/agents - ls ~/.clawdbot/agents//sessions - ls ~/.clawdbot/agents//sessions - -Expected: both session directories exist and each contains at least one `.jsonl` transcript file after sending messages. - -## Validation and Acceptance - -Acceptance is user-visible behavior: - -- Creating a project named `example-project` results in a new folder at `~/example-project` with: - - a `.git/` directory (Git repo initialized) - - a `.gitignore` file containing rules that ignore `.env` files -- Creating two tiles in a project produces two different `agentId`s and their sessions do not collide: - - after sending messages, transcripts appear under two different directories in `~/.clawdbot/agents//sessions/` -- Existing UI behavior remains functional: - - The canvas still renders and tiles can be created/moved/resized. - - `npm run lint` succeeds. - - `npm run build` succeeds. - -## Idempotence and Recovery - -- Project creation must be safe to retry: - - If the target directory already exists, the API should not delete or overwrite existing files; it should return a clear error or create a unique suffixed directory (document which approach you choose in the Decision Log). - - If `git init` has already been run, do not reinitialize; treat it as success. - - If `.gitignore` exists, only add missing ignore lines; do not delete user content. - -- Tile creation must be safe to retry: - - Never overwrite an existing `auth-profiles.json` in a destination agent dir. - - Never overwrite existing workspace bootstrap files; only create missing ones. - -If the store becomes invalid JSON, prefer failing with an actionable error rather than silently resetting it (silent reset loses data). - -## Artifacts and Notes - -When you land the implementation, include a short “evidence bundle” here as indented snippets: - - - Output of: ls -la ~/example-project - - Contents of: ~/example-project/.gitignore (relevant lines only) - - Output of: ls ~/.clawdbot/agents//sessions - - Output of: npm run lint - - Output of: npm run build - -## Interfaces and Dependencies - -Avoid new dependencies unless they remove real complexity. - -Implement with: - -- Next.js App Router route handlers (`app/api/.../route.ts`) using `export const runtime = "nodejs";` -- Node built-ins: - - `fs` / `fs/promises` for filesystem operations - - `path` and `os` for path construction - - `child_process` (`spawnSync` or `execFile`) for running `git init` - -New/updated types: - -- In `src/lib/projects/types.ts`, update: - - export type ProjectTile = { - id: string; - name: string; - agentId: string; - role: "coding" | "research" | "marketing"; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; - }; - -- In `src/lib/projects/types.ts`, update: - - export type ProjectsStore = { - version: 2; - activeProjectId: string | null; - projects: Project[]; - }; - -Create helper modules (names are suggestions; keep them small and focused): - -- `src/lib/ids/slugify.ts` for turning project names into safe folder names. -- `src/lib/ids/agentId.ts` for generating safe, <=64-char agent IDs. -- `src/lib/fs/git.ts` for `git init` and `.gitignore` management. - -Define these helpers precisely so two different implementers produce the same behavior: - -- `slugifyProjectName(name: string): string`: - - Trim whitespace. - - Lowercase. - - Replace any run of non-`[a-z0-9]` characters with `-`. - - Trim leading/trailing `-`. - - Return the result; if empty, throw an error that the caller converts into HTTP 400. - -- `generateAgentId(params: { projectSlug: string; role: "coding" | "research" | "marketing"; seed: string }): string`: - - Compute `base = "proj-" + projectSlug + "-" + role + "-" + seed`. - - Normalize: - - Lowercase. - - Replace any run of characters not in `[a-z0-9_-]` with `-`. - - Trim leading/trailing `-`. - - If empty, fall back to `"proj-unknown-" + role + "-" + seed` (this is the only acceptable fallback; it prevents crashes if projectSlug is weird). - - Enforce length: - - If longer than 64 chars, truncate from the left side by trimming `projectSlug` first, keeping the suffix `-" + role + "-" + seed` intact, and ensuring the final string is <= 64. - - The `seed` should be 6 chars derived from the tile UUID (for example `tileId.replaceAll("-", "").slice(0, 6)`), so collisions are extremely unlikely without needing a registry. - -For the Git helper, explicitly specify required operations: - -- `ensureGitRepo(dir: string): { warnings: string[] }`: - - Create `dir` recursively if missing. - - If `.git/` does not exist, run `git init` in that directory. - - Ensure `.gitignore` contains these exact lines (append missing): - - .env - .env.* - !.env.example - -Plan change notes: - -- (2026-01-25 18:30Z) Initial ExecPlan drafted based on current code audit. -- (2026-01-25 18:55Z) Updated progress and decision log after implementing Milestones 1–2 changes in code. -- (2026-01-25 19:31Z) Implemented Milestones 3–4 code paths and marked progress accordingly. diff --git a/.agent/EXECPLAN2.md b/.agent/EXECPLAN2.md deleted file mode 100644 index 12b09eb1..00000000 --- a/.agent/EXECPLAN2.md +++ /dev/null @@ -1,199 +0,0 @@ -# Refactor Clawdbot Agent UI to Create-Next-App Best Practices - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, the Clawdbot Agent UI will follow the create-next-app best practices end to end: a `src/`-rooted App Router layout, a feature-first structure, a shared component system using shadcn/ui, validated environment variables, consistent logging helpers, and a proper quality gate stack (lint, typecheck, unit tests, e2e tests, and OpenTelemetry instrumentation). A new contributor should be able to locate UI, domain logic, and shared primitives quickly, and the project should ship with predictable tooling and test coverage that proves the UI still loads and core utilities behave as expected. - -You can see it working by running `npm run dev` and loading the canvas UI as before, then running `npm run lint`, `npm run typecheck`, `npm run test`, and `npm run e2e` to confirm the new quality gates pass. - -## Progress - -- [x] (2026-01-27 00:00Z) Copied the ExecPlan requirements into `.agent/PLANS.md` and drafted this plan based on a repo audit. -- [x] (2026-01-27 19:58Z) Establish the new `src/`-based structure, move files, and update imports to the `@/*` alias. -- [x] (2026-01-27 20:09Z) Add shared infrastructure (shadcn/ui, env validation, logger, http helpers, instrumentation) and update code to use it. -- [x] (2026-01-27 20:13Z) Add quality gates (Prettier integration, Vitest, Playwright) and create initial tests. -- [x] (2026-01-27 20:23Z) Verify the refactor by running lint, typecheck, unit tests, e2e tests, and a dev-server smoke check. - -## Surprises & Discoveries - -- Observation: `tsc --noEmit` initially failed because `.next/types/validator.ts` referenced the old `/app` paths after the move. - Evidence: `Cannot find module '../../app/page.js'` errors until a fresh `next build` regenerated `.next/types`. - -- Observation: Playwright needed browser binaries installed before the e2e smoke test could launch. - Evidence: `browserType.launch: Executable doesn't exist ... Please run: npx playwright install`. - -- Observation: `next dev` failed when `src/app/globals.css` imported markdown styles from `../styles/markdown.css`. - Evidence: `CssSyntaxError: ... Can't resolve '../styles/markdown.css' in '/Users/.../src/app'` until the styles moved under `src/app/styles`. - -## Decision Log - -- Decision: Move the App Router tree from `/app` to `/src/app` and update the `@/*` alias to point at `src/*`. - Rationale: The create-next-app best practices expect a `src/` root and make internal imports consistent across server and client code. - Date/Author: 2026-01-27 / Codex - -- Decision: Introduce `src/features/canvas` for canvas UI and state, while keeping cross-cutting domain logic in `src/lib/*`. - Rationale: The canvas UI is a single feature used by one route, so colocating its components and state improves navigability without overhauling shared domain code. - Date/Author: 2026-01-27 / Codex - -- Decision: Use shadcn/ui with the default “new-york” style and zinc palette, and keep Tailwind v4 as-is. - Rationale: This aligns with the skill defaults and avoids introducing additional styling systems. - Date/Author: 2026-01-27 / Codex - -- Decision: Add minimal unit and e2e tests that exercise existing, stable behavior rather than inventing new UI flows. - Rationale: The goal is to validate the refactor without forcing new product decisions. - Date/Author: 2026-01-27 / Codex - -- Decision: Mock `/api/projects` in the Playwright smoke test to force an empty store response. - Rationale: The UI renders the empty-state copy only when there are no projects; mocking keeps the smoke test deterministic even if a developer has existing workspace data. - Date/Author: 2026-01-27 / Codex - -- Decision: Move markdown styles to `src/app/styles/markdown.css` and import them via `./styles/markdown.css` from `src/app/globals.css`. - Rationale: Turbopack failed to resolve the previous `../styles/markdown.css` import during `next dev`; keeping the file under `src/app` keeps dev builds stable. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -(To be filled in once milestones are complete.) - -## Context and Orientation - -This repository is a Next.js 16 App Router UI with the router currently rooted at `/app` and shared code in `/src`. There is no `src/app` directory yet, imports often use deep relative paths (for example `../src/lib/...`), and there is no testing stack beyond ESLint. There is also no OpenTelemetry instrumentation and no shared UI primitives. - -Key files and directories today: - -The App Router lives in `app/` with `app/layout.tsx`, `app/page.tsx`, `app/globals.css`, and multiple API routes under `app/api/*` (for example `app/api/projects/route.ts` and `app/api/gateway/route.ts`). UI components live in `src/components/` and are tightly coupled to the canvas route. Client state and reducers are in `src/state/store.tsx`. Shared domain logic is already in `src/lib/` (for example `src/lib/gateway/`, `src/lib/projects/`, and `src/lib/clawdbot/`). - -There is an existing ExecPlan at `.agent/EXECPLAN.md` that addresses different functionality. This plan does not depend on it and stands alone. - -## Plan of Work - -First, move the App Router and UI code to a `src/`-rooted structure that matches the create-next-app layout. This includes relocating the `app/` tree to `src/app`, placing canvas-specific UI and state under `src/features/canvas`, and ensuring all internal imports use the `@/*` alias. After the file moves, update `tsconfig.json` and any import paths so the app builds cleanly without deep relative paths. - -Next, add the infrastructure expected by the skill: initialize shadcn/ui, add shared `src/components/ui` primitives, and introduce base `src/lib` helpers for environment validation, logging, HTTP helpers, and tracing. Update the existing code to use these helpers and centralize duplicated logic (notably the gateway config parsing used by `app/api/gateway/route.ts`). Add OpenTelemetry instrumentation via `src/instrumentation.ts` and ensure the service name matches the project. - -Finally, add quality gates and tests. Integrate Prettier into the ESLint flat config, add `typecheck`, `test`, and `e2e` scripts, and configure Vitest and Playwright. Write initial unit tests for stable, pure utilities (for example `slugifyProjectName`) and a lightweight e2e smoke test that verifies the canvas UI loads. Run lint, typecheck, unit tests, e2e tests, and a dev-server smoke check to validate the refactor. - -## Concrete Steps - -Work from the repo root `/Users/georgepickett/clawdbot-agent-ui`. - -1. Move the App Router to `src/app` and establish the feature structure using `git mv` so history is preserved. - Example commands: - git mv app src/app - git mv src/components src/features/canvas/components - git mv src/state src/features/canvas/state - mkdir -p src/components/shared src/components/ui src/features src/hooks src/styles tests/unit tests/e2e - -2. Update import paths to the new locations and to the `@/*` alias. Every import that currently starts with `../src/` or `../../src/` should be replaced with `@/` and point to the new structure. Make sure `src/app/page.tsx`, all API route files under `src/app/api/`, and all moved components and state modules compile cleanly. - -3. Update `tsconfig.json` so the `@/*` alias maps to `./src/*`. Ensure all TypeScript path imports align with the new structure. - -4. Initialize shadcn/ui and add a starter component. Run the CLI with the opinionated answers from the skill: style `new-york`, base color `zinc`, CSS variables `yes`, global CSS `src/app/globals.css`, components alias `@/components`, utils alias `@/lib/utils`, and UI alias `@/components/ui`. Then add the Button component. - Example commands: - npx shadcn@latest init - npx shadcn@latest add button - -5. Add shared `src/lib` helpers and wire them into the existing code. - - Create `src/lib/env.ts` and validate environment variables using Zod. Include optional server variables used in config resolution (`MOLTBOT_STATE_DIR`, `CLAWDBOT_STATE_DIR`, `MOLTBOT_CONFIG_PATH`, `CLAWDBOT_CONFIG_PATH`) and an optional client variable (`NEXT_PUBLIC_GATEWAY_URL`). Use this module in server-side config resolution and in the client hook to default the gateway URL. - - Create `src/lib/logger.ts` as a small wrapper over `console` that exposes `info`, `warn`, `error`, and `debug`, and replace direct `console.*` usage in the gateway client and API routes with the logger to keep logging consistent and easy to adjust. - - Create `src/lib/http.ts` with a `fetchJson(input, init)` helper that throws a useful error when responses are not ok. Update `src/lib/projects/client.ts` (and any other client fetchers) to use this helper. - - Create `src/lib/tracing.ts` as the shared tracing helper, and add `src/instrumentation.ts` that calls `registerOTel` from `@vercel/otel` with `serviceName: "clawdbot-agent-ui"`. - -6. Reduce duplicated gateway config logic by refactoring `src/app/api/gateway/route.ts` to rely on the existing `src/lib/clawdbot/config.ts` helpers (or introduce a small `src/lib/clawdbot/gateway.ts` helper if needed) so there is a single source of truth for state dir/config resolution. - -7. Split the markdown styling into `src/styles/markdown.css` and import it from `src/app/globals.css`, leaving the Tailwind v4 `@import "tailwindcss";` line intact. - -8. Add quality gates and testing configuration. - - Install dependencies (npm is used by this repo): - npm install zod @vercel/otel - npm install -D prettier eslint-config-prettier vitest @testing-library/react @testing-library/jest-dom jsdom @playwright/test - - Update `eslint.config.mjs` to include `eslint-config-prettier/flat` and keep the existing ignores. Update `package.json` scripts to: - lint: eslint . - typecheck: tsc --noEmit - test: vitest - e2e: playwright test - - Create `vitest.config.ts` with a JSDOM environment and a setup file that imports `@testing-library/jest-dom`. Add at least two unit tests in `tests/unit/`: - - - `tests/unit/slugifyProjectName.test.ts` should assert that `slugifyProjectName("My Project")` becomes `"my-project"` and that an all-symbol input throws with the current error message. - - `tests/unit/fetchJson.test.ts` should stub `fetch` and assert that non-ok responses throw and ok responses return parsed JSON. - - Create `playwright.config.ts` with a `webServer` that runs `npm run dev` on port 3000. Add `tests/e2e/canvas-smoke.spec.ts` that loads `/` and asserts the empty-state copy “Create a workspace to begin.” is visible. - -9. Run verification commands and record outputs in the Progress section as you go. - -## Validation and Acceptance - -The refactor is accepted when the app still runs and all quality gates pass. - -Run the following from `/Users/georgepickett/clawdbot-agent-ui`: - -- `npm run lint` and expect no ESLint errors. -- `npm run typecheck` and expect no TypeScript errors. -- `npm run test` and expect all unit tests to pass (the new tests must fail before the implementation and pass after). -- `npm run e2e` and expect the Playwright smoke test to pass. -- `npm run dev`, open `http://localhost:3000`, and confirm the canvas UI loads and the empty-state message appears when no workspace is selected. - -For each milestone, follow the verification workflow: - -1. Tests to write: create the unit tests described above and confirm they fail before the helper implementations or refactors are complete. -2. Implementation: perform the moves, refactors, and helper additions described in the Plan of Work. -3. Verification: re-run the relevant tests and commands until they pass. -4. Commit: after each milestone succeeds, commit the changes with a message like “Milestone 1: Move app to src and update imports”. - -## Idempotence and Recovery - -The file moves can be re-run safely with `git mv` and do not delete data. If a move goes to the wrong location, move it back and re-run the import updates. Dependency installs are safe to re-run; if conflicts occur, remove `node_modules` and run `npm install` again. If any tests or builds fail, revert to the last successful commit and re-apply the current milestone with smaller, verified steps. - -## Artifacts and Notes - -Include short transcripts of any failing test errors or build errors encountered during the refactor in this section so the next contributor can see what broke and why. Keep snippets concise and focused on the error and fix. - -`npm run test` initially failed with: - ReferenceError: expect is not defined - at tests/setup.ts:1 -Fix: swap `@testing-library/jest-dom` import to `@testing-library/jest-dom/vitest` and restrict Vitest to `tests/unit/**`. - -`npm run e2e` initially failed with: - Error: browserType.launch: Executable doesn't exist at .../chromium_headless_shell... -Fix: run `npx playwright install` to fetch browsers. - -`npm run e2e` then failed with: - Error: getByText('Create a workspace to begin.') ... element(s) not found -Fix: mock `/api/projects` in the Playwright test to return an empty store. - -`npm run dev` failed with: - CssSyntaxError: ... Can't resolve '../styles/markdown.css' in '/Users/.../src/app' -Fix: move markdown styles into `src/app/styles/markdown.css` and update the import to `./styles/markdown.css`. - -## Interfaces and Dependencies - -New dependencies to add include `zod` for environment validation, `@vercel/otel` for OpenTelemetry, `prettier` and `eslint-config-prettier` for formatting alignment, `vitest` plus React Testing Library and `jsdom` for unit tests, and `@playwright/test` for e2e testing. The shadcn/ui CLI will add its own dependencies (notably `class-variance-authority`, `tailwind-merge`, `clsx`, and `@radix-ui/react-slot`) when the Button component is installed. - -Define these modules explicitly: - -In `src/lib/env.ts`, export `env` as the parsed result of a Zod schema containing the optional server variables and the optional `NEXT_PUBLIC_GATEWAY_URL` string. - -In `src/lib/logger.ts`, export a `logger` object with `info`, `warn`, `error`, and `debug` methods; each should delegate to the matching `console` method. - -In `src/lib/http.ts`, export `fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise` that throws an `Error` with the response body’s `error` field (if present) or a default message. - -In `src/lib/tracing.ts`, export a `registerTracing()` function that calls `registerOTel` or is invoked by `src/instrumentation.ts`. - -In `src/instrumentation.ts`, export a `register()` function that calls `registerOTel({ serviceName: "clawdbot-agent-ui" })`. - -Plan update note (2026-01-27 19:58Z): Marked milestone 1 complete after moving the App Router to `src/app`, relocating canvas components/state under `src/features/canvas`, and switching imports to the `@/*` alias with the updated TypeScript path mapping. -Plan update note (2026-01-27 20:09Z): Marked milestone 2 complete after adding shadcn/ui, env validation, logger/http/tracing helpers, gateway config reuse, and extracting markdown styles into `src/styles/markdown.css`. -Plan update note (2026-01-27 20:13Z): Marked milestone 3 complete after wiring Prettier into ESLint, adding Vitest + Playwright configs, and creating initial unit and e2e tests. -Plan update note (2026-01-27 20:23Z): Marked milestone 4 complete after running lint/typecheck/tests/e2e, installing Playwright browsers, and stabilizing the smoke test via an `/api/projects` mock. -Plan update note (2026-01-27 20:38Z): Moved markdown styles under `src/app/styles` and updated `globals.css` import to fix `next dev` resolution errors. diff --git a/.agent/EXECPLAN3.md b/.agent/EXECPLAN3.md deleted file mode 100644 index 462f9c0a..00000000 --- a/.agent/EXECPLAN3.md +++ /dev/null @@ -1,212 +0,0 @@ -# Refactor Canvas Zoom/Pan for a Figma-like Agent Workspace - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md` (repository root). - -## Purpose / Big Picture - -After this change, the Agent UI canvas supports a “real canvas” interaction model that feels closer to Figma: zoom is anchored to the cursor (so the point under your mouse stays under your mouse while zooming), trackpad pinch zoom works, mouse wheel can zoom (without breaking trackpad two-finger pan), and canvas panning is smooth and predictable. Managing many agent tiles becomes practical because you can quickly zoom to inspect a tile, zoom out for overview, and use a minimap to navigate large layouts. - -You can see it working by starting the dev server, creating/opening a workspace with multiple agent tiles, then pinch-zooming on a trackpad (the point under the cursor stays pinned), using a mouse wheel to zoom, two-finger scrolling to pan, dragging empty canvas space to pan, and using a minimap overlay to jump around when tiles are spread out. - -## Progress - -- [x] (2026-01-27 00:00Z) Audited current canvas transform/zoom behavior and drafted ExecPlan. -- [x] (2026-01-27) Milestone 1: Add transform math utilities + unit tests that define cursor-anchored zoom behavior. -- [x] (2026-01-27) Milestone 2: Implement wheel/pinch zoom + trackpad pan with rAF throttling; keep existing buttons working. -- [x] (2026-01-27) Milestone 3: Add minimap (overview) and “zoom to fit” navigation helpers for large canvases. -- [x] (2026-01-27) Milestone 4: Add Playwright interaction coverage and polish (performance + edge cases). - -## Surprises & Discoveries - -(To be filled in during implementation.) - -## Decision Log - -- Decision: Implement the interaction model without introducing a third-party canvas/graph library (no React Flow / no D3 zoom). - Rationale: The current UI already has tile layout and drag/resize behavior; the biggest UX gap is transform math + input handling. Keeping it in-house minimizes dependency surface and keeps the behavior easy to tailor. - Date/Author: 2026-01-27 / Codex - -- Decision: Use “zoom to cursor” math and multiplicative zoom deltas (exponential scaling) rather than linear +/- 0.1 steps for wheel/pinch. - Rationale: Cursor-anchored zoom is the core Figma-like affordance; multiplicative zoom feels consistent across zoom levels and matches common canvas UX expectations. - Date/Author: 2026-01-27 / Codex - -- Decision: Default input mapping: trackpad two-finger scroll pans; trackpad pinch (wheel event with ctrlKey) zooms; mouse wheel zooms when it appears to be a wheel (line-based deltas) and otherwise pans. - Rationale: Browsers do not reliably distinguish “mouse wheel” vs “trackpad scroll” in a principled way. This heuristic keeps trackpad pan usable while still enabling mouse wheel zoom without requiring keyboard shortcuts. - Date/Author: 2026-01-27 / Codex - -- Decision: Trackpad pan subtracts wheel deltas from offsets (scroll down reveals lower world coordinates). - Rationale: Matches standard scroll direction expectations while keeping zoom anchor math unchanged. - Date/Author: 2026-01-27 / Codex - -- Decision: `zoomToFit` accepts the current transform to preserve state when there are no tiles. - Rationale: The helper needs a fallback transform; passing it in keeps the helper pure and avoids hidden defaults. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -- Outcome: Canvas interactions now support cursor-anchored zoom (wheel/pinch), smooth pan, minimap navigation, and zoom-to-fit, with shared transform math and coverage in unit/e2e tests. - -## Context and Orientation - -This repo is a Next.js App Router UI. The “canvas” is implemented as a full-screen `CanvasViewport` that renders multiple draggable/resizable “agent tiles”. - -Relevant files include `src/features/canvas/components/CanvasViewport.tsx` (canvas surface + CSS transform), `src/features/canvas/components/AgentTile.tsx` (tile drag/resize; pointer deltas are divided by zoom), `src/features/canvas/components/HeaderBar.tsx` (zoom controls + zoom readout), `src/features/canvas/state/store.tsx` (the `CanvasTransform` state and `setCanvas` reducer action), and `src/app/page.tsx` (wires state to the viewport and header). - -Current behavior (as of 2026-01-27) is that zoom only changes via header +/- buttons and is applied as CSS `translate(offsetX, offsetY) scale(zoom)` with `transformOrigin: "0 0"`. Panning is pointer-drag on empty canvas space only (no trackpad two-finger pan and no wheel support). Zoom is not cursor-anchored; it effectively zooms around the top-left origin of the inner container. - -Definitions used in this plan: “world coordinates” are the coordinate space where tile positions and sizes are stored (tile `{ position: {x,y}, size: {width,height} }` in `AgentTile` data). “viewport/screen coordinates” are CSS pixels relative to the visible canvas viewport element. The transform maps world → screen as `screen = offset + zoom * world`, where `offset` is `{offsetX, offsetY}` in screen pixels. - -This plan also uses a few browser/DOM terms. `requestAnimationFrame` (abbreviated “rAF” below) is a browser API that runs a callback before the next repaint; we use it to coalesce many rapid pointer/wheel events into at most one state update per frame. A “passive” event listener is one that cannot call `preventDefault()`; we must use a non-passive `wheel` listener so we can prevent the browser’s own page zoom behavior during trackpad pinch. A `WheelEvent`’s `deltaMode` describes whether `deltaX/deltaY` are in pixels (typical trackpads) or lines (typical mouse wheels). - -## Plan of Work - -This change is mostly about (1) getting transform math correct and testable, and (2) implementing input handling that feels intentional and smooth under real device behavior (mouse wheels, trackpads, pinch gestures). The work proceeds in small steps so that the core math is locked down with tests before wiring up user input. - -Milestone 1 creates a small, pure “canvas transform math” module and unit tests that specify cursor-anchored zoom and viewport/world conversions. This is the foundation; everything else builds on it. - -Milestone 2 wires wheel/pinch/pan into `CanvasViewport` using non-passive event listeners (so we can `preventDefault()` to stop browser-page zoom during pinch) and rAF throttling (so transform updates do not cause jank). Existing header zoom buttons are updated to use the same transform math (zooming around viewport center). - -Milestone 3 adds an overview minimap that visualizes tile bounds and the current viewport rectangle, plus a “zoom to fit” action that frames all tiles with padding. This is the “manage many tiles” accelerator. - -Milestone 4 adds Playwright coverage for the interaction contract and cleans up edge cases (clamping, empty canvas, huge deltas, selection behavior), keeping the implementation simple but robust. - -## Concrete Steps - -All commands below run from: - - /Users/georgepickett/clawdbot-agent-ui - -### Milestone 1: Transform Math + Unit Tests - -Acceptance for this milestone is that a pure function can compute a new `CanvasTransform` that zooms at a given viewport point (cursor-anchored), preserving the world point under the cursor across zoom changes, and that conversions between screen and world coordinates are correct and covered by unit tests. - -1. Create `src/features/canvas/lib/transform.ts` (new). - - Implement these exported functions (keep them pure and small). `clampZoom(zoom: number): number` clamps to a chosen range (decide in this milestone; the tests encode the final decision). `screenToWorld(transform, screen)` computes `{ (screen.x - offsetX) / zoom, (screen.y - offsetY) / zoom }` and `worldToScreen(transform, world)` computes `{ offsetX + world.x * zoom, offsetY + world.y * zoom }`. `zoomAtScreenPoint(transform, nextZoomRaw, screenPoint)` computes the world point under `screenPoint` using `screenToWorld`, clamps `nextZoomRaw`, then sets offsets so the same world point maps back to `screenPoint` at the clamped zoom via `nextOffsetX = screenPoint.x - world.x * nextZoom` and `nextOffsetY = screenPoint.y - world.y * nextZoom`, returning `{ zoom: nextZoom, offsetX: nextOffsetX, offsetY: nextOffsetY }`. - - Keep `CanvasTransform` imported from `src/features/canvas/state/store.tsx` (do not redefine the type). Choose a zoom clamp range appropriate for reading tile content; a reasonable starting point is `minZoom=0.25` and `maxZoom=3.0`, but the tests should encode the final decision. - -2. Add unit tests in `tests/unit/canvasTransform.test.ts` (new). - - Write these tests first and confirm they fail until you implement the functions. Add a round-trip test that asserts `worldToScreen` then `screenToWorld` are inverses within float tolerance (for example using `{ zoom: 1.5, offsetX: 120, offsetY: -80 }`). Add a cursor-anchored zoom test that asserts `zoomAtScreenPoint` preserves the world point under the cursor within tolerance. Add a clamp test that asserts values below/above min/max are clamped appropriately. - -3. Run: - - npm run test -- tests/unit/canvasTransform.test.ts - - Expect all new tests to pass. - -4. Commit: - - git add -A - git commit -m "Milestone 1: Add cursor-anchored canvas transform math" - -### Milestone 2: Wheel/Pinch Zoom + Trackpad Pan (Smooth) - -Acceptance for this milestone is that trackpad pinch zoom works (browser page zoom does not trigger while the cursor is over the canvas), mouse wheel zoom works, trackpad two-finger scroll pans the canvas (so you can navigate without drag), dragging empty canvas space still pans (existing behavior), and panning/zooming feels smooth (no stutter from excessive state updates). - -1. Update `src/features/canvas/components/CanvasViewport.tsx` to handle wheel and pinch. - - Attach a native `wheel` event listener to the viewport element with `{ passive: false }` so `preventDefault()` reliably works. On wheel events, compute `screenPoint` relative to the viewport element (use `getBoundingClientRect()` and `event.clientX/Y`). Decide whether the wheel event is zoom vs pan: treat `event.ctrlKey === true` as zoom (common trackpad-pinch signal), otherwise treat line-based deltas (`event.deltaMode`) as zoom (typical mouse wheel), and treat remaining pixel-based deltas as pan (typical trackpad scroll). When zooming, use multiplicative scaling (for example `nextZoom = transform.zoom * Math.exp(-event.deltaY * ZOOM_SENSITIVITY)`) and apply `zoomAtScreenPoint(transform, nextZoom, screenPoint)` from Milestone 1. When panning, update offsets by wheel deltas; verify the sign feels like direct manipulation and record the final choice in the Decision Log. - - For smoothness, do not call `onUpdateTransform` on every raw wheel/pointer event. Throttle to animation frames by storing a pending transform update in a ref and scheduling a single `requestAnimationFrame` to apply the latest pending transform. Keep the existing pointer-drag pan, but apply the same rAF throttling to pointermove updates so panning stays smooth with many tiles. - -2. Update `src/app/page.tsx` zoom handlers to use the new math and feel consistent. - - Replace linear `zoom +/- 0.1` with multiplicative steps (for example `zoom *= 1.1` and `zoom /= 1.1`) using the same clamp. Anchor button-based zoom to the viewport center (not cursor) for predictability; ensure the handler computes a viewport-center screen point and calls `zoomAtScreenPoint` rather than directly patching zoom. Avoid creating a second transform path: all zoom changes (wheel/pinch/buttons) should go through the same math utility so behavior stays consistent. - -3. Manually verify this behavior and record brief observations under `Artifacts and Notes`. Start the dev server (`npm run dev`), open the app, create/open a workspace, create several agent tiles, then pinch-zoom with a trackpad (verify browser page zoom does not happen while over the canvas and the point under the cursor remains pinned), two-finger scroll (verify pan), and mouse wheel (verify zoom, using a mouse rather than a trackpad). - -4. Add or update Playwright coverage (keep it stable/deterministic). - - Extend `tests/e2e/canvas-smoke.spec.ts` or add `tests/e2e/canvas-zoom-pan.spec.ts` (preferred) by mocking `/api/projects` to return one workspace with one tile positioned away from the origin. - - Add one test that dispatches wheel events over the canvas surface and asserts the zoom percentage text changes. Add a second test that dispatches a trackpad-like wheel (pixel deltas, no ctrlKey) and verifies the tile’s screen position changes by comparing `boundingBox()` before and after; `boundingBox()` is Playwright’s API for reading an element’s rendered rectangle in CSS pixels. If Playwright wheel synthesis cannot reliably reproduce the intended wheel characteristics (especially `deltaMode`), focus on the zoom readout plus a clear bounding box change and document the limitation in `Artifacts and Notes`. If needed, add a stable `data-*` attribute on the canvas viewport element to query it reliably (for example `data-canvas-viewport`). - -5. Run: - - npm run test - npm run e2e - -6. Commit: - - git add -A - git commit -m "Milestone 2: Add wheel/pinch zoom and smooth pan" - -### Milestone 3: Minimap + Zoom to Fit - -Acceptance for this milestone is that a minimap appears when there is at least one tile, it shows tile rectangles and the current viewport rectangle, clicking (or dragging) in the minimap recenters the viewport to that location, and a “Zoom to Fit” action frames all tiles with padding. - -1. Add `src/features/canvas/components/CanvasMinimap.tsx` (new). - - Keep the minimap simple and SVG-based. It takes `tiles: AgentTile[]`, `transform: CanvasTransform`, `viewportSize: { width: number; height: number }` (measured from `CanvasViewport` via a `ResizeObserver`, a browser API that notifies you when an element’s size changes), and `onUpdateTransform(patch: Partial): void`. It computes the world bounds of all tiles using tile position and size, adds padding in world units, then computes the current viewport world rectangle using the transform (`worldLeft = -offsetX / zoom`, `worldTop = -offsetY / zoom`, `worldWidth = viewportWidth / zoom`, `worldHeight = viewportHeight / zoom`). Render an SVG with a `viewBox` matching the content bounds; `viewBox` is the SVG coordinate system used to map world units into the minimap panel. Draw tile rects and the viewport rect. On click/drag in the minimap, convert minimap coordinates back to a world point and update offsets so that world point becomes the viewport center. - -2. Wire minimap into `src/app/page.tsx` (or into `CanvasViewport` as an overlay). - - Place it as a floating panel in a corner (for example bottom-right) with `pointer-events-auto` and a small footprint so it doesn’t interfere with tiles. - -3. Add “Zoom to Fit” button to `src/features/canvas/components/HeaderBar.tsx`. - - Implement a helper in `src/features/canvas/lib/transform.ts` named `zoomToFit(tiles: AgentTile[], viewportSize: { width: number; height: number }, paddingPx: number): CanvasTransform`. If there are no tiles it should return the current transform unchanged. Otherwise it should compute a zoom that fits all tile bounds within the viewport (minus padding), clamp that zoom, and compute offsets so the bounds are centered. - -4. Tests: - - Add `tests/unit/canvasZoomToFit.test.ts` (new) to assert that `zoomToFit` produces a transform where the fitted bounds map within the viewport with padding. - -5. Run: - - npm run test - npm run e2e - -6. Commit: - - git add -A - git commit -m "Milestone 3: Add minimap and zoom-to-fit" - -### Milestone 4: Polish + Edge Cases - -Acceptance for this milestone is that there are no regressions in tile drag/resize under zoom, canvas interactions remain responsive with many tiles, and edge cases are handled without mystery behavior (empty canvas, extreme wheel deltas, zoom clamping). - -1. Verify tile interactions under zoom by dragging a tile while zoomed in and out (movement tracks cursor correctly) and resizing a tile while zoomed in and out (size changes are proportional and clamped). - -2. Keep performance guardrails in place: ensure wheel/pan uses rAF throttling (no synchronous state update loops), add `will-change: transform;` to the scaled inner container if needed, and consider adding `overscroll-behavior: none;` and `touch-action: none;` to the canvas surface so browser scroll/zoom gestures do not fight the canvas. - -3. Expand Playwright tests only as far as they remain deterministic. - -4. Run final verification: - - npm run lint - npm run typecheck - npm run test - npm run e2e - -5. Commit: - - git add -A - git commit -m "Milestone 4: Polish canvas interactions and add coverage" - -## Validation and Acceptance - -This work is accepted when, on a developer machine, the canvas supports cursor-anchored zoom (pinch or wheel) and the world point under the cursor remains stable while zooming; trackpad two-finger scroll pans (not zoom) while mouse wheel zoom works; existing header zoom controls still work and feel consistent with wheel/pinch zoom; a minimap provides an overview and navigation for canvases with many tiles; and `npm run test` and `npm run e2e` pass, with the new unit tests failing before implementation and passing after. - -## Idempotence and Recovery - -The transform refactor is safe to apply incrementally because it is additive first (pure math module + tests), then wiring changes. If input handling becomes confusing or flaky, revert to the last milestone commit and re-apply one interaction at a time (wheel zoom first, then trackpad pan, then minimap). Keep the old header zoom buttons functional throughout so the UI remains usable even while iterating. - -## Artifacts and Notes - -Record short evidence snippets here during implementation, such as unit test failure output that guided a math fix, a brief note on the final chosen zoom clamp range and why, and any device-specific observations (for example “Chrome pinch zoom sets ctrlKey=true on wheel events on macOS”). - -- 2026-01-27: Added transform math utils + tests. Clamp range set to 0.25–3.0 to keep tiles readable while allowing overview. `npm run test -- tests/unit/canvasTransform.test.ts` passes. -- 2026-01-27: Added wheel/pinch zoom + rAF throttling, updated header zoom anchoring, and added Playwright coverage. Playwright wheel events always surfaced as line-based deltas, so trackpad-pan simulation was unreliable; tests cover zoom readout changes and tile bounds updates instead. `npm run test` and `npm run e2e` pass. -- 2026-01-27: Added SVG minimap + zoom-to-fit helper/button, plus `zoomToFit` unit tests. `npm run test` and `npm run e2e` pass. -- 2026-01-27: Added overscroll/touch-action guardrails and `will-change: transform` on the scaled canvas content; lint/typecheck/test/e2e pass. -- 2026-01-27: Switched canvas scaling to use CSS `zoom` when supported (fallback to transform scale) to keep zoomed text crisp. - -## Interfaces and Dependencies - -No new third-party dependencies are required for this plan. The core interfaces are `CanvasTransform` from `src/features/canvas/state/store.tsx`, new pure transform helpers in `src/features/canvas/lib/transform.ts`, and a minimap component in `src/features/canvas/components/CanvasMinimap.tsx`. The implementation must keep a single source of truth for transform math by routing all zoom changes (wheel/pinch/buttons/zoom-to-fit) through `src/features/canvas/lib/transform.ts`. - -Plan creation note (2026-01-27 00:00Z): Created this ExecPlan after auditing current zoom/pan behavior in `CanvasViewport.tsx`, `AgentTile.tsx`, and `page.tsx`, and after researching common canvas UX conventions (cursor-anchored zoom, pinch zoom, scroll-to-pan, minimap + zoom-to-fit) to keep the plan self-contained and beginner-executable. diff --git a/.agent/EXECPLAN4.md b/.agent/EXECPLAN4.md deleted file mode 100644 index ea424216..00000000 --- a/.agent/EXECPLAN4.md +++ /dev/null @@ -1,174 +0,0 @@ -# Show Thinking Traces in Agent Chat Tiles - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Maintain this document in accordance with `.agent/PLANS.md` (repository root). - -## Purpose / Big Picture - -After this change, the Agent Canvas UI surfaces the model's "thinking" blocks alongside assistant replies. When a chat run streams, the tile shows a live thinking trace; when the run completes or when history is loaded, the thinking trace is preserved in the output timeline so the user can read it later. This makes the UI match the session log format (for example the `content[]` items with `type: "thinking"` in `~/.clawdbot/agents/.../sessions/*.jsonl`) and removes the current gap where reasoning is either hidden or truncated. - -You can see it working by starting the dev server, sending a message to an agent with thinking enabled, and watching the tile output show a "Thinking" block before the assistant response. Reloading the page should still show those thinking blocks in history for that session. - -## Progress - -- [x] (2026-01-27 23:46Z) Milestone 1: Added `src/lib/text/extractThinking.ts` with extraction/formatting helpers and `tests/unit/extractThinking.test.ts`; unit tests pass. -- [ ] (2026-01-27 23:55Z) Milestone 2: Wired thinking traces into live streaming, history loading, and tile rendering; traces collapse after completion with a toggleable summary; README updated (manual UI verification still pending). - -## Surprises & Discoveries - -- Observation: No surprises encountered while parsing thinking blocks or wiring UI rendering. - Evidence: `npm run test -- tests/unit/extractThinking.test.ts` passed on first run. - -## Decision Log - -- Decision: Treat "thinking traces" as first-class output lines, rendered before the assistant reply and persisted in history. - Rationale: The session logs include explicit thinking blocks; representing them as output lines preserves them after streaming ends and keeps the UI timeline coherent. - Date/Author: 2026-01-27 / Codex - -- Decision: Parse thinking from `message.content[]` items with `type: "thinking"`, with `...` tag parsing as back-compat. - Rationale: The provided session log uses `content[]` with `type: "thinking"`, but older logs can embed thinking tags; supporting both matches existing Clawdbot UI behavior. - Date/Author: 2026-01-27 / Codex - -- Decision: Render live thinking with the raw markdown content, but store completed thinking as a formatted trace block in output history. - Rationale: Live thinking should reflect the model output as-is, while history benefits from consistent styling and collapsible traces. - Date/Author: 2026-01-27 / Codex - -- Decision: Represent completed traces as a prefixed markdown string and render them as collapsed `
` blocks with a "Trace" summary. - Rationale: Output lines remain simple strings for persistence, while the UI can detect and render a toggleable collapsed view without adding a new data model. - Date/Author: 2026-01-27 / Codex - -## Outcomes & Retrospective - -- Outcome: Thinking extraction helpers and unit tests added; UI now surfaces thinking during streaming and persists it in history. Manual end-to-end verification remains to confirm runtime behavior. - -## Context and Orientation - -This repo is a Next.js App Router UI for the Clawdbot agent canvas. Chat traffic arrives over the gateway WebSocket and is handled in `src/app/page.tsx`, which updates tile state stored in `src/features/canvas/state/store.tsx`. Tiles render their outputs in `src/features/canvas/components/AgentTile.tsx` using `ReactMarkdown` and the `agent-markdown` styles in `src/app/styles/markdown.css`. - -Relevant paths for this change: -- `src/app/page.tsx`: Gateway event handlers for `chat` and `agent`, history loading (`chat.history`), and the current thinking trace extraction helpers. -- `src/features/canvas/components/AgentTile.tsx`: Renders the tile output area and the inline "thinking" block. -- `src/lib/text/extractText.ts`: Extracts assistant/user text and strips `` tags from assistant responses. -- `tests/unit/*`: Vitest unit tests; new parsing utilities should be tested here. - -The provided session log at `/Users/georgepickett/.clawdbot/agents/proj-clawdbot-agent-ui-agent-5aab/sessions/b9773235-f5b1-46b4-8eb6-86bbd312828b.jsonl` shows the exact thinking payload shape: assistant `message.content[]` includes `{ type: "thinking", thinking: "...", thinkingSignature: "..." }` ahead of tool calls and text. - -## Plan of Work - -The work has two milestones. First, create a small parsing utility that can extract thinking text from the same message shapes found in the session log and format it for display, backed by unit tests. Second, wire that utility into the live chat stream handler and the history loader so thinking traces show during streaming and remain visible in the output timeline after completion. The tile rendering should display the live thinking trace as markdown (not just a truncated first line) and the output timeline should include formatted thinking blocks before assistant replies. Update the README with a short note about thinking traces so the behavior is documented. - -## Concrete Steps - -All commands below run from: - - /Users/georgepickett/clawdbot-agent-ui - -### Milestone 1: Thinking extraction + tests - -Acceptance for this milestone is that we can extract thinking text from the message shapes in the session log (content arrays with `type: "thinking"`) and from embedded `` tags, and that the behavior is covered by unit tests. - -1. Add a new helper in `src/lib/text/extractThinking.ts`. - - Implement and export: - - `extractThinking(message: unknown): string | null` -- returns concatenated thinking text when `message.content` is an array of `{ type: "thinking", thinking: string }` entries, or when raw text contains `...` tags. Return `null` for empty/whitespace-only results. - - `formatThinkingMarkdown(text: string): string` -- returns a markdown block that visually separates thinking from normal output (for example: a "Thinking:" label followed by italicized non-empty lines). Keep it deterministic so tests can assert exact output. - - The helper should not mutate inputs and should not rely on DOM APIs. - -2. Add unit tests in `tests/unit/extractThinking.test.ts` (new). - - Write tests first and confirm they fail before implementation. Cover these cases: - - Extracts a single thinking block from `content[]` and returns trimmed text. - - Extracts multiple thinking blocks and joins them with `\n` in order. - - Extracts thinking from a string containing `...` tags. - - Returns `null` when no thinking exists or when the thinking text is only whitespace. - - `formatThinkingMarkdown` produces the expected labeled/italicized markdown for multi-line thinking. - -3. Run: - - npm run test -- tests/unit/extractThinking.test.ts - - Expect the new tests to pass. - -4. Commit: - - git add -A - git commit -m "Milestone 1: Add thinking extraction helpers" - -### Milestone 2: UI wiring + history + docs - -Acceptance for this milestone is that thinking traces are visible while a run streams, persisted in the output timeline after completion, and included when loading chat history. The output should show the thinking block before the assistant reply. - -1. Update `src/app/page.tsx` to use the new helpers. - - - Replace `formatThinkingTrace`/`extractThinkingTrace` with calls to `extractThinking` and `formatThinkingMarkdown`. - - In the chat event handler (`event.event === "chat"`): - - When `payload.state === "delta"`, if a thinking block is present, set `tile.thinkingTrace` to the raw thinking text (not truncated). Keep the tile status as `running`. - - When `payload.state === "final"`, extract thinking from the final message (or use any pending `tile.thinkingTrace`), format it with `formatThinkingMarkdown`, and `appendOutput` it before appending the assistant's final text. Then clear `thinkingTrace` and `streamText` as today. - - In `buildHistoryLines`, for each assistant message, extract thinking and insert the formatted thinking markdown line before the assistant response line in the returned `lines` array. Keep user lines unchanged. - -2. Update `src/features/canvas/components/AgentTile.tsx` to render live thinking as markdown. - - - Render the `thinkingTrace` block using `ReactMarkdown` so multi-line thinking and markdown formatting appear correctly. - - Keep the existing visual styling (amber block) but remove truncation logic; the content should be the full thinking trace as sent by the model. - -3. Update `README.md` with a short section explaining that thinking traces are displayed when the model sends `content[]` entries of type `thinking` or `` blocks, and that the thinking level selector controls whether those traces appear. - -4. Manual verification: - - - Run `npm run dev`. - - Open the UI, select an agent tile, set thinking to `low` or `medium`, and send a short message. - - Confirm that while the run is streaming, a "thinking" block appears in the tile output, and once the response completes the thinking block remains in the output history above the assistant reply. - - Reload the page and confirm the thinking block persists via history loading. - -5. Commit: - - git add -A - git commit -m "Milestone 2: Surface thinking traces in chat tiles" - -## Validation and Acceptance - -Run unit tests and verify an end-to-end chat: - -- Unit tests: `npm run test -- tests/unit/extractThinking.test.ts` should pass. -- Manual UI check: start the dev server and confirm thinking blocks appear live and persist after completion and reload. - -The change is accepted when an agent run shows the thinking trace as a distinct block before the assistant message, and history reloads show the same thinking traces from `chat.history`. - -## Idempotence and Recovery - -All steps are safe to rerun. If a test or manual check fails, revert the latest commit, adjust the helper or UI wiring, and rerun the same commands. No persistent data migrations are required; only UI rendering changes and parsing utilities are added. - -## Artifacts and Notes - -- Example thinking payload (from session log): - - { "type": "thinking", "thinking": "**Running initial repo listing**", "thinkingSignature": "..." } - -- Expected output ordering in a tile after completion: - - _Running initial repo listing_ - - - -- Test run: - - npm run test -- tests/unit/extractThinking.test.ts - PASS tests/unit/extractThinking.test.ts (6 tests) - -Plan update note: 2026-01-27 -- added wheel handling so selected tile output scrolls without page scroll; changed completed traces to render as a single collapsible "Thinking" block and updated README wording. - -## Interfaces and Dependencies - -- `src/lib/text/extractThinking.ts` - - `extractThinking(message: unknown): string | null` - - `formatThinkingMarkdown(text: string): string` - -- `src/app/page.tsx` - - Use `extractThinking` and `formatThinkingMarkdown` in `buildHistoryLines` and chat event handling. - -- `src/features/canvas/components/AgentTile.tsx` - - Render `thinkingTrace` via `ReactMarkdown` inside the existing styled block. - -No new external dependencies are required. diff --git a/.agent/EXECPLAN5.md b/.agent/EXECPLAN5.md deleted file mode 100644 index 7196a09b..00000000 --- a/.agent/EXECPLAN5.md +++ /dev/null @@ -1,97 +0,0 @@ -# Replace the custom canvas with the ReactFlow-based canvas used in crabwalk - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -This plan is governed by `/Users/georgepickett/clawdbot-agent-ui/.agent/PLANS.md` and must be maintained in accordance with it. - -## Purpose / Big Picture - -The current canvas pan/zoom behavior feels inconsistent and “buggy” because it is custom, uses CSS zoom, and depends on device-specific wheel semantics. After this change, the canvas will behave like the crabwalk monitor canvas: smooth, predictable pan/zoom, reliable zoom-to-fit, and consistent minimap/controls interactions. A user will be able to zoom with the mouse wheel, drag the background to pan, resize tiles cleanly, and see the zoom readout update accurately. This can be verified by running the app, interacting with the canvas, and running the updated Playwright tests that simulate wheel zoom, drag pan, and tile resize. - -## Progress - -- [x] (2026-01-27 00:00Z) Drafted ExecPlan based on the crabwalk ReactFlow implementation. -- [x] (2026-01-28 00:00Z) Replace the custom canvas with ReactFlow and keep zoom readout, tile drag, and tile resize working. -- [x] (2026-01-28 00:00Z) Update automated tests to match the new canvas behavior and verify zoom, pan, and resize. - -## Surprises & Discoveries - -- Observation: crabwalk’s monitor canvas uses `@xyflow/react` (ReactFlow) with `Controls`, `MiniMap`, and `Background`, and relies on its built-in pan/zoom behavior rather than custom math. The package exports `NodeResizer`, which we can use for resize handles inside custom nodes. - Evidence: `/Users/georgepickett/crabwalk/src/components/monitor/ActionGraph.tsx` plus `/Users/georgepickett/crabwalk/node_modules/@xyflow/react/dist/esm/additional-components/NodeResizer/types.d.ts`. -- Observation: ReactFlow uses a named `ReactFlow` export (no default), and forcing selection via node props can cause update loops; letting ReactFlow own selection and syncing via callbacks avoided the runtime error. - Evidence: `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasFlow.tsx` runtime error overlay during early test runs. - -## Decision Log - -- Decision: Use `@xyflow/react` (same library and defaults as crabwalk) for the canvas engine, and wire our tiles into custom ReactFlow nodes with `NodeResizer` for resizing. - Rationale: This directly copies crabwalk’s proven pan/zoom behavior and removes our custom CSS-zoom transform path, which is the likely source of inconsistent UX. - Date/Author: 2026-01-27, Codex. - -## Outcomes & Retrospective - -ReactFlow now drives the canvas with built-in pan/zoom, minimap, and controls; tile drag and resize update project state, and the header zoom readout stays in sync. The e2e suite now covers wheel zoom, background pan, and NodeResizer-based resizing on the new canvas. - -## Context and Orientation - -The current canvas lives in `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx`, with the rendering handled by `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasViewport.tsx` and `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasMinimap.tsx`. The transform math lives in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/lib/transform.ts`, and the tile UI is in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTile.tsx`. The canvas zoom/offset state is stored in `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/state/store.tsx` as `CanvasTransform` with `zoom`, `offsetX`, and `offsetY`. The crabwalk canvas we want to copy is implemented with ReactFlow in `/Users/georgepickett/crabwalk/src/components/monitor/ActionGraph.tsx` using `ReactFlow`, `Controls`, `MiniMap`, and `Background`, with pan/zoom handled by the library and no custom CSS zoom logic. - -In this repo, tile positions and sizes are stored in the project state and persisted via `/Users/georgepickett/clawdbot-agent-ui/src/lib/projects/client`. Any new canvas implementation must continue to update tile position and size in state so existing persistence and UI flows remain intact. The zoom readout in the header (`/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/HeaderBar.tsx`) is currently derived from `state.canvas.zoom`, so the new canvas must keep that value updated as the user pans and zooms. - -## Plan of Work - -First, add ReactFlow (`@xyflow/react`) to the UI dependencies and import its base styles so the canvas engine renders and handles pointer interactions correctly. Next, replace the custom `CanvasViewport` and `CanvasMinimap` with a new `CanvasFlow` component that wraps a `ReactFlow` instance, matching crabwalk’s configuration (`Controls`, `MiniMap`, `Background`, `fitView`, `minZoom`, `maxZoom`). Then adapt the tile UI to render as a custom ReactFlow node: remove the absolute positioning from `AgentTile` and instead drive width and height via the ReactFlow node style, while adding a `NodeResizer` to keep tile resizing functional. Wire node drag and resize updates to dispatch tile position/size updates into the canvas store. Finally, update the zoom controls and readout to use the ReactFlow viewport state (x, y, zoom), and update or add Playwright tests to verify wheel zoom, drag pan, and resize behavior on the new canvas. - -## Concrete Steps - -Work in `/Users/georgepickett/clawdbot-agent-ui`. - -1) Add ReactFlow as a dependency and include its stylesheet. Update `package.json` to include `@xyflow/react` (use the same major version as crabwalk, `^12.10.0`), then add an import of `@xyflow/react/dist/style.css` in a global entry such as `/Users/georgepickett/clawdbot-agent-ui/src/app/globals.css` or a new canvas component that is guaranteed to be loaded on the client. - -2) Create a new canvas component, for example `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/CanvasFlow.tsx`, that wraps `ReactFlowProvider` and `ReactFlow` and exposes the same outward props as the current `CanvasViewport`, plus a new `onInit` callback to pass the `ReactFlowInstance` back up to `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx`. Use `nodeTypes` to register a custom node component (see next step). Configure `ReactFlow` similarly to crabwalk: `fitView`, `fitViewOptions={{ padding: 0.2 }}`, `minZoom={0.1}`, `maxZoom={2}`, and include `Background`, `Controls`, and `MiniMap` so the core interaction model matches crabwalk. - -3) Add a custom node component for tiles, for example `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTileNode.tsx`. This component should render the existing `AgentTile` UI, but without absolute left/top positioning. Use the `NodeResizer` from `@xyflow/react` inside this component to provide resize handles. Configure `NodeResizer` with `minWidth` and `minHeight` matching the existing `MIN_SIZE` (560 x 440), and wire `onResizeEnd` to dispatch a tile size update into the store using the new width and height provided by `NodeResizer`. Ensure the root element still includes `data-tile` so existing tests can find it. - -4) Refactor `/Users/georgepickett/clawdbot-agent-ui/src/features/canvas/components/AgentTile.tsx` so it no longer sets `left` and `top` inline styles. It should instead rely on the ReactFlow node wrapper for position and only set `width` and `height` based on `tile.size`. Remove the custom drag handlers (`handleDragStart`) so ReactFlow is the only drag system, and add a stable drag handle element on the tile header (for example by adding a `data-drag-handle` attribute) so ReactFlow can be configured to drag only from the header without interfering with text inputs. - -5) Replace usage of `CanvasViewport` and `CanvasMinimap` in `/Users/georgepickett/clawdbot-agent-ui/src/app/page.tsx` with the new `CanvasFlow` component. Keep the existing `viewportRef` for size measurement but wire `CanvasFlow` to expose the ReactFlow instance via `onInit`. Update the zoom handlers (`handleZoomIn`, `handleZoomOut`, `handleZoomReset`, `handleZoomToFit`) to call ReactFlow’s `zoomIn`, `zoomOut`, `setViewport`, and `fitView` respectively, and then update the canvas store’s `zoom`, `offsetX`, and `offsetY` using `useOnViewportChange` (or `onMove`) so the header readout remains accurate. - -6) Update `CanvasFlow` to keep store state and ReactFlow in sync. Use `onNodesChange` (and/or `onNodeDragStop`) to dispatch tile position updates when a node finishes dragging, `onNodeClick` or `onSelectionChange` to update the selected tile, and `onPaneClick` to clear selection. Use `onMove` or `useOnViewportChange` to call `onUpdateTransform({ zoom, offsetX: x, offsetY: y })` whenever the viewport changes, so `state.canvas` continues to drive readouts and placement calculations. - -7) Update or add Playwright tests to reflect the new canvas behavior. The existing tests in `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` should continue to verify that a wheel event changes the zoom readout and affects tile bounds, but adjust event payloads or selectors if ReactFlow introduces different DOM structure. Add a new test that drags the canvas background to pan and asserts that a tile’s bounding box moves relative to the viewport. Add a resize test that drags a NodeResizer handle and asserts the tile bounding box width/height changes. Keep these tests focused on user-visible behavior rather than implementation details. - -## Validation and Acceptance - -Acceptance is met when the canvas behaves like crabwalk’s: wheel zoom is smooth and consistent, drag-to-pan works across devices, tiles drag and resize without jitter, the minimap and controls are functional, and the zoom readout updates as the viewport changes. - -For each milestone, follow this verification workflow. - -Milestone 1: ReactFlow canvas with draggable tiles and viewport syncing. - -1. Tests to write: Update `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` to assert the zoom readout changes after a wheel event on the canvas root, and add a new test `pan-drag-shifts-tiles` in the same file that drags the canvas background and verifies the tile’s bounding box moves relative to its previous position. -2. Implementation: Add `@xyflow/react`, create `CanvasFlow`, hook it into `page.tsx`, and refactor `AgentTile` to be usable in a ReactFlow node. Use `onMove` (or `useOnViewportChange`) to keep `state.canvas` in sync. -3. Verification: Run `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` and confirm the new tests fail before changes and pass after the ReactFlow integration is complete. -4. Commit: After tests pass, commit with the message `Milestone 1: ReactFlow canvas and viewport sync`. - -Milestone 2: Tile resizing via NodeResizer and persistence. - -1. Tests to write: Add `resize-handle-updates-tile-size` to `/Users/georgepickett/clawdbot-agent-ui/tests/e2e/canvas-zoom-pan.spec.ts` (or a new `canvas-resize.spec.ts`) that drags a resize handle on a tile and asserts its bounding box width and height change by at least a small threshold. -2. Implementation: Add `AgentTileNode` with `NodeResizer`, wire `onResizeEnd` to update tile size in the store, and ensure tile size persists on re-render by mapping node width/height from `tile.size`. -3. Verification: Run `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` (or the new file) and confirm the resize test fails before the change and passes after. -4. Commit: After tests pass, commit with the message `Milestone 2: Tile resizing with NodeResizer`. - -## Idempotence and Recovery - -These steps are safe to run multiple times because dependency changes are additive and all code edits are deterministic. If ReactFlow integration causes regressions, revert to the previous commit boundary at the end of each milestone, or temporarily re-enable `CanvasViewport` by restoring its usage in `page.tsx`. If a test becomes flaky due to interaction timing, increase Playwright’s drag step delays or wait conditions rather than disabling the test. - -## Artifacts and Notes - -When validating, capture short command outputs in this section (for example, the final lines of `npm run e2e` showing the pass/fail summary). Keep any terminal transcripts short and focused on confirming success. - -- `npm run e2e -- tests/e2e/canvas-zoom-pan.spec.ts` - - 4 passed (4.1s) - -## Interfaces and Dependencies - -Use `@xyflow/react` for the canvas engine, including `ReactFlow`, `ReactFlowProvider`, `Controls`, `MiniMap`, `Background`, `NodeResizer`, and `useOnViewportChange` or `onMove` events to observe viewport changes. The custom node component (`AgentTileNode`) should accept `data` containing the `AgentTile` plus callbacks for selection, move, resize, rename, and send actions. The `CanvasFlow` component should accept `tiles`, `selectedTileId`, and callback props mirroring the current `CanvasViewport` API, and should expose a `ReactFlowInstance` through `onInit` so the header can trigger `zoomIn`, `zoomOut`, `setViewport`, and `fitView`. - -Changes made: Initial plan drafted based on crabwalk’s ReactFlow canvas and the current clawdbot-agent-ui canvas structure. diff --git a/.agent/EXECPLAN6.md b/.agent/EXECPLAN6.md deleted file mode 100644 index 4e18bb80..00000000 --- a/.agent/EXECPLAN6.md +++ /dev/null @@ -1,141 +0,0 @@ -# Avatar-first agent tiles with options menu - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -This plan must be maintained in accordance with the repository planning guide at `.agent/PLANS.md`. - -## Purpose / Big Picture - -After this change, creating a new agent shows a compact, avatar-first tile: the agent name appears above a circular avatar, and a single “Send a command” input sits below. The transcript window stays hidden until the first message is sent; then the conversation and thinking traces appear below the avatar. Model and thinking controls move into a small gear options panel, and the running/idle status plus delete action move out of the main view to reduce clutter. A user can start a new agent and immediately see a face and a single input box, and after sending a command they see the transcript panel appear beneath the avatar. - -## Progress - -- [x] (2026-01-28 00:00Z) Authored initial ExecPlan for avatar-first tiles and options menu. -- [x] (2026-01-28 17:35Z) Milestone 1: Add Multiavatar dependency, avatar helpers, and unit tests. -- [x] (2026-01-28 18:05Z) Milestone 2: Restructure AgentTile layout, add options menu, update default tile sizing, and add e2e coverage. - -## Surprises & Discoveries - -- Observation: Next dev server (Turbopack) could not resolve the symlinked `@multiavatar/multiavatar` package from `file:../Multiavatar`. - Evidence: Next dev overlay showed “Module not found: Can't resolve '@multiavatar/multiavatar'” when loading the canvas. - -## Decision Log - -- Decision: Use Multiavatar seeded by `agentId` and strip the built-in environment circle so the UI owns the circular container. - Rationale: `agentId` stays stable across renames, and a single UI circle avoids double backgrounds. - Date/Author: 2026-01-28 / Codex - -- Decision: Hide the transcript panel until there is output or streaming text. - Rationale: This matches the “face + input only” requirement for new agents and avoids a large empty chat window. - Date/Author: 2026-01-28 / Codex - -- Decision: Make model/thinking selectors available in a gear options panel and relocate status and delete actions there. - Rationale: The user requested an options menu and a rethink of the status/delete placement; there is no backend stop/run toggle today, so status stays read-only. - Date/Author: 2026-01-28 / Codex - -- Decision: Switch to the GitHub-hosted `@multiavatar/multiavatar` package and import from `@multiavatar/multiavatar/esm`. - Rationale: Eliminates the local file dependency while keeping a resolvable ESM entry for Next/Turbopack. - Date/Author: 2026-01-28 / Codex - -## Outcomes & Retrospective - -Implemented avatar-first tiles with a gear options panel, added Multiavatar helper/test coverage, and validated behavior with Playwright. Remaining follow-ups are optional UX tweaks (e.g., options panel behavior) based on user feedback. - -## Context and Orientation - -The agent canvas UI renders tiles via `src/features/canvas/components/AgentTile.tsx`, which is wrapped by `src/features/canvas/components/AgentTileNode.tsx` and placed on the canvas in `src/features/canvas/components/CanvasFlow.tsx`. Tile runtime state lives in `src/features/canvas/state/store.tsx` and is created from the persisted `ProjectTile` data defined in `src/lib/projects/types.ts`. New tiles are created by the API route `src/app/api/projects/[projectId]/tiles/route.ts`, which also defines the default tile size and name. The top-level page uses `src/app/page.tsx` to connect the canvas to gateway events and to send messages; it appends user and assistant output lines to each tile. - -Avatar generation code is sourced from the GitHub-hosted `@multiavatar/multiavatar` package and wrapped by `src/lib/avatars/multiavatar.ts` to produce an SVG data URL for an `` tag. - -Playwright e2e tests live under `tests/e2e/`, and Vitest unit tests live under `tests/unit/`. Existing e2e tests already mock `/api/projects` to supply tiles. - -## Plan of Work - -First, add the Multiavatar dependency via GitHub, then create a small avatar helper module that turns a seed string into an SVG string and a safe data URL. Add a unit test to validate that the helper returns valid SVG and a data URL prefix so the build breaks early if the dependency is not wired correctly. - -Next, refactor `AgentTile` into an avatar-first layout: name above avatar, input below, and a transcript window that only renders once there is output or streaming. Add a gear button that reveals model and thinking selectors plus the status indicator and delete action. Use simple, accessible HTML (button + popover or details/summary) and add minimal `aria-label`/`data-` hooks for Playwright. Update the minimum tile size and the API’s default tile size to match the compact layout. Finally, add a Playwright test that verifies the avatar, input, and options panel are present for a new tile and that the transcript panel is hidden initially. - -## Concrete Steps - -Work from the repo root `/Users/georgepickett/clawdbot-agent-ui`. - -1. Ensure the Multiavatar build exists by checking `../Multiavatar/dist/esm/index.js`. If the file is missing, run `npm install` and `npm run build` inside `/Users/georgepickett/Multiavatar` to generate `dist/`. - -2. Add the local dependency to `package.json` as `"@multiavatar/multiavatar": "file:../Multiavatar"`, then run `npm install` to update `package-lock.json`. - -3. Create `src/lib/avatars/multiavatar.ts` with two exported functions: `buildAvatarSvg(seed: string): string` and `buildAvatarDataUrl(seed: string): string`. Validate that `seed` is a non-empty string and throw a clear error if not. Call `multiavatar(seed, true)` (no environment circle) and wrap the SVG in a `data:image/svg+xml;utf8,` URL with `encodeURIComponent`. - -4. Add `tests/unit/multiavatar.test.ts` that asserts: - - `buildAvatarSvg("Agent A")` starts with ``. - - `buildAvatarDataUrl("Agent A")` starts with `data:image/svg+xml;utf8,` and includes an encoded `` inside a circular container (`rounded-full`, `overflow-hidden`). Use `alt` text like `Avatar for ${name}` for testability. - -6. Refactor `src/features/canvas/components/AgentTile.tsx`: - - Replace the current header row and transcript-first layout with a vertical stack: name input (centered), avatar, input row, then transcript panel. - - Keep the name editable with the same onBlur/onKeyDown logic, but style it as a centered label above the avatar. - - Introduce a gear button (using `lucide-react` settings icon) that opens an options panel containing the Model selector, Thinking selector, a read-only status indicator, and a “Delete agent” button. This panel should be accessible (keyboard focusable) and should not interfere with tile dragging. - - Remove the always-visible “No output yet.” message; instead, render the transcript panel only when there is output (`tile.outputLines.length > 0`), streaming text, or active thinking. - - Keep existing output rendering logic, including thinking traces and streamed text, but position it in the new panel below the input. - -7. Update `MIN_TILE_SIZE` in `AgentTile.tsx` and the default `size` in `src/app/api/projects/[projectId]/tiles/route.ts` to match the compact layout (for example, around 420x520). Ensure the new minimum still allows the transcript to be visible when present. - -8. Add a Playwright test `tests/e2e/agent-tile-avatar.spec.ts` that: - - Mocks `/api/projects` to return a store with one tile and no output lines. - - Navigates to `/` and asserts the avatar image with alt text is visible and the “Send a command” input exists. - - Opens the gear options panel and asserts the Model and Thinking selectors are visible. - - Asserts that the transcript panel is not present when output lines are empty (use a `data-testid` on the transcript container if needed). - -9. Run unit and e2e tests: `npm test` and `npm run e2e`. If Playwright requires the dev server, use the same workflow as existing e2e tests. - -## Validation and Acceptance - -The change is accepted when: - -- Creating a new agent renders a tile with a centered name above a circular avatar and an input labeled “Send a command,” and no transcript window is visible until a message is sent. -- After sending a command, the transcript window appears below the avatar and contains the user message and assistant output, with thinking traces formatted as before. -- A gear options panel exists on the tile, containing Model and Thinking controls, a status indicator, and a delete action. -- The avatar is generated from Multiavatar using a stable seed and displays consistently across reloads. - -Milestone 1 verification workflow: - -1. Tests to write: `tests/unit/multiavatar.test.ts` with assertions for `buildAvatarSvg` and `buildAvatarDataUrl`. -2. Implementation: add the local dependency, implement `src/lib/avatars/multiavatar.ts`. -3. Verification: run `npm test -- tests/unit/multiavatar.test.ts` and confirm it passes after failing before. -4. Commit: `git commit -m "Milestone 1: add avatar helper and tests"`. - -Milestone 2 verification workflow: - -1. Tests to write: `tests/e2e/agent-tile-avatar.spec.ts` to assert avatar, input, options panel, and hidden transcript. -2. Implementation: refactor `AgentTile`, add `AgentAvatar`, update tile sizes, and add necessary `aria-label`/`data-testid` hooks. -3. Verification: run `npm run e2e -- tests/e2e/agent-tile-avatar.spec.ts` and confirm it passes; run `npm test` to ensure unit tests still pass. -4. Commit: `git commit -m "Milestone 2: avatar-first tile layout and options menu"`. - -## Idempotence and Recovery - -All steps are safe to repeat. If `npm install` fails after adding the file dependency, remove the entry from `package.json`, run `npm install` to return to a clean state, then re-add the dependency once the Multiavatar build is confirmed. If UI changes cause layout regressions, revert the specific commit for the milestone and reapply the plan with adjusted sizes or layout choices. - -## Artifacts and Notes - -Expected unit test output excerpt: - - ✓ buildAvatarSvg returns svg - ✓ buildAvatarDataUrl returns data url - -Expected e2e assertions: - - - Avatar image is visible in the tile. - - “Send a command” input is visible. - - Options menu reveals Model and Thinking selectors. - - Transcript container is absent when there is no output. - -## Interfaces and Dependencies - -Use the local `@multiavatar/multiavatar` package from `../Multiavatar` via a file dependency. Define a small helper module at `src/lib/avatars/multiavatar.ts` with: - - export function buildAvatarSvg(seed: string): string - export function buildAvatarDataUrl(seed: string): string - -`buildAvatarSvg` must throw a descriptive error if `seed` is empty. `buildAvatarDataUrl` must return a `data:image/svg+xml;utf8,` URL that can be used in an `` tag. The `AgentAvatar` component must accept `seed` and `name` props and render an image with `alt="Avatar for ${name}"`. - -Plan update (2026-01-28): Documented the switch to the GitHub-hosted Multiavatar package and updated progress to reflect completed milestones. diff --git a/.agent/PLANS.md b/.agent/PLANS.md index fc181026..15d9583b 100644 --- a/.agent/PLANS.md +++ b/.agent/PLANS.md @@ -6,7 +6,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. -When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously. For each milestone, write failing tests first (when tests are specified), implement until all tests pass, then commit the verified changes before proceeding to the next milestone. If the repo uses Beads, use `br ready` to select work and update issue status as you progress. +When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently. When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. @@ -48,8 +48,6 @@ Be idempotent and safe. Write the steps so they can be run multiple times withou Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project’s toolchain and how to interpret their results. -When specifying tests, prefer a test-first approach: describe which tests to write and what they should assert before describing the implementation. This allows the implementing agent to write failing tests first, then implement until the tests pass. Specify the test file paths, test function names, and the exact assertions expected. If the project has an existing test structure, follow its conventions. - Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. ## Milestones @@ -58,32 +56,6 @@ Milestones are narrative, not bureaucracy. If you break the work into milestones Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. -## Verification and Test-Driven Milestones - -Every milestone must include built-in verification steps that allow the implementing agent to confirm correctness without human intervention. Prefer test-driven development: write failing tests that define the milestone's acceptance criteria before writing the implementation. The milestone is not complete until all tests pass. - -When designing a milestone, follow this verification pattern: - -1. Define the acceptance criteria as concrete, observable behaviors. -2. Write tests (unit, integration, or end-to-end as appropriate) that exercise these behaviors. Run them to confirm they fail for the expected reasons. -3. Implement the feature or change. -4. Run the tests again. The milestone is complete only when all tests pass and any other validation steps succeed. -5. After all tests pass, the implementing agent is permitted (and encouraged) to commit the changes with a clear commit message describing the milestone completed. - -If tests are not feasible for a particular milestone (e.g., infrastructure setup, configuration changes, or exploratory prototypes), specify alternative verification steps: commands to run, outputs to observe, or states to confirm. The key requirement is that the agent can autonomously verify success without asking for human confirmation. - -Commits should be frequent and atomic. Each milestone that passes verification should be committed before proceeding to the next. This creates a clean history of incremental progress and allows safe rollback if later milestones encounter issues. The commit message should reference the milestone and summarize what was achieved. - -## Issue Tracking with Beads - -ExecPlans integrate with Beads (`br`) for local issue tracking. When a repo has Beads initialized (`.beads/` directory exists), use it to track milestones as issues. - -When authoring an ExecPlan, create a Beads issue for each milestone: `br create "Milestone N: " --type task --priority <0-4> --description "<scope and acceptance criteria>"`. Use `br dep add <child> <parent>` to express milestone dependencies. Record the issue IDs in the Progress section. - -When implementing, use `br ready --json` to select the next unblocked milestone. Claim it with `br update <id> --status in_progress`. After verification passes, close it with `br close <id> --reason "Tests pass, committed"`. Run `br sync --flush-only` before committing to include the issue state in git history. - -If Beads is not initialized or the user has not requested issue tracking, skip these steps. - ## Living plans and design decisions * ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. @@ -114,11 +86,11 @@ Prefer additive code changes followed by subtractions that keep tests passing. P Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work. - - [x] (2025-10-01 13:00Z) Example completed step. [BEAD-001] - - [ ] Example incomplete step. [BEAD-002] - - [ ] Example partially completed step (completed: X; remaining: Y). [BEAD-003] + - [x] (2025-10-01 13:00Z) Example completed step. + - [ ] Example incomplete step. + - [ ] Example partially completed step (completed: X; remaining: Y). - Use timestamps to measure rates of progress. If using Beads, include the issue ID in brackets after each step. + Use timestamps to measure rates of progress. ## Surprises & Discoveries @@ -155,14 +127,6 @@ Prefer additive code changes followed by subtractions that keep tests passing. P Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <project’s test command> and expect <N> passed; the new test <name> fails before the change and passes after>". - For each milestone, specify the verification workflow: - 1. Tests to write: List the test file paths, test function names, and assertions. These tests should be written first and must fail before implementation. - 2. Implementation: Describe the changes to make. - 3. Verification: Run the tests. The milestone is complete only when all tests pass. - 4. Commit: After verification passes, commit the changes with a message referencing the milestone. - - Example: "Write test_user_creation in tests/test_users.py that asserts a 201 response with user ID. Run pytest tests/test_users.py -k test_user_creation and confirm it fails. Implement the endpoint. Run the test again and confirm it passes. Commit with message 'Milestone 1: Add user creation endpoint'." - ## Idempotence and Recovery If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. diff --git a/.agent/README.md b/.agent/README.md new file mode 100644 index 00000000..1222c12f --- /dev/null +++ b/.agent/README.md @@ -0,0 +1,17 @@ +# Agent Workspace Notes + +This directory intentionally mixes tracked and local-only files. + +Tracked in git: +- `PLANS.md` +- `done/` + +Local-only: +- `execplan-pending.md` +- `local/` (symlink to machine-private notes) + +The `post-checkout` git hook creates `local/` as a symlink to: +- `${OPENCLAW_STUDIO_PRIVATE_AGENT_DIR}` if set +- otherwise `~/.codex/private/openclaw-studio` + +Store EC2 credentials, host-specific notes, and other sensitive material under that private path. diff --git a/.agent/future-plans/20260127-1228-gateway-resilience.md b/.agent/future-plans/20260127-1228-gateway-resilience.md deleted file mode 100644 index 4e5b6491..00000000 --- a/.agent/future-plans/20260127-1228-gateway-resilience.md +++ /dev/null @@ -1,5 +0,0 @@ -GatewayClient (src/lib/gateway/GatewayClient.ts) performs a one-shot connect and the hook in src/lib/gateway/useGatewayConnection.ts only auto-connects once, so any transient gateway failure requires a manual refresh. The UI in src/app/page.tsx only gates actions on the connection status and does not provide a structured retry UX. - -Implement a reconnect strategy with exponential backoff and jitter, add a retrying status and lastError fields, and respect GatewayResponseError.retryAfterMs when present. Update HeaderBar or page UI to show connection state, last error, and a retry or cancel action; optionally persist gatewayUrl/token edits in local storage or via a small API endpoint so users do not retype on reload. - -Acceptance criteria: on socket close, the client retries with backoff until connected or explicitly disconnected; the UI displays next retry timing and lets the user cancel; tests with fake timers validate backoff and stop behavior. Open question: should auto-reconnect be disabled after auth failures, and how do we detect that from gateway responses. diff --git a/.agent/future-plans/20260127-1228-onboarding-docs.md b/.agent/future-plans/20260127-1228-onboarding-docs.md deleted file mode 100644 index 871ce34e..00000000 --- a/.agent/future-plans/20260127-1228-onboarding-docs.md +++ /dev/null @@ -1,5 +0,0 @@ -README.md is empty and USER.md contains only a preference, while critical operational details live in code, including config search paths in src/lib/clawdbot/config.ts, gateway defaults in src/lib/clawdbot/gateway.ts, env variables in src/lib/env.ts, and local state in src/app/api/projects/store.ts. This makes onboarding and troubleshooting slow and error-prone. - -Write a concise README with setup steps, dev and test commands from package.json, required env/config, gateway expectations, and where workspace state is stored. Add a short troubleshooting section for missing config and gateway errors; optionally consolidate USER.md into README if it is not used elsewhere to reduce scattered docs. - -Acceptance criteria: README explains how to run the UI with a local gateway, lists required env/config paths, and documents common failure modes with fixes. Open question: should docs standardize on .moltbot vs .clawdbot naming and which path is preferred for new installs. diff --git a/.agent/future-plans/20260127-1228-project-store-hardening.md b/.agent/future-plans/20260127-1228-project-store-hardening.md deleted file mode 100644 index 9e535238..00000000 --- a/.agent/future-plans/20260127-1228-project-store-hardening.md +++ /dev/null @@ -1,5 +0,0 @@ -The projects store is persisted as JSON at ~/.clawdbot/agent-canvas/projects.json (src/app/api/projects/store.ts) with manual parsing and minimal validation, and the UI auto-saves every 250ms in src/features/canvas/state/store.tsx without handling save failures. This setup risks silent corruption or data loss when parsing fails or concurrent writes occur. - -Introduce a zod schema for ProjectsStore and tile structures, perform validation on load and save, and switch to atomic writes (write temp then rename) with a backup copy when parsing fails. Surface explicit error codes from src/app/api/projects/route.ts, and update the client store to pause autosave and show a blocking error with retry guidance. Touch src/app/api/projects/store.ts, src/app/api/projects/route.ts, src/lib/projects/types.ts, src/lib/http.ts, and src/features/canvas/state/store.tsx. - -Acceptance criteria: invalid store files produce actionable errors while preserving the last good file; saves are atomic and verified; the UI shows a clear failure state and does not silently overwrite data; tests cover migration and validation. Open question: how should concurrent writes from multiple tabs/processes be resolved, and do we need a version conflict policy. diff --git a/.agent/future-plans/20260127-1228-test-coverage.md b/.agent/future-plans/20260127-1228-test-coverage.md deleted file mode 100644 index c551702b..00000000 --- a/.agent/future-plans/20260127-1228-test-coverage.md +++ /dev/null @@ -1,5 +0,0 @@ -Test coverage is minimal: tests/unit only includes fetchJson and slugifyProjectName, and tests/e2e has a single canvas smoke test, while the main chat flow and polling logic live in src/app/page.tsx and the state machine in src/features/canvas/state/store.tsx. This leaves key behaviors (gateway errors, chat history reconciliation, and autosave) unverified. - -Add unit tests for the canvas reducer actions, store migration/validation, and GatewayClient request/response handling. Expand Playwright e2e to cover create workspace, add tile, send a message with a mocked gateway, and error states; reuse tests/setup.ts for common mocks and fixtures. - -Acceptance criteria: tests cover both success and failure cases for store load/save and gateway errors, and e2e runs deterministically with a mocked gateway. Open question: should the gateway be mocked via a local WebSocket test server or an in-browser stub to keep tests fast and reliable. diff --git a/.agent/future-plans/20260127-1248-model-capabilities.md b/.agent/future-plans/20260127-1248-model-capabilities.md deleted file mode 100644 index b7b02366..00000000 --- a/.agent/future-plans/20260127-1248-model-capabilities.md +++ /dev/null @@ -1,5 +0,0 @@ -Model options and thinking levels are hard-coded in the UI (src/features/canvas/components/AgentTile.tsx) while the runtime settings are pushed via sessions.patch in src/app/page.tsx without any capability validation. This creates a mismatch risk when the gateway or config supports different models or disallows thinking settings, leading to confusing failures that only surface as output lines. - -Add a gateway capabilities endpoint (for example /api/gateway/capabilities) that reports supported models and thinking levels, backed by config or a lightweight gateway handshake, and thread that data into the canvas UI. Update src/app/page.tsx to load capabilities once, store them in state, and make AgentTile render options dynamically with disabled or hidden unsupported options; add a fallback to show a "custom model" input if a tile already has a model not in the list. - -Acceptance criteria: the model/thinking dropdowns always reflect the gateway capability response; unsupported selections are blocked with a clear inline message; and the UI still renders existing tiles with custom model values. Open question: should capabilities come from config only or a live gateway handshake, and how do we cache them to avoid blocking initial render. diff --git a/.agent/future-plans/20260127-1248-state-dir-unification.md b/.agent/future-plans/20260127-1248-state-dir-unification.md deleted file mode 100644 index aec99733..00000000 --- a/.agent/future-plans/20260127-1248-state-dir-unification.md +++ /dev/null @@ -1,5 +0,0 @@ -State directory resolution is inconsistent: config lookup in src/lib/clawdbot/config.ts searches both ~/.moltbot and ~/.clawdbot, but agent workspace paths in src/lib/projects/agentWorkspace.ts and agent state paths in src/app/api/projects/[projectId]/tiles/route.ts and src/app/api/projects/[projectId]/tiles/[tileId]/route.ts are hard-coded to ~/.clawdbot. This can split state across directories depending on environment variables, which makes onboarding and cleanup confusing. - -Extract a single state-dir resolver (for example in src/lib/clawdbot/stateDir.ts) that applies the same env and fallback rules as loadClawdbotConfig, then update all filesystem paths that rely on ~/.clawdbot to use it. Touch src/lib/projects/agentWorkspace.ts, src/app/api/projects/[projectId]/tiles/route.ts, src/app/api/projects/[projectId]/tiles/[tileId]/route.ts, and any other locations that build agent paths from CLAWDBOT_STATE_DIR; add a small unit test to lock down path resolution ordering. - -Acceptance criteria: all agent workspace and state paths resolve from the same function and honor MOLTBOT_STATE_DIR/CLAWDBOT_STATE_DIR consistently; documentation reflects the single source of truth; and tests cover env override precedence. Open question: should MOLTBOT_STATE_DIR always win over CLAWDBOT_STATE_DIR, or do we need a migration path for existing users. diff --git a/.agent/future-plans/20260127-1248-tile-deletion-safety.md b/.agent/future-plans/20260127-1248-tile-deletion-safety.md deleted file mode 100644 index 96b45409..00000000 --- a/.agent/future-plans/20260127-1248-tile-deletion-safety.md +++ /dev/null @@ -1,5 +0,0 @@ -Deleting a tile triggers server-side directory removal in src/app/api/projects/[projectId]/tiles/[tileId]/route.ts via fs.rmSync, but the UI delete button in src/features/canvas/components/AgentTile.tsx does not confirm or summarize what will be removed. This is a high-risk action because it can delete both the agent workspace and agent state directories without any reversible path. - -Add a confirmation flow that shows the exact directories slated for deletion and require a typed confirmation for tiles with existing workspaces; consider adding a server-side archive mode that moves directories to a trash location under the state dir instead of deleting outright. Update the delete endpoint to support a mode flag (archive vs delete), return the resolved paths in its response, and update the client to opt into archive by default with an explicit "permanently delete" option. - -Acceptance criteria: tile deletion cannot proceed without an explicit confirmation, the API returns the paths that were archived/deleted, and the UI exposes a reversible archive workflow. Open question: should archives auto-expire or require a manual cleanup command, and where should that live in the UI. diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..dfbe87ed --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Optional overrides for local dev. +# Default behavior uses ~/.openclaw and falls back to ~/.moltbot or ~/.clawdbot. + +# Point to a specific state directory. +OPENCLAW_STATE_DIR=/Users/yourname/.openclaw +# MOLTBOT_STATE_DIR=/Users/yourname/.moltbot +# CLAWDBOT_STATE_DIR=/Users/yourname/.clawdbot + +# Point to a specific config file. +OPENCLAW_CONFIG_PATH=/Users/yourname/.openclaw/openclaw.json +# MOLTBOT_CONFIG_PATH=/Users/yourname/.moltbot/moltbot.json +# CLAWDBOT_CONFIG_PATH=/Users/yourname/.clawdbot/clawdbot.json + +# Default upstream gateway URL for the Studio host (used when Studio settings are missing). +# Defaults to ws://127.0.0.1:18789 +NEXT_PUBLIC_GATEWAY_URL=ws://127.0.0.1:18789 + +# Optional: override the SSH target used for gateway-host operations. +# This SSH target is used when deleting agents to move their workspace/state into ~/.openclaw/trash on the gateway host. +OPENCLAW_GATEWAY_SSH_TARGET= + +# Optional: SSH user when SSH target is derived from the gateway URL (default: ubuntu). +OPENCLAW_GATEWAY_SSH_USER= + +# Optional: which existing agent to copy auth profiles from when creating tiles. +CLAWDBOT_DEFAULT_AGENT_ID=main diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 00000000..a2d6c44d --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || exit 0)" +agent_dir="$repo_root/.agent" +private_dir="${OPENCLAW_STUDIO_PRIVATE_AGENT_DIR:-$HOME/.codex/private/openclaw-studio}" +local_link="$agent_dir/local" + +mkdir -p "$agent_dir" +mkdir -p "$private_dir" + +if [ -e "$local_link" ] && [ ! -L "$local_link" ]; then + exit 0 +fi + +if [ -L "$local_link" ]; then + target="$(readlink "$local_link")" + if [ "$target" = "$private_dir" ]; then + exit 0 + fi +fi + +ln -sfn "$private_dir" "$local_link" diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..7269cb48 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,21 @@ +--- +name: Issue +about: Report a bug or request a change +--- + +## Summary + +## Steps to reproduce +1. + +## Expected + +## Actual + +## Environment +- OS: +- Node: +- UI version/commit: +- Gateway running? (yes/no) + +## Logs/screenshots diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..d83a048c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Summary +- + +## Testing +- [ ] Not run (explain why) +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm run test` +- [ ] `npm run e2e` + +## AI-assisted +- [ ] AI-assisted (briefly describe what and include prompts/logs if helpful) diff --git a/.gitignore b/.gitignore index 5ef6a520..d2c03075 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,34 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +playwright-report +test-results +.playwright-home +/.playwright-cli +/output/playwright + +# agent state +/.agent/* +!/.agent/PLANS.md +!/.agent/README.md +!/.agent/done/ +!/.agent/done/** +/.agent/done/**/*.md +/.agent/local +/.agent/execplan-pending.md +/.agent/*.local.md +/.agent/future-plans +/.openclaw +/.clawdbot +/.moltbot +/agent-canvas +/worktrees +/.claude + +# local issue tracker +/.beads + +# bv (beads viewer) local config and caches +.bv/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..2ae2317e --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +loglevel=silent diff --git a/.playwright-home/Library/Preferences/nextjs-nodejs/config.json b/.playwright-home/Library/Preferences/nextjs-nodejs/config.json deleted file mode 100644 index 4ae15cc5..00000000 --- a/.playwright-home/Library/Preferences/nextjs-nodejs/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "telemetry": { - "notifiedAt": "1769545120941", - "anonymousId": "3f54b52ec8bbe85520879d44e931326f6fcd173b5a609a81139c9da573ca3142", - "salt": "22ea424b2c776e6b5015e872856b1ff8" - } -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 763382eb..26dce3a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1 +1,12 @@ -If you have any doubts about how clawdbot works or anything about that repo - take a look at ~/clawdbot (the source of truth for the backend of this app) \ No newline at end of file +# Agent Instructions + +Keep repository instructions generic and safe for open source. + +This repo is a frontend for OpenClaw. The OpenClaw source code lives at ~/openclaw. + +Do not modify the OpenClaw source code. When the user asks for changes, they are asking for changes to OpenClaw Studio (this app). Your solutions should be applied to this app but to understand the full context of implementing your solution, you will need to search through OpenClaw's source code. + +If present, also load local private overlay instructions from: +`$HOME/.codex/agents/openclaw-studio.local.md` + +Do not commit personal, environment-specific, or secret instructions to this repository. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..ddc9a65d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,191 @@ +# Architecture + +## High-level overview & goals +OpenClaw Studio is a gateway-first, single-user Next.js App Router UI for managing OpenClaw agents. It provides: +- A focused UI with fleet list, primary agent panel, and inspect sidebar. +- Local persistence for gateway connection + focused-view preferences via a JSON settings file. +- A same-origin WebSocket bridge (`/api/gateway/ws`) from browser to the upstream OpenClaw gateway. +- Gateway-backed edits for agent config and agent files. + +Primary goals: +- **Gateway-first**: agents, sessions, and config live in the gateway; Studio stores only UI settings. +- **Remote-friendly**: tailnet/remote gateways are first-class. +- **Clear boundaries**: client UI vs server routes vs external gateway/config. +- **Predictable state**: gateway is source of truth; local settings only for focused preferences + connection. +- **Maintainability**: feature-focused modules, minimal abstractions. + +Non-goals: +- Multi-tenant or multi-user concurrency. +- Server-side rendering of data from external services. + +## Architectural style +**Layered + vertical slice (feature-first)** within Next.js App Router: +- UI components + feature state in `src/features`. +- Shared utilities and adapters in `src/lib`. +- Server-side route handlers under `src/app/api`. + +This keeps feature cohesion high while preserving a clear client/server boundary. + +## Main modules / bounded contexts +- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). The Skills section in `AgentSettingsPanel` is split into `Access` (per-agent allowlist mode + toggles) and `Library` (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. +- **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings. +- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: <CODE> ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`. +- **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway. +- **Gateway SSH helpers** (`src/lib/ssh/gateway-host.ts`): shared SSH target resolution and JSON-over-SSH execution for server routes. `runSshJson` centralizes `ssh -o BatchMode=yes` invocation, JSON parsing, and actionable error extraction; callers with large payloads (for example base64 media reads) can opt into a higher `maxBuffer` rather than duplicating `spawnSync` calls. +- **Gateway-backed config + agent-file edits** (`src/lib/gateway/agentConfig.ts`, `src/lib/gateway/agentFiles.ts`, `src/lib/gateway/execApprovals.ts`, `src/features/agents/components/AgentInspectPanels.tsx`): agent create/rename/heartbeat/delete and per-agent overrides via `config.get` + `config.patch`, agent file read/write via `agents.files.get` and `agents.files.set`, and per-agent exec approvals via `exec.approvals.get` + `exec.approvals.set`. +- **Heartbeat helpers** (`src/lib/gateway/agentConfig.ts`): resolves per-agent heartbeat state (enabled + schedule) by combining gateway config (`config.get`) and status (`status`) for the settings panel, triggers `wake` for “run now”, and owns the heartbeat type shapes and gateway config mutation helpers. +- **Session lifecycle actions** (`src/features/agents/state/store.tsx`, `src/app/page.tsx`): per-agent “New session” calls gateway `sessions.reset` on the current session key and resets local runtime transcript state. +- **Local OpenClaw config + paths** (`src/lib/clawdbot`): state/config path resolution with `OPENCLAW_*` env overrides (`src/lib/clawdbot/paths.ts`). Gateway URL/token in Studio are sourced from studio settings. +- **Shared agent config-list helpers** (`src/lib/gateway/agentConfig.ts`): pure `agents.list` read/write/upsert helpers used by gateway config patching to keep list-shape semantics aligned. +- **Shared utilities** (`src/lib/*`): env, ids, names, avatars, message parsing/normalization (including tool-line formatting) in `src/lib/text/message-extract.ts`, cron types + selector helpers + gateway call helpers in `src/lib/cron/types.ts`, logging, filesystem helpers. + +## Directory layout (top-level) +- `src/app`: Next.js App Router pages, layouts, global styles, and API routes. +- `src/features`: feature-first UI modules (currently focused agent-management components under `features/agents`). +- `src/lib`: domain utilities, adapters, API clients, and shared logic. +- `src/components`: shared UI components (minimal use today). +- `src/styles`: shared styling assets. +- `server`: custom Node server and WS proxy for gateway bridging + access gate. +- `public`: static assets. +- `tests`, `playwright.config.ts`, `vitest.config.ts`: automated testing. + +## Data flow & key boundaries +### 1) Studio settings + focused preferences +- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir`, with legacy fallbacks in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences. +- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings by reading and writing `openclaw-studio/settings.json` under the resolved state dir. +- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings and coalesce debounced `/api/studio` patch writes. + +Flow: +1. UI loads settings from `/api/studio`. +2. Gateway URL/token seed the connection panel and auto-connect. +3. Focused filter + selected agent are loaded for the current gateway. +4. UI schedules focused and gateway patches through the coordinator; both paths converge on `/api/studio`. + +### 2) Agent runtime (gateway) +- **Client-side boundary**: `GatewayClient` connects to Studio-origin `/api/gateway/ws` via `resolveStudioProxyGatewayUrl()` and wraps the vendored `GatewayBrowserClient`. +- **Server-side boundary**: custom server proxy (`server/gateway-proxy.js`) is in the middle for upstream URL/token resolution and connect-frame token injection. + +Flow: +1. UI loads gateway URL/token from `/api/studio` (defaulting to `NEXT_PUBLIC_GATEWAY_URL`, or `ws://localhost:18789` when that env var is unset). +2. Browser opens WS to Studio `/api/gateway/ws` (`ws://` on `http`, `wss://` on `https`). +3. Proxy loads upstream URL/token from Studio settings on the server and opens upstream WS. +4. Proxy forwards `connect` and subsequent frames; it injects auth token server-side if the connect frame has none. +5. If upstream connect fails, the proxy sends an error response for the `connect` request (with a `studio.*` error code when possible) and closes the Studio-origin WS. The browser-side gateway client converts a failed `connect` response into a WS close with code `4008` and a reason like `connect failed: <CODE> ...`; `GatewayClient.connect()` parses this into `GatewayResponseError`, and `useGatewayConnection` decides whether/when to auto-retry based on `connectErrorCode` (through `resolveGatewayAutoRetryDelayMs`). +6. UI requests `agents.list` and builds session keys via `buildAgentMainSessionKey(agentId, mainKey)`. +7. A single gateway listener in `src/app/page.tsx` classifies `presence`/`heartbeat`/`chat`/`agent` events through `classifyGatewayEventKind` in `src/features/agents/state/runtimeEventBridge.ts`, then routes runtime payloads through `src/features/agents/state/runtimeEventPolicy.ts` and executes intents via `src/features/agents/state/gatewayRuntimeEventHandler.ts`; it also independently tracks `exec.approval.requested` / `exec.approval.resolved` for in-chat approval cards. +8. Agent store updates agent output/state. +9. Pending approval queues are pruned by expiry timestamp (with a short grace window), so stale cards and stale `awaitingUserInput` badges self-clear even when no resolved event arrives. + +### 3) Agent create + per-agent setup +- **Agent files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`. +- **Create + heartbeat + rename + per-agent overrides**: stored in gateway config and updated via `config.get` + `config.patch`. +- **Exec approvals policy**: managed after creation through settings/runtime approval flows. + +Flow: +1. `AgentCreateModal` captures only create payload (`name`, optional `avatarSeed`) in a one-step launch flow (`src/features/agents/components/AgentCreateModal.tsx`). +2. `runCreateAgentMutationLifecycle` enqueues one create mutation, applies queue guardrails, and drives create block status (`src/features/agents/operations/mutationLifecycleWorkflow.ts`). +3. `createGatewayAgent` calls `config.get` to derive workspace, then calls `agents.create` with `{ name, workspace }` (`src/lib/gateway/agentConfig.ts`). +4. Studio reloads fleet state and focuses the created agent chat in `src/app/page.tsx`. +5. Any authority/runtime adjustments happen after creation through settings operations such as `updateExecutionRoleViaStudio` (`src/features/agents/operations/agentPermissionsOperation.ts`). + +### 4) Cron summaries + settings controls +- **Cron**: the UI calls gateway cron methods directly (`cron.list`, `cron.add`, `cron.run`, `cron.remove`) for latest-update previews and agent settings controls. +- **Create flow**: `AgentSettingsPanel` collects a `CronCreateDraft` in a modal wizard, `buildCronJobCreateInput` maps it to a gateway-safe payload (`src/lib/cron/createPayloadBuilder.ts`), and `performCronCreateFlow` executes create + scoped refresh (`src/features/agents/operations/cronCreateOperation.ts`). + +### 5) Session settings synchronization +- **UI boundary**: `AgentChatPanel` emits model/thinking callbacks from the agent header; `src/app/page.tsx` delegates both through one mutation helper. +- **Mutation boundary**: `applySessionSettingMutation` in `src/features/agents/state/sessionSettingsMutations.ts` owns optimistic store updates, `sessionCreated` guard logic, sync success updates, and user-facing failure lines. +- **Transport boundary**: `syncGatewaySessionSettings` in `src/lib/gateway/GatewayClient.ts` is the only client-side builder/invoker for `sessions.patch` payloads. + +## Cross-cutting concerns +- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State/config path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`/`OPENCLAW_CONFIG_PATH` with legacy fallbacks. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`). +- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine. +- **Logging**: API routes and the gateway client use built-in `console.*` logging. +- **Error handling**: + - API routes return JSON `{ error }` with appropriate status. + - `fetchJson` throws when `!res.ok`, surfaces errors to UI state. + - `StudioSettingsCoordinator` logs failed async persistence writes (debounced flush or queued patch failures) so settings-save errors are observable. + - Gateway connect failures with `INVALID_REQUEST: invalid config` surface a doctor hint in Studio (`npx openclaw doctor --fix` / `pnpm openclaw doctor --fix`). + - Gateway connect failures that close with `connect failed: <CODE> ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven). + - Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages. +- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. Home-scoped path autocomplete is implemented directly in `src/app/api/path-suggestions/route.ts`. These helpers are used for local settings and path suggestions, not for agent file edits. +- **Remote gateway tools over SSH**: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in `src/lib/ssh/gateway-host.ts` own SSH invocation and JSON parsing so routes do not hand-roll `spawnSync` error handling. +- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry. +- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`. + +## Major design decisions & trade-offs +- **Local settings file over DB**: fast, local-first persistence for gateway connection + focused preferences; trade-off is no concurrency or multi-user support. +- **Same-origin WS proxy instead of direct browser->gateway WS**: allows server-side token custody/injection and easier local/remote switching; trade-off is one extra hop and custom-server ownership. +- **Gateway-first agent records**: records map 1:1 to `agents.list` entries with main sessions; trade-off is no local-only agent concept. +- **Gateway-backed config + agent-file edits**: create/rename/heartbeat/delete and per-agent overrides via `config.patch`, agent files via `agents.files.get`/`agents.files.set`, and per-agent exec approvals via `exec.approvals.set`; trade-off is reliance on gateway availability. +- **Fleet hydration snapshot/derive split**: `hydrateAgentFleetFromGateway` loads gateway/settings snapshots (I/O) and delegates all derived decisions (seeds, exec policy resolution, summary selection) to a pure derivation helper; trade-off is one extra module and a more explicit snapshot input, but the derivation becomes independently testable. +- **Extract page-level workflows into operations**: keep `src/app/page.tsx` as wiring by moving workflow policy into operation modules (for example reconcile in `src/features/agents/operations/agentReconcileOperation.ts` and manual exec approval resolve in `src/features/agents/approvals/execApprovalResolveOperation.ts`); trade-off is more modules, but the workflows become independently unit testable without React rendering. +- **Structured connect errors + retry policy helper**: `GatewayClient.connect()` preserves gateway connect-failure codes and `useGatewayConnection` gates auto-retry via `resolveGatewayAutoRetryDelayMs`; trade-off is one more helper plus extra state (`connectErrorCode`), but the behavior is less brittle than string matching. +- **Narrow local config mutation boundary**: Studio does not write `openclaw.json` directly today; if a local-only integration is introduced, keep any local writes narrowly scoped to that integration and reuse shared list helpers instead of ad-hoc mutation paths; trade-off is less flexibility for local-only experimentation, but clearer ownership and lower drift risk. +- **Shared `agents.list` helper layer**: gateway and local config paths now consume one pure helper module for list parsing/writing/upsert behavior; trade-off is one more shared dependency, but it reduces semantic drift and duplicate bug surface. +- **Single gateway settings endpoint**: `/api/studio` is the sole Studio gateway URL/token source; trade-off is migration pressure on any older local-config-based callers, but it removes ambiguous ownership and dead paths. +- **Shared client settings coordinator module**: `src/lib/studio/coordinator.ts` now owns `/api/studio` transport plus load/schedule/flush behavior for gateway + focused state; trade-off is introducing a central client singleton, but it removes wrapper indirection and duplicate timers/fetch paths. +- **Single shared JSON-over-SSH helper**: server routes that need to run a gateway-side script over SSH should use `runSshJson` in `src/lib/ssh/gateway-host.ts` (and opt into a larger `maxBuffer` when expecting large payloads) rather than duplicating `spawnSync` + JSON parsing; trade-off is one shared dependency, but it reduces drift risk and keeps error surfacing consistent. +- **Vendored gateway client + sync script**: reduces drift from upstream OpenClaw UI; trade-off is maintaining a sync path and local copies of upstream helpers. +- **Feature-first organization**: increases cohesion in UI; trade-off is more discipline to keep shared logic in `lib`. +- **Node runtime for API routes**: required for filesystem access and tool proxying; trade-off is Node-only server runtime. +- **Event-driven summaries + on-demand history**: keeps the dashboard lightweight; trade-off is history not being available until requested. +- **Runtime policy/executor split for event handling**: one listener path in `src/app/page.tsx` classifies frames through `src/features/agents/state/runtimeEventBridge.ts`, derives side-effect-free decisions in `src/features/agents/state/runtimeEventPolicy.ts`, and executes those intents in `src/features/agents/state/gatewayRuntimeEventHandler.ts`; trade-off is additional intent-shape maintenance, but lower coupling between lifecycle policy and side effects. +- **Single gateway event intake subscription**: one `client.onEvent` path now handles both summary-refresh events (`presence`/`heartbeat`) and runtime stream events (`chat`/`agent`) using bridge classification helpers; trade-off is a larger callback surface, but fewer lifecycle and cleanup divergence points. +- **Shared session-setting mutation path**: model and thinking-level updates now pass through one UI mutation helper plus one gateway sync helper (`src/features/agents/state/sessionSettingsMutations.ts` + `src/lib/gateway/GatewayClient.ts`), reducing divergence between optimistic state and remote patch flows. + +## Mermaid diagrams +### C4 Level 1 (System Context) +```mermaid +C4Context + title OpenClaw Studio - System Context + Person(user, "User", "Operates agents locally") + System(ui, "OpenClaw Studio", "Next.js App Router UI") + System(proxy, "Studio WS Proxy", "Custom server /api/gateway/ws") + System_Ext(gateway, "OpenClaw Gateway", "WebSocket runtime") + System_Ext(fs, "Local Filesystem", "settings.json and other local reads (e.g. path suggestions)") + + Rel(user, ui, "Uses") + Rel(ui, proxy, "WebSocket frames") + Rel(proxy, gateway, "WebSocket frames") + Rel(ui, fs, "HTTP to API routes -> fs read/write") +``` + +### C4 Level 2 (Containers/Components) +```mermaid +C4Container + title OpenClaw Studio - Containers + Person(user, "User") + + Container_Boundary(app, "Next.js App") { + Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client") + Container(api, "API Routes", "Next.js route handlers", "Studio settings, path suggestions, gateway-host state tools") + Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection") + } + + Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime") + Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads (e.g. path suggestions)") + + Rel(user, client, "Uses") + Rel(client, api, "HTTP JSON") + Rel(client, proxy, "WebSocket /api/gateway/ws") + Rel(proxy, gateway, "WebSocket") + Rel(api, fs, "Read/Write") + Rel(proxy, fs, "Read settings/token") +``` + +## Explicit forbidden patterns +- Do not read/write local files directly from client components. +- Do not reintroduce local projects/workspaces as a source of truth for agent records. +- Do not write agent rename/heartbeat/override data directly to `openclaw.json`; use gateway `config.patch`. +- Do not read/write agent files on the local filesystem; use the gateway tools proxy. +- Do not add parallel gateway settings endpoints; `/api/studio` is the only supported Studio gateway URL/token path. +- Do not add new generic local `openclaw.json` mutation wrappers for runtime agent-management flows; if a local-only integration is introduced, keep any local writes narrowly scoped and well tested. +- Do not store gateway tokens or secrets in client-side persistent storage. +- Do not add new global mutable state outside `AgentStoreProvider` for agent UI data. +- Do not silently swallow errors in API routes; always return actionable errors. +- Do not add heavy abstractions or frameworks unless there is clear evidence of need. + +## Future-proofing notes +- If multi-user support becomes a goal, replace the settings file with a DB-backed service and introduce authentication at the API boundary. +- If gateway protocol evolves, isolate changes within `src/lib/gateway` and keep UI call sites stable. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5cb3c553 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +Thanks for helping improve OpenClaw Studio. + +- For external bugs and feature requests: please use GitHub Issues. +- For repo work tracked by our on-host agent squad: we use Notion. + +## Before you start +- Install OpenClaw and confirm the gateway runs locally. +- This repo is UI-only and reads config from `~/.openclaw` with legacy fallback to `~/.moltbot` or `~/.clawdbot`. +- It does not run or build the gateway from source. + +## Local setup +```bash +git clone https://github.com/grp06/openclaw-studio.git +cd openclaw-studio +npm install +cp .env.example .env +npm run dev +``` + +## Testing +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run e2e` (requires `npx playwright install`) + +## UX audit cleanup +- For `localhost-ux-improvement` runs, always clean generated UX artifacts before committing: + - `npm run cleanup:ux-artifacts` +- This clears `output/playwright/ux-audit/`, `.agent/ux-audit.md`, and `.agent/execplan-pending.md`. + +## Task tracking + +We track implementation work for this repo in Notion. + +## Pull requests +- Keep PRs focused and small. +- Prefer **one task → one PR**. +- Include the tests you ran. +- Link to the relevant issue/task. +- If you changed gateway behavior, call it out explicitly. + +## Reporting issues +When filing an issue, please include: +- Reproduction steps +- OS and Node version +- Any relevant logs or screenshots + +## Minimal PR template +```md +## Summary +- + +## Testing +- [ ] Not run (explain why) +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm run test` +- [ ] `npm run e2e` + +## AI-assisted +- [ ] AI-assisted (briefly describe what and include prompts/logs if helpful) +``` + +## Minimal issue template +```md +## Summary + +## Steps to reproduce +1. + +## Expected + +## Actual + +## Environment +- OS: +- Node: +- UI version/commit: +- Gateway running? (yes/no) + +## Logs/screenshots +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e5d87d92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 George Pickett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5663f29d..a86feb38 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,132 @@ -# clawdbot-agent-ui +![Home screen](home-screen.png) -Agent Canvas UI for Clawdbot. +# OpenClaw Studio -## Thinking traces +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/YJVMZ9yf) -When a model sends `message.content[]` entries with `type: "thinking"` (or embeds -`<thinking>...</thinking>` tags), the UI renders those traces before the assistant -response. After the response completes, traces collapse into a single toggleable -"Thinking" block so they can be reopened on demand. The Thinking selector on each -tile controls whether the model emits these traces. +OpenClaw Studio is a clean web dashboard for OpenClaw. Use it to connect to your Gateway, see your agents, chat, manage approvals, and configure jobs from one place. + +⭐ Drop a star to help us grow! ⭐ + +It helps more developers discover the project. + +## Get Started (Pick Your Setup) + +If your Gateway is already running, pick the scenario that matches where your Gateway and Studio will run: + +- [A. Gateway local, Studio local (same computer)](#a-gateway-local-studio-local-same-computer) +- [B. Gateway in the cloud, Studio local (your laptop)](#b-gateway-in-the-cloud-studio-local-your-laptop) +- [C. Studio in the cloud, Gateway in the cloud](#c-studio-in-the-cloud-gateway-in-the-cloud) + +All setups use the same install/run path (recommended): `npx -y openclaw-studio@latest` + +## Requirements + +- Node.js 18+ (LTS recommended) +- An OpenClaw Gateway URL + token +- Tailscale (optional, recommended for remote access) + +## A) Gateway local, Studio local (same computer) + +```bash +npx -y openclaw-studio@latest +cd openclaw-studio +npm run dev +``` + +1. Open http://localhost:3000 +2. In Studio, set: + - Upstream URL: `ws://localhost:18789` + - Upstream Token: your gateway token (for example: `openclaw config get gateway.auth.token`) + +## B) Gateway in the cloud, Studio local (your laptop) + +Run Studio on your laptop as above, then set an upstream URL your laptop can reach. + +Recommended (Tailscale Serve on the gateway host): + +1. On the gateway host: + - `tailscale serve --yes --bg --https 443 http://127.0.0.1:18789` +2. In Studio (on your laptop): + - Upstream URL: `wss://<gateway-host>.ts.net` + - Upstream Token: your gateway token + +Alternative (SSH tunnel): + +1. From your laptop: + - `ssh -L 18789:127.0.0.1:18789 user@<gateway-host>` +2. In Studio: + - Upstream URL: `ws://localhost:18789` + +## C) Studio in the cloud, Gateway in the cloud + +This is the “always-on” setup. The easiest secure version is to keep the Gateway private and expose Studio over Tailscale. + +1. On the VPS that will run Studio: + - Run Studio (same commands as above). +2. Expose Studio over tailnet HTTPS: + - `tailscale serve --yes --bg --https 443 http://127.0.0.1:3000` +3. Open Studio from your laptop/phone: + - `https://<studio-host>.ts.net` +4. In Studio, set: + - Upstream URL: `wss://<gateway-host>.ts.net` (or whatever your gateway is reachable at from the Studio host) + - Upstream Token: your gateway token + +Notes: +- Avoid serving Studio behind `/studio` unless you configure `basePath` and rebuild. +- If Studio is reachable beyond loopback, `STUDIO_ACCESS_TOKEN` is required. + +## How It Connects (Mental Model) + +There are **two separate network paths**: + +1. Browser -> Studio: HTTP for the UI, plus a WebSocket to `ws(s)://<studio-host>:3000/api/gateway/ws` +2. Studio -> Gateway (upstream): a second WebSocket opened by the Studio Node server to your configured Upstream URL + +This is why `ws://localhost:18789` means “gateway on the Studio host”, not “gateway on your phone”. + +## Install from source (advanced) + +```bash +git clone https://github.com/grp06/openclaw-studio.git +cd openclaw-studio +npm install +npm run dev +``` + +## Configuration + +Paths and key settings: +- OpenClaw config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`) +- Studio settings: `~/.openclaw/openclaw-studio/settings.json` +- Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`) +- `STUDIO_ACCESS_TOKEN`: required when binding Studio to a public host (`HOST=0.0.0.0`, `HOST=::`, or non-loopback hostnames/IPs); optional for loopback-only binds (`127.0.0.1`, `::1`, `localhost`) + +## UI guide + +See `docs/ui-guide.md` for UI workflows (agent creation, cron jobs, exec approvals). + +## PI + chat streaming + +See `docs/pi-chat-streaming.md` for how Studio bridges browser WebSocket traffic to the upstream Gateway, how runtime streaming arrives (`chat`/`agent` events), and how the chat UI renders tool calls, thinking traces, and final transcript lines. + +## Permissions + sandboxing + +See `docs/permissions-sandboxing.md` for how agent creation choices (tool policy, sandbox config, exec approvals) flow from Studio into the OpenClaw Gateway and how upstream OpenClaw enforces them at runtime (workspaces, sandbox mounts, tool availability, and exec approval prompts). + +## Color system + +See `docs/color-system.md` for the semantic color contract, status mappings, and guardrails that keep action/status/danger usage consistent across the UI. + +## Troubleshooting + +If the UI loads but “Connect” fails, it’s usually Studio->Gateway: +- Confirm the upstream URL/token in the UI (stored on the Studio host at `<state dir>/openclaw-studio/settings.json`). +- `EPROTO` / “wrong version number”: you used `wss://...` to a non-TLS endpoint (use `ws://...`, or put the gateway behind HTTPS). +- Assets 404 under `/studio`: serve Studio at `/` or configure `basePath` and rebuild. +- 401 “Studio access token required”: `STUDIO_ACCESS_TOKEN` is enabled; open `/?access_token=...` once to set the cookie. +- Helpful error codes: `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, `studio.upstream_closed`. + +## Architecture + +See `ARCHITECTURE.md` for details on modules and data flow. diff --git a/USER.md b/USER.md deleted file mode 100644 index 302f11f2..00000000 --- a/USER.md +++ /dev/null @@ -1,4 +0,0 @@ -# USER.md - -## Preferences -- Favorite color: green diff --git a/agent-creation-permissions-copy.md b/agent-creation-permissions-copy.md new file mode 100644 index 00000000..72300b5a --- /dev/null +++ b/agent-creation-permissions-copy.md @@ -0,0 +1,147 @@ +# Agent creation permissioning copy (pre-removal) + +Source: `2e33f60` removed guided creation files and the copy came from its parent +tree (`2e33f60^`). + +## `src/features/agents/components/AgentCreateModal.tsx` copy + +### Step headers + +- `Define Ownership` +- `Assign full accountability.` +- `Set Authority Level` +- `Define how independently this agent can act.` +- `Launch Agent` +- `Review mandate and activate.` +- `Activation begins immediately.` +- `Name reflects its role. You can change it later.` +- `You can adjust ownership and authority later.` +- `Authority can be configured after launch.` + +### Authority profiles + +- `Conservative` +- `Acts with review required.` +- `Can modify code and files directly` +- `Code and files access is on by default` +- `No automatic system actions` + +- `Collaborative` +- `Acts with approval.` +- `Can modify code and files directly` +- `Runs system actions with approval` +- `Uses web access for context` + +- `Autonomous` +- `Acts independently.` +- `Can modify your codebase directly` +- `Can operate your system automatically` +- `Uses web access while iterating` + +### Authority summary text + +- `Autonomous - acts independently` +- `Collaborative - acts with approval` +- `Conservative - acts with review required` + +### Authority configuration UI copy + +- `Web Access` +- `Internet research and fetch tools.` +- `Code and Files` +- `Controls codebase and file modification behavior.` +- `Can modify your codebase directly.` +- `System Access` +- `Controls system actions and command execution.` +- `Can operate your system automatically.` +- `Off` +- `On` +- `Ask first` +- `Auto` +- `Can run commands automatically without approval prompts.` + +### Step/action and control labels + +- `Fine-tune capabilities (optional)` +- `Hide fine-tune capabilities` +- `Additional capabilities (optional)` (button label logic in this version) +- `Recommended` +- `Conservative autonomy profile` +- `Collaborative autonomy profile` +- `Autonomous autonomy profile` +- `Web access off` +- `Web access on` +- `File changes on` +- `Command execution off` +- `Command execution ask first` +- `Command execution auto` + +### Modal summary section + +- `This agent will be accountable for:` +- `This agent will:` +- `Authority:` +- `On launch it will:` +- `What does this agent fully own?` +- `This agent will be accountable for:` + +### Other launch text + +- `Agent name` +- `You can rename this agent later in settings.` +- `You can rename or change the avatar later.` +- `You can rename this agent later.` +- `Choose avatar` +- `Shuffle` +- `Launch agent` +- `Launching...` +- `You can adjust ownership and authority later.` +- `This agent will own:` (from summary card format, e.g. title + description) + +## `src/features/agents/creation/compiler.ts` copy + +### Starter and template copy (permission-relevant intent) + +- `Evidence-first synthesis with broad access defaults.` +- `Safe code changes with broad execution defaults.` +- `High-autonomy coding with broad execution permissions.` +- `Campaign drafting defaults with broad access.` +- `Follow-up and planning support with broad defaults.` +- `General-purpose baseline with broad defaults.` + +### Boundaries / control language + +- `Do not publish, send, or launch externally without approval.` +- `Evidence beats intuition when stakes are non-trivial.` +- `Unknowns should be visible, not hidden.` +- `A concise synthesis is more useful than a long dump.` +- `Correctness and reversibility come before speed.` +- `Small scoped changes reduce operational risk.` + +### Control mode defaults + +- `Agent name is required.` +- `Auto exec cannot be enabled when approval security is set to deny.` +- `Auto exec requires runtime tools to be enabled.` + +### Capability summary copy + +- `Persona files: custom IDENTITY.md + SOUL.md; AGENTS.md remains the gateway default.` +- `Web access is enabled for search and fetch tools.` +- `Web access is disabled.` +- `Can apply file edits directly on the host filesystem.` +- `Sessions run without sandbox isolation.` +- `Command execution is disabled.` +- `Can run commands automatically without approval prompts.` +- `Can run commands with approval prompts.` + +### Execution role/profile summaries generated by compile + +- `No command execution.` (from conservative mapping in inspect/derived role view in same era) +- `Commands require approval.` +- `Can operate your system automatically.` (mode summaries and tooltips) + +## Notes + +- This is a copy extraction for restoration/reuse, not a diff. +- The live strings came from the commit that removed the guided creation flow. diff --git a/docs/permissions-sandboxing.md b/docs/permissions-sandboxing.md new file mode 100644 index 00000000..e416d777 --- /dev/null +++ b/docs/permissions-sandboxing.md @@ -0,0 +1,350 @@ +# Permissions, Sandboxing, and Workspaces (Studio -> Gateway -> PI) + +This document exists to onboard coding agents quickly when debugging: +- Why an agent can or cannot read/write files +- Why command execution requires approvals (or not) +- Why a sandboxed run behaves differently from a non-sandboxed run +- How “create agent” choices in **OpenClaw Studio** flow into the **OpenClaw Gateway** (often running on an EC2 host) where enforcement actually happens + +Scope: +- Studio one-step agent creation and post-create authority updates, including exact gateway calls. +- The upstream OpenClaw implementation that persists and enforces those settings at runtime. + +Non-scope: +- Full PI internal reasoning/toolchain. Studio does not implement PI logic; it configures and displays the Gateway session. +- Any private EC2 runbook or SSH/hostnames. Keep this doc repo-safe. + +## Mental Model (First Principles) + +Studio is a UI + proxy. It does two things related to “permissions”: +1. Writes **configuration** into the Gateway (per-agent overrides in `openclaw.json`). +2. Writes **policy** into the Gateway (per-agent exec approvals in `exec-approvals.json`). + +The Gateway (OpenClaw) is the enforcement point: +- It decides whether a session is sandboxed. +- It decides which workspace is mounted into the sandbox. +- It constructs the PI toolset (read/write/edit/apply_patch/exec/etc) based on config + sandbox context. +- It asks for exec approvals when policy requires it and broadcasts approval events. + +## Glossary + +- **Gateway**: OpenClaw Gateway WebSocket server (upstream project). +- **Studio**: this repo. Next.js UI plus a Node WS proxy. +- **Agent**: an OpenClaw agent entry stored in gateway config (`agents.list[]`). +- **Session key**: OpenClaw session identifier. Studio uses `agent:<agentId>:<mainKey>` for the agent’s “main” session. +- **Agent workspace**: a directory on the Gateway host filesystem configured per-agent (where bootstrap files and edits live). +- **Sandbox workspace**: a separate directory used when a session is sandboxed and `workspaceAccess` is not `rw`. +- **Sandbox mode** (`sandbox.mode`): when to sandbox (`off`, `non-main`, `all`). +- **Workspace access** (`sandbox.workspaceAccess`): how the sandbox relates to the agent workspace (`none`, `ro`, `rw`). +- **Tool policy** (`tools.profile`, `tools.alsoAllow`, `tools.deny`): allow/deny gating for PI tools (OpenClaw resolves effective policy). +- **Exec approvals policy**: per-agent `{ security, ask, allowlist }` stored in exec approvals file; drives “Allow once / Always allow / Deny” UX. + +## Studio: Where “Permissions” Are Chosen + +Agent creation is intentionally lightweight: +- `src/features/agents/components/AgentCreateModal.tsx` captures `name` and optional avatar shuffle seed. +- `src/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create. +- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`. + +After creation, Studio applies a permissive default capability envelope: +- Commands: `Auto` +- Web access: `On` +- File tools: `On` + +Implementation: +- `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`. +- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) persists those defaults. + +Further capability changes happen from the `Capabilities` tab: +- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) + - updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`) + - updates tool-group overrides for runtime, web, and file access (`config.get` + `config.patch` via `updateGatewayAgentOverrides`) + - updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`) + +### Runtime Tool Groups Used By Capability Updates + +Studio capability updates rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially: +- `group:runtime` -> runtime execution tools (`exec`, `process`) + +Internal mapping detail: +- Command mode `off|ask|auto` maps to role logic (`conservative|collaborative|autonomous`) for policy generation. +- UI exposes direct capability controls, not role labels. + +## Studio -> Gateway: “Create Agent” End-to-End + +Primary entry points: +- `src/features/agents/operations/mutationLifecycleWorkflow.ts` +- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) + +Sequence: + +```mermaid +sequenceDiagram + participant UI as Studio UI + participant L as Create lifecycle + participant GC as Studio GatewayClient + participant G as OpenClaw Gateway + + UI->>L: submit({ name, avatarSeed? }) + L->>GC: createGatewayAgent(name) + GC->>G: config.get + G-->>GC: { path: ".../openclaw.json", ... } + GC->>G: agents.create({ name, workspace: "<stateDir>/workspace-<slug>" }) + G-->>GC: { agentId, workspace } + L-->>UI: completion(agentId) +``` + +### How Studio Chooses the Default Workspace Path + +Studio computes a default workspace path from the gateway’s config path: +- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) + +Logic: +1. Call `config.get` and read `snapshot.path` (the gateway host config path). +2. Compute `stateDir = dirname(configPath)`. +3. Compute `workspace = join(stateDir, "workspace-" + slugify(name))`. +4. Call `agents.create({ name, workspace })`. + +Important: for a remote gateway (EC2), that `workspace` path refers to the gateway host filesystem, not your laptop. + +## Studio: Sandbox Env Allowlist Sync (Current Scope) + +Create flow does not perform setup writes during initial create anymore. If Studio needs to ensure sandbox env allowlist entries, that behavior should be attached to explicit settings/config operations rather than create-time side effects. + +## OpenClaw (Upstream): What `agents.create` Actually Does + +Gateway method: +- `openclaw/src/gateway/server-methods/agents.ts` (`"agents.create"`) + +Key behaviors: +- Normalizes `agentId` from the provided `name` (and reserves `"default"`). +- Uses the provided `workspace` and resolves it to an absolute path. +- Writes a config entry for the agent (including the workspace dir and agent dir). +- Ensures the workspace directory exists and that bootstrap files exist (unless `agents.defaults.skipBootstrap` is set). +- Ensures the session transcripts directory exists for the agent. +- Writes the config file only after those directories exist (to avoid persisting a broken agent entry). +- Appends `- Name: ...` (and optional emoji/avatar) to `IDENTITY.md` in the workspace. + +So: the “workspace” is not a UI-only concept; it is a real directory created on the Gateway host. + +## OpenClaw (Upstream): Sandbox Semantics + +Sandbox configuration resolution: +- `openclaw/src/agents/sandbox/config.ts` (`resolveSandboxConfigForAgent`) + +Sandbox context creation (where workspace selection happens): +- `openclaw/src/agents/sandbox/context.ts` (`resolveSandboxContext`) + +Docker mount behavior: +- `openclaw/src/agents/sandbox/docker.ts` (`createSandboxContainer`) + +### Sandbox Mode (`sandbox.mode`) + +Modes (as implemented upstream): +- `off`: sessions are not sandboxed. +- `all`: every session is sandboxed. +- `non-main`: sandbox all sessions except the agent’s main session key. + +The “main session key” comparison is done against the configured main key, with alias-canonicalization: +- Upstream canonicalizes the session key before comparing so that main-session aliases are treated as “main” (see `canonicalizeMainSessionAlias` in upstream sandbox runtime-status). +- If `session.scope` is `global`, the main session key is `global` and `non-main` effectively means “sandbox everything except the global session”. + +Upstream implementation reference: +- `openclaw/src/agents/sandbox/runtime-status.ts` (`resolveSandboxRuntimeStatus`) + +### Sandbox Scope (`sandbox.scope`) + +Sandbox scope controls how sandboxes are shared and therefore what persists between runs: +- `session`: per-session sandbox workspace/container (highest isolation, most churn) +- `agent`: per-agent sandbox workspace/container keyed by agent id (shared across that agent’s sessions) +- `shared`: one sandbox workspace/container shared across everything (lowest isolation) + +Upstream implementation reference: +- `openclaw/src/agents/sandbox/types.ts` (`SandboxScope`) +- `openclaw/src/agents/sandbox/shared.ts` (`resolveSandboxScopeKey`) + +### Workspace Access (`sandbox.workspaceAccess`) + +Upstream behavior (important): +- `rw`: + - The sandbox uses the **agent workspace** as the sandbox root. + - PI filesystem tools (`read`/`write`/`edit`/`apply_patch`) operate on the agent workspace. +- `ro`: + - The sandbox uses a **sandbox workspace** as the sandbox root (writable sandbox dir). + - The real agent workspace is mounted at `/agent` read-only for command-line inspection. + - PI filesystem tools are additionally restricted: upstream disables write/edit/apply_patch in this mode (see below). +- `none`: + - The sandbox uses a **sandbox workspace** as the sandbox root. + - The agent workspace is not mounted into the container. + +Sandbox workspace root default: +- `openclaw/src/agents/sandbox/constants.ts` uses `<STATE_DIR>/sandboxes` (where `STATE_DIR` defaults to `~/.openclaw` unless overridden by `OPENCLAW_STATE_DIR`). + +Sandbox workspace seeding: +- When using a sandbox workspace root, upstream seeds missing bootstrap files from the agent workspace and ensures bootstrap exists: + - `openclaw/src/agents/sandbox/workspace.ts` (`ensureSandboxWorkspace`) + - The sandbox workspace also syncs skills from the agent workspace (best-effort) in `resolveSandboxContext`. + +### Hard Enforcement: Filesystem Tool Root Guard + +In upstream OpenClaw, sandboxed filesystem tools are rooted and guarded: +- `openclaw/src/agents/pi-tools.read.ts` (`assertSandboxPath` usage) + +Result: +- `read`/`write`/`edit` tools cannot access paths outside the sandbox root, even if the container has other mounts (like `/agent`). + +This is intentional: the “filesystem tools” and “exec tool” have different access characteristics inside a sandbox. + +## Sandbox Tool Policy (Separate From Per-Agent Tool Overrides) + +OpenClaw has an additional sandbox-only tool allow/deny policy: +- `tools.sandbox.tools.allow|deny` (global) +- `agents.list[].tools.sandbox.tools.allow|deny` (per-agent override) + +Upstream resolution: +- `openclaw/src/agents/sandbox/tool-policy.ts` (`resolveSandboxToolPolicyForAgent`) + +Important nuance: +- If `tools.sandbox.tools.allow` is present and non-empty, it becomes an allowlist. +- If it is set to an empty array, upstream will still auto-add `image` to the allowlist (unless explicitly denied), which often turns “empty” into effectively “image-only”. +- If you want “allow everything” semantics in sandbox policy, prefer `["*"]` over `[]` to avoid the image auto-add corner case. + +This is why Studio treats some configs as “broken” and repairs them (see below). + +### Policy Layering (Why “Allowed” Can Still Be Blocked) + +In a sandboxed session, a tool must pass multiple gates: +- The normal tool policy gates (`tools.profile`, `tools.allow|alsoAllow`, `tools.deny`, plus any provider/group/subagent policies upstream applies). +- The sandbox tool policy gate (`tools.sandbox.tools.allow|deny` resolved for that agent). + +So even if Studio enables `group:runtime` for an agent, the tool can still be blocked in sandboxed sessions if sandbox tool policy denies it. + +## OpenClaw (Upstream): Tool Availability and `workspaceAccess=ro` + +PI tool construction: +- `openclaw/src/agents/pi-tools.ts` (`createOpenClawCodingTools`) + +Key enforcement: +- When sandboxed, upstream removes the normal host `write`/`edit` tools. +- It only adds sandboxed `write`/`edit` tools if `workspaceAccess !== "ro"`. +- It disables `apply_patch` in sandbox when `workspaceAccess === "ro"`. + +This is why “`workspaceAccess=ro`” means more than “mount it read-only”: +- It is also a tool-policy gate that prevents direct file writes/edits through PI tools. + +### Studio Note: Authority Is No Longer Compiled During Create + +Studio create flow no longer compiles authority/sandbox settings during initial create. + +When capabilities are changed post-create, Studio uses: +- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) + +That operation updates: +- exec approvals policy (`exec.approvals.set`) +- per-agent tool overrides (`config.patch` via `updateGatewayAgentOverrides`) +- session exec host/security/ask (`sessions.patch`) + +Upstream enforcement is unchanged: `workspaceAccess="ro"` still disables PI `write`/`edit`/`apply_patch` in sandboxed sessions. + +## Session-Level Exec Settings (Where `exec` Runs) + +Separately from per-agent config and exec approvals, OpenClaw supports per-session exec settings: +- `execHost`: `sandbox | gateway | node` +- `execSecurity`: `deny | allowlist | full` +- `execAsk`: `off | on-miss | always` + +These are stored in the gateway session store and mutated with `sessions.patch`: +- Upstream method: `openclaw/src/gateway/server-methods/sessions.ts` (`"sessions.patch"`) +- Patch application: `openclaw/src/gateway/sessions-patch.ts` +- Session entry shape includes `execHost|execSecurity|execAsk`: `openclaw/src/config/sessions/types.ts` + +Studio uses these fields to keep “what the UI expects” aligned with gateway runtime: +- Hydration derives the expected values using the exec approvals policy plus sandbox mode: + - `src/features/agents/operations/agentFleetHydrationDerivation.ts` + - Special case: if `sandbox.mode === "all"` and there are exec overrides, Studio forces `execHost = "sandbox"` to avoid accidentally running on the host. +- On first send (or when out of sync), Studio patches the session: + - `src/features/agents/operations/chatSendOperation.ts` calls `syncGatewaySessionSettings(...)` + - Transport: `src/lib/gateway/GatewayClient.ts` (`sessions.patch`) + +Net effect: +- Exec approvals policy controls whether the user will be prompted to approve. +- Session exec settings control where execution happens (sandbox vs host) and the default `security/ask` values for runs. + +## OpenClaw (Upstream): Exec Approvals (Policy + Events) + +Exec approvals file (defaults upstream): +- `openclaw/src/infra/exec-approvals.ts` + - default file path: `~/.openclaw/exec-approvals.json` + - default socket path: `~/.openclaw/exec-approvals.sock` + +Gateway methods (persist policy): +- `openclaw/src/gateway/server-methods/exec-approvals.ts` + - `exec.approvals.get` returns `{ path, exists, hash, file }` (socket token is redacted in responses) + - `exec.approvals.set` requires a matching `baseHash` when the file already exists (prevents lost updates) + +Approval request/resolve + broadcast events: +- `openclaw/src/gateway/server-methods/exec-approval.ts` + - broadcasts `exec.approval.requested` + - broadcasts `exec.approval.resolved` + +Exec tool approval decision logic: +- `openclaw/src/agents/bash-tools.exec.ts` (calls `requiresExecApproval`, `evaluateShellAllowlist`, etc.) + +Studio wiring for policy persistence: +- Studio writes per-agent policy with `exec.approvals.set`: + - `src/lib/gateway/execApprovals.ts` (`upsertGatewayAgentExecApprovals`) + +Studio wiring for UX: +- Studio listens to `exec.approval.requested` and `exec.approval.resolved` and renders in-chat approval cards. +- When the user clicks approve/deny, Studio calls `exec.approval.resolve`. + +## Debug Checklist (When Something Feels “Wrong”) + +1. Determine if the session is sandboxed and what workspace it is using. + - Upstream CLI helper: `openclaw sandbox explain --agent <agentId>` (see upstream `src/commands/sandbox-explain.ts`) +2. Confirm what Studio wrote: + - Agent overrides: `config.get` and inspect `agents.list[]` entry for the agent. + - Exec approvals: `exec.approvals.get` and inspect `file.agents[agentId]`. +3. If file edits are not happening: + - Check `sandbox.workspaceAccess` (if `ro`, upstream disables write/edit/apply_patch tools in sandbox). + - Check tool policy (`tools.profile`, `tools.alsoAllow`, `tools.deny`) for explicit denies on `write`/`edit`/`apply_patch`. +4. If approvals are not showing up: + - Check exec approvals `security` + `ask`. + - Check allowlist patterns (a match may suppress prompts when `ask=on-miss`). +5. If the agent can see different files than expected: + - `workspaceAccess=rw` means “tools operate on the agent workspace”. + - `workspaceAccess=ro|none` means “tools operate on a sandbox workspace”. + - `/agent` mount exists only for `workspaceAccess=ro` and is accessible via sandbox exec, not via filesystem tools. + +## Studio Post-Create “Permissions” Flows (Not Just Creation) + +Studio can also change permissions after an agent exists. + +### Capabilities Permissions Updates + +Studio’s permissions flow applies coordinated changes from one save action: +- Exec approvals policy (per-agent, persisted in exec approvals file) +- Tool allow/deny for runtime/web/fs groups (`group:runtime`, `group:web`, `group:fs`) in agent config +- Session exec settings (`execHost|execSecurity|execAsk`) via `sessions.patch` + +Code: +- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) + +UI model: +- Direct controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`) +- Create modal remains permission-light (name/avatar only) and create flow immediately applies permissive defaults (`Auto`, web on, file tools on). + +Why it matters: +- You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied. +- You can have permissive approvals but still be safe if `execHost` is forced to `sandbox` when sandboxing is enabled. + +### One-Shot Sandbox Tool Policy Repair + +On connect, Studio scans the gateway config for agents that are sandboxed (`sandbox.mode === "all"`) and have an explicitly empty sandbox allowlist (`tools.sandbox.tools.allow = []`), and repairs those entries by setting: +- `agents.list[].tools.sandbox.tools.allow = ["*"]` + +Code: +- Detection + repair enqueue: `src/app/page.tsx` (`repair-sandbox-tool-allowlist`) +- Gateway write: `src/lib/gateway/agentConfig.ts` (`updateGatewayAgentOverrides`) + +This exists to prevent sandboxed sessions from effectively losing access to almost all sandbox tools due to an empty allowlist interacting with upstream sandbox tool-policy behavior. diff --git a/docs/pi-chat-streaming.md b/docs/pi-chat-streaming.md new file mode 100644 index 00000000..b2a7ec2f --- /dev/null +++ b/docs/pi-chat-streaming.md @@ -0,0 +1,373 @@ +# PI + Chat Streaming (Studio Side) + +This document exists to onboard coding agents quickly when debugging chat issues in OpenClaw Studio. + +Scope: +- Describes how Studio connects to the OpenClaw Gateway, how runtime streaming arrives over WebSockets, and how the UI renders it. +- Treats **PI** as “the coding agent running behind the Gateway” (an OpenClaw agent). Studio does not implement PI logic; it displays and controls the Gateway session. + +Non-scope: +- PI internals and model/tool execution details. Those live in the OpenClaw repository and the Gateway implementation. + +## Key Files (Start Here) + +- Studio server entry + upgrade wiring: `server/index.js` +- Browser WS bridge to upstream gateway: `server/gateway-proxy.js` +- Browser WS URL (always same-origin `/api/gateway/ws`): `src/lib/gateway/proxy-url.ts` +- Browser gateway protocol client (vendored): `src/lib/gateway/openclaw/GatewayBrowserClient.ts` +- Studio gateway wrapper + connect policy: `src/lib/gateway/GatewayClient.ts` +- Runtime stream classification and merge helpers: `src/features/agents/state/runtimeEventBridge.ts` +- Runtime event executor (streaming -> state -> transcript lines): `src/features/agents/state/gatewayRuntimeEventHandler.ts` +- Chat rendering: `src/features/agents/components/AgentChatPanel.tsx`, `src/features/agents/components/chatItems.ts` +- Message parsing (text/thinking/tool markers): `src/lib/text/message-extract.ts` +- History sync + transcript merge: `src/features/agents/operations/historySyncOperation.ts`, `src/features/agents/state/transcript.ts` + +## Relationship To OpenClaw (What’s Vendored Here) + +Studio vendors the browser Gateway client used to speak the Gateway protocol: +- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts` +- Sync script: `scripts/sync-openclaw-gateway-client.ts` +- Current sync source path used by that script: `~/clawdbot/ui/src/ui/gateway.ts` + +Important: +- Studio does not currently sync `GatewayBrowserClient.ts` directly from `~/openclaw`. +- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned. + +If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studio’s vendored client is in sync with the Gateway version you’re running. + +## Upstream Source Of Truth (OpenClaw) + +For chat streaming behavior, these upstream files are authoritative: +- `~/openclaw/src/gateway/protocol/schema/logs-chat.ts` (`chat.send`, `chat.history`, and chat event schema) +- `~/openclaw/src/gateway/server-methods/chat.ts` (`chat.send` ack + idempotency, `chat.history` payload shaping/sanitization) +- `~/openclaw/src/gateway/server-chat.ts` (`agent` event fanout and synthetic `chat` delta/final bridging) +- `~/openclaw/src/agents/pi-embedded-subscribe.ts` and handlers (`assistant`/`tool`/`lifecycle` stream emission) + +When updating this doc, verify behavior against those files, not assumptions. + +## Terminology + +- Studio: this repo, a Next.js UI with a custom Node server. +- Gateway (upstream): the OpenClaw Gateway WebSocket server (default `ws://localhost:18789`). +- WS bridge / proxy: Studio’s server-side WebSocket that bridges the browser to the upstream Gateway. +- Frame: JSON message over WebSocket (request/response/event). +- Run: a single streamed execution identified by `runId`. +- Session: identified by `sessionKey` (Studio uses `agent:<agentId>:<mainKey>` for main sessions). + +## High-Level Network Path + +There are two separate WebSocket hops, plus a protocol-level `connect` request: + +```mermaid +sequenceDiagram + participant B as Browser (Studio UI) + participant S as Studio server (WS proxy) + participant G as OpenClaw Gateway (upstream) + + B->>S: WS connect /api/gateway/ws + B->>S: req(connect) (Gateway protocol frame) + S->>G: WS connect upstream (url from settings.json) + S->>G: req(connect) (injects token if missing) + G-->>S: res(connect) + S-->>B: res(connect) + G-->>S: event(chat/agent/presence/heartbeat) + S-->>B: event(...) +``` + +Files: +- WS proxy entrypoint: `server/index.js` +- WS proxy implementation: `server/gateway-proxy.js` + +Notes: +- The browser never opens a WebSocket directly to the upstream Gateway URL. The browser always speaks to the Studio same-origin bridge at `/api/gateway/ws` (computed by `src/lib/gateway/proxy-url.ts`). +- The “upstream gateway URL” shown in Studio settings is used by the Studio server (the proxy) to open the upstream connection. + +## End-To-End Flow (PI Run -> UI) + +This is the “happy path” you want in your head when debugging: + +1. User types in the chat composer and hits Send (`src/features/agents/components/AgentChatPanel.tsx`). +2. Studio calls `chat.send` with `sessionKey` and `idempotencyKey = runId` (`src/features/agents/operations/chatSendOperation.ts`). +3. Gateway runs the agent (PI) for that session. +4. While the run is executing, the Gateway may stream: + - `event: "agent"` frames for live partial output (`stream: "assistant"`), live thinking (`reason*`/`think*` streams), tool calls/results (`stream: "tool"`), and lifecycle (`stream: "lifecycle"`). + - `event: "chat"` frames for the chat message stream (`state: "delta" | "final" | ...`). + - Both streams can describe the same run progression from different layers (`agent` stream events and `chat` message events), so Studio must merge idempotently. +5. Studio merges those events into: + - live fields (`streamText`, `thinkingTrace`) via batched `queueLivePatch` (fast UI updates without committing to the transcript yet) + - committed transcript lines (`outputLines`) via `appendOutput` (final messages, tool lines, meta/timestamp, thinking trace) +6. The chat panel renders: + - historical transcript from `outputLines` + - an extra “live assistant” card at the bottom built from `streamText` + `thinkingTrace` while `status === "running"`. + +The key wiring is in: +- Event subscription + dispatch: `src/app/page.tsx` +- Runtime event handler: `src/features/agents/state/gatewayRuntimeEventHandler.ts` +- Store reducer: `src/features/agents/state/store.tsx` + +## Studio Settings (Where Gateway URL/Token Come From) + +Studio persists Gateway connection settings on the Studio host (not in browser storage). The UI loads them into browser memory at runtime: +- `~/.openclaw/openclaw-studio/settings.json` (see `README.md` for the canonical location) + +The WS proxy loads these settings server-side and opens the upstream connection. + +Files: +- Settings file access (WS proxy): `server/studio-settings.js` +- Settings API route (browser -> server): `src/app/api/studio/route.ts` +- Client-side load/patch coordinator: `src/lib/studio/coordinator.ts` +- Settings storage + fallback behavior used by `/api/studio`: `src/lib/studio/settings-store.ts` + +Connection note: +- In the browser, `useGatewayConnection()` stores the upstream URL/token in memory (loaded from `/api/studio`) but connects the WebSocket to Studio via `resolveStudioProxyGatewayUrl()`; the upstream URL is passed as `authScopeKey` (not as the WebSocket URL). See `src/lib/gateway/GatewayClient.ts`. + +Token resolution note: +- The Studio server resolves an upstream token from `openclaw-studio/settings.json`, and if it is missing it may fall back to the local OpenClaw config in `openclaw.json` (token + port). This behavior exists in both the WS proxy path (`server/studio-settings.js`) and the `/api/studio` storage layer (`src/lib/studio/settings-store.ts`) and they should remain consistent. +- During `connect`, the WS proxy forwards browser-provided auth (`params.auth.token` or `params.device.signature`) as-is. It injects the host-resolved token only when browser auth is absent. `studio.gateway_token_missing` is returned only when neither browser auth nor host token is available. + +## WebSocket Frame Shapes + +Studio expects Gateway frames shaped like: + +```json +{ "type": "req", "id": "uuid", "method": "connect", "params": { } } +{ "type": "res", "id": "uuid", "ok": true, "payload": { } } +{ "type": "res", "id": "uuid", "ok": false, "error": { "code": "…", "message": "…" } } +{ "type": "event", "event": "chat", "payload": { } } +``` + +Types live in: +- `src/lib/gateway/GatewayClient.ts` + +### Connect handshake + +The first *protocol frame* from the browser must be `req(connect)`. The WS proxy: +- Rejects non-`connect` frames until connected. +- Opens an upstream WS to the configured Gateway URL. +- Injects `auth.token` into the connect params if the connect frame does not already contain a token, and if it does not include a device signature. +- Returns `studio.gateway_token_missing` only when no browser auth is present and no host token can be resolved. +- Sets an `Origin` header for the upstream WebSocket derived from the upstream URL (and normalizes loopback hostnames to `localhost`). + +Code: +- Connect enforcement + token injection: `server/gateway-proxy.js` + +### Connect failures + +On failure to load settings or open upstream, the proxy sends an error `res` for the connect request (when possible) and then closes the WS. + +Important detail (how errors become actionable in the UI): +- The browser-side Gateway client (`src/lib/gateway/openclaw/GatewayBrowserClient.ts`) closes the WebSocket with close code `4008` and a reason like `connect failed: <CODE> <MESSAGE>` after it receives a failed `res(connect)`. `GatewayClient.connect()` parses that close into `GatewayResponseError(code, message)` for UI retry policy and user-facing errors. +- Separately, the proxy may also close with `1011` / `connect failed`; the “connect failed: …” close reason that the UI parses is produced by the browser client, not the proxy. +- WebSocket close reasons are truncated to 123 UTF-8 bytes in the browser client to avoid protocol errors on long messages. + +Error codes used by the proxy include: +- `studio.gateway_url_missing` +- `studio.gateway_token_missing` +- `studio.gateway_url_invalid` +- `studio.settings_load_failed` +- `studio.upstream_error` +- `studio.upstream_closed` + +## Reconnects And Retries + +There are two layers of retry behavior: + +- Transport reconnect (after a successful hello): the vendored browser client reconnects the browser->Studio WebSocket with backoff when it closes, and continues emitting events after reconnect. See `src/lib/gateway/openclaw/GatewayBrowserClient.ts`. +- Initial connect failure retry: when the initial `connect` handshake fails (for example bad token), `GatewayClient.connect()` tears down the vendored client and returns a rejected promise; `useGatewayConnection()` may schedule a limited re-attempt unless the error code is known non-retryable. See `resolveGatewayAutoRetryDelayMs` in `src/lib/gateway/GatewayClient.ts`. + +## Studio Access Gate + +When Studio is bound to a public host, `STUDIO_ACCESS_TOKEN` is required. For loopback-only binds, it remains optional. When enabled, Studio enforces a simple access gate: +- HTTP: blocks `/api/*` routes unless the correct cookie is present; you can set it once via `/?access_token=...`. +- WebSocket: blocks `/api/gateway/ws` upgrades unless the cookie is present. + +Files: +- Gate implementation: `server/access-gate.js` +- Gate integration for WS upgrades: `server/index.js` + +## Streaming: What the Gateway Sends and How Studio Uses It + +Studio classifies gateway events by `event` name: +- `presence`, `heartbeat`: summary refresh triggers +- `chat`: runtime chat messages (delta/final) +- `agent`: runtime per-stream deltas (assistant/thinking/tool/lifecycle) + +Code: +- Classification: `src/features/agents/state/runtimeEventBridge.ts` +- Execution: `src/features/agents/state/gatewayRuntimeEventHandler.ts` + +## Live Fields vs Committed Transcript (Why Streaming Can “Look Weird”) + +Studio intentionally separates: +- Live streaming UI: `AgentState.streamText` and `AgentState.thinkingTrace` are updated via `queueLivePatch`, which batches patches and coalesces multiple deltas before they hit React state (`src/app/page.tsx`). +- Committed transcript: `AgentState.outputLines` is appended via `appendOutput`. These are the lines that become the durable on-screen transcript and are later merged with `chat.history` results (`src/features/agents/state/store.tsx`). + +This split is why you can see: +- “live” assistant output update rapidly at the bottom card during a run +- then a finalized assistant message (plus tool lines / thinking trace / meta timestamp) appear in the transcript on `final` + +### `event: "chat"` payload + +Studio treats `chat` events as the canonical “message” stream for transcript completion. Expected fields: +- `runId` +- `sessionKey` +- `state`: `delta | final | aborted | error` +- `message` (shape varies; Studio extracts text/thinking/tool metadata defensively) + +Key behaviors (Studio-side): +- Ignores user/system roles for transcript append (but uses them for status/summary). +- User messages shown in the transcript are primarily from local optimistic send and from `chat.history` sync (not from runtime `chat` user-role events). +- On `final`, appends: + - a `[[meta]]{...}` line (timestamp and thinking duration when available) + - a `[[trace]]` thinking block when extracted + - tool call/result markdown lines when present + - the assistant text (if any) +- If a `final` assistant message arrives without an extractable thinking trace, Studio may request `chat.history` as recovery. +- `chat.send` is idempotency-keyed upstream and returns a started ack before async completion; this is why history reconciliation can race with runtime events and must be idempotent. + +### `event: "agent"` payload + +Studio uses `agent` events for live streaming and richer tool/lifecycle updates. Expected fields: +- `runId` +- `stream`: `assistant | tool | lifecycle | <reasoning stream>` +- `data`: record with `text`/`delta` and stream-specific keys + +Stream handling (high-level): +- `assistant`: merges `data.delta` into a live `streamText` for the UI. +- reasoning stream (anything that is not `assistant`, `tool`, `lifecycle` and matches hints like `reason`/`think`/`analysis`/`trace`): merged into `thinkingTrace`. +- `tool`: formats tool call and tool result lines using `[[tool]]` and `[[tool-result]]`. +- `lifecycle`: start/end/error transitions; if a run reaches `end` without chat final events, Studio may flush the last streamed assistant text as a fallback final transcript entry. + +Code: +- Runtime agent stream merge + append: `src/features/agents/state/gatewayRuntimeEventHandler.ts` + +## How Chat UI Renders Streaming + +Studio keeps an `outputLines: string[]` transcript per agent, plus live fields like `streamText` and `thinkingTrace`. + +Rendering pipeline: +- `outputLines` contains: + - user messages as `> ...` + - assistant messages as raw markdown text + - tool call/results with prefixes `[[tool]]` and `[[tool-result]]` + - optional meta lines `[[meta]]{...}` for timestamps and thinking durations + - optional thinking trace lines `[[trace]] ...` +- The panel derives structured chat items from `outputLines` and (optionally) live streaming state. +- UI toggles that change rendering: + - `showThinkingTraces`: hides/shows `[[trace]]` thinking entries. + - `toolCallingEnabled`: when off, tool lines are hidden and some exec tool results may be shown as assistant text. + +### Rendering contract + +- Assistant markdown renders as assistant markdown. Studio does not wrap normal assistant markdown in a synthetic `Output` container. +- Tool cards render only from explicit marker lines: `[[tool]]` and `[[tool-result]]`. +- List-marker visibility comes from chat markdown styles in `src/app/styles/markdown.css`; stream parsing does not invent list bullets. + +Files: +- Chat panel UI: `src/features/agents/components/AgentChatPanel.tsx` +- Transcript parsing into items: `src/features/agents/components/chatItems.ts` +- Message extraction helpers (text/thinking/tool parsing): `src/lib/text/message-extract.ts` +- Media line rewrite (images/audio/video rendered in markdown): `src/lib/text/media-markdown.ts` + +## Sending Messages (Browser -> PI via Gateway) + +Send path (high level): +- UI submits a message through `sendChatMessageViaStudio()` which: + - Sets agent state to running and clears live streams. + - Optionally resets local transcript state for `/new` or `/reset` (local UI behavior). + - Optimistically appends the user line (`> ...`) to the transcript. + - Ensures session settings are synced once via `sessions.patch` (model/thinking/exec settings) before first send. + - Calls `chat.send` with `idempotencyKey = runId` and `deliver: false`. + +Stop path: +- UI calls `chat.abort` to stop an active run. + +Files: +- Send operation: `src/features/agents/operations/chatSendOperation.ts` +- Session settings sync transport: `src/lib/gateway/GatewayClient.ts` +- Stop call site: `src/app/page.tsx` + +## Post-Connect Side Effects (Local Gateway Only) + +After a successful connection, Studio may mutate gateway config when the upstream gateway URL is local: +- It reads `config.get` and may write `config.set` to ensure `gateway.reload.mode` is `"hot"` for local Studio usage. + +File: +- Reload mode enforcement: `src/lib/gateway/gatewayReloadMode.ts` + +## Sequence Gaps (Dropped Events) + +Gateway event frames may include `seq`. The vendored browser client tracks `seq` and reports gaps (`expected`, `received`) via `onGap`. + +Studio behavior on gap: +- Logs a warning. +- Forces a summary snapshot refresh and reconciles running agents. + +Files: +- Gap detection: `src/lib/gateway/openclaw/GatewayBrowserClient.ts` +- Gap handling: `src/app/page.tsx` + +## History Sync (Recovery, Load More) + +Studio can fetch history via `chat.history` and merge it into the transcript. + +Key points: +- Studio intentionally treats gateway history as canonical for timestamps/final ordering. +- History merge is designed to avoid duplicates and reconcile local optimistic sends. +- History parsing intentionally skips some system-ish content (heartbeat prompts, restart sentinel messages, and UI metadata prefixes). See `buildHistoryLines()` in `src/features/agents/state/runtimeEventBridge.ts`. +- Transcript v2 can be toggled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_V2`. +- Transcript debug logs can be enabled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG`. + +Files: +- History operation: `src/features/agents/operations/historySyncOperation.ts` +- Transcript merge/sort primitives: `src/features/agents/state/transcript.ts` + +## Exec Approvals In Chat (Related To “PI Runs”) + +Some runs require exec approval. These are surfaced as in-chat cards and are handled separately from the `chat`/`agent` runtime stream. + +Files: +- Event to pending-card state: `src/features/agents/approvals/execApprovalEvents.ts` +- Resolve operation: `src/features/agents/approvals/execApprovalResolveOperation.ts` +- Wiring (subscribe + render): `src/app/page.tsx`, `src/features/agents/components/AgentChatPanel.tsx` + +## Media Rendering (Images From Agent Output) + +If an agent outputs lines like: +- `MEDIA: /home/ubuntu/.openclaw/.../image.png` + +Studio may render them inline: +1. UI rewrites eligible `MEDIA:` lines into markdown images (`![](/api/gateway/media?path=...)`) but avoids rewriting inside fenced code blocks. +2. The browser requests `/api/gateway/media`. +3. The API route reads the image either locally (only under `~/.openclaw`) or over SSH for remote gateways, and returns the bytes with the correct `Content-Type`. + +Files: +- Rewrite helper: `src/lib/text/media-markdown.ts` +- Media API route: `src/app/api/gateway/media/route.ts` +- SSH helper + env vars (`OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`): `src/lib/ssh/gateway-host.ts` + +## Debugging Checklist (When Chat “Feels Buggy”) + +Start with the hop where symptoms appear. + +WS bridge / connectivity: +- Studio server logs (proxy): `server/gateway-proxy.js` +- Common failures: wrong `ws://` vs `wss://`, missing token, gateway closed, upstream TLS mismatch + +Streaming correctness (missing/duplicated output): +- Event classification + runtime stream merge: `src/features/agents/state/gatewayRuntimeEventHandler.ts` +- Text/thinking/tool extraction quirks: `src/lib/text/message-extract.ts` +- UI item derivation and collapsing rules: `src/features/agents/components/chatItems.ts` +- Dedupe of tool lines per run + closed-run ignore window: `src/features/agents/state/gatewayRuntimeEventHandler.ts` + +History and ordering issues: +- `chat.history` merge logic and dedupe: `src/features/agents/operations/historySyncOperation.ts` +- Transcript entry ordering/fingerprints: `src/features/agents/state/transcript.ts` + +Media not rendering: +- `MEDIA:` rewrite behavior and code-fence skipping: `src/lib/text/media-markdown.ts` +- Image fetch route behavior (local vs SSH, allowlisted extensions, size limits): `src/app/api/gateway/media/route.ts` + +If you need Gateway-side observability: +- Capture the exact `connect` settings used by Studio (URL + token are stored server-side in the Studio settings file). +- Inspect Gateway logs on the Gateway host using your environment’s service/log tooling. diff --git a/docs/ui-guide.md b/docs/ui-guide.md new file mode 100644 index 00000000..55468a91 --- /dev/null +++ b/docs/ui-guide.md @@ -0,0 +1,57 @@ +# Studio UI Guide + +This doc describes the current Studio IA and behavior. + +## Agent Surfaces + +### Chat (default) +- Selecting an agent opens chat as the primary workspace. +- Chat header controls include: + - New session + - Personality shortcut + - Settings shortcut +- New session resets the current agent session and clears visible transcript state in Studio. + +### Settings Sidebar +- The settings cog opens one sidebar with four tabs: + 1. Personality + 2. Capabilities + 3. Automations + 4. Advanced + +## Personality +- Personality is the first tab when opening settings. +- Rename agent lives in Personality. +- Personality file tabs are intentionally limited to: + - Personality (`SOUL.md`) + - Instructions (`AGENTS.md`) + - About You (`USER.md`) + - Identity (`IDENTITY.md`) +- Underlying persistence still saves the full gateway-backed agent file set. + +## Capabilities +- Capabilities exposes direct controls (no role preset labels): + - Run commands: Off / Ask / Auto + - Web access: Off / On + - File tools: Off / On +- Skills and Browser automation are visible as coming-soon toggles. + +## Automations +- User-facing language is schedules/automations (not cron-first terminology). +- Schedule creation uses template -> task -> schedule -> review flow. +- Heartbeats are represented in this tab as coming soon. + +## Advanced +- Advanced contains: + - Display toggles (Show tool calls, Show thinking) + - Open Full Control UI + - Delete agent (danger zone) +- Session controls are not in Advanced. + +## Agent Creation Defaults +- Create modal captures only name/avatar. +- After creation, Studio applies permissive defaults: + - Commands: Auto + - Web access: On + - File tools: On +- Post-create UX keeps chat as primary and auto-opens Capabilities sidebar for onboarding. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2deced19..c2dc6d47 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,12 @@ import prettier from "eslint-config-prettier/flat"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + files: ["server/**/*.js", "scripts/**/*.js"], + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: @@ -13,6 +19,9 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + + // Vendored third-party code (kept as-is; linting it adds noise). + "src/lib/avatars/vendor/**", ]), prettier, ]); diff --git a/home-screen.png b/home-screen.png new file mode 100644 index 00000000..3fc5345a Binary files /dev/null and b/home-screen.png differ diff --git a/package-lock.json b/package-lock.json index d1a9b3cd..90e5c487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,28 @@ { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", + "@noble/ed25519": "^3.0.0", "@radix-ui/react-slot": "^1.2.4", "@vercel/otel": "^2.1.0", - "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", - "next": "16.1.4", + "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "react-mentions-ts": "^5.4.7", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", - "zod": "^4.3.6" + "ws": "^8.18.3" }, "devDependencies": { "@playwright/test": "^1.58.0", @@ -31,8 +32,9 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", - "eslint-config-next": "16.1.4", + "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.8.1", @@ -1730,15 +1732,15 @@ } }, "node_modules/@next/env": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz", - "integrity": "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.4.tgz", - "integrity": "sha512-38WMjGP8y+1MN4bcZFs+GTcBe0iem5GGTzFE5GWW/dWdRKde7LOXH3lQT2QuoquVWyfl2S0fQRchGmeacGZ4Wg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1746,9 +1748,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz", - "integrity": "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "cpu": [ "arm64" ], @@ -1762,9 +1764,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.4.tgz", - "integrity": "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "cpu": [ "x64" ], @@ -1778,9 +1780,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.4.tgz", - "integrity": "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "cpu": [ "arm64" ], @@ -1794,9 +1796,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.4.tgz", - "integrity": "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "cpu": [ "arm64" ], @@ -1810,9 +1812,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.4.tgz", - "integrity": "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "cpu": [ "x64" ], @@ -1826,9 +1828,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.4.tgz", - "integrity": "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "cpu": [ "x64" ], @@ -1842,9 +1844,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.4.tgz", - "integrity": "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "cpu": [ "arm64" ], @@ -1858,9 +1860,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz", - "integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "cpu": [ "x64" ], @@ -1873,6 +1875,15 @@ "node": ">= 10" } }, + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2868,55 +2879,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3021,6 +2983,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.53.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", @@ -3694,38 +3666,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@xyflow/react": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", - "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", - "license": "MIT", - "dependencies": { - "@xyflow/system": "0.0.74", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.74", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", - "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4322,12 +4262,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4455,111 +4389,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5144,13 +4973,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.4.tgz", - "integrity": "sha512-iCrrNolUPpn/ythx0HcyNRfUBgTkaNBXByisKUbusPGCl8DMkDXXAu7exlSTSLGTIsH9lFE/c4s/3Qiyv2qwdA==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.4", + "@next/eslint-plugin-next": "16.1.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -8210,12 +8039,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.4.tgz", - "integrity": "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==", + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", "dependencies": { - "@next/env": "16.1.4", + "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", @@ -8229,14 +8058,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.4", - "@next/swc-darwin-x64": "16.1.4", - "@next/swc-linux-arm64-gnu": "16.1.4", - "@next/swc-linux-arm64-musl": "16.1.4", - "@next/swc-linux-x64-gnu": "16.1.4", - "@next/swc-linux-x64-musl": "16.1.4", - "@next/swc-win32-arm64-msvc": "16.1.4", - "@next/swc-win32-x64-msvc": "16.1.4", + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { @@ -8846,6 +8675,22 @@ "react": ">=18" } }, + "node_modules/react-mentions-ts": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/react-mentions-ts/-/react-mentions-ts-5.4.7.tgz", + "integrity": "sha512-bTK6joPmyvLckVf1v7vE2xSSeqvL4ZwuzFvGZpt+IrtdDOdFZjiwTUBo5920kiE6WbH/v1PP81xAo6pQ0NQ0Pg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "class-variance-authority": ">=0.6.0", + "clsx": ">=2.0.0", + "react": ">=19.0.0", + "react-dom": ">=19.0.0", + "tailwind-merge": ">=3.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10261,15 +10106,6 @@ "punycode": "^2.1.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -10693,7 +10529,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10752,6 +10587,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -10770,34 +10606,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index aa5c4c9e..56c33e89 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,39 @@ { - "name": "clawdbot-agent-ui", + "name": "openclaw-studio", "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "node server/index.js --dev", + "dev:turbo": "node server/index.js --dev", "build": "next build", - "start": "next start", + "start": "node server/index.js", "lint": "eslint .", + "cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs", + "sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts", + "migrate:architecture": "node scripts/migrate-architecture.ts", + "studio:setup": "node scripts/studio-setup.js", + "smoke:dev-server": "node scripts/smoke-dev-server.mjs", "typecheck": "tsc --noEmit", "test": "vitest", - "e2e": "playwright test" + "e2e": "playwright test", + "pw:open:max": "scripts/playwright-open-maximized.sh" }, "dependencies": { "@multiavatar/multiavatar": "github:multiavatar/Multiavatar", + "@noble/ed25519": "^3.0.0", "@radix-ui/react-slot": "^1.2.4", "@vercel/otel": "^2.1.0", - "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", - "next": "16.1.4", + "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", "react-markdown": "^10.1.0", + "react-mentions-ts": "^5.4.7", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", - "zod": "^4.3.6" + "ws": "^8.18.3" }, "devDependencies": { "@playwright/test": "^1.58.0", @@ -35,8 +43,9 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", - "eslint-config-next": "16.1.4", + "eslint-config-next": "16.1.6", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.8.1", diff --git a/playwright.config.ts b/playwright.config.ts index e3accfa8..fd356b41 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "@playwright/test"; +import path from "node:path"; export default defineConfig({ testDir: "./tests/e2e", use: { @@ -8,5 +9,10 @@ export default defineConfig({ command: "npm run dev", port: 3000, reuseExistingServer: !process.env.CI, + env: { + ...process.env, + OPENCLAW_STATE_DIR: path.resolve("./tests/fixtures/openclaw-empty-state"), + NEXT_PUBLIC_GATEWAY_URL: "", + }, }, }); diff --git a/readme-image.png b/readme-image.png new file mode 100644 index 00000000..89f6ae0b Binary files /dev/null and b/readme-image.png differ diff --git a/scripts/cleanup-ux-artifacts.mjs b/scripts/cleanup-ux-artifacts.mjs new file mode 100644 index 00000000..ee1484f5 --- /dev/null +++ b/scripts/cleanup-ux-artifacts.mjs @@ -0,0 +1,88 @@ +import { constants as fsConstants, promises as fs } from "node:fs"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const uxAuditDir = path.join(repoRoot, "output", "playwright", "ux-audit"); +const transientFiles = [ + path.join(repoRoot, ".agent", "ux-audit.md"), + path.join(repoRoot, ".agent", "execplan-pending.md"), +]; + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function clearDirContents(dir) { + await ensureDir(dir); + const entries = await fs.readdir(dir); + await Promise.all( + entries.map((entry) => + fs.rm(path.join(dir, entry), { recursive: true, force: true }), + ), + ); +} + +async function removeIfPresent(filePath) { + try { + await fs.unlink(filePath); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return; + } + throw error; + } +} + +function run(command, args) { + return spawnSync(command, args, { encoding: "utf8" }); +} + +async function stopPlaywrightSessions() { + const codeHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); + const pwcli = path.join(codeHome, "skills", "playwright", "scripts", "playwright_cli.sh"); + try { + await fs.access(pwcli, fsConstants.X_OK); + } catch { + return; + } + const result = run(pwcli, ["session-stop-all"]); + if (result.status === 0) return; + if (result.error) { + throw result.error; + } +} + +function killPattern(pattern) { + const result = run("pkill", ["-f", pattern]); + if (result.status === 0 || result.status === 1) return; + if (result.error && result.error.code === "ENOENT") return; + if (result.error) throw result.error; +} + +function cleanupPlaywrightProcesses() { + killPattern("ms-playwright/daemon"); + killPattern("playwright/cli.js run-mcp-server"); + killPattern("chrome-headless-shell"); + killPattern("Google Chrome --headless"); + killPattern("Chromium --headless"); +} + +async function main() { + await stopPlaywrightSessions(); + cleanupPlaywrightProcesses(); + await clearDirContents(uxAuditDir); + for (const transientFile of transientFiles) { + await removeIfPresent(transientFile); + } + console.log("cleanup:ux-artifacts complete"); +} + +main().catch((error) => { + console.error("cleanup:ux-artifacts failed"); + console.error(error); + process.exit(1); +}); diff --git a/scripts/create-discord-channel.ts b/scripts/create-discord-channel.ts deleted file mode 100644 index 8b20264d..00000000 --- a/scripts/create-discord-channel.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createDiscordChannelForAgent } from "@/lib/discord/discordChannel"; - -const args = new Map<string, string>(); -for (const entry of process.argv.slice(2)) { - const [key, value] = entry.split("="); - if (!key || value === undefined) continue; - args.set(key.replace(/^--/, ""), value); -} - -const agentName = args.get("agent-name"); -const agentId = args.get("agent-id"); -const workspaceDir = args.get("workspace-dir"); -if (!agentName || !agentId || !workspaceDir) { - console.error( - "Usage: node scripts/create-discord-channel.ts --agent-name=<name> --agent-id=<id> --workspace-dir=<path> [--guild-id=<id>]" - ); - process.exit(1); -} - -const guildId = args.get("guild-id"); - -createDiscordChannelForAgent({ agentName, agentId, guildId, workspaceDir }) - .then((result) => { - console.log( - JSON.stringify( - { - channelId: result.channelId, - channelName: result.channelName, - guildId: result.guildId, - }, - null, - 2 - ) - ); - }) - .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create Discord channel."; - console.error(message); - process.exit(1); - }); diff --git a/scripts/migrate-architecture.ts b/scripts/migrate-architecture.ts new file mode 100644 index 00000000..c388deab --- /dev/null +++ b/scripts/migrate-architecture.ts @@ -0,0 +1,468 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const WORKSPACE_FILE_NAMES = [ + "AGENTS.md", + "SOUL.md", + "IDENTITY.md", + "USER.md", + "HEARTBEAT.md", + "TOOLS.md", + "MEMORY.md", +]; +const WORKSPACE_IGNORE_ENTRIES = [...WORKSPACE_FILE_NAMES, "memory/"]; +const STORE_VERSION = 3; +const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"]; +const NEW_STATE_DIRNAME = ".openclaw"; +const CONFIG_FILENAME = "openclaw.json"; +const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"]; + +const resolveUserPath = (input: string, homedir: () => string = os.homedir) => { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir()); + return path.resolve(expanded); + } + return path.resolve(trimmed); +}; + +const resolveStateDir = (env = process.env, homedir = os.homedir) => { + const override = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override, homedir); + const newDir = path.join(homedir(), NEW_STATE_DIRNAME); + const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)); + if (fs.existsSync(newDir)) return newDir; + const existingLegacy = legacyDirs.find((dir) => { + try { + return fs.existsSync(dir); + } catch { + return false; + } + }); + return existingLegacy ?? newDir; +}; + +const resolveConfigPathCandidates = (env = process.env, homedir = os.homedir) => { + const explicit = + env.OPENCLAW_CONFIG_PATH?.trim() || + env.MOLTBOT_CONFIG_PATH?.trim() || + env.CLAWDBOT_CONFIG_PATH?.trim(); + if (explicit) return [resolveUserPath(explicit, homedir)]; + + const candidates = []; + const stateDir = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (stateDir) { + const resolved = resolveUserPath(stateDir, homedir); + candidates.push(path.join(resolved, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); + } + + const defaultDirs = [ + path.join(homedir(), NEW_STATE_DIRNAME), + ...LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)), + ]; + for (const dir of defaultDirs) { + candidates.push(path.join(dir, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); + } + return candidates; +}; + +const resolveAgentCanvasDir = () => path.join(resolveStateDir(), "openclaw-studio"); + +const resolveAgentWorktreeDir = (projectId: string, agentId: string) => + path.join(resolveAgentCanvasDir(), "worktrees", projectId, agentId); + +const parseAgentIdFromSessionKey = (sessionKey: string, fallback = "main") => { + const match = sessionKey.match(/^agent:([^:]+):/); + return match ? match[1] : fallback; +}; + +const parseJsonLoose = (raw: string) => { + try { + return JSON.parse(raw); + } catch { + const cleaned = raw.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(cleaned); + } +}; + +const loadStore = (storePath: string) => { + const raw = fs.readFileSync(storePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.projects)) { + throw new Error(`Workspaces store is invalid at ${storePath}.`); + } + return parsed; +}; + +const normalizeStore = (store: { projects?: unknown[]; activeProjectId?: unknown }) => { + const projects = Array.isArray(store.projects) + ? (store.projects as Array<Record<string, unknown>>) + : []; + const normalizedProjects = projects.map((project) => { + const projectId = typeof project.id === "string" ? project.id : ""; + const tiles = Array.isArray(project.tiles) ? project.tiles : []; + return { + id: projectId, + name: typeof project.name === "string" ? project.name : "", + repoPath: typeof project.repoPath === "string" ? project.repoPath : "", + createdAt: typeof project.createdAt === "number" ? project.createdAt : Date.now(), + updatedAt: typeof project.updatedAt === "number" ? project.updatedAt : Date.now(), + archivedAt: typeof project.archivedAt === "number" ? project.archivedAt : null, + tiles: tiles.map((tile) => { + const agentId = + typeof tile.agentId === "string" && tile.agentId.trim() + ? tile.agentId.trim() + : parseAgentIdFromSessionKey( + typeof tile.sessionKey === "string" ? tile.sessionKey : "" + ); + return { + ...tile, + agentId, + role: typeof tile.role === "string" ? tile.role : "coding", + workspacePath: + typeof tile.workspacePath === "string" && tile.workspacePath.trim() + ? tile.workspacePath + : resolveAgentWorktreeDir(projectId, agentId), + archivedAt: typeof tile.archivedAt === "number" ? tile.archivedAt : null, + }; + }), + }; + }); + const activeProjectId = + typeof store.activeProjectId === "string" && + normalizedProjects.some( + (project) => project.id === store.activeProjectId && !project.archivedAt + ) + ? store.activeProjectId + : normalizedProjects.find((project) => !project.archivedAt)?.id ?? null; + return { + version: STORE_VERSION, + activeProjectId, + projects: normalizedProjects, + }; +}; + +const resolveGitDir = (worktreeDir: string) => { + const gitPath = path.join(worktreeDir, ".git"); + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) { + return gitPath; + } + if (!stat.isFile()) { + throw new Error(`.git is not a file or directory at ${gitPath}`); + } + const raw = fs.readFileSync(gitPath, "utf8"); + const match = raw.trim().match(/^gitdir:\s*(.+)$/i); + if (!match || !match[1]) { + throw new Error(`Unable to resolve gitdir from ${gitPath}`); + } + return path.resolve(worktreeDir, match[1].trim()); +}; + +const ensureWorktreeIgnores = (worktreeDir: string, files: string[]) => { + if (files.length === 0) return; + const gitDir = resolveGitDir(worktreeDir); + const infoDir = path.join(gitDir, "info"); + fs.mkdirSync(infoDir, { recursive: true }); + const excludePath = path.join(infoDir, "exclude"); + const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; + const lines = existing.split(/\r?\n/); + const additions = files.filter((entry) => !lines.includes(entry)); + if (additions.length === 0) return; + let next = existing; + if (next.length > 0 && !next.endsWith("\n")) { + next += "\n"; + } + next += `${additions.join("\n")}\n`; + fs.writeFileSync(excludePath, next, "utf8"); +}; + +const ensureAgentWorktree = (repoPath: string, worktreeDir: string, branchName: string) => { + const trimmedRepo = repoPath.trim(); + if (!trimmedRepo) { + throw new Error("Repository path is required."); + } + if (!fs.existsSync(trimmedRepo)) { + throw new Error(`Repository path does not exist: ${trimmedRepo}`); + } + const repoStat = fs.statSync(trimmedRepo); + if (!repoStat.isDirectory()) { + throw new Error(`Repository path is not a directory: ${trimmedRepo}`); + } + if (!fs.existsSync(path.join(trimmedRepo, ".git"))) { + throw new Error(`Repository is missing a .git directory: ${trimmedRepo}`); + } + + if (fs.existsSync(worktreeDir)) { + const stat = fs.statSync(worktreeDir); + if (!stat.isDirectory()) { + throw new Error(`Worktree path is not a directory: ${worktreeDir}`); + } + if (!fs.existsSync(path.join(worktreeDir, ".git"))) { + throw new Error(`Existing worktree is missing .git at ${worktreeDir}`); + } + return; + } + + fs.mkdirSync(path.dirname(worktreeDir), { recursive: true }); + const branchCheck = spawnSync("git", ["rev-parse", "--verify", branchName], { + cwd: trimmedRepo, + encoding: "utf8", + }); + const args = + branchCheck.status === 0 + ? ["worktree", "add", worktreeDir, branchName] + : ["worktree", "add", "-b", branchName, worktreeDir]; + const result = spawnSync("git", args, { cwd: trimmedRepo, encoding: "utf8" }); + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + throw new Error( + stderr + ? `git worktree add failed for ${worktreeDir}: ${stderr}` + : `git worktree add failed for ${worktreeDir}.` + ); + } +}; + +const ensureWorkspaceFiles = (workspaceDir: string) => { + fs.mkdirSync(workspaceDir, { recursive: true }); + for (const name of WORKSPACE_FILE_NAMES) { + const filePath = path.join(workspaceDir, name); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, "", "utf8"); + } + } + fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true }); +}; + +const copyWorkspaceFile = (fromPath: string, toPath: string) => { + if (!fs.existsSync(fromPath)) return false; + if (fs.existsSync(toPath)) { + const current = fs.readFileSync(toPath, "utf8"); + if (current.trim()) return false; + } + fs.copyFileSync(fromPath, toPath); + return true; +}; + +const copyWorkspaceMemory = (fromDir: string, toDir: string) => { + if (!fs.existsSync(fromDir)) return 0; + fs.mkdirSync(toDir, { recursive: true }); + let copied = 0; + for (const entry of fs.readdirSync(fromDir, { withFileTypes: true })) { + const fromPath = path.join(fromDir, entry.name); + const toPath = path.join(toDir, entry.name); + if (entry.isDirectory()) { + copied += copyWorkspaceMemory(fromPath, toPath); + } else if (!fs.existsSync(toPath)) { + fs.copyFileSync(fromPath, toPath); + copied += 1; + } + } + return copied; +}; + +const reserveLegacyPath = (targetPath: string) => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + let candidate = `${targetPath}.legacy-${stamp}`; + let suffix = 1; + while (fs.existsSync(candidate)) { + candidate = `${targetPath}.legacy-${stamp}-${suffix}`; + suffix += 1; + } + return candidate; +}; + +const ensureLegacyWorktreeSlot = (worktreeDir: string) => { + if (!fs.existsSync(worktreeDir)) return null; + if (fs.existsSync(path.join(worktreeDir, ".git"))) return null; + const legacyPath = reserveLegacyPath(worktreeDir); + fs.renameSync(worktreeDir, legacyPath); + return legacyPath; +}; + +const readAgentList = (config: { + agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> }; +}) => { + const agents = config.agents ?? {}; + const list = Array.isArray(agents.list) ? agents.list : []; + return list.filter((entry) => Boolean(entry && typeof entry === "object")); +}; + +const writeAgentList = ( + config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } }, + list: Array<{ id?: string; name?: string; workspace?: string }> +) => { + const agents = config.agents ?? {}; + agents.list = list; + config.agents = agents; +}; + +const upsertAgentEntry = ( + config: { agents?: { list?: Array<{ id?: string; name?: string; workspace?: string }> } }, + entry: { agentId: string; agentName: string; workspaceDir: string } +) => { + const list = readAgentList(config); + let changed = false; + let found = false; + const next = list.map((item) => { + if (item.id !== entry.agentId) return item; + found = true; + const nextItem = { ...item }; + if (entry.agentName && entry.agentName !== item.name) { + nextItem.name = entry.agentName; + changed = true; + } + if (entry.workspaceDir !== item.workspace) { + nextItem.workspace = entry.workspaceDir; + changed = true; + } + return nextItem; + }); + if (!found) { + next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir }); + changed = true; + } + if (changed) { + writeAgentList(config, next); + } + return changed; +}; + +const loadClawdbotConfig = () => { + const candidates = resolveConfigPathCandidates(); + const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME); + const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath; + if (!fs.existsSync(configPath)) { + throw new Error(`Missing config at ${configPath}.`); + } + const raw = fs.readFileSync(configPath, "utf8"); + return { config: parseJsonLoose(raw), configPath }; +}; + +const saveClawdbotConfig = (configPath: string, config: unknown) => { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); +}; + +const migrate = () => { + const stateDir = resolveStateDir(); + const storePath = path.join(stateDir, "openclaw-studio", "projects.json"); + if (!fs.existsSync(storePath)) { + console.error(`Missing projects store at ${storePath}.`); + process.exit(1); + } + + const rawStore = loadStore(storePath); + const store = normalizeStore(rawStore); + + const backupPath = `${storePath}.backup-${Date.now()}`; + fs.copyFileSync(storePath, backupPath); + + const warnings = []; + const errors = []; + const legacyMoves = []; + + let config = null; + let configPath = ""; + let configLoaded = false; + try { + const loaded = loadClawdbotConfig(); + config = loaded.config; + configPath = loaded.configPath; + configLoaded = true; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load config."; + warnings.push(`Agent config not updated: ${message}`); + } + + for (const project of store.projects) { + const repoPath = typeof project.repoPath === "string" ? project.repoPath : ""; + for (const tile of project.tiles) { + const agentId = + typeof tile.agentId === "string" && tile.agentId.trim() + ? tile.agentId.trim() + : parseAgentIdFromSessionKey(tile.sessionKey ?? ""); + const worktreeDir = resolveAgentWorktreeDir(project.id, agentId); + if (tile.workspacePath !== worktreeDir) { + tile.workspacePath = worktreeDir; + } + const branchName = `agent/${agentId}`; + let legacyPath = null; + try { + legacyPath = ensureLegacyWorktreeSlot(worktreeDir); + if (legacyPath) { + legacyMoves.push({ from: legacyPath, to: worktreeDir }); + } + ensureAgentWorktree(repoPath, worktreeDir, branchName); + ensureWorktreeIgnores(worktreeDir, WORKSPACE_IGNORE_ENTRIES); + ensureWorkspaceFiles(worktreeDir); + if (legacyPath) { + for (const name of WORKSPACE_FILE_NAMES) { + const fromPath = path.join(legacyPath, name); + const toPath = path.join(worktreeDir, name); + copyWorkspaceFile(fromPath, toPath); + } + copyWorkspaceMemory(path.join(legacyPath, "memory"), path.join(worktreeDir, "memory")); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error."; + errors.push(`Worktree migration failed for ${project.id}/${agentId}: ${message}`); + continue; + } + + if (configLoaded && config) { + try { + upsertAgentEntry(config, { + agentId, + agentName: tile.name ?? agentId, + workspaceDir: worktreeDir, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error."; + warnings.push(`Failed to update agent config for ${agentId}: ${message}`); + } + } + } + } + + fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf8"); + + if (configLoaded && config && configPath) { + saveClawdbotConfig(configPath, config); + } + + if (legacyMoves.length > 0) { + console.log("Legacy workspace directories renamed:"); + for (const move of legacyMoves) { + console.log(` ${move.from} -> ${move.to}`); + } + } + if (warnings.length > 0) { + console.log("Warnings:"); + for (const warning of warnings) { + console.log(` - ${warning}`); + } + } + if (errors.length > 0) { + console.error("Migration errors:"); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exitCode = 1; + } + + console.log(`Migration complete. Store backup: ${backupPath}`); +}; + +migrate(); diff --git a/scripts/playwright-open-maximized.sh b/scripts/playwright-open-maximized.sh new file mode 100755 index 00000000..c08acced --- /dev/null +++ b/scripts/playwright-open-maximized.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +url="${1:-http://localhost:3000}" +session="${PLAYWRIGHT_CLI_SESSION:-uiexplore-$(date +%s)}" + +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +PWCLI="$CODEX_HOME/skills/playwright/scripts/playwright_cli.sh" + +if [[ ! -x "$PWCLI" ]]; then + echo "Playwright wrapper not found at $PWCLI" >&2 + exit 1 +fi + +export PLAYWRIGHT_CLI_SESSION="$session" +export PLAYWRIGHT_CLI_AUTO_RESIZE=0 + +"$PWCLI" open --headed "$url" +"$PWCLI" run-code "(async (page) => { const cdp = await page.context().newCDPSession(page); const win = await cdp.send('Browser.getWindowForTarget'); await cdp.send('Browser.setWindowBounds', { windowId: win.windowId, bounds: { windowState: 'maximized' } }); })" diff --git a/scripts/smoke-dev-server.mjs b/scripts/smoke-dev-server.mjs new file mode 100644 index 00000000..da38e4ad --- /dev/null +++ b/scripts/smoke-dev-server.mjs @@ -0,0 +1,82 @@ +import { spawn } from "node:child_process"; +import net from "node:net"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const getFreePort = async () => { + for (let i = 0; i < 30; i++) { + const port = 20000 + Math.floor(Math.random() * 20000); + const ok = await new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); + if (ok) return port; + } + throw new Error("Failed to find a free port for smoke test."); +}; + +const main = async () => { + const port = await getFreePort(); + const url = `http://127.0.0.1:${port}/`; + + const child = spawn(process.execPath, ["server/index.js", "--dev"], { + env: { + ...process.env, + HOST: "127.0.0.1", + PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const lines = []; + const pushLines = (chunk) => { + const text = String(chunk ?? ""); + for (const line of text.split(/\r?\n/)) { + if (!line) continue; + lines.push(line); + if (lines.length > 80) lines.shift(); + } + }; + child.stdout.on("data", pushLines); + child.stderr.on("data", pushLines); + + const deadline = Date.now() + 60_000; + let lastErr = null; + + try { + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Dev server exited early with code ${child.exitCode}.`); + } + + try { + const res = await fetch(url, { redirect: "manual" }); + if (res.status >= 200 && res.status < 500) { + process.stdout.write(`OK ${res.status} ${url}\n`); + return; + } + lastErr = new Error(`Unexpected status ${res.status} for ${url}`); + } catch (err) { + lastErr = err; + } + + await sleep(500); + } + + throw new Error( + `Timed out waiting for dev server to respond at ${url}. Last error: ${lastErr?.message || "unknown"}` + ); + } finally { + child.kill("SIGTERM"); + await Promise.race([new Promise((r) => child.once("exit", r)), sleep(2000)]); + } +}; + +main().catch((err) => { + process.stderr.write(String(err?.stack || err) + "\n"); + process.exitCode = 1; +}); + diff --git a/scripts/studio-setup.js b/scripts/studio-setup.js new file mode 100644 index 00000000..06b29303 --- /dev/null +++ b/scripts/studio-setup.js @@ -0,0 +1,90 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const readline = require("node:readline/promises"); + +const { resolveStudioSettingsPath } = require("../server/studio-settings"); + +const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; + +const parseArgs = (argv) => { + return { + force: argv.includes("--force"), + }; +}; + +const tryReadGatewayTokenFromOpenclawCli = () => { + try { + const raw = execFileSync("openclaw", ["config", "get", "gateway.auth.token"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const token = String(raw ?? "").trim(); + return token || null; + } catch { + return null; + } +}; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const settingsPath = resolveStudioSettingsPath(process.env); + const settingsDir = path.dirname(settingsPath); + + if (fs.existsSync(settingsPath) && !args.force) { + console.error( + `Studio settings already exist at ${settingsPath}. Re-run with --force to overwrite.` + ); + process.exitCode = 1; + return; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const urlAnswer = await rl.question( + `Upstream Gateway URL [${DEFAULT_GATEWAY_URL}]: ` + ); + const gatewayUrl = (urlAnswer || DEFAULT_GATEWAY_URL).trim(); + if (!gatewayUrl) { + throw new Error("Gateway URL is required."); + } + + const tokenDefault = tryReadGatewayTokenFromOpenclawCli(); + const tokenPrompt = tokenDefault + ? "Upstream Gateway Token [detected from openclaw]: " + : "Upstream Gateway Token: "; + const tokenAnswer = await rl.question(tokenPrompt); + const token = (tokenAnswer || tokenDefault || "").trim(); + if (!token) { + throw new Error( + "Gateway token is required. Provide it, or install/openclaw so it can be auto-detected." + ); + } + + fs.mkdirSync(settingsDir, { recursive: true }); + const next = { + version: 1, + gateway: { + url: gatewayUrl, + token, + }, + }; + fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8"); + + console.info(`Wrote Studio settings to ${settingsPath}.`); + } finally { + rl.close(); + } +} + +main().catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(msg); + process.exitCode = 1; +}); + diff --git a/scripts/sync-openclaw-gateway-client.ts b/scripts/sync-openclaw-gateway-client.ts new file mode 100644 index 00000000..93de866c --- /dev/null +++ b/scripts/sync-openclaw-gateway-client.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const sourcePath = path.join(os.homedir(), "clawdbot", "ui", "src", "ui", "gateway.ts"); +const destPath = path.join( + repoRoot, + "src", + "lib", + "gateway", + "openclaw", + "GatewayBrowserClient.ts" +); + +if (!fs.existsSync(sourcePath)) { + console.error(`Missing upstream gateway client at ${sourcePath}.`); + process.exit(1); +} + +let contents = fs.readFileSync(sourcePath, "utf8"); +contents = contents + .replace( + /from "\.\.\/\.\.\/\.\.\/src\/gateway\/protocol\/client-info\.js";/g, + 'from "./client-info";' + ) + .replace( + /from "\.\.\/\.\.\/\.\.\/src\/gateway\/device-auth\.js";/g, + 'from "./device-auth-payload";' + ); + +fs.mkdirSync(path.dirname(destPath), { recursive: true }); +fs.writeFileSync(destPath, contents, "utf8"); +console.log(`Synced gateway client to ${destPath}.`); diff --git a/server/access-gate.js b/server/access-gate.js new file mode 100644 index 00000000..a1dcdfa1 --- /dev/null +++ b/server/access-gate.js @@ -0,0 +1,90 @@ +const { URL } = require("node:url"); + +const parseCookies = (header) => { + const raw = typeof header === "string" ? header : ""; + if (!raw.trim()) return {}; + const out = {}; + for (const part of raw.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!key) continue; + out[key] = value; + } + return out; +}; + +const buildRedirectUrl = (req, nextPathWithQuery) => { + const host = req.headers?.host || "localhost"; + const proto = + String(req.headers?.["x-forwarded-proto"] || "").toLowerCase() === "https" + ? "https" + : "http"; + return `${proto}://${host}${nextPathWithQuery}`; +}; + +function createAccessGate(options) { + const token = String(options?.token ?? "").trim(); + const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access"; + const queryParam = String(options?.queryParam ?? "access_token").trim() || "access_token"; + + const enabled = Boolean(token); + + const isAuthorized = (req) => { + if (!enabled) return true; + const cookieHeader = req.headers?.cookie; + const cookies = parseCookies(cookieHeader); + return cookies[cookieName] === token; + }; + + const handleHttp = (req, res) => { + if (!enabled) return false; + const host = req.headers?.host || "localhost"; + const url = new URL(req.url || "/", `http://${host}`); + const provided = url.searchParams.get(queryParam); + + if (provided !== null) { + if (provided !== token) { + res.statusCode = 401; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Invalid Studio access token." })); + return true; + } + + url.searchParams.delete(queryParam); + const cookieValue = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax`; + res.statusCode = 302; + res.setHeader("Set-Cookie", cookieValue); + res.setHeader("Location", buildRedirectUrl(req, url.pathname + url.search)); + res.end(); + return true; + } + + if (url.pathname.startsWith("/api/")) { + if (!isAuthorized(req)) { + res.statusCode = 401; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + error: + "Studio access token required. Open /?access_token=... once to set a cookie.", + }) + ); + return true; + } + } + + return false; + }; + + const allowUpgrade = (req) => { + if (!enabled) return true; + return isAuthorized(req); + }; + + return { enabled, handleHttp, allowUpgrade }; +} + +module.exports = { createAccessGate }; + diff --git a/server/gateway-proxy.js b/server/gateway-proxy.js new file mode 100644 index 00000000..bd639099 --- /dev/null +++ b/server/gateway-proxy.js @@ -0,0 +1,275 @@ +const { WebSocket, WebSocketServer } = require("ws"); + +const buildErrorResponse = (id, code, message) => { + return { + type: "res", + id, + ok: false, + error: { code, message }, + }; +}; + +const isObject = (value) => Boolean(value && typeof value === "object"); + +const safeJsonParse = (raw) => { + try { + return JSON.parse(raw); + } catch { + return null; + } +}; + +const resolvePathname = (url) => { + const raw = typeof url === "string" ? url : ""; + const idx = raw.indexOf("?"); + return (idx === -1 ? raw : raw.slice(0, idx)) || "/"; +}; + +const injectAuthToken = (params, token) => { + const next = isObject(params) ? { ...params } : {}; + const auth = isObject(next.auth) ? { ...next.auth } : {}; + auth.token = token; + next.auth = auth; + return next; +}; + +const resolveOriginForUpstream = (upstreamUrl) => { + const url = new URL(upstreamUrl); + const proto = url.protocol === "wss:" ? "https:" : "http:"; + const hostname = + url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0" + ? "localhost" + : url.hostname; + const host = url.port ? `${hostname}:${url.port}` : hostname; + return `${proto}//${host}`; +}; + +const hasNonEmptyToken = (params) => { + const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : ""; + return typeof raw === "string" && raw.trim().length > 0; +}; + +const hasNonEmptyPassword = (params) => { + const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : ""; + return typeof raw === "string" && raw.trim().length > 0; +}; + +const hasNonEmptyDeviceToken = (params) => { + const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : ""; + return typeof raw === "string" && raw.trim().length > 0; +}; + +const hasCompleteDeviceAuth = (params) => { + const device = params && isObject(params) && isObject(params.device) ? params.device : null; + if (!device) { + return false; + } + const id = typeof device.id === "string" ? device.id.trim() : ""; + const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : ""; + const signature = typeof device.signature === "string" ? device.signature.trim() : ""; + const nonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; + const signedAt = device.signedAt; + return ( + id.length > 0 && + publicKey.length > 0 && + signature.length > 0 && + nonce.length > 0 && + Number.isFinite(signedAt) && + signedAt >= 0 + ); +}; + +function createGatewayProxy(options) { + const { + loadUpstreamSettings, + allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws", + log = () => {}, + logError = (msg, err) => console.error(msg, err), + } = options || {}; + + if (typeof loadUpstreamSettings !== "function") { + throw new Error("createGatewayProxy requires loadUpstreamSettings()."); + } + + const wss = new WebSocketServer({ noServer: true }); + + wss.on("connection", (browserWs) => { + let upstreamWs = null; + let upstreamReady = false; + let connectRequestId = null; + let connectResponseSent = false; + let closed = false; + + const closeBoth = (code, reason) => { + if (closed) return; + closed = true; + try { + browserWs.close(code, reason); + } catch {} + try { + upstreamWs?.close(code, reason); + } catch {} + }; + + const sendToBrowser = (frame) => { + if (browserWs.readyState !== WebSocket.OPEN) return; + browserWs.send(JSON.stringify(frame)); + }; + + const sendConnectError = (code, message) => { + if (connectRequestId && !connectResponseSent) { + connectResponseSent = true; + sendToBrowser(buildErrorResponse(connectRequestId, code, message)); + } + closeBoth(1011, "connect failed"); + }; + + browserWs.on("message", async (raw) => { + const parsed = safeJsonParse(String(raw ?? "")); + if (!parsed || !isObject(parsed)) { + closeBoth(1003, "invalid json"); + return; + } + + if (!upstreamWs) { + if (parsed.type !== "req" || parsed.method !== "connect") { + closeBoth(1008, "connect required"); + return; + } + const id = typeof parsed.id === "string" ? parsed.id : ""; + if (!id) { + closeBoth(1008, "connect id required"); + return; + } + connectRequestId = id; + const browserHasAuth = + hasNonEmptyToken(parsed.params) || + hasNonEmptyPassword(parsed.params) || + hasNonEmptyDeviceToken(parsed.params) || + hasCompleteDeviceAuth(parsed.params); + + let upstreamUrl = ""; + let upstreamToken = ""; + try { + const settings = await loadUpstreamSettings(); + upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : ""; + upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : ""; + } catch (err) { + logError("Failed to load upstream gateway settings.", err); + sendConnectError("studio.settings_load_failed", "Failed to load Studio gateway settings."); + return; + } + + if (!upstreamUrl) { + sendConnectError( + "studio.gateway_url_missing", + "Upstream gateway URL is not configured on the Studio host." + ); + return; + } + if (!upstreamToken && !browserHasAuth) { + sendConnectError( + "studio.gateway_token_missing", + "Upstream gateway token is not configured on the Studio host." + ); + return; + } + + let upstreamOrigin = ""; + try { + upstreamOrigin = resolveOriginForUpstream(upstreamUrl); + } catch { + sendConnectError( + "studio.gateway_url_invalid", + "Upstream gateway URL is invalid on the Studio host." + ); + return; + } + + upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin }); + + upstreamWs.on("open", () => { + upstreamReady = true; + if (browserHasAuth) { + upstreamWs.send(JSON.stringify(parsed)); + return; + } + + const connectFrame = { + ...parsed, + params: injectAuthToken(parsed.params, upstreamToken), + }; + upstreamWs.send(JSON.stringify(connectFrame)); + }); + + upstreamWs.on("message", (upRaw) => { + const upParsed = safeJsonParse(String(upRaw ?? "")); + if (upParsed && isObject(upParsed) && upParsed.type === "res") { + const resId = typeof upParsed.id === "string" ? upParsed.id : ""; + if (resId && connectRequestId && resId === connectRequestId) { + connectResponseSent = true; + } + } + if (browserWs.readyState === WebSocket.OPEN) { + browserWs.send(String(upRaw ?? "")); + } + }); + + upstreamWs.on("close", (ev) => { + const reason = typeof ev?.reason === "string" ? ev.reason : ""; + if (!connectResponseSent) { + sendToBrowser( + buildErrorResponse( + connectRequestId, + "studio.upstream_closed", + `Upstream gateway closed (${ev.code}): ${reason}` + ) + ); + } + closeBoth(1012, "upstream closed"); + }); + + upstreamWs.on("error", (err) => { + logError("Upstream gateway WebSocket error.", err); + sendConnectError( + "studio.upstream_error", + "Failed to connect to upstream gateway WebSocket." + ); + }); + + log("proxy connected"); + return; + } + + if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) { + closeBoth(1013, "upstream not ready"); + return; + } + + upstreamWs.send(JSON.stringify(parsed)); + }); + + browserWs.on("close", () => { + closeBoth(1000, "client closed"); + }); + + browserWs.on("error", (err) => { + logError("Browser WebSocket error.", err); + closeBoth(1011, "client error"); + }); + }); + + const handleUpgrade = (req, socket, head) => { + if (!allowWs(req)) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }; + + return { wss, handleUpgrade }; +} + +module.exports = { createGatewayProxy }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..1f689dee --- /dev/null +++ b/server/index.js @@ -0,0 +1,130 @@ +const http = require("node:http"); +const next = require("next"); + +const { createAccessGate } = require("./access-gate"); +const { createGatewayProxy } = require("./gateway-proxy"); +const { assertPublicHostAllowed, resolveHosts } = require("./network-policy"); +const { loadUpstreamGatewaySettings } = require("./studio-settings"); + +const resolvePort = () => { + const raw = process.env.PORT?.trim() || "3000"; + const port = Number(raw); + if (!Number.isFinite(port) || port <= 0) return 3000; + return port; +}; + +const resolvePathname = (url) => { + const raw = typeof url === "string" ? url : ""; + const idx = raw.indexOf("?"); + return (idx === -1 ? raw : raw.slice(0, idx)) || "/"; +}; + +async function main() { + const dev = process.argv.includes("--dev"); + const hostnames = Array.from(new Set(resolveHosts(process.env))); + const hostname = hostnames[0] ?? "127.0.0.1"; + const port = resolvePort(); + for (const host of hostnames) { + assertPublicHostAllowed({ + host, + studioAccessToken: process.env.STUDIO_ACCESS_TOKEN, + }); + } + + const app = next({ + dev, + hostname, + port, + ...(dev ? { webpack: true } : null), + }); + const handle = app.getRequestHandler(); + + const accessGate = createAccessGate({ + token: process.env.STUDIO_ACCESS_TOKEN, + }); + + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => { + const settings = loadUpstreamGatewaySettings(process.env); + return { url: settings.url, token: settings.token }; + }, + allowWs: (req) => { + if (resolvePathname(req.url) !== "/api/gateway/ws") return false; + if (!accessGate.allowUpgrade(req)) return false; + return true; + }, + }); + + await app.prepare(); + const handleUpgrade = app.getUpgradeHandler(); + const handleServerUpgrade = (req, socket, head) => { + if (resolvePathname(req.url) === "/api/gateway/ws") { + proxy.handleUpgrade(req, socket, head); + return; + } + handleUpgrade(req, socket, head); + }; + + const createServer = () => + http.createServer((req, res) => { + if (accessGate.handleHttp(req, res)) return; + handle(req, res); + }); + + const servers = hostnames.map(() => createServer()); + + const attachUpgradeHandlers = (server) => { + server.on("upgrade", handleServerUpgrade); + server.on("newListener", (eventName, listener) => { + if (eventName !== "upgrade") return; + if (listener === handleServerUpgrade) return; + process.nextTick(() => { + server.removeListener("upgrade", listener); + }); + }); + }; + + for (const server of servers) { + attachUpgradeHandlers(server); + } + + const listenOnHost = (server, host) => + new Promise((resolve, reject) => { + const onError = (err) => { + server.off("error", onError); + reject(err); + }; + server.once("error", onError); + server.listen(port, host, () => { + server.off("error", onError); + resolve(); + }); + }); + + const closeServer = (server) => + new Promise((resolve) => { + if (!server.listening) return resolve(); + server.close(() => resolve()); + }); + + try { + await Promise.all(servers.map((server, index) => listenOnHost(server, hostnames[index]))); + } catch (err) { + await Promise.all(servers.map((server) => closeServer(server))); + throw err; + } + + const hostForBrowser = hostnames.some((value) => value === "127.0.0.1" || value === "::1") + ? "localhost" + : hostname === "0.0.0.0" || hostname === "::" + ? "localhost" + : hostname; + + const browserUrl = `http://${hostForBrowser}:${port}`; + console.info(`Open in browser: ${browserUrl}`); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/server/network-policy.js b/server/network-policy.js new file mode 100644 index 00000000..1bf6457d --- /dev/null +++ b/server/network-policy.js @@ -0,0 +1,84 @@ +const net = require("node:net"); + +const normalizeHost = (host) => { + let raw = String(host ?? "").trim().toLowerCase(); + if (!raw) return ""; + + if (raw.startsWith("[")) { + const end = raw.indexOf("]"); + if (end !== -1) { + return raw.slice(1, end).trim(); + } + } + + const colonCount = (raw.match(/:/g) || []).length; + if (colonCount === 1) { + const idx = raw.lastIndexOf(":"); + const maybePort = raw.slice(idx + 1); + if (/^\d+$/.test(maybePort)) { + raw = raw.slice(0, idx); + } + } + + return raw; +}; + +const resolveHosts = (env = process.env) => { + const host = String(env.HOST ?? "").trim(); + if (host) return [host]; + return ["127.0.0.1", "::1"]; +}; + +const resolveHost = (env = process.env) => { + const hosts = resolveHosts(env); + return hosts[0] ?? "127.0.0.1"; +}; + +const isIpv4Loopback = (value) => value.startsWith("127."); + +const isIpv6Loopback = (value) => { + if (value === "::1" || value === "0:0:0:0:0:0:0:1") return true; + if (!value.startsWith("::ffff:")) return false; + const mapped = value.slice("::ffff:".length); + return net.isIP(mapped) === 4 && isIpv4Loopback(mapped); +}; + +const isPublicHost = (host) => { + const normalized = normalizeHost(host); + if (!normalized) return false; + + if (normalized === "localhost") return false; + if (normalized === "0.0.0.0" || normalized === "::") { + return true; + } + + const ipVersion = net.isIP(normalized); + if (ipVersion === 4) { + return !isIpv4Loopback(normalized); + } + if (ipVersion === 6) { + return !isIpv6Loopback(normalized); + } + + return true; +}; + +const assertPublicHostAllowed = ({ host, studioAccessToken }) => { + if (!isPublicHost(host)) return; + + const token = String(studioAccessToken ?? "").trim(); + if (token) return; + + const normalized = normalizeHost(host) || String(host ?? "").trim() || "(unknown)"; + throw new Error( + `Refusing to bind Studio to public host "${normalized}" without STUDIO_ACCESS_TOKEN. ` + + "Set STUDIO_ACCESS_TOKEN or bind HOST to 127.0.0.1/::1/localhost." + ); +}; + +module.exports = { + resolveHosts, + resolveHost, + isPublicHost, + assertPublicHostAllowed, +}; diff --git a/server/studio-settings.js b/server/studio-settings.js new file mode 100644 index 00000000..a35b96e9 --- /dev/null +++ b/server/studio-settings.js @@ -0,0 +1,112 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"]; +const NEW_STATE_DIRNAME = ".openclaw"; + +const resolveUserPath = (input) => { + const trimmed = String(input ?? "").trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir()); + return path.resolve(expanded); + } + return path.resolve(trimmed); +}; + +const resolveDefaultHomeDir = () => { + const home = os.homedir(); + if (home) { + try { + if (fs.existsSync(home)) return home; + } catch {} + } + return os.tmpdir(); +}; + +const resolveStateDir = (env = process.env) => { + const override = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override); + + const home = resolveDefaultHomeDir(); + const newDir = path.join(home, NEW_STATE_DIRNAME); + const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(home, dir)); + try { + if (fs.existsSync(newDir)) return newDir; + } catch {} + for (const dir of legacyDirs) { + try { + if (fs.existsSync(dir)) return dir; + } catch {} + } + return newDir; +}; + +const resolveStudioSettingsPath = (env = process.env) => { + return path.join(resolveStateDir(env), "openclaw-studio", "settings.json"); +}; + +const readJsonFile = (filePath) => { + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw); +}; + +const DEFAULT_GATEWAY_URL = "ws://localhost:18789"; +const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; + +const isRecord = (value) => Boolean(value && typeof value === "object"); + +const readOpenclawGatewayDefaults = (env = process.env) => { + try { + const stateDir = resolveStateDir(env); + const configPath = path.join(stateDir, OPENCLAW_CONFIG_FILENAME); + const parsed = readJsonFile(configPath); + if (!isRecord(parsed)) return null; + const gateway = isRecord(parsed.gateway) ? parsed.gateway : null; + if (!gateway) return null; + const auth = isRecord(gateway.auth) ? gateway.auth : null; + const token = typeof auth?.token === "string" ? auth.token.trim() : ""; + const port = + typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null; + if (!token) return null; + const url = port ? `ws://localhost:${port}` : ""; + if (!url) return null; + return { url, token }; + } catch { + return null; + } +}; + +const loadUpstreamGatewaySettings = (env = process.env) => { + const settingsPath = resolveStudioSettingsPath(env); + const parsed = readJsonFile(settingsPath); + const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null; + const url = typeof gateway?.url === "string" ? gateway.url.trim() : ""; + const token = typeof gateway?.token === "string" ? gateway.token.trim() : ""; + if (!token) { + const defaults = readOpenclawGatewayDefaults(env); + if (defaults) { + return { + url: url || defaults.url, + token: defaults.token, + settingsPath, + }; + } + } + return { + url: url || DEFAULT_GATEWAY_URL, + token, + settingsPath, + }; +}; + +module.exports = { + resolveStateDir, + resolveStudioSettingsPath, + loadUpstreamGatewaySettings, +}; diff --git a/skills-overview.md b/skills-overview.md new file mode 100644 index 00000000..d1391a41 --- /dev/null +++ b/skills-overview.md @@ -0,0 +1,277 @@ +# Skills in OpenClaw + OpenClaw Studio + +This document explains skills from first principles, how they work in the OpenClaw runtime (`~/openclaw`), and how OpenClaw Studio currently exposes them in UX. + +It is intended as design context for rethinking the Skills UX. + +## 1) Why skills exist (first principles) + +Skills are the mechanism OpenClaw uses to give agents reusable operational know-how without hardcoding that know-how into core runtime logic. + +At a product level, a skill is: +- A unit of capability guidance (`SKILL.md`) that teaches an agent how to perform a job. +- A gated unit of readiness (only available when required binaries/env/config/OS are satisfied). +- A portable package format compatible with AgentSkills (`agentskills.io`) so skill content can be authored and shared outside a single product. + +Without skills, every workflow instruction would need to live in prompts, app code, or ad hoc user messages. Skills create a middle layer: structured capability packs that are discoverable, filterable, and enforceable. + +## 2) AgentSkills.io context + +OpenClaw intentionally uses AgentSkills-compatible `SKILL.md` structure and semantics. + +Why this matters: +- Interoperability: skills can move between ecosystems that understand AgentSkills. +- Community/network effects: external skill ecosystems (for OpenClaw specifically, ClawHub) can be leveraged instead of reinventing proprietary formats. +- UX consistency: users can reason about “a skill folder with `SKILL.md` + metadata gates” instead of app-specific abstractions. + +OpenClaw adds product-specific metadata under `metadata.openclaw` (install specs, gating fields, primary env key, etc.) while keeping the base skill shape compatible. + +## 3) Skill object model + +A skill is loaded from a directory containing `SKILL.md` with frontmatter. + +Minimum frontmatter: +- `name` +- `description` + +Important optional fields used by OpenClaw: +- `metadata.openclaw.always` +- `metadata.openclaw.skillKey` +- `metadata.openclaw.primaryEnv` +- `metadata.openclaw.os` +- `metadata.openclaw.requires.{bins, anyBins, env, config}` +- `metadata.openclaw.install[]` +- `user-invocable` +- `disable-model-invocation` +- `command-dispatch`, `command-tool`, `command-arg-mode` + +In runtime, this becomes a normalized `SkillEntry`: +- Raw skill (`name`, `description`, `source`, file paths) +- Parsed frontmatter +- Resolved OpenClaw metadata +- Invocation policy flags + +## 4) Where skills come from (discovery + precedence) + +OpenClaw merges multiple sources into one effective skill set. + +Current merge precedence in code (lowest -> highest): +1. `skills.load.extraDirs` and plugin-contributed skill dirs (`source: openclaw-extra`) +2. Bundled skills (`openclaw-bundled`) +3. Managed/global local skills (`~/.openclaw/skills`, `openclaw-managed`) +4. Personal agents skills (`~/.agents/skills`, `agents-skills-personal`) +5. Project agents skills (`<workspace>/.agents/skills`, `agents-skills-project`) +6. Workspace skills (`<workspace>/skills`, `openclaw-workspace`) + +Name conflicts are resolved by “last writer wins” according to this order. + +## 5) Eligibility and gating model + +Eligibility is not just “is this skill installed.” It is computed every load/snapshot using: +- Per-skill disable (`skills.entries.<skillKey>.enabled === false`) +- Bundled allowlist (`skills.allowBundled`) for bundled skills only +- Runtime requirements: + - `requires.bins` (all required) + - `requires.anyBins` (at least one) + - `requires.env` + - `requires.config` + - `os` +- Remote node eligibility (macOS node bin probing can satisfy certain requirements) +- `always: true` short-circuiting requirement failures + +Status output carries: +- `eligible` / `blocked` +- structured `missing` reasons +- `configChecks` with `{ path, satisfied }` (not secret values) +- install options derived from metadata + +## 6) Agent-level filtering semantics + +OpenClaw has a separate per-agent skill filter via `agents.list[].skills`: +- Missing `skills` key: all discovered skills are allowed +- `skills: []`: no skills allowed +- `skills: ["a", "b"]`: allowlist mode + +This filter is normalized and passed into snapshot generation as `skillFilter`. + +In practice this is the key UX distinction: +- Discovery/readiness is global + workspace-derived. +- “Can this specific agent use it?” is per-agent allowlist. + +## 7) Snapshot + prompt lifecycle + +Skills are snapshotted into session state (`skillsSnapshot`) to avoid re-scanning every turn. + +Snapshot contains: +- prebuilt prompt block +- lightweight skill metadata (`name`, `primaryEnv`, required env names) +- normalized `skillFilter` +- resolved skills list +- version + +Lifecycle: +1. First turn/new session builds snapshot. +2. File watcher / remote-node events bump snapshot version. +3. Later turns refresh snapshot only if version is newer. +4. Prompt injection uses snapshot prompt when present. + +Watcher scope includes: +- workspace `skills/` +- workspace `.agents/skills` +- `~/.openclaw/skills` +- `~/.agents/skills` +- configured extra dirs +- plugin skill dirs + +Watcher monitors `SKILL.md` patterns (not entire trees) and debounces changes. + +## 8) Runtime execution behavior + +During an agent run: +1. Skill env overrides are applied (`skills.entries.*.env` + `apiKey` mapping to `primaryEnv`). +2. Overrides are sanitized/guarded (dangerous host env keys blocked). +3. Skills prompt is injected. +4. Environment is restored after run. + +Invocation behavior: +- `disable-model-invocation: true` keeps skill out of model prompt. +- `user-invocable: true` exposes slash commands. +- Optional direct tool dispatch can bypass model routing. + +Sandbox nuance: +- For non-`rw` sandbox workspaces, OpenClaw syncs skills into sandbox workspace (best-effort) so skill files remain accessible. + +## 9) Gateway API surface for skills + +Core RPC methods: +- `skills.status` -> returns `SkillStatusReport` for an agent workspace. +- `skills.install` -> installs dependencies for a skill install option. +- `skills.update` -> updates `skills.entries.<skillKey>` config (`enabled`, `apiKey`, `env`). +- `skills.bins` -> aggregates required bins across agent workspaces. + +Important scope behavior: +- `skills.install` is executed against the default agent workspace (not arbitrary selected agent workspace). +- `skills.update` writes gateway config (`openclaw.json`) and is gateway-wide state mutation. + +Security detail: +- `skills.status` exposes config check satisfaction, not raw secret config values. + +## 10) OpenClaw Studio UX (current behavior) + +### 10.1 Route and navigation model + +Studio settings currently live on root route with a query-driven settings mode: +- Canonical settings state is `/?settingsAgentId=<agentId>`. +- `/agents/[agentId]/settings` currently redirects to that query route. + +Left nav tabs in settings mode: +- Behavior +- Capabilities +- Skills +- Automations +- Advanced + +### 10.2 Skills tab data and interactions + +When either `Skills` or `System setup` tab is active and connected, Studio: +1. Calls `skills.status`. +2. Reads current per-agent allowlist from gateway config (`agents.list[].skills`). +3. Renders two distinct settings surfaces: + +`Skills` tab (agent-scoped): +- Shows one list focused on “what this agent can use”. +- Per-skill allow toggle (`Skill <name>` switch) for agent access only. +- Simplified status chips (`Ready`, `Setup required`, `Not supported`). +- Search + status filters for scanning. +- Non-ready rows provide `Open System Setup` instead of inline setup actions. + +`System setup` tab (gateway-scoped): +- Explicitly states that setup actions affect all agents. +- Shows setup queue and full readiness details. +- Per-skill `Configure` modal with setup/lifecycle actions: + - install dependencies (`skills.install`) + - save API key (`skills.update` with `apiKey`) + - global enable/disable (`skills.update` with `enabled`) + - remove removable skill directories via Studio remove route +- Supports transition handoff from agent row to preselected skill setup context. + +### 10.3 Mutation wiring from Studio + +Per-agent access mutations: +- `updateGatewayAgentSkillsAllowlist` in Studio writes `config.set` with retry-on-stale-hash behavior. +- Agent toggles continue to rely on allowlist semantics (`undefined` means all, explicit array means selected-only). + +System setup mutations: +- Install -> `skills.install` +- API key save -> `skills.update` +- Remove files -> Studio route `/api/gateway/skills/remove` (local fs or SSH helper) + +Removal has strict guards: +- Only specific sources removable (`openclaw-managed`, `openclaw-workspace`). +- Must stay inside allowed root. +- Cannot remove skills root directory. +- Must look like a real skill dir (`SKILL.md` exists). + +### 10.4 Scope warning shown in Studio + +Studio computes the default agent id and passes install-scope context into the system setup surface. + +Current scope copy behavior: +- `Skills` tab copy states controls apply to the current agent. +- `System setup` tab copy states actions apply to all agents. +- Install target caveat (default-agent workspace behavior) is shown in system setup context and setup modal context, where install actions actually occur. + +This keeps scope and install-target warnings accurate while minimizing noise in the agent access flow. + +## 11) What recent `.agent/done` plans show + +Sorted by most recent creation time in `openclaw-studio/.agent/done`, the latest items are mostly bugfix exec plans (streaming, proxy auth, stale config, cron rollback, etc.). + +The most recent plan with explicit skills direction is: +- `ui-execplan-stuff.md` (2026-02-20 create time), which intentionally scoped skills as coming-soon during that IA pass. + +Additional files with incidental skill mentions: +- `simplify-agent-creation-starter-kits.md` +- `ux-zero-agent-layout-consolidation.md` + +Interpretation: +- The current Studio code now has a real Skills tab and mutation flow, but the older IA/doc language still contains “coming soon” assumptions in places. +- For redesign, trust current code behavior over older plan phrasing. + +## 12) UX redesign constraints that are not optional + +Any redesign should preserve these distinctions: + +1. Three separate scopes: +- Agent allowlist scope (`agents.list[].skills`) +- Gateway setup scope (`skills.entries.*`, installs) +- Source/discovery scope (workspace/managed/bundled/extra/plugin) + +2. Eligibility vs enablement: +- A skill can be enabled by allowlist but still blocked by missing requirements. +- A skill can be eligible but disabled by agent allowlist. + +3. Session-snapshot behavior: +- Skills changes may not appear mid-turn; they apply on next turn/snapshot refresh. + +4. Install target caveat: +- Install currently targets default agent workspace context in gateway path. + +5. Security posture: +- Secret values should never be exposed in status surfaces. +- Removal must stay bounded to allowed roots and verified skill dirs. + +## 13) Practical mental model for reviewing a Skills screenshot + +If you hand a screenshot to another LLM for UX feedback, ask it to evaluate on three axes: + +1. **Scope clarity** +- Can a user tell what is per-agent vs gateway-wide? + +2. **Readiness clarity** +- Can a user tell blocked vs eligible and why? + +3. **Action safety** +- Are destructive/setup actions clearly separated from allowlist toggles? + +If a design fails any of those axes, users will misconfigure skills even if controls are technically correct. diff --git a/src/app/[...invalid]/page.tsx b/src/app/[...invalid]/page.tsx new file mode 100644 index 00000000..e097daa0 --- /dev/null +++ b/src/app/[...invalid]/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function InvalidRoutePage() { + redirect("/"); +} diff --git a/src/app/agents/[agentId]/settings/page.tsx b/src/app/agents/[agentId]/settings/page.tsx new file mode 100644 index 00000000..7eacbf41 --- /dev/null +++ b/src/app/agents/[agentId]/settings/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; + +import { buildSettingsRouteHref } from "@/features/agents/operations/settingsRouteWorkflow"; + +export default async function AgentSettingsPage({ + params, +}: { + params: Promise<{ agentId?: string }> | { agentId?: string }; +}) { + const resolvedParams = await params; + const agentId = (resolvedParams?.agentId ?? "").trim(); + if (!agentId) { + redirect("/"); + } + redirect(buildSettingsRouteHref(agentId)); +} diff --git a/src/app/api/gateway/agent-state/route.ts b/src/app/api/gateway/agent-state/route.ts new file mode 100644 index 00000000..2bc6304d --- /dev/null +++ b/src/app/api/gateway/agent-state/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; + +import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local"; +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { + resolveConfiguredSshTarget, + resolveGatewaySshTargetFromGatewayUrl, +} from "@/lib/ssh/gateway-host"; +import { + restoreAgentStateOverSsh, + trashAgentStateOverSsh, +} from "@/lib/ssh/agent-state"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; + +export const runtime = "nodejs"; + +type TrashAgentStateRequest = { + agentId: string; +}; + +type RestoreAgentStateRequest = { + agentId: string; + trashDir: string; +}; + +const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value); + +const resolveAgentStateSshTarget = (): string | null => { + const configured = resolveConfiguredSshTarget(process.env); + if (configured) return configured; + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url ?? ""; + if (isLocalGatewayUrl(gatewayUrl)) return null; + return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); +}; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as unknown; + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); + } + const { agentId } = body as Partial<TrashAgentStateRequest>; + const trimmed = typeof agentId === "string" ? agentId.trim() : ""; + if (!trimmed) { + return NextResponse.json({ error: "agentId is required." }, { status: 400 }); + } + if (!isSafeAgentId(trimmed)) { + return NextResponse.json({ error: `Invalid agentId: ${trimmed}` }, { status: 400 }); + } + + const sshTarget = resolveAgentStateSshTarget(); + const result = sshTarget + ? trashAgentStateOverSsh({ sshTarget, agentId: trimmed }) + : trashAgentStateLocally({ agentId: trimmed }); + return NextResponse.json({ result }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to trash agent workspace/state."; + console.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = (await request.json()) as unknown; + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); + } + const { agentId, trashDir } = body as Partial<RestoreAgentStateRequest>; + const trimmedAgent = typeof agentId === "string" ? agentId.trim() : ""; + const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : ""; + if (!trimmedAgent) { + return NextResponse.json({ error: "agentId is required." }, { status: 400 }); + } + if (!trimmedTrash) { + return NextResponse.json({ error: "trashDir is required." }, { status: 400 }); + } + if (!isSafeAgentId(trimmedAgent)) { + return NextResponse.json({ error: `Invalid agentId: ${trimmedAgent}` }, { status: 400 }); + } + + const sshTarget = resolveAgentStateSshTarget(); + const result = sshTarget + ? restoreAgentStateOverSsh({ + sshTarget, + agentId: trimmedAgent, + trashDir: trimmedTrash, + }) + : restoreAgentStateLocally({ + agentId: trimmedAgent, + trashDir: trimmedTrash, + }); + return NextResponse.json({ result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to restore agent state."; + console.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/gateway/dotenv-keys/route.ts b/src/app/api/gateway/dotenv-keys/route.ts new file mode 100644 index 00000000..83109aa0 --- /dev/null +++ b/src/app/api/gateway/dotenv-keys/route.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import path from "node:path"; +import { NextResponse } from "next/server"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { + resolveConfiguredSshTarget, + resolveGatewaySshTargetFromGatewayUrl, + runSshJson, +} from "@/lib/ssh/gateway-host"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; + +export const runtime = "nodejs"; + +type DotenvKeysResponse = { keys: string[] }; + +const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +const parseDotEnvKeys = (raw: string): string[] => { + const keys: string[] = []; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed; + const idx = withoutExport.indexOf("="); + if (idx === -1) continue; + const key = withoutExport.slice(0, idx).trim(); + if (!ENV_KEY_PATTERN.test(key)) continue; + const value = withoutExport.slice(idx + 1).trim(); + if (!value) continue; + keys.push(key); + } + return Array.from(new Set(keys)).sort(); +}; + +const readLocalDotEnvKeys = (): string[] => { + const envPath = path.join(resolveStateDir(), ".env"); + if (!fs.existsSync(envPath)) return []; + const raw = fs.readFileSync(envPath, "utf8"); + return parseDotEnvKeys(raw); +}; + +const DOTENV_KEYS_SCRIPT = ` +set -euo pipefail + +python3 - <<'PY' +import json +import pathlib +import re + +pattern = re.compile(r"^[A-Z_][A-Z0-9_]*$") +env_path = pathlib.Path.home() / ".openclaw" / ".env" +keys = [] + +try: + raw = env_path.read_text(encoding="utf-8") +except FileNotFoundError: + raw = "" + +for line in raw.splitlines(): + trimmed = line.strip() + if not trimmed or trimmed.startswith("#"): + continue + if trimmed.startswith("export "): + trimmed = trimmed[len("export "):].strip() + if "=" not in trimmed: + continue + key, value = trimmed.split("=", 1) + key = key.strip() + if not pattern.fullmatch(key): + continue + value = value.strip() + if not value: + continue + keys.append(key) + +print(json.dumps({"keys": sorted(set(keys))})) +PY +`; + +const readRemoteDotEnvKeys = (sshTarget: string): string[] => { + const result = runSshJson({ + sshTarget, + argv: ["bash", "-s"], + input: DOTENV_KEYS_SCRIPT, + label: "read dotenv keys", + fallbackMessage: "Failed to read remote ~/.openclaw/.env.", + }) as DotenvKeysResponse; + return Array.isArray(result?.keys) ? result.keys.filter((key) => typeof key === "string") : []; +}; + +const resolveDotEnvSshTarget = (): string | null => { + const configured = resolveConfiguredSshTarget(process.env); + if (configured) return configured; + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url ?? ""; + if (!gatewayUrl.trim()) return null; + if (isLocalGatewayUrl(gatewayUrl)) return null; + return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); +}; + +export async function GET() { + try { + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url ?? ""; + + const sshTarget = resolveDotEnvSshTarget(); + const keys = sshTarget ? readRemoteDotEnvKeys(sshTarget) : readLocalDotEnvKeys(); + + if (!isLocalGatewayUrl(gatewayUrl) && !sshTarget) { + return NextResponse.json( + { error: "Gateway is remote but no SSH target is configured." }, + { status: 400 }, + ); + } + + return NextResponse.json({ keys }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to read dotenv keys."; + console.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + diff --git a/src/app/api/gateway/media/route.ts b/src/app/api/gateway/media/route.ts new file mode 100644 index 00000000..bd48e1c9 --- /dev/null +++ b/src/app/api/gateway/media/route.ts @@ -0,0 +1,216 @@ +import { NextResponse } from "next/server"; + +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { + resolveConfiguredSshTarget, + resolveGatewaySshTargetFromGatewayUrl, + runSshJson, +} from "@/lib/ssh/gateway-host"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +export const runtime = "nodejs"; + +const MAX_MEDIA_BYTES = 25 * 1024 * 1024; + +const MIME_BY_EXT: Record<string, string> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +}; + +const expandTildeLocal = (value: string): string => { + const trimmed = value.trim(); + if (trimmed === "~") return os.homedir(); + if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2)); + return trimmed; +}; + +const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("path is required"); + if (trimmed.length > 4096) throw new Error("path too long"); + if (/[^\S\r\n]*[\0\r\n]/.test(trimmed)) throw new Error("path contains invalid characters"); + + const ext = path.extname(trimmed).toLowerCase(); + const mime = MIME_BY_EXT[ext]; + if (!mime) throw new Error(`Unsupported media extension: ${ext || "(none)"}`); + + return { trimmed, mime }; +}; + +const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => { + const { trimmed, mime } = validateRawMediaPath(raw); + + const expanded = expandTildeLocal(trimmed); + if (!path.isAbsolute(expanded)) { + throw new Error("path must be absolute or start with ~/"); + } + + const resolved = path.resolve(expanded); + + const allowedRoot = path.join(os.homedir(), ".openclaw"); + const allowedPrefix = `${allowedRoot}${path.sep}`; + if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) { + throw new Error(`Refusing to read media outside ${allowedRoot}`); + } + + return { resolved, mime }; +}; + +const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => { + const { trimmed, mime } = validateRawMediaPath(raw); + + if (!(trimmed.startsWith("/") || trimmed === "~" || trimmed.startsWith("~/"))) { + throw new Error("path must be absolute or start with ~/"); + } + + // Remote side enforces ~/.openclaw; this guard lets Studio on macOS request + // /home/ubuntu/.openclaw/... without tripping local homedir checks. + const normalized = trimmed.replaceAll("\\\\", "/"); + const inOpenclaw = + normalized === "~/.openclaw" || + normalized.startsWith("~/.openclaw/") || + normalized.includes("/.openclaw/"); + if (!inOpenclaw) { + throw new Error("Refusing to read remote media outside ~/.openclaw"); + } + + return { remotePath: trimmed, mime }; +}; + +const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => { + const stat = await fs.stat(resolvedPath); + if (!stat.isFile()) { + throw new Error("path is not a file"); + } + if (stat.size > MAX_MEDIA_BYTES) { + throw new Error(`media file too large (${stat.size} bytes)`); + } + const buf = await fs.readFile(resolvedPath); + return { bytes: buf, size: stat.size }; +}; + +const REMOTE_READ_SCRIPT = ` +set -euo pipefail + +python3 - "$1" <<'PY' +import base64 +import json +import mimetypes +import os +import pathlib +import sys + +raw = sys.argv[1].strip() +if not raw: + print(json.dumps({"error": "path is required"})) + raise SystemExit(2) + +p = pathlib.Path(os.path.expanduser(raw)) +try: + resolved = p.resolve(strict=True) +except FileNotFoundError: + print(json.dumps({"error": f"file not found: {raw}"})) + raise SystemExit(3) + +home = pathlib.Path.home().resolve() +allowed = (home / ".openclaw").resolve() +if resolved != allowed and allowed not in resolved.parents: + print(json.dumps({"error": f"Refusing to read media outside {allowed}"})) + raise SystemExit(4) + +ext = resolved.suffix.lower() +mime = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +}.get(ext) or (mimetypes.guess_type(str(resolved))[0] or "") + +if not mime.startswith("image/"): + print(json.dumps({"error": f"Unsupported media extension: {ext or '(none)'}"})) + raise SystemExit(5) + +size = resolved.stat().st_size +max_bytes = ${MAX_MEDIA_BYTES} +if size > max_bytes: + print(json.dumps({"error": f"media file too large ({size} bytes)"})) + raise SystemExit(6) + +data = base64.b64encode(resolved.read_bytes()).decode("ascii") +print(json.dumps({"ok": True, "mime": mime, "size": size, "data": data})) +PY +`; + +const resolveSshTarget = (): string | null => { + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url ?? ""; + if (isLocalGatewayUrl(gatewayUrl)) return null; + const configured = resolveConfiguredSshTarget(process.env); + if (configured) return configured; + return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); +}; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const rawPath = (searchParams.get("path") ?? "").trim(); + + const sshTarget = resolveSshTarget(); + + if (!sshTarget) { + const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath); + const { bytes, size } = await readLocalMedia(resolved); + const body = new Blob([Uint8Array.from(bytes)], { type: mime }); + return new Response(body, { + headers: { + "Content-Type": mime, + "Content-Length": String(size), + "Cache-Control": "no-store", + }, + }); + } + + const { remotePath, mime } = validateRemoteMediaPath(rawPath); + + const payload = runSshJson({ + sshTarget, + argv: ["bash", "-s", "--", remotePath], + label: "gateway media read", + input: REMOTE_READ_SCRIPT, + fallbackMessage: `Failed to fetch media over ssh (${sshTarget})`, + maxBuffer: Math.ceil(MAX_MEDIA_BYTES * 1.6), + }) as { + ok?: boolean; + data?: string; + mime?: string; + size?: number; + }; + + const b64 = payload.data ?? ""; + if (!b64) { + throw new Error("Remote media fetch returned empty data"); + } + + const buf = Buffer.from(b64, "base64"); + const responseMime = payload.mime || mime; + const body = new Blob([Uint8Array.from(buf)], { type: responseMime }); + + return new Response(body, { + headers: { + "Content-Type": responseMime, + "Content-Length": String(buf.length), + "Cache-Control": "no-store", + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to fetch media"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/api/gateway/route.ts b/src/app/api/gateway/route.ts deleted file mode 100644 index bee4f81f..00000000 --- a/src/app/api/gateway/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; - -import { loadClawdbotConfig } from "@/lib/clawdbot/config"; -import { resolveGatewayConfig } from "@/lib/clawdbot/gateway"; -import { logger } from "@/lib/logger"; - -export const runtime = "nodejs"; - -export async function GET() { - try { - const { config } = loadClawdbotConfig(); - const { gatewayUrl, token } = resolveGatewayConfig(config); - return NextResponse.json({ gatewayUrl, token }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load gateway config."; - if (message.startsWith("Missing config at")) { - return NextResponse.json({ error: message }, { status: 404 }); - } - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/gateway/skills/remove/route.ts b/src/app/api/gateway/skills/remove/route.ts new file mode 100644 index 00000000..fa747e7c --- /dev/null +++ b/src/app/api/gateway/skills/remove/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; + +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { removeSkillLocally } from "@/lib/skills/remove-local"; +import type { RemovableSkillSource, SkillRemoveRequest } from "@/lib/skills/types"; +import { + resolveConfiguredSshTarget, + resolveGatewaySshTargetFromGatewayUrl, +} from "@/lib/ssh/gateway-host"; +import { removeSkillOverSsh } from "@/lib/ssh/skills-remove"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; + +export const runtime = "nodejs"; + +const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([ + "openclaw-managed", + "openclaw-workspace", +]); + +const normalizeRequired = (value: unknown, field: string): string => { + if (typeof value !== "string") { + throw new Error(`${field} is required.`); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${field} is required.`); + } + return trimmed; +}; + +const resolveSkillRemovalSshTarget = (): string | null => { + const configured = resolveConfiguredSshTarget(process.env); + if (configured) return configured; + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url ?? ""; + if (isLocalGatewayUrl(gatewayUrl)) return null; + return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); +}; + +const normalizeRemoveRequest = (body: unknown): SkillRemoveRequest => { + if (!body || typeof body !== "object") { + throw new Error("Invalid request payload."); + } + + const record = body as Partial<Record<keyof SkillRemoveRequest, unknown>>; + const sourceRaw = normalizeRequired(record.source, "source"); + if (!REMOVABLE_SOURCES.has(sourceRaw as RemovableSkillSource)) { + throw new Error(`Unsupported skill source for removal: ${sourceRaw}`); + } + + return { + skillKey: normalizeRequired(record.skillKey, "skillKey"), + source: sourceRaw as RemovableSkillSource, + baseDir: normalizeRequired(record.baseDir, "baseDir"), + workspaceDir: normalizeRequired(record.workspaceDir, "workspaceDir"), + managedSkillsDir: normalizeRequired(record.managedSkillsDir, "managedSkillsDir"), + }; +}; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as unknown; + const removeRequest = normalizeRemoveRequest(body); + + const sshTarget = resolveSkillRemovalSshTarget(); + const result = sshTarget + ? removeSkillOverSsh({ sshTarget, request: removeRequest }) + : removeSkillLocally(removeRequest); + + return NextResponse.json({ result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to remove skill."; + const status = + message.includes("required") || + message.includes("Invalid request payload") || + message.includes("Unsupported skill source") || + message.includes("Refusing to remove") || + message.includes("not a directory") || + message.includes("Gateway URL is missing") || + message.includes("Invalid gateway URL") + ? 400 + : 500; + if (status >= 500) { + console.error(message); + } + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/path-suggestions/route.ts b/src/app/api/path-suggestions/route.ts new file mode 100644 index 00000000..697066d5 --- /dev/null +++ b/src/app/api/path-suggestions/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveUserPath } from "@/lib/clawdbot/paths"; + +export const runtime = "nodejs"; + +type PathAutocompleteEntry = { + name: string; + fullPath: string; + displayPath: string; + isDirectory: boolean; +}; + +type PathAutocompleteResult = { + query: string; + directory: string; + entries: PathAutocompleteEntry[]; +}; + +type PathAutocompleteOptions = { + query: string; + maxResults?: number; + homedir?: () => string; +}; + +const normalizeQuery = (query: string): string => { + const trimmed = query.trim(); + if (!trimmed) { + throw new Error("Query is required."); + } + if (trimmed === "~") { + return "~/"; + } + if (trimmed.startsWith("~")) { + return trimmed; + } + const withoutLeading = trimmed.replace(/^[\\/]+/, ""); + return `~/${withoutLeading}`; +}; + +const isWithinHome = (target: string, home: string): boolean => { + const relative = path.relative(home, target); + if (!relative) return true; + return !relative.startsWith("..") && !path.isAbsolute(relative); +}; + +const listPathAutocompleteEntries = ({ + query, + maxResults = 10, + homedir = os.homedir, +}: PathAutocompleteOptions): PathAutocompleteResult => { + const normalized = normalizeQuery(query); + const resolvedHome = path.resolve(homedir()); + const resolvedQuery = resolveUserPath(normalized, homedir); + if (!isWithinHome(resolvedQuery, resolvedHome)) { + throw new Error("Path must stay within the home directory."); + } + + const endsWithSlash = normalized.endsWith("/") || normalized.endsWith(path.sep); + const directoryPath = endsWithSlash ? resolvedQuery : path.dirname(resolvedQuery); + const prefix = endsWithSlash ? "" : path.basename(resolvedQuery); + + if (!isWithinHome(directoryPath, resolvedHome)) { + throw new Error("Path must stay within the home directory."); + } + if (!fs.existsSync(directoryPath)) { + throw new Error(`Directory does not exist: ${directoryPath}`); + } + const stat = fs.statSync(directoryPath); + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${directoryPath}`); + } + + const limit = Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : 10; + + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith(".")) + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => { + const fullPath = path.join(directoryPath, entry.name); + const relative = path.relative(resolvedHome, fullPath); + const normalizedRelative = relative.split(path.sep).join("/"); + const displayBase = `~/${normalizedRelative}`; + return { + name: entry.name, + fullPath, + displayPath: entry.isDirectory() ? `${displayBase}/` : displayBase, + isDirectory: entry.isDirectory(), + } satisfies PathAutocompleteEntry; + }) + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .slice(0, limit); + + return { query: normalized, directory: directoryPath, entries }; +}; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const rawQuery = searchParams.get("q"); + const query = rawQuery && rawQuery.trim() ? rawQuery.trim() : "~/"; + const result = listPathAutocompleteEntries({ query, maxResults: 10 }); + return NextResponse.json(result); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to list path suggestions."; + console.error(message); + const status = message.includes("does not exist") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/projects/[projectId]/discord/route.ts b/src/app/api/projects/[projectId]/discord/route.ts deleted file mode 100644 index 6eba158a..00000000 --- a/src/app/api/projects/[projectId]/discord/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import { createDiscordChannelForAgent } from "@/lib/discord/discordChannel"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { loadStore } from "../../store"; - -export const runtime = "nodejs"; - -type DiscordChannelRequest = { - guildId?: string; - agentId: string; - agentName: string; -}; - -export async function POST( - request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - const body = (await request.json()) as DiscordChannelRequest; - const guildId = typeof body?.guildId === "string" ? body.guildId.trim() : undefined; - const agentId = typeof body?.agentId === "string" ? body.agentId.trim() : ""; - const agentName = typeof body?.agentName === "string" ? body.agentName.trim() : ""; - if (!agentId || !agentName) { - return NextResponse.json( - { error: "Agent id and name are required." }, - { status: 400 } - ); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const workspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, agentId); - const result = await createDiscordChannelForAgent({ - agentId, - agentName, - guildId: guildId || undefined, - workspaceDir, - }); - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create Discord channel."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts deleted file mode 100644 index accc6c40..00000000 --- a/src/app/api/projects/[projectId]/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import { - loadClawdbotConfig, - removeAgentEntry, - saveClawdbotConfig, -} from "@/lib/clawdbot/config"; -import { deleteAgentArtifacts } from "@/lib/projects/fs.server"; -import { loadStore, saveStore } from "../store"; - -export const runtime = "nodejs"; - -export async function DELETE( - _request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const warnings: string[] = []; - let configInfo: { config: Record<string, unknown>; configPath: string } | null = null; - try { - configInfo = loadClawdbotConfig(); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - for (const tile of project.tiles) { - if (!tile.agentId?.trim()) { - warnings.push(`Missing agentId for tile ${tile.id}; skipped agent cleanup.`); - continue; - } - deleteAgentArtifacts(trimmedProjectId, tile.agentId, warnings); - if (configInfo) { - removeAgentEntry(configInfo.config, tile.agentId); - } - } - if (configInfo) { - saveClawdbotConfig(configInfo.configPath, configInfo.config); - } - - const projects = store.projects.filter((project) => project.id !== trimmedProjectId); - const activeProjectId = - store.activeProjectId === trimmedProjectId - ? projects[0]?.id ?? null - : store.activeProjectId; - const nextStore = { - version: 2 as const, - activeProjectId, - projects, - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts deleted file mode 100644 index e26b498c..00000000 --- a/src/app/api/projects/[projectId]/tiles/[tileId]/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { NextResponse } from "next/server"; - -import { logger } from "@/lib/logger"; -import type { ProjectTileUpdatePayload } from "@/lib/projects/types"; -import { deleteAgentArtifacts } from "@/lib/projects/fs.server"; -import { - loadClawdbotConfig, - removeAgentEntry, - saveClawdbotConfig, - upsertAgentEntry, -} from "@/lib/clawdbot/config"; -import { loadStore, saveStore } from "../../../store"; - -export const runtime = "nodejs"; - -export async function DELETE( - _request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const { projectId, tileId } = await context.params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ); - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - - const warnings: string[] = []; - if (!tile.agentId?.trim()) { - warnings.push(`Missing agentId for tile ${tile.id}; skipped agent cleanup.`); - } else { - deleteAgentArtifacts(trimmedProjectId, tile.agentId, warnings); - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = removeAgentEntry(config, tile.agentId); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - } - - const nextTiles = project.tiles.filter((entry) => entry.id !== trimmedTileId); - if (nextTiles.length === project.tiles.length) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - const nextStore = { - ...store, - version: 2 as const, - projects: store.projects.map((entry) => - entry.id === trimmedProjectId - ? { ...entry, tiles: nextTiles, updatedAt: Date.now() } - : entry - ), - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PATCH( - request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const { projectId, tileId } = await context.params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ); - } - const body = (await request.json()) as ProjectTileUpdatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - const avatarSeed = - typeof body?.avatarSeed === "string" ? body.avatarSeed.trim() : ""; - if (!name && !avatarSeed) { - return NextResponse.json( - { error: "Tile update requires a name or avatar seed." }, - { status: 400 } - ); - } - if (body?.avatarSeed !== undefined && !avatarSeed) { - return NextResponse.json({ error: "Avatar seed is invalid." }, { status: 400 }); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return NextResponse.json({ error: "Tile not found." }, { status: 404 }); - } - - const warnings: string[] = []; - if (name) { - const nextWorkspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, tile.agentId); - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = upsertAgentEntry(config, { - agentId: tile.agentId, - agentName: name, - workspaceDir: nextWorkspaceDir, - }); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - } - - const nextTiles = project.tiles.map((entry) => - entry.id === trimmedTileId - ? { - ...entry, - name: name || entry.name, - avatarSeed: avatarSeed || entry.avatarSeed, - } - : entry - ); - const nextStore = { - ...store, - version: 2 as const, - projects: store.projects.map((entry) => - entry.id === trimmedProjectId - ? { ...entry, tiles: nextTiles, updatedAt: Date.now() } - : entry - ), - }; - saveStore(nextStore); - return NextResponse.json({ store: nextStore, warnings }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to rename tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts b/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts deleted file mode 100644 index 5fd3d9ce..00000000 --- a/src/app/api/projects/[projectId]/tiles/[tileId]/workspace-files/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { NextResponse } from "next/server"; - -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { WORKSPACE_FILE_NAMES, type WorkspaceFileName } from "@/lib/projects/workspaceFiles"; -import type { ProjectTileWorkspaceFilesUpdatePayload } from "@/lib/projects/types"; -import { loadStore } from "../../../../store"; - -export const runtime = "nodejs"; - -const isWorkspaceFileName = (value: string): value is WorkspaceFileName => - WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); - -const readWorkspaceFile = (workspaceDir: string, name: WorkspaceFileName) => { - const filePath = path.join(workspaceDir, name); - if (!fs.existsSync(filePath)) { - return { name, content: "", exists: false }; - } - const stat = fs.statSync(filePath); - if (!stat.isFile()) { - throw new Error(`${name} exists but is not a file.`); - } - return { name, content: fs.readFileSync(filePath, "utf8"), exists: true }; -}; - -const resolveTile = async ( - params: Promise<{ projectId: string; tileId: string }> -) => { - const { projectId, tileId } = await params; - const trimmedProjectId = projectId.trim(); - const trimmedTileId = tileId.trim(); - if (!trimmedProjectId || !trimmedTileId) { - return { - error: NextResponse.json( - { error: "Workspace id and tile id are required." }, - { status: 400 } - ), - }; - } - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return { error: NextResponse.json({ error: "Workspace not found." }, { status: 404 }) }; - } - const tile = project.tiles.find((entry) => entry.id === trimmedTileId); - if (!tile) { - return { error: NextResponse.json({ error: "Tile not found." }, { status: 404 }) }; - } - return { projectId: trimmedProjectId, tileId: trimmedTileId, tile }; -}; - -export async function GET( - _request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const resolved = await resolveTile(context.params); - if ("error" in resolved) { - return resolved.error; - } - const { projectId, tile } = resolved; - const workspaceDir = resolveAgentWorkspaceDir(projectId, tile.agentId); - if (!fs.existsSync(workspaceDir)) { - return NextResponse.json({ error: "Agent workspace not found." }, { status: 404 }); - } - const files = WORKSPACE_FILE_NAMES.map((name) => - readWorkspaceFile(workspaceDir, name) - ); - return NextResponse.json({ files }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspace files."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PUT( - request: Request, - context: { params: Promise<{ projectId: string; tileId: string }> } -) { - try { - const resolved = await resolveTile(context.params); - if ("error" in resolved) { - return resolved.error; - } - const { projectId, tile } = resolved; - const workspaceDir = resolveAgentWorkspaceDir(projectId, tile.agentId); - if (!fs.existsSync(workspaceDir)) { - return NextResponse.json({ error: "Agent workspace not found." }, { status: 404 }); - } - - const body = (await request.json()) as ProjectTileWorkspaceFilesUpdatePayload; - if (!body || !Array.isArray(body.files)) { - return NextResponse.json({ error: "Files payload is invalid." }, { status: 400 }); - } - - for (const entry of body.files) { - const name = typeof entry?.name === "string" ? entry.name.trim() : ""; - if (!name || !isWorkspaceFileName(name)) { - return NextResponse.json( - { error: `Invalid file name: ${entry?.name ?? ""}` }, - { status: 400 } - ); - } - if (typeof entry.content !== "string") { - return NextResponse.json({ error: `Invalid content for ${name}.` }, { status: 400 }); - } - } - - for (const entry of body.files) { - const name = entry.name as WorkspaceFileName; - const filePath = path.join(workspaceDir, name); - fs.writeFileSync(filePath, entry.content, "utf8"); - } - - const files = WORKSPACE_FILE_NAMES.map((name) => - readWorkspaceFile(workspaceDir, name) - ); - return NextResponse.json({ files }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to save workspace files."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/[projectId]/tiles/route.ts b/src/app/api/projects/[projectId]/tiles/route.ts deleted file mode 100644 index 5d87457a..00000000 --- a/src/app/api/projects/[projectId]/tiles/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import type { - ProjectTile, - ProjectTileCreatePayload, - ProjectTileCreateResult, - ProjectTileRole, - ProjectsStore, -} from "@/lib/projects/types"; -import { resolveAgentWorkspaceDir } from "@/lib/projects/agentWorkspace"; -import { resolveClawdbotStateDir } from "@/lib/projects/fs.server"; -import { - loadClawdbotConfig, - saveClawdbotConfig, - upsertAgentEntry, -} from "@/lib/clawdbot/config"; -import { generateAgentId } from "@/lib/ids/agentId"; -import { provisionWorkspaceFiles } from "@/lib/projects/workspaceFiles.server"; -import { loadStore, saveStore } from "../../store"; - -export const runtime = "nodejs"; - -const ROLE_VALUES: ProjectTileRole[] = ["coding", "research", "marketing"]; - -const copyAuthProfiles = (agentId: string): string[] => { - const warnings: string[] = []; - const stateDir = resolveClawdbotStateDir(); - const sourceAgentId = process.env.CLAWDBOT_DEFAULT_AGENT_ID ?? "main"; - const source = path.join(stateDir, "agents", sourceAgentId, "agent", "auth-profiles.json"); - const destination = path.join(stateDir, "agents", agentId, "agent", "auth-profiles.json"); - - if (fs.existsSync(destination)) { - return warnings; - } - if (!fs.existsSync(source)) { - warnings.push(`No auth profiles found at ${source}; agent may need login.`); - return warnings; - } - fs.mkdirSync(path.dirname(destination), { recursive: true }); - fs.copyFileSync(source, destination); - return warnings; -}; - -const updateStoreProject = ( - store: ProjectsStore, - projectId: string, - tile: ProjectTile -) => { - return { - ...store, - version: 2 as const, - projects: store.projects.map((project) => - project.id === projectId - ? { - ...project, - tiles: [...project.tiles, tile], - updatedAt: Date.now(), - } - : project - ), - }; -}; - -export async function POST( - request: Request, - context: { params: Promise<{ projectId: string }> } -) { - try { - const { projectId } = await context.params; - const trimmedProjectId = projectId.trim(); - if (!trimmedProjectId) { - return NextResponse.json({ error: "Workspace id is required." }, { status: 400 }); - } - - const body = (await request.json()) as ProjectTileCreatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - const role = body?.role; - if (!name) { - return NextResponse.json({ error: "Tile name is required." }, { status: 400 }); - } - if (!role || !ROLE_VALUES.includes(role)) { - return NextResponse.json({ error: "Tile role is invalid." }, { status: 400 }); - } - - const store = loadStore(); - const project = store.projects.find((entry) => entry.id === trimmedProjectId); - if (!project) { - return NextResponse.json({ error: "Workspace not found." }, { status: 404 }); - } - - const tileId = randomUUID(); - const projectSlug = path.basename(project.repoPath); - let agentId = ""; - try { - agentId = generateAgentId({ projectSlug, tileName: name }); - } catch (err) { - const message = err instanceof Error ? err.message : "Invalid agent name."; - return NextResponse.json({ error: message }, { status: 400 }); - } - if (project.tiles.some((entry) => entry.agentId === agentId)) { - return NextResponse.json( - { error: `Agent id already exists: ${agentId}` }, - { status: 409 } - ); - } - const sessionKey = `agent:${agentId}:main`; - const offset = project.tiles.length * 36; - const workspaceDir = resolveAgentWorkspaceDir(trimmedProjectId, agentId); - const tile: ProjectTile = { - id: tileId, - name, - agentId, - role, - sessionKey, - model: "openai-codex/gpt-5.2-codex", - thinkingLevel: null, - avatarSeed: agentId, - position: { x: 80 + offset, y: 200 + offset }, - size: { width: 420, height: 520 }, - }; - - const nextStore = updateStoreProject(store, trimmedProjectId, tile); - saveStore(nextStore); - - const { warnings: workspaceWarnings } = provisionWorkspaceFiles(workspaceDir); - const warnings = [...workspaceWarnings, ...copyAuthProfiles(agentId)]; - try { - const { config, configPath } = loadClawdbotConfig(); - const changed = upsertAgentEntry(config, { - agentId, - agentName: name, - workspaceDir, - }); - if (changed) { - saveClawdbotConfig(configPath, config); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to update clawdbot.json."; - warnings.push(`Agent config not updated: ${message}`); - } - if (warnings.length > 0) { - logger.warn(`Tile created with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectTileCreateResult = { - store: nextStore, - tile, - warnings, - }; - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create tile."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/open/route.ts b/src/app/api/projects/open/route.ts deleted file mode 100644 index 9ad1d33c..00000000 --- a/src/app/api/projects/open/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import { resolveHomePath } from "@/lib/projects/fs.server"; -import type { - Project, - ProjectOpenPayload, - ProjectOpenResult, - ProjectsStore, -} from "@/lib/projects/types"; -import { loadStore, saveStore } from "../store"; - -export const runtime = "nodejs"; - -const normalizeProjectsStore = (store: ProjectsStore): ProjectsStore => { - const projects = Array.isArray(store.projects) ? store.projects : []; - const activeProjectId = - typeof store.activeProjectId === "string" && - projects.some((project) => project.id === store.activeProjectId) - ? store.activeProjectId - : projects[0]?.id ?? null; - return { - version: 2, - activeProjectId, - projects, - }; -}; - -export async function POST(request: Request) { - try { - const body = (await request.json()) as ProjectOpenPayload; - const rawPath = typeof body?.path === "string" ? body.path.trim() : ""; - if (!rawPath) { - return NextResponse.json({ error: "Workspace path is required." }, { status: 400 }); - } - - const resolvedPath = resolveHomePath(rawPath); - if (!path.isAbsolute(resolvedPath)) { - return NextResponse.json( - { error: "Workspace path must be an absolute path." }, - { status: 400 } - ); - } - if (!fs.existsSync(resolvedPath)) { - return NextResponse.json( - { error: `Workspace path does not exist: ${resolvedPath}` }, - { status: 404 } - ); - } - const stat = fs.statSync(resolvedPath); - if (!stat.isDirectory()) { - return NextResponse.json( - { error: `Workspace path is not a directory: ${resolvedPath}` }, - { status: 400 } - ); - } - - const repoPath = fs.realpathSync(resolvedPath); - const name = path.basename(repoPath); - if (!name || name === path.parse(repoPath).root) { - return NextResponse.json( - { error: "Workspace path must point to a directory with a name." }, - { status: 400 } - ); - } - - const store = loadStore(); - if (store.projects.some((project) => project.repoPath === repoPath)) { - return NextResponse.json( - { error: "Workspace already exists for this path." }, - { status: 409 } - ); - } - - const warnings: string[] = []; - if (!fs.existsSync(path.join(repoPath, ".git"))) { - warnings.push("No .git directory found for this workspace path."); - } - - const now = Date.now(); - const project: Project = { - id: randomUUID(), - name, - repoPath, - createdAt: now, - updatedAt: now, - tiles: [], - }; - - const nextStore = normalizeProjectsStore({ - version: 2, - activeProjectId: project.id, - projects: [...store.projects, project], - }); - - saveStore(nextStore); - - if (warnings.length > 0) { - logger.warn(`Workspace opened with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectOpenResult = { - store: nextStore, - warnings, - }; - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to open workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts deleted file mode 100644 index fe3df57c..00000000 --- a/src/app/api/projects/route.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { NextResponse } from "next/server"; - -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { logger } from "@/lib/logger"; -import type { - Project, - ProjectCreatePayload, - ProjectCreateResult, - ProjectsStore, -} from "@/lib/projects/types"; -import { ensureGitRepo } from "@/lib/fs/git"; -import { slugifyProjectName } from "@/lib/ids/slugify"; -import { loadStore, saveStore } from "./store"; - -export const runtime = "nodejs"; - -const normalizeProjectsStore = (store: ProjectsStore): ProjectsStore => { - const projects = Array.isArray(store.projects) ? store.projects : []; - const activeProjectId = - typeof store.activeProjectId === "string" && - projects.some((project) => project.id === store.activeProjectId) - ? store.activeProjectId - : projects[0]?.id ?? null; - return { - version: 2, - activeProjectId, - projects, - }; -}; - -export async function GET() { - try { - const store = normalizeProjectsStore(loadStore()); - return NextResponse.json(store); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspaces."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function POST(request: Request) { - try { - const body = (await request.json()) as ProjectCreatePayload; - const name = typeof body?.name === "string" ? body.name.trim() : ""; - - if (!name) { - return NextResponse.json({ error: "Workspace name is required." }, { status: 400 }); - } - - let slug = ""; - try { - slug = slugifyProjectName(name); - } catch (err) { - const message = - err instanceof Error ? err.message : "Workspace name produced an empty folder name."; - return NextResponse.json({ error: message }, { status: 400 }); - } - - const store = loadStore(); - const { repoPath, warnings: pathWarnings } = resolveProjectPath(slug); - const gitResult = ensureGitRepo(repoPath); - const warnings = [...pathWarnings, ...gitResult.warnings]; - - const now = Date.now(); - const project: Project = { - id: randomUUID(), - name, - repoPath, - createdAt: now, - updatedAt: now, - tiles: [], - }; - - const nextStore = normalizeProjectsStore({ - version: 2, - activeProjectId: project.id, - projects: [...store.projects, project], - }); - - saveStore(nextStore); - - if (warnings.length > 0) { - logger.warn(`Workspace created with warnings: ${warnings.join(" ")}`); - } - - const result: ProjectCreateResult = { - store: nextStore, - warnings, - }; - - return NextResponse.json(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create workspace."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -export async function PUT(request: Request) { - try { - const body = (await request.json()) as ProjectsStore; - if (!body || !Array.isArray(body.projects)) { - return NextResponse.json({ error: "Invalid workspaces payload." }, { status: 400 }); - } - const normalized = normalizeProjectsStore(body); - saveStore(normalized); - return NextResponse.json(normalized); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to save workspaces."; - logger.error(message); - return NextResponse.json({ error: message }, { status: 500 }); - } -} - -const resolveProjectPath = (slug: string): { repoPath: string; warnings: string[] } => { - const warnings: string[] = []; - const basePath = path.join(os.homedir(), slug); - if (!fs.existsSync(basePath)) { - return { repoPath: basePath, warnings }; - } - let suffix = 2; - let candidate = basePath; - while (fs.existsSync(candidate)) { - candidate = path.join(os.homedir(), `${slug}-${suffix}`); - suffix += 1; - } - warnings.push(`Workspace folder already exists. Created ${candidate} instead.`); - return { repoPath: candidate, warnings }; -}; diff --git a/src/app/api/projects/store.ts b/src/app/api/projects/store.ts deleted file mode 100644 index fd603b12..00000000 --- a/src/app/api/projects/store.ts +++ /dev/null @@ -1,103 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; - -import type { Project, ProjectsStore } from "@/lib/projects/types"; - -const STORE_VERSION: ProjectsStore["version"] = 2; -const STORE_DIR = path.join(os.homedir(), ".clawdbot", "agent-canvas"); -const STORE_PATH = path.join(STORE_DIR, "projects.json"); - -export type ProjectsStorePayload = ProjectsStore; - -export const ensureStoreDir = () => { - if (!fs.existsSync(STORE_DIR)) { - fs.mkdirSync(STORE_DIR, { recursive: true }); - } -}; - -export const defaultStore = (): ProjectsStore => ({ - version: STORE_VERSION, - activeProjectId: null, - projects: [], -}); - -const parseAgentId = (sessionKey: string): string => { - const match = sessionKey.match(/^agent:([^:]+):/); - return match ? match[1] : "main"; -}; - -type RawTile = { - id: string; - name: string; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; - agentId?: string; - role?: "coding" | "research" | "marketing"; -}; - -type RawProject = Omit<Project, "tiles"> & { tiles: RawTile[] }; - -type RawStore = { - version?: number; - activeProjectId?: string | null; - projects?: RawProject[]; -}; - -const migrateV1Store = (store: { activeProjectId?: string | null; projects: RawProject[] }) => { - const projects = store.projects.map((project) => ({ - ...project, - tiles: project.tiles.map((tile) => ({ - ...tile, - agentId: parseAgentId(typeof tile.sessionKey === "string" ? tile.sessionKey : ""), - role: "coding" as const, - })), - })); - return { - version: STORE_VERSION, - activeProjectId: store.activeProjectId ?? null, - projects, - }; -}; - -export const loadStore = (): ProjectsStore => { - ensureStoreDir(); - if (!fs.existsSync(STORE_PATH)) { - const seed = defaultStore(); - fs.writeFileSync(STORE_PATH, JSON.stringify(seed, null, 2), "utf8"); - return seed; - } - const raw = fs.readFileSync(STORE_PATH, "utf8"); - try { - const parsed = JSON.parse(raw) as RawStore; - if (!parsed || !Array.isArray(parsed.projects)) { - throw new Error(`Workspaces store is invalid at ${STORE_PATH}.`); - } - if (!parsed.projects.every((project) => Array.isArray(project.tiles))) { - throw new Error(`Workspaces store is invalid at ${STORE_PATH}.`); - } - if (parsed.version === 2) { - return parsed as ProjectsStore; - } - const migrated = migrateV1Store({ - activeProjectId: parsed.activeProjectId ?? null, - projects: parsed.projects, - }); - saveStore(migrated); - return migrated; - } catch (err) { - const details = err instanceof Error ? err.message : "Unknown error."; - if (details.includes(STORE_PATH)) { - throw new Error(details); - } - throw new Error(`Failed to parse workspaces store at ${STORE_PATH}: ${details}`); - } -}; - -export const saveStore = (store: ProjectsStore) => { - ensureStoreDir(); - fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8"); -}; diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts new file mode 100644 index 00000000..a8caccd9 --- /dev/null +++ b/src/app/api/studio/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; + +import { type StudioSettingsPatch } from "@/lib/studio/settings"; +import { + applyStudioSettingsPatch, + loadLocalGatewayDefaults, + loadStudioSettings, +} from "@/lib/studio/settings-store"; + +export const runtime = "nodejs"; + +const isPatch = (value: unknown): value is StudioSettingsPatch => + Boolean(value && typeof value === "object"); + +export async function GET() { + try { + const settings = loadStudioSettings(); + const localGatewayDefaults = loadLocalGatewayDefaults(); + return NextResponse.json({ settings, localGatewayDefaults }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load studio settings."; + console.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = (await request.json()) as unknown; + if (!isPatch(body)) { + return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); + } + const settings = applyStudioSettingsPatch(body); + return NextResponse.json({ settings }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save studio settings."; + console.error(message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 7b1b14b2..4d8c2c7a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,210 +1,1324 @@ @import "tailwindcss"; @import "tw-animate-css"; -@import "@xyflow/react/dist/style.css"; +@import "../../node_modules/react-mentions-ts/styles/tailwind.css"; @import "./styles/markdown.css"; @custom-variant dark (&:is(.dark *)); :root { - --panel: rgba(255, 255, 255, 0.82); - --panel-border: rgba(25, 20, 16, 0.12); - --accent: oklch(0.967 0.001 286.375); - --accent-strong: #2563eb; - --muted: oklch(0.967 0.001 286.375); - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.141 0.005 285.823); - --sidebar-primary: oklch(0.21 0.006 285.885); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.21 0.006 285.885); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.705 0.015 286.067); + --neutral-tint-rgb: rgb(90 132 154); + --neutral-tint: #5a849a; + --background: #f7f9fc; + --foreground: rgb(27 33 43); + --card: #ffffff; + --card-foreground: rgb(27 33 43); + --popover: #ffffff; + --popover-foreground: rgb(27 33 43); + --primary: oklch(0.56 0.1 214); + --primary-foreground: oklch(0.98 0.004 230); + --secondary: color-mix(in srgb, var(--neutral-tint-rgb) 14%, transparent); + --secondary-foreground: rgb(35 43 54); + --muted: color-mix(in srgb, var(--neutral-tint-rgb) 11%, transparent); + --muted-foreground: rgb(87 98 114 / 0.9); + --accent: color-mix(in srgb, var(--neutral-tint-rgb) 14%, transparent); + --accent-foreground: rgb(35 43 54); + --destructive: oklch(0.58 0.22 22); + --destructive-foreground: oklch(0.99 0.004 90); + --border: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --input: color-mix(in srgb, var(--neutral-tint-rgb) 13%, transparent); + --ring: oklch(0.56 0.1 214); + --chart-1: oklch(0.58 0.07 220); + --chart-2: oklch(0.66 0.08 170); + --chart-3: oklch(0.62 0.07 250); + --chart-4: oklch(0.71 0.06 205); + --chart-5: oklch(0.5 0.06 240); + --sidebar: rgb(255 255 255 / 0.92); + --sidebar-foreground: var(--foreground); + --sidebar-primary: oklch(0.56 0.1 214); + --sidebar-primary-foreground: oklch(0.98 0.004 230); + --sidebar-accent: color-mix(in srgb, var(--neutral-tint-rgb) 14%, transparent); + --sidebar-accent-foreground: var(--accent-foreground); + --sidebar-border: color-mix(in srgb, var(--neutral-tint-rgb) 16%, transparent); + --sidebar-ring: oklch(0.56 0.1 214); + --sidebar-card-bg: color-mix(in srgb, var(--neutral-tint-rgb) 6%, transparent); + --sidebar-card-border: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --sidebar-input-bg: color-mix(in srgb, var(--neutral-tint-rgb) 9%, transparent); + --sidebar-input-border: color-mix(in srgb, var(--neutral-tint-rgb) 26%, transparent); + --sidebar-control-off: color-mix(in srgb, var(--neutral-tint-rgb) 12%, transparent); + --sidebar-control-on: color-mix(in oklch, var(--primary) 74%, var(--surface-2)); + --radius: 1rem; + --radius-small: 6px; + --radius-card: 14px; + --radius-container: 20px; + --font-display: "Bebas Neue", sans-serif; + --font-sans: "IBM Plex Sans", sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --shadow-color: oklch(0.19 0.01 255); + --shadow-opacity: 0.16; + --shadow-blur: 28px; + --shadow-spread: -6px; + --shadow-offset-x: 0px; + --shadow-offset-y: 10px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --tracking-normal: 0.005em; + --panel: color-mix(in oklch, var(--surface-1) 96%, white 4%); + --panel-border: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --surface-0: var(--background); + --surface-1: var(--card); + --surface-2: color-mix(in srgb, var(--neutral-tint-rgb) 8%, transparent); + --surface-3: color-mix(in srgb, var(--neutral-tint-rgb) 14%, transparent); + --surface-selected: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --surface-selected-border: color-mix(in srgb, var(--neutral-tint-rgb) 34%, transparent); + --elev-overlay-1: rgb(255 255 255 / 0.05); + --elev-overlay-2: rgb(255 255 255 / 0.08); + --elev-overlay-3: rgb(255 255 255 / 0.12); + --elev-edge-1: color-mix(in oklch, var(--border) 70%, transparent); + --elev-edge-2: color-mix(in oklch, var(--border) 84%, transparent); + --plane-workspace-bg: color-mix(in oklch, var(--surface-1) 92%, var(--surface-0) 8%); + --plane-workspace-border: color-mix(in oklch, var(--panel-border) 88%, var(--surface-0)); + --plane-sidepanel-bg: color-mix(in oklch, var(--surface-2) 12%, var(--surface-1)); + --plane-sidepanel-border: color-mix(in oklch, var(--panel-border) 94%, var(--surface-2)); + --plane-card-bg: color-mix(in oklch, var(--sidebar-card-bg) 92%, var(--surface-1)); + --plane-card-border: color-mix(in oklch, var(--sidebar-card-border) 92%, var(--surface-2)); + --chat-surface-bg: color-mix(in oklch, var(--surface-1) 92%, white 8%); + --chat-assistant-bg: color-mix(in oklch, var(--surface-2) 92%, var(--surface-1)); + --chat-assistant-border: color-mix(in oklch, var(--border) 70%, transparent); + --chat-user-bg: color-mix(in srgb, var(--neutral-tint-rgb) 8%, transparent); + --chat-user-border: color-mix(in oklch, var(--border) 74%, transparent); + --chat-user-header-bg: color-mix(in srgb, var(--neutral-tint-rgb) 12%, transparent); + --action-bg: color-mix(in oklch, var(--primary) 92%, white 8%); + --action-bg-hover: color-mix(in oklch, var(--primary) 88%, white 12%); + --action-border: color-mix(in oklch, var(--primary) 52%, var(--border)); + --action-fg: var(--primary-foreground); + --danger-soft-bg: color-mix(in oklch, var(--destructive) 13%, transparent); + --danger-soft-border: color-mix(in oklch, var(--destructive) 34%, transparent); + --danger-soft-fg: color-mix(in oklch, var(--destructive) 78%, black 22%); + --status-idle-bg: color-mix(in oklch, var(--surface-3) 86%, transparent); + --status-idle-border: color-mix(in oklch, var(--border) 74%, transparent); + --status-idle-fg: color-mix(in oklch, var(--foreground) 72%, var(--muted-foreground) 28%); + --status-running-bg: color-mix(in oklch, #1ea465 16%, transparent); + --status-running-border: color-mix(in oklch, #1b8f59 38%, transparent); + --status-running-fg: color-mix(in oklch, #0e6a40 72%, black 28%); + --status-error-bg: var(--danger-soft-bg); + --status-error-border: var(--danger-soft-border); + --status-error-fg: var(--danger-soft-fg); + --status-connecting-bg: color-mix(in oklch, #b97811 16%, transparent); + --status-connecting-border: color-mix(in oklch, #9f660f 36%, transparent); + --status-connecting-fg: color-mix(in oklch, #6a4100 78%, black 22%); + --status-connected-bg: color-mix(in oklch, #21888f 18%, transparent); + --status-connected-border: color-mix(in oklch, #1b7277 38%, transparent); + --status-connected-fg: color-mix(in oklch, #15585c 78%, black 22%); + --status-disconnected-bg: var(--status-idle-bg); + --status-disconnected-border: var(--status-idle-border); + --status-disconnected-fg: var(--status-idle-fg); + --status-approval-bg: color-mix(in oklch, #a77113 16%, transparent); + --status-approval-border: color-mix(in oklch, #8f6011 34%, transparent); + --status-approval-fg: color-mix(in oklch, #6a4504 82%, black 18%); + --command-bg: color-mix(in oklch, #081018 88%, var(--surface-1)); + --command-border: color-mix(in oklch, #344d5e 62%, transparent); + --command-fg: rgb(234 242 248 / 0.98); + --command-copy-bg: color-mix(in oklch, #182838 82%, var(--surface-1)); + --command-copy-hover-bg: color-mix(in oklch, #243c52 78%, var(--surface-1)); + --command-copy-border: color-mix(in oklch, #3d6178 62%, transparent); + --model-control-border: color-mix(in oklch, var(--action-bg) 34%, var(--sidebar-input-border)); +} + +.dark { + --radius: 1rem; + --neutral-tint-rgb: rgb(108 146 176); + --neutral-tint: #6c92b0; + --background: #0b1118; + --foreground: rgb(246 250 255 / 0.97); + --card: #121a24; + --card-foreground: rgb(246 250 255 / 0.97); + --popover: #121a24; + --popover-foreground: rgb(246 250 255 / 0.94); + --primary: #22a8cc; + --primary-foreground: rgb(247 252 255 / 0.98); + --secondary: color-mix(in srgb, var(--neutral-tint-rgb) 14%, transparent); + --secondary-foreground: rgb(246 250 255 / 0.95); + --muted: color-mix(in srgb, var(--neutral-tint-rgb) 12%, transparent); + --muted-foreground: rgb(224 236 248 / 0.78); + --accent: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --accent-foreground: rgb(246 250 255 / 0.94); + --destructive: oklch(0.62 0.21 22); + --destructive-foreground: rgb(255 255 255 / 0.96); + --border: color-mix(in srgb, var(--neutral-tint-rgb) 30%, transparent); + --input: color-mix(in srgb, var(--neutral-tint-rgb) 22%, transparent); + --ring: #30bce2; + --chart-1: oklch(0.69 0.09 218); + --chart-2: oklch(0.7 0.08 170); + --chart-3: oklch(0.68 0.08 255); + --chart-4: oklch(0.7 0.07 205); + --chart-5: oklch(0.62 0.07 240); + --sidebar: #0f1721; + --sidebar-foreground: rgb(246 250 255 / 0.94); + --sidebar-primary: #22a8cc; + --sidebar-primary-foreground: rgb(247 252 255 / 0.98); + --sidebar-accent: color-mix(in srgb, var(--neutral-tint-rgb) 20%, transparent); + --sidebar-accent-foreground: rgb(246 250 255 / 0.94); + --sidebar-border: color-mix(in srgb, var(--neutral-tint-rgb) 24%, transparent); + --sidebar-ring: #30bce2; + --sidebar-card-bg: color-mix(in oklch, var(--surface-2) 88%, var(--surface-1)); + --sidebar-card-border: color-mix(in srgb, var(--neutral-tint-rgb) 36%, transparent); + --sidebar-input-bg: color-mix(in oklch, var(--surface-2) 84%, var(--surface-1)); + --sidebar-input-border: color-mix(in srgb, var(--neutral-tint-rgb) 40%, transparent); + --sidebar-control-off: color-mix(in oklch, var(--surface-2) 88%, var(--surface-1)); + --sidebar-control-on: color-mix(in oklch, var(--primary) 82%, var(--surface-2)); + --panel: color-mix(in oklch, var(--surface-1) 96%, black 4%); + --panel-border: color-mix(in srgb, var(--neutral-tint-rgb) 36%, transparent); + --shadow-color: oklch(0.08 0.012 250); + --shadow-opacity: 0.24; + --surface-0: #0b1118; + --surface-1: #121a24; + --surface-2: #1a2533; + --surface-3: #223142; + --surface-selected: #26384c; + --surface-selected-border: #3c5a76; + --elev-overlay-1: rgb(255 255 255 / 0.03); + --elev-overlay-2: rgb(255 255 255 / 0.06); + --elev-overlay-3: rgb(255 255 255 / 0.09); + --elev-edge-1: color-mix(in oklch, var(--surface-selected-border) 42%, var(--border)); + --elev-edge-2: color-mix(in oklch, var(--surface-selected-border) 64%, var(--border)); + --plane-workspace-bg: color-mix(in oklch, var(--surface-1) 94%, var(--surface-0) 6%); + --plane-workspace-border: color-mix(in oklch, var(--panel-border) 88%, var(--surface-0)); + --plane-sidepanel-bg: color-mix(in oklch, var(--surface-2) 20%, var(--surface-1)); + --plane-sidepanel-border: color-mix(in oklch, var(--panel-border) 96%, var(--surface-2)); + --plane-card-bg: color-mix(in oklch, var(--surface-2) 76%, var(--surface-1)); + --plane-card-border: color-mix(in oklch, var(--surface-selected-border) 44%, var(--border)); + --chat-surface-bg: color-mix(in oklch, var(--surface-1) 88%, black 12%); + --chat-assistant-bg: color-mix(in oklch, var(--surface-2) 90%, var(--surface-1)); + --chat-assistant-border: color-mix(in oklch, var(--border) 74%, var(--surface-selected-border) 26%); + --chat-user-bg: color-mix(in oklch, var(--surface-1) 86%, var(--surface-0)); + --chat-user-border: color-mix(in oklch, var(--border) 82%, transparent); + --chat-user-header-bg: color-mix(in oklch, var(--surface-2) 72%, var(--surface-1)); + --action-bg: color-mix(in oklch, var(--primary) 88%, white 12%); + --action-bg-hover: color-mix(in oklch, var(--primary) 84%, white 16%); + --action-border: color-mix(in oklch, var(--primary) 68%, var(--border)); + --action-fg: var(--primary-foreground); + --danger-soft-bg: color-mix(in oklch, var(--destructive) 18%, transparent); + --danger-soft-border: color-mix(in oklch, var(--destructive) 42%, transparent); + --danger-soft-fg: color-mix(in oklch, var(--destructive) 82%, white 18%); + --status-idle-bg: color-mix(in oklch, var(--surface-3) 66%, var(--surface-2)); + --status-idle-border: color-mix(in oklch, var(--surface-selected-border) 78%, var(--border)); + --status-idle-fg: color-mix(in oklch, var(--foreground) 92%, var(--muted-foreground) 8%); + --status-running-bg: color-mix(in oklch, #20aa68 22%, transparent); + --status-running-border: color-mix(in oklch, #2ec57a 46%, transparent); + --status-running-fg: color-mix(in oklch, #7be3ad 68%, white 32%); + --status-error-bg: var(--danger-soft-bg); + --status-error-border: var(--danger-soft-border); + --status-error-fg: var(--danger-soft-fg); + --status-connecting-bg: color-mix(in oklch, #b97811 24%, transparent); + --status-connecting-border: color-mix(in oklch, #cf8a14 50%, transparent); + --status-connecting-fg: color-mix(in oklch, #f5cb86 66%, white 34%); + --status-connected-bg: color-mix(in oklch, #239199 24%, transparent); + --status-connected-border: color-mix(in oklch, #27a8b0 52%, transparent); + --status-connected-fg: color-mix(in oklch, #9be5e8 68%, white 32%); + --status-disconnected-bg: var(--status-idle-bg); + --status-disconnected-border: var(--status-idle-border); + --status-disconnected-fg: var(--status-idle-fg); + --status-approval-bg: color-mix(in oklch, #a77113 26%, transparent); + --status-approval-border: color-mix(in oklch, #c68518 48%, transparent); + --status-approval-fg: color-mix(in oklch, #f2c986 68%, white 32%); + --command-bg: color-mix(in oklch, #060c12 90%, var(--surface-1)); + --command-border: color-mix(in oklch, #325066 68%, transparent); + --command-fg: rgb(236 243 248 / 0.96); + --command-copy-bg: color-mix(in oklch, #1b2d3f 86%, var(--surface-1)); + --command-copy-hover-bg: color-mix(in oklch, #254056 82%, var(--surface-1)); + --command-copy-border: color-mix(in oklch, #406882 66%, transparent); + --model-control-border: color-mix(in oklch, var(--action-bg) 48%, var(--sidebar-input-border)); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-display); - --font-mono: var(--font-code); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); -} - -body { - min-height: 100vh; - background: radial-gradient(1000px circle at 10% -10%, #f7dac1 0%, transparent 55%), - radial-gradient(900px circle at 90% -20%, #cfe2f5 0%, transparent 48%), - #f4efe7; - font-family: var(--font-display), sans-serif; + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-shadow-color: var(--shadow-color); + --color-surface-0: var(--surface-0); + --color-surface-1: var(--surface-1); + --color-surface-2: var(--surface-2); + --color-surface-3: var(--surface-3); + --radius-sm: var(--radius-small); + --radius-md: var(--radius-small); + --radius-lg: var(--radius-card); + --radius-xl: var(--radius-container); + --radius-2xl: var(--radius-container); + --radius-3xl: var(--radius-container); + --radius-4xl: var(--radius-container); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: "Baskervville", serif; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xs: 0 1px 1px color-mix(in oklch, var(--shadow-color) 10%, transparent); + --shadow-xs: 0 2px 6px color-mix(in oklch, var(--shadow-color) 16%, transparent); + --shadow-sm: 0 7px 18px -9px color-mix(in oklch, var(--shadow-color) 35%, transparent); + --shadow: 0 10px 24px -14px color-mix(in oklch, var(--shadow-color) 42%, transparent); + --shadow-md: 0 14px 36px -17px color-mix(in oklch, var(--shadow-color) 48%, transparent); + --shadow-lg: 0 24px 60px -26px color-mix(in oklch, var(--shadow-color) 58%, transparent); + --shadow-xl: 0 36px 90px -38px color-mix(in oklch, var(--shadow-color) 66%, transparent); + --shadow-2xl: 0 44px 120px -45px color-mix(in oklch, var(--shadow-color) 78%, transparent); +} + +@layer base { + html { + font-size: 112%; + } + + * { + @apply border-border outline-ring/50; + box-sizing: border-box; + } + + body { + @apply bg-background text-foreground; + min-height: 100vh; + margin: 0; + letter-spacing: var(--tracking-normal); + font-family: var(--font-sans), sans-serif; + position: relative; + overflow: hidden; + } + + body::before { + content: ""; + position: fixed; + inset: -30%; + z-index: -2; + background: + radial-gradient(circle at 16% 18%, color-mix(in oklch, var(--border) 24%, transparent), transparent 42%), + radial-gradient(circle at 84% 8%, color-mix(in oklch, var(--muted) 58%, transparent), transparent 34%), + linear-gradient(148deg, color-mix(in oklch, var(--background) 94%, white) 0%, var(--background) 62%, color-mix(in oklch, var(--background) 90%, black) 100%); + transform: rotate(-2deg) scale(1.04); + } + + body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + background-image: + linear-gradient(transparent 95%, color-mix(in oklch, var(--border) 58%, transparent) 96%), + linear-gradient(90deg, transparent 95%, color-mix(in oklch, var(--border) 52%, transparent) 96%); + background-size: 30px 30px; + opacity: 0.14; + pointer-events: none; + } + + .dark body::before { + inset: 0; + transform: none; + background: var(--background); + } + + .dark body::after { + background-image: none; + opacity: 0; + } +} + +button:not(:disabled), +input[type="button"]:not(:disabled), +input[type="submit"]:not(:disabled), +input[type="reset"]:not(:disabled), +summary, +[role="button"]:not([aria-disabled="true"]) { + cursor: pointer; } * { - box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--foreground) 11%, var(--border)) transparent; +} + +.dark * { + scrollbar-color: color-mix(in oklch, var(--foreground) 34%, var(--border)) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--foreground) 11%, var(--border)); + border-radius: 999px; + border: 2px solid transparent; +} + +*::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--foreground) 17%, var(--border)); +} + +.dark *::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--foreground) 36%, var(--border)); +} + +.dark *::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--foreground) 46%, var(--border)); +} + +textarea { + scrollbar-gutter: auto; +} + +textarea::-webkit-scrollbar { + width: 0; + height: 0; +} + +textarea:hover::-webkit-scrollbar, +textarea:focus-visible::-webkit-scrollbar, +textarea:focus::-webkit-scrollbar { + width: 10px; + height: 10px; } .glass-panel { + border: 0; background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: 24px; - box-shadow: 0 24px 60px rgba(31, 26, 21, 0.15); - backdrop-filter: blur(18px); + backdrop-filter: none; + border-radius: var(--radius-container); + box-shadow: var(--shadow-xs); } -.canvas-surface { - background: radial-gradient(circle at 1px 1px, rgba(25, 20, 16, 0.08) 1px, transparent 0) - 0 0 / 32px 32px, - rgba(255, 255, 255, 0.6); - border: 1px solid var(--panel-border); - border-radius: 28px; - box-shadow: 0 30px 80px rgba(31, 26, 21, 0.18); - overscroll-behavior: none; - touch-action: none; +.fade-up { + animation: fadeUp 540ms cubic-bezier(0.2, 0.74, 0.2, 1) both; } -.canvas-content { - will-change: transform; +.fade-up-delay { + animation: fadeUp 540ms cubic-bezier(0.2, 0.74, 0.2, 1) 120ms both; } -.react-flow__node.selectable.selected, -.react-flow__node.selectable:focus, -.react-flow__node.selectable:focus-visible, -.react-flow__node-default.selectable.selected, -.react-flow__node-default.selectable:focus, -.react-flow__node-default.selectable:focus-visible { - outline: none; +.agent-avatar-selected { + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 36%, transparent); +} + +.agent-name-selected { + border-color: color-mix(in oklch, var(--primary) 40%, var(--border)); + box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--primary) 16%, transparent); +} + +.agent-inspect-panel { + position: relative; + height: 100%; + width: 100%; + border-radius: var(--radius-container); + background: transparent; + color: var(--sidebar-foreground); + border: 0; box-shadow: none; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--border) 60%, transparent) transparent; } -.react-flow { - --xy-node-border: 1px solid transparent; - --xy-node-border-selected: 1px solid transparent; - --xy-selection-border: 1px solid transparent; - --xy-resize-background-color: transparent; +.sidebar-shell { + box-shadow: var(--shadow-xs); + background: color-mix(in oklch, var(--panel) 84%, white 16%); } -.react-flow__resize-control.line { - border-color: transparent; +.dark .sidebar-shell { + box-shadow: var(--shadow-2xs); + background: var(--plane-sidepanel-bg); } -.react-flow__resize-control.handle { - background-color: transparent; - border-color: transparent; +.sidebar-section { + padding-block: 1.5rem; } -.tile-resize-handle { - height: 8px; - width: 112px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.7); - background-color: rgba(255, 255, 255, 0.9); - box-shadow: 0 6px 12px rgba(15, 23, 42, 0.18); - opacity: 0; - transition: opacity 160ms ease; - z-index: 2; +.sidebar-section:first-of-type { + padding-top: 1rem; +} + +.sidebar-section + .sidebar-section { + margin-top: 2rem; +} + +.sidebar-section-title { + font-size: 1rem; + line-height: 1.2; + font-weight: 600; + color: color-mix(in oklch, var(--foreground) 96%, var(--muted-foreground) 4%); +} + +.sidebar-copy { + font-size: 0.82rem; + line-height: 1.55; +} + +.sidebar-card { + border: 1px solid var(--sidebar-card-border); + border-radius: var(--radius-card); + background: var(--sidebar-card-bg); + box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 5%, transparent); +} + +.sidebar-input { + border: 1px solid var(--sidebar-input-border); + background: var(--sidebar-input-bg); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease; +} + +.sidebar-input:focus, +.sidebar-input:focus-visible { + border-color: color-mix(in oklch, var(--ring) 72%, var(--sidebar-input-border)); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 22%, transparent) !important; +} + +.ui-switch { + position: relative; + display: inline-flex; + height: 32px; + width: 54px; + align-items: center; + border-radius: var(--radius-small); + border: 0; + background: var(--sidebar-control-off); + box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent); + transition: + background-color 180ms ease, + box-shadow 180ms ease; +} + +.ui-switch--on { + background: var(--sidebar-control-on); + box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--sidebar-control-on) 56%, transparent); +} + +.ui-switch-thumb { + height: 26px; + width: 26px; + border-radius: calc(var(--radius-small) - 2px); + background: var(--primary-foreground); + box-shadow: 0 2px 5px color-mix(in oklch, black 20%, transparent); + transform: translateX(3px); + transition: transform 180ms ease; +} + +.ui-switch--on .ui-switch-thumb { + transform: translateX(25px); +} + +.sidebar-btn-primary { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + border-radius: calc(var(--radius) + 0.16rem); + border: 1px solid var(--action-border); + background: var(--action-bg); + color: var(--action-fg); + transition: filter 180ms ease; +} + +.sidebar-btn-primary:hover { + filter: brightness(1.04); +} + +.sidebar-btn-ghost { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border-radius: calc(var(--radius) + 0.08rem); + border: 1px solid color-mix(in oklch, var(--border) 92%, transparent); + background: color-mix(in oklch, var(--surface-1) 82%, transparent); + color: color-mix(in oklch, var(--foreground) 76%, var(--muted-foreground) 24%); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.sidebar-btn-ghost:hover { + border-color: color-mix(in oklch, var(--border) 76%, var(--foreground) 24%); + background: color-mix(in oklch, var(--surface-2) 88%, transparent); +} + +.sidebar-btn-utility { + display: inline-flex; + min-height: 34px; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: color-mix(in oklch, var(--muted-foreground) 78%, var(--foreground) 22%); + transition: color 180ms ease; +} + +.sidebar-btn-utility:hover { + color: color-mix(in oklch, var(--foreground) 72%, var(--muted-foreground) 28%); + text-decoration: underline; + text-underline-offset: 2px; +} + +.ui-panel { + border: 1px solid var(--panel-border); + border-radius: var(--radius-card); + background: var(--panel); + box-shadow: var(--shadow-sm); +} + +.dark .ui-panel { + border-color: var(--plane-workspace-border); + background: var(--plane-workspace-bg); + box-shadow: var(--shadow-2xs); +} + +.ui-depth-workspace { + background: var(--plane-workspace-bg); +} + +.dark .ui-depth-workspace { + border-color: var(--plane-workspace-border); + background: var(--plane-workspace-bg); + box-shadow: + inset 0 1px 0 var(--elev-overlay-1), + var(--shadow-2xs); +} + +.ui-depth-sidepanel { + background: var(--plane-sidepanel-bg); +} + +.dark .ui-depth-sidepanel { + border-color: var(--plane-sidepanel-border); + background: var(--plane-sidepanel-bg); + box-shadow: + 0 0 0 1px color-mix(in oklch, var(--elev-edge-1) 72%, transparent), + inset 0 1px 0 var(--elev-overlay-1), + var(--shadow-xs); +} + +.ui-section, +.sidebar-section { + padding-block: 1.5rem; +} + +.ui-section:first-of-type, +.sidebar-section:first-of-type { + padding-top: 1rem; +} + +.ui-section + .ui-section, +.sidebar-section + .sidebar-section { + margin-top: 1.5rem; +} + +.sidebar-section + .sidebar-section { + border-top: 1px solid color-mix(in oklch, var(--border) 66%, transparent); + padding-top: 1.35rem; } -.react-flow__node:hover .tile-resize-handle, -.react-flow__node.selected .tile-resize-handle { +.ui-section-title, +.sidebar-section-title { + font-size: 1rem; + line-height: 1.2; + font-weight: 600; + letter-spacing: -0.01em; + color: color-mix(in oklch, var(--foreground) 96%, var(--muted-foreground) 4%); +} + +.sidebar-section-title { + display: inline-flex; + align-items: center; + min-height: 1.7rem; + border: 1px solid color-mix(in oklch, var(--border) 72%, transparent); + border-radius: calc(var(--radius-small) - 2px); + padding: 0.12rem 0.5rem; + background: color-mix(in oklch, var(--surface-2) 86%, transparent); +} + +.ui-card, +.sidebar-card { + border: 1px solid var(--sidebar-card-border); + border-radius: var(--radius-card); + background: var(--sidebar-card-bg); + box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 5%, transparent); +} + +.dark .ui-card, +.dark .sidebar-card { + border-color: var(--plane-card-border); + background: var(--plane-card-bg); + box-shadow: + inset 0 1px 0 var(--elev-overlay-1), + 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent); +} + +.ui-menu-popover { + background: var(--popover); +} + +.ui-card-selected { + border-color: var(--surface-selected-border); + background: var(--surface-selected); + box-shadow: + inset 0 0 0 1px color-mix(in oklch, var(--primary) 38%, transparent), + 0 1px 2px color-mix(in oklch, var(--foreground) 7%, transparent); +} + +.dark .ui-card-selected { + border-color: color-mix(in oklch, var(--primary) 32%, var(--plane-card-border)); + background: color-mix(in oklch, var(--surface-selected) 78%, var(--plane-card-bg)); + box-shadow: + inset 0 0 0 1px color-mix(in oklch, var(--primary) 48%, transparent), + inset 0 1px 0 var(--elev-overlay-2), + 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent); +} + +.ui-card-select-indicator { + pointer-events: none; + position: absolute; + left: 0; + top: 0.7rem; + bottom: 0.7rem; + width: 5px; + border-radius: 0 9999px 9999px 0; + background: linear-gradient( + 180deg, + color-mix(in oklch, var(--primary) 96%, white 4%) 0%, + color-mix(in oklch, var(--primary) 84%, var(--surface-selected-border)) 55%, + color-mix(in oklch, var(--primary) 68%, var(--surface-selected-border)) 100% + ); + transition: opacity 180ms ease; +} + +.ui-input, +.sidebar-input { + border: 1px solid var(--sidebar-input-border); + background: var(--sidebar-input-bg); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease; +} + +.ui-input:focus, +.ui-input:focus-visible, +.sidebar-input:focus, +.sidebar-input:focus-visible { + border-color: color-mix(in oklch, var(--ring) 72%, var(--sidebar-input-border)); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 22%, transparent) !important; +} + +.ui-btn-primary, +.sidebar-btn-primary { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + border: 1px solid var(--action-border); + background: var(--action-bg); + color: var(--action-fg); + transition: filter 180ms ease; +} + +.ui-btn-primary:hover, +.sidebar-btn-primary:hover { + background: var(--action-bg-hover); + filter: none; +} + +.ui-btn-send { + border-radius: var(--radius-small); + background: color-mix(in oklch, var(--action-bg) 92%, var(--surface-1)); +} + +.ui-btn-send:hover { + background: color-mix(in oklch, var(--action-bg-hover) 92%, var(--surface-1)); +} + +.ui-btn-secondary { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + border: 1px solid color-mix(in oklch, var(--border) 82%, transparent); + background: color-mix(in oklch, var(--surface-1) 92%, transparent); + color: color-mix(in oklch, var(--foreground) 74%, var(--muted-foreground) 26%); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.ui-btn-secondary:hover { + border-color: color-mix(in oklch, var(--border) 62%, var(--foreground) 38%); + background: color-mix(in oklch, var(--surface-2) 90%, transparent); +} + +.ui-btn-ghost, +.sidebar-btn-ghost { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + border: 0; + background: color-mix(in oklch, var(--surface-1) 82%, transparent); + color: color-mix(in oklch, var(--foreground) 76%, var(--muted-foreground) 24%); + transition: + border-color 180ms ease, + background-color 180ms ease; +} + +.ui-btn-ghost:hover, +.sidebar-btn-ghost:hover { + background: color-mix(in oklch, var(--surface-2) 96%, transparent); + box-shadow: var(--shadow-2xs); +} + +.ui-btn-icon { + --ui-btn-icon-size: 2.25rem; + display: inline-flex; + height: var(--ui-btn-icon-size); + width: var(--ui-btn-icon-size); + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + border: 0; + background: color-mix(in oklch, var(--surface-3) 92%, transparent); + color: color-mix(in oklch, var(--foreground) 72%, var(--muted-foreground) 28%); + transition: + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease; +} + +.ui-btn-icon-sm { + --ui-btn-icon-size: 1.95rem; +} + +.ui-btn-icon-xs { + --ui-btn-icon-size: 1.6rem; +} + +.agent-avatar-shuffle-btn { + border: 1px solid color-mix(in oklch, var(--border) 74%, transparent); + background: color-mix(in oklch, var(--surface-1) 92%, var(--surface-2)); + color: color-mix(in oklch, var(--foreground) 80%, var(--muted-foreground) 20%); + box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 14%, transparent); + opacity: 0.92; +} + +.ui-btn-icon.agent-avatar-shuffle-btn:hover, +.ui-btn-icon.agent-avatar-shuffle-btn:focus-visible { opacity: 1; + background: color-mix(in oklch, var(--surface-1) 98%, var(--surface-2)); + color: color-mix(in oklch, var(--foreground) 92%, var(--muted-foreground) 8%); + box-shadow: 0 1px 3px color-mix(in oklch, var(--foreground) 18%, transparent); } -.tile-resize-handle.react-flow__resize-control.bottom { - transform: translate(-50%, -100%); +.ui-topbar { + border-bottom: 1px solid color-mix(in oklch, var(--border) 72%, transparent); + border-radius: 0; + background: color-mix(in oklch, var(--surface-1) 96%, var(--surface-0)); + box-shadow: none; } -.fade-up { - animation: fadeUp 600ms ease-out both; +.dark .ui-topbar { + border-bottom-color: color-mix(in oklch, var(--border) 88%, var(--surface-selected-border)); + background: color-mix(in oklch, var(--surface-1) 94%, var(--surface-0)); } -.fade-up-delay { - animation: fadeUp 600ms ease-out 120ms both; +.ui-topbar .ui-btn-icon { + --ui-btn-icon-size: 1.55rem; + background: transparent; + box-shadow: none; } -.agent-avatar-selected { - animation: agentAvatarPulse 2.6s ease-in-out infinite; +.ui-topbar .ui-btn-icon:hover { + background: color-mix(in oklch, var(--surface-2) 72%, transparent); + box-shadow: none; +} + +.ui-btn-icon:hover { + background: color-mix(in oklch, var(--surface-2) 98%, transparent); + box-shadow: var(--shadow-2xs); + color: color-mix(in oklch, var(--foreground) 88%, var(--muted-foreground) 12%); +} + +.ui-segment { + display: grid; + gap: 0.45rem; + border: 1px solid color-mix(in oklch, var(--border) 78%, transparent); + border-radius: var(--radius-small); + padding: 0.2rem; + background: color-mix(in oklch, var(--surface-2) 78%, transparent); + box-shadow: none; +} + +.ui-segment-command-mode { + gap: 0.46rem; + padding: 0.32rem; +} + +.dark .ui-segment-command-mode { + border-color: color-mix(in oklch, var(--border) 94%, transparent); + background: color-mix(in oklch, var(--surface-2) 92%, var(--surface-1)); + box-shadow: inset 0 1px 0 var(--elev-overlay-1); +} + +.ui-segment-item { + border: 1px solid transparent; + border-radius: calc(var(--radius-small) - 2px); + background: color-mix(in oklch, var(--surface-1) 88%, var(--surface-2)); + color: color-mix(in oklch, var(--foreground) 64%, var(--muted-foreground) 36%); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + box-shadow: none; + transition: + background-color 180ms ease, + box-shadow 180ms ease, + color 180ms ease; +} + +.ui-segment-item[data-active="true"], +.ui-segment-item[aria-pressed="true"] { + background: color-mix(in oklch, var(--surface-selected) 88%, var(--surface-1)); + border: 1px solid var(--surface-selected-border); + color: color-mix(in oklch, var(--foreground) 90%, var(--muted-foreground) 10%); + box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--primary) 24%, transparent); +} + +.ui-segment-fleet-filter { + padding-left: 0; + padding-right: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.ui-segment-fleet-filter .ui-segment-item { + border: 0; + box-shadow: none; +} + +.ui-segment-fleet-filter .ui-segment-item[data-active="true"], +.ui-segment-fleet-filter .ui-segment-item[aria-pressed="true"] { + border: 0; + box-shadow: none; +} + +.ui-segment-command-mode .ui-segment-item { + min-height: 2.125rem; + border-color: color-mix(in oklch, var(--border) 68%, transparent); + background: color-mix(in oklch, var(--surface-1) 86%, var(--surface-2)); + color: color-mix(in oklch, var(--foreground) 74%, var(--muted-foreground) 26%); +} + +.dark .ui-segment-command-mode .ui-segment-item { + border-color: color-mix(in oklch, var(--border) 78%, transparent); + background: color-mix(in oklch, var(--surface-1) 90%, var(--surface-2)); + color: color-mix(in oklch, var(--foreground) 80%, var(--muted-foreground) 20%); +} + +.ui-segment-command-mode .ui-segment-item[data-active="true"], +.ui-segment-command-mode .ui-segment-item[aria-pressed="true"] { + color: color-mix(in oklch, var(--foreground) 96%, var(--muted-foreground) 4%); + border-color: color-mix(in oklch, var(--primary) 64%, var(--surface-selected-border)); + background: color-mix(in oklch, var(--primary) 24%, var(--surface-selected)); box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); + inset 0 1px 0 var(--elev-overlay-2), + inset 0 0 0 1px color-mix(in oklch, var(--primary) 42%, transparent); } -@keyframes agentAvatarPulse { - 0% { - box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); - } - 50% { - box-shadow: - 0 0 0 6px rgba(59, 130, 246, 0.14), - 0 10px 22px rgba(15, 23, 42, 0.16); - } - 100% { - box-shadow: - 0 0 0 0 rgba(59, 130, 246, 0.22), - 0 8px 18px rgba(15, 23, 42, 0.12); - } +.ui-selected { + background: var(--surface-selected); + border: 1px solid var(--surface-selected-border); + box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--primary) 24%, transparent); +} + +.ui-chat-scroll { + border: 1px solid color-mix(in oklch, var(--border) 72%, transparent); + border-radius: var(--radius-card); + background: var(--chat-surface-bg); +} + +.dark .ui-chat-scroll { + border-color: color-mix(in oklch, var(--plane-workspace-border) 90%, var(--border)); + background: color-mix(in oklch, var(--surface-1) 95%, var(--surface-0)); +} + +.ui-chat-scroll-borderless { + border: 0 !important; + border-radius: 0; + background: transparent; +} + +.dark .ui-chat-scroll-borderless { + border: 0 !important; + background: transparent; +} + +.ui-chat-user-card { + border: 1px solid var(--chat-user-border); + background: var(--chat-user-bg); + box-shadow: 0 1px 3px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.ui-chat-assistant-card { + border: 1px solid var(--chat-assistant-border); + border-radius: var(--radius-small); + background: var(--chat-assistant-bg); + padding: 0.92rem 1.08rem; + box-shadow: inset 0 1px 0 var(--elev-overlay-1); +} + +.dark .ui-chat-assistant-card { + padding: 1rem 1.2rem; + box-shadow: + inset 0 1px 0 var(--elev-overlay-2), + 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent); +} + +.ui-chat-thinking { + border: 1px solid color-mix(in oklch, var(--border) 48%, transparent); + background: color-mix(in oklch, var(--surface-1) 92%, transparent); + color: color-mix(in oklch, var(--muted-foreground) 86%, var(--foreground) 14%); +} + +.ui-settings-row { + border: 1px solid color-mix(in oklch, var(--border) 56%, transparent); + border-radius: var(--radius-small); + background: color-mix(in oklch, var(--surface-2) 78%, transparent); +} + +.ui-chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-small); + border: 1px solid var(--status-idle-border); + background: var(--status-idle-bg); + box-shadow: inset 0 1px 0 var(--elev-overlay-1); + color: var(--status-idle-fg); +} + +.ui-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in oklch, var(--border) 60%, transparent) transparent; +} + +.dark .ui-scroll { + scrollbar-color: color-mix(in oklch, var(--foreground) 36%, var(--border)) transparent; +} + +.ui-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.ui-scroll::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: color-mix(in oklch, var(--border) 58%, transparent); +} + +.dark .ui-scroll::-webkit-scrollbar-thumb { + background: color-mix(in oklch, var(--foreground) 34%, var(--border)); +} + +.dark .ui-scroll::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklch, var(--foreground) 44%, var(--border)); +} + +.lucide { + stroke-width: 1.75; +} + +.agent-inspect-panel .text-\[10px\] { + font-size: 11px; +} + +.agent-inspect-panel .text-\[11px\] { + font-size: 12px; +} + +[data-agent-panel] .text-\[10px\] { + font-size: 11px; +} + +[data-agent-panel] .text-\[11px\] { + font-size: 12px; +} + +.console-title { + font-family: var(--font-display), sans-serif; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.type-page-title { + font-size: 23px; + font-weight: 500; + line-height: 1.16; + letter-spacing: 0.005em; +} + +.type-agent-name { + font-size: 16px; + font-weight: 500; + line-height: 1.2; +} + +.type-secondary-heading { + font-size: 15px; + font-weight: 500; + line-height: 1.3; +} + +.type-body { + font-size: 14px; + font-weight: 400; + line-height: 1.68; +} + +.type-meta { + font-size: 12.5px; + font-weight: 400; + line-height: 1.35; + letter-spacing: 0.01em; +} + +.ui-badge { + display: inline-flex; + align-items: center; + border-radius: var(--radius-small); + padding: 0.18rem 0.58rem; + font-family: var(--font-mono), monospace; + font-size: 12px; + font-weight: 400; + letter-spacing: 0.045em; + line-height: 1.1; +} + +.ui-control-important { + border-color: var(--model-control-border); + background: color-mix(in oklch, var(--surface-1) 80%, var(--surface-2)); + box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 8%, transparent); +} + +.ui-alert-danger { + border: 1px solid var(--danger-soft-border); + background: var(--danger-soft-bg); + color: var(--danger-soft-fg); +} + +.ui-text-danger { + color: var(--danger-soft-fg); +} + +.ui-text-success { + color: var(--status-running-fg); +} + +.ui-badge-status-idle { + border: 1px solid var(--status-idle-border); + background: var(--status-idle-bg); + color: var(--status-idle-fg); + box-shadow: inset 0 1px 0 var(--elev-overlay-1); +} + +.ui-badge-status-running { + border: 1px solid var(--status-running-border); + background: var(--status-running-bg); + color: var(--status-running-fg); +} + +.ui-badge-status-error { + border: 1px solid var(--status-error-border); + background: var(--status-error-bg); + color: var(--status-error-fg); +} + +.ui-badge-status-connecting { + border: 1px solid var(--status-connecting-border); + background: var(--status-connecting-bg); + color: var(--status-connecting-fg); +} + +.ui-badge-status-connected { + border: 1px solid var(--status-connected-border); + background: var(--status-connected-bg); + color: var(--status-connected-fg); +} + +.ui-badge-status-disconnected { + border: 1px solid var(--status-disconnected-border); + background: var(--status-disconnected-bg); + color: var(--status-disconnected-fg); +} + +.ui-badge-approval { + border: 1px solid var(--status-approval-border); + background: var(--status-approval-bg); + color: var(--status-approval-fg); +} + +.ui-btn-danger { + border: 1px solid var(--danger-soft-border); + background: var(--danger-soft-bg); + color: var(--danger-soft-fg); +} + +.ui-btn-danger:hover { + background: color-mix(in oklch, var(--destructive) 20%, transparent); +} + +.ui-btn-icon-danger { + color: var(--danger-soft-fg); +} + +.ui-btn-icon-danger:hover { + background: color-mix(in oklch, var(--destructive) 18%, transparent); + color: var(--danger-soft-fg); +} + +.ui-dot-status-disconnected, +.ui-dot-status-connecting, +.ui-dot-status-connected { + border-radius: 9999px; +} + +.ui-dot-status-disconnected { + background: var(--status-disconnected-fg); +} + +.ui-dot-status-connecting { + background: var(--status-connecting-fg); +} + +.ui-dot-status-connected { + background: var(--status-connected-fg); +} + +.ui-command-surface { + border: 1px solid var(--command-border); + background: var(--command-bg); +} + +.ui-command-surface code { + color: var(--command-fg); +} + +.ui-command-copy { + border: 1px solid var(--command-copy-border); + background: var(--command-copy-bg); + color: var(--command-fg); +} + +.ui-command-copy:hover { + background: var(--command-copy-hover-bg); +} + +.status-ping { + opacity: 0.9; +} + +.typing-dots { + display: inline-flex; + align-items: center; + gap: 3px; +} + +.typing-dots > span { + width: 4px; + height: 4px; + border-radius: 9999px; + background: currentColor; + opacity: 0.28; + animation: typingPulse 960ms ease-in-out infinite; +} + +.typing-dots > span:nth-child(2) { + animation-delay: 160ms; +} + +.typing-dots > span:nth-child(3) { + animation-delay: 320ms; +} + +:where(button, input, textarea, select, [role="button"], summary):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + box-shadow: none !important; +} + +.agent-rename-input:focus, +.agent-rename-input:focus-visible { + outline: none !important; + outline-offset: 0; + border-color: color-mix(in oklch, var(--ring) 62%, var(--sidebar-input-border)); + box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring) 20%, transparent) !important; +} + +.agent-rename-control:focus-visible { + outline: none !important; + outline-offset: 0; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring) 20%, transparent) !important; +} + +.chat-composer-input:focus, +.chat-composer-input:focus-visible { + outline: none !important; + outline-offset: 0 !important; + box-shadow: none !important; } @keyframes fadeUp { from { opacity: 0; - transform: translateY(12px); + transform: translateY(14px); } to { opacity: 1; @@ -212,45 +1326,15 @@ body { } } -.dark { - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.92 0.004 286.32); - --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.552 0.016 285.938); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.552 0.016 285.938); -} - -@layer base { - * { - @apply border-border outline-ring/50; +@keyframes typingPulse { + 0%, + 80%, + 100% { + opacity: 0.28; + transform: translateY(0); } - body { - @apply bg-background text-foreground; + 40% { + opacity: 0.95; + transform: translateY(-1px); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 42c6d1f6..cd85ffb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,46 @@ import type { Metadata } from "next"; -import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google"; +import { Bebas_Neue, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import "./globals.css"; -const display = Space_Grotesk({ +export const metadata: Metadata = { + title: "OpenClaw Studio", + description: "Focused operator studio for the OpenClaw gateway.", +}; + +const display = Bebas_Neue({ variable: "--font-display", + weight: "400", subsets: ["latin"], +}); + +const sans = IBM_Plex_Sans({ + variable: "--font-sans", weight: ["400", "500", "600", "700"], + subsets: ["latin"], }); const mono = IBM_Plex_Mono({ - variable: "--font-code", + variable: "--font-mono", + weight: ["400", "500", "600"], subsets: ["latin"], - weight: ["400", "500"], }); -export const metadata: Metadata = { - title: "Clawdbot Agent Canvas", - description: "Standalone operator canvas for the Clawdbot gateway.", -}; - export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - <html lang="en"> - <body className={`${display.variable} ${mono.variable} antialiased`}> + <html lang="en" suppressHydrationWarning> + <head> + <script + dangerouslySetInnerHTML={{ + __html: + "(function(){try{var t=localStorage.getItem('theme');var m=window.matchMedia('(prefers-color-scheme: dark)').matches;var d=t?t==='dark':m;document.documentElement.classList.toggle('dark',d);}catch(e){}})();", + }} + /> + </head> + <body className={`${display.variable} ${sans.variable} ${mono.variable} antialiased`}> {children} </body> </html> diff --git a/src/app/page.tsx b/src/app/page.tsx index e732302d..bec5448e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1093 +1,1776 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -// (ReactFlowInstance import removed) -import { CanvasFlow } from "@/features/canvas/components/CanvasFlow"; -import { HeaderBar } from "@/features/canvas/components/HeaderBar"; -import { MIN_TILE_SIZE } from "@/features/canvas/components/AgentTile"; -import { screenToWorld, worldToScreen } from "@/features/canvas/lib/transform"; -import { extractText } from "@/lib/text/extractText"; -import { extractThinking, formatThinkingMarkdown } from "@/lib/text/extractThinking"; -import { useGatewayConnection } from "@/lib/gateway/useGatewayConnection"; -import type { EventFrame } from "@/lib/gateway/frames"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal"; import { - AgentCanvasProvider, - getActiveProject, - useAgentCanvasStore, -} from "@/features/canvas/state/store"; -import { createProjectDiscordChannel } from "@/lib/projects/client"; -import { createRandomAgentName, normalizeAgentName } from "@/lib/names/agentNames"; -import type { AgentTile, ProjectRuntime } from "@/features/canvas/state/store"; -// (CANVAS_BASE_ZOOM import removed) - -type ChatEventPayload = { - runId: string; - sessionKey: string; - state: "delta" | "final" | "aborted" | "error"; - message?: unknown; - errorMessage?: string; -}; - -type AgentEventPayload = { - runId: string; - seq?: number; - stream?: string; - data?: Record<string, unknown>; - sessionKey?: string; -}; - -const PROJECT_PROMPT_BLOCK_RE = /^(?:Project|Workspace) path:[\s\S]*?\n\s*\n/i; -const PROJECT_PROMPT_INLINE_RE = /^(?:Project|Workspace) path:[\s\S]*?memory_search\.\s*/i; -const RESET_PROMPT_RE = - /^A new session was started via \/new or \/reset[\s\S]*?reasoning\.\s*/i; -const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi; -const UI_METADATA_PREFIX_RE = - /^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i; - -const stripUiMetadata = (text: string) => { - if (!text) return text; - let cleaned = text.replace(RESET_PROMPT_RE, ""); - const beforeProjectStrip = cleaned; - cleaned = cleaned.replace(PROJECT_PROMPT_INLINE_RE, ""); - if (cleaned === beforeProjectStrip) { - cleaned = cleaned.replace(PROJECT_PROMPT_BLOCK_RE, ""); - } - cleaned = cleaned.replace(MESSAGE_ID_RE, "").trim(); - return cleaned; -}; + AgentBrainPanel, + AgentSettingsPanel, +} from "@/features/agents/components/AgentInspectPanels"; +import { FleetSidebar } from "@/features/agents/components/FleetSidebar"; +import { HeaderBar } from "@/features/agents/components/HeaderBar"; +import { ConnectionPanel } from "@/features/agents/components/ConnectionPanel"; +import { GatewayConnectScreen } from "@/features/agents/components/GatewayConnectScreen"; +import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel"; +import { + isHeartbeatPrompt, +} from "@/lib/text/message-extract"; +import { + useGatewayConnection, +} from "@/lib/gateway/GatewayClient"; +import { + type GatewayModelChoice, + type GatewayModelPolicySnapshot, +} from "@/lib/gateway/models"; +import { + AgentStoreProvider, + getFilteredAgents, + getSelectedAgent, + type FocusFilter, + useAgentStore, +} from "@/features/agents/state/store"; +import type { AgentState } from "@/features/agents/state/store"; +import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler"; +import { + type CronJobSummary, + formatCronJobDisplay, + listCronJobs, + resolveLatestCronJobForAgent, +} from "@/lib/cron/types"; +import { + createGatewayAgent, + readConfigAgentList, + resolveDefaultConfigAgentId, + slugifyAgentName, +} from "@/lib/gateway/agentConfig"; +import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; +import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator"; +import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations"; +import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; +import { + isGatewayDisconnectLikeError, + type EventFrame, +} from "@/lib/gateway/GatewayClient"; +import { + useConfigMutationQueue, + type ConfigMutationKind, +} from "@/features/agents/operations/useConfigMutationQueue"; +import { useGatewayConfigSyncController } from "@/features/agents/operations/useGatewayConfigSyncController"; +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import { randomUUID } from "@/lib/uuid"; +import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types"; +import { + planAwaitingUserInputPatches, + planPendingPruneDelay, + planPrunedPendingState, +} from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import { + runGatewayEventIngressOperation, + runPauseRunForExecApprovalOperation, + runResolveExecApprovalOperation, +} from "@/features/agents/approvals/execApprovalRunControlOperation"; +import { + mergePendingApprovalsForFocusedAgent, +} from "@/features/agents/approvals/pendingStore"; +import { + resolveLatestUpdateKind, +} from "@/features/agents/operations/latestUpdateWorkflow"; +import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation"; +import { + resolveAgentPermissionsDraft, +} from "@/features/agents/operations/agentPermissionsOperation"; +import { + executeStudioBootstrapLoadCommands, + executeStudioFocusedPatchCommands, + executeStudioFocusedPreferenceLoadCommands, + runStudioBootstrapLoadOperation, + runStudioFocusFilterPersistenceOperation, + runStudioFocusedPreferenceLoadOperation, + runStudioFocusedSelectionPersistenceOperation, +} from "@/features/agents/operations/studioBootstrapOperation"; +import { + CREATE_AGENT_DEFAULT_PERMISSIONS, + applyCreateAgentBootstrapPermissions, + executeCreateAgentBootstrapCommands, + runCreateAgentBootstrapOperation, +} from "@/features/agents/operations/createAgentBootstrapOperation"; +import { + buildQueuedMutationBlock, + isCreateBlockTimedOut, + resolveConfigMutationStatusLine, + runCreateAgentMutationLifecycle, + type CreateAgentBlockState, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; +import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController"; +import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController"; +import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController"; +import { + SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM, + parseSettingsRouteAgentIdFromQueryParam, + parseSettingsRouteAgentIdFromPathname, + type InspectSidebarState, + type SettingsRouteTab, +} from "@/features/agents/operations/settingsRouteWorkflow"; +import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController"; +const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500; +type MobilePane = "fleet" | "chat"; +type SettingsSidebarItem = SettingsRouteTab; -type ChatHistoryMessage = Record<string, unknown>; +const RESERVED_MAIN_AGENT_ID = "main"; -type ChatHistoryResult = { - sessionKey: string; - sessionId?: string; - messages: ChatHistoryMessage[]; - thinkingLevel?: string; -}; +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); -const buildHistoryLines = (messages: ChatHistoryMessage[]) => { - const lines: string[] = []; - let lastAssistant: string | null = null; - let lastRole: string | null = null; - for (const message of messages) { - const role = typeof message.role === "string" ? message.role : "other"; - const extracted = extractText(message); - const text = stripUiMetadata(extracted?.trim() ?? ""); - const thinking = - role === "assistant" ? formatThinkingMarkdown(extractThinking(message) ?? "") : ""; - if (!text && !thinking) continue; - if (role === "user") { - if (text) { - lines.push(`> ${text}`); - } - lastRole = "user"; - } else if (role === "assistant") { - if (thinking) { - lines.push(thinking); - } - if (text) { - lines.push(text); - lastAssistant = text; - } - lastRole = "assistant"; - } +const normalizeControlUiBasePath = (basePath: string): string => { + let normalized = basePath.trim(); + if (!normalized || normalized === "/") return ""; + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; } - const deduped: string[] = []; - for (const line of lines) { - if (deduped[deduped.length - 1] === line) continue; - deduped.push(line); + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); } - return { lines: deduped, lastAssistant, lastRole }; + return normalized; }; -const mergeHistoryWithPending = (historyLines: string[], currentLines: string[]) => { - if (currentLines.length === 0) return historyLines; - if (historyLines.length === 0) return historyLines; - const merged = [...historyLines]; - let cursor = 0; - for (const line of currentLines) { - let foundIndex = -1; - for (let i = cursor; i < merged.length; i += 1) { - if (merged[i] === line) { - foundIndex = i; - break; - } - } - if (foundIndex !== -1) { - cursor = foundIndex + 1; - continue; +const resolveControlUiUrl = (params: { + gatewayUrl: string; + configSnapshot: GatewayModelPolicySnapshot | null; +}): string | null => { + const rawGatewayUrl = params.gatewayUrl.trim(); + if (!rawGatewayUrl) return null; + + let controlUiEnabled = true; + let controlUiBasePath = ""; + + const config = params.configSnapshot?.config; + if (isRecord(config)) { + const configRecord = config as Record<string, unknown>; + const gateway = isRecord(configRecord["gateway"]) + ? (configRecord["gateway"] as Record<string, unknown>) + : null; + const controlUi = gateway && isRecord(gateway.controlUi) ? gateway.controlUi : null; + if (controlUi && typeof controlUi.enabled === "boolean") { + controlUiEnabled = controlUi.enabled; } - if (line.startsWith("> ")) { - merged.splice(cursor, 0, line); - cursor += 1; + if (typeof controlUi?.basePath === "string") { + controlUiBasePath = normalizeControlUiBasePath(controlUi.basePath); } } - return merged; -}; -const buildProjectMessage = (project: ProjectRuntime | null, message: string) => { - const trimmed = message.trim(); - if (!project || !project.repoPath.trim()) { - return trimmed; - } - if (trimmed.startsWith("/")) { - return trimmed; - } - return `Workspace path: ${project.repoPath}. Operate within this repository. You may also read/write your agent workspace files (IDENTITY.md, USER.md, HEARTBEAT.md, TOOLS.md, MEMORY.md). Use MEMORY.md or memory/*.md directly for durable memory; do not rely on memory_search.\n\n${trimmed}`; -}; + if (!controlUiEnabled) return null; -const findTileBySessionKey = ( - projects: ProjectRuntime[], - sessionKey: string -): { projectId: string; tileId: string } | null => { - for (const project of projects) { - const tile = project.tiles.find((entry) => entry.sessionKey === sessionKey); - if (tile) { - return { projectId: project.id, tileId: tile.id }; + try { + const url = new URL(rawGatewayUrl); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; } + url.pathname = controlUiBasePath ? `${controlUiBasePath}/` : "/"; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; } - return null; }; -const findTileByRunId = ( - projects: ProjectRuntime[], - runId: string -): { projectId: string; tileId: string } | null => { - for (const project of projects) { - const tile = project.tiles.find((entry) => entry.runId === runId); - if (tile) { - return { projectId: project.id, tileId: tile.id }; - } +const resolveNextNewAgentName = (agents: AgentState[]) => { + const baseName = "New Agent"; + const existingNames = new Set( + agents.map((agent) => agent.name.trim().toLowerCase()).filter((name) => name.length > 0) + ); + const existingIds = new Set( + agents + .map((agent) => agent.agentId.trim().toLowerCase()) + .filter((agentId) => agentId.length > 0) + ); + const baseLower = baseName.toLowerCase(); + if (!existingNames.has(baseLower) && !existingIds.has(slugifyAgentName(baseName))) return baseName; + for (let index = 2; index < 10000; index += 1) { + const candidate = `${baseName} ${index}`; + if (existingNames.has(candidate.toLowerCase())) continue; + if (existingIds.has(slugifyAgentName(candidate))) continue; + return candidate; } - return null; + throw new Error("Unable to allocate a unique agent name."); }; -const AgentCanvasPage = () => { - const { client, status } = useGatewayConnection(); - +const AgentStudioPage = () => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const settingsRouteAgentId = useMemo( + () => + parseSettingsRouteAgentIdFromQueryParam( + searchParams.get(SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM) + ) ?? parseSettingsRouteAgentIdFromPathname(pathname ?? ""), + [pathname, searchParams] + ); + const settingsRouteActive = settingsRouteAgentId !== null; + const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator()); const { - state, - dispatch, - createTile, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - } = useAgentCanvasStore(); - const project = getActiveProject(state); - const [showProjectForm, setShowProjectForm] = useState(false); - const [showOpenProjectForm, setShowOpenProjectForm] = useState(false); - const [projectName, setProjectName] = useState(""); - const [projectPath, setProjectPath] = useState(""); - const [projectWarnings, setProjectWarnings] = useState<string[]>([]); - const [openProjectWarnings, setOpenProjectWarnings] = useState<string[]>([]); - const historyInFlightRef = useRef<Set<string>>(new Set()); - const historyPollsRef = useRef<Map<string, number>>(new Map()); + client, + status, + gatewayUrl, + token, + localGatewayDefaults, + error: gatewayError, + connect, + disconnect, + useLocalGatewayDefaults, + setGatewayUrl, + setToken, + } = useGatewayConnection(settingsCoordinator); + + const { state, dispatch, hydrateAgents, setError, setLoading } = useAgentStore(); + const [showConnectionPanel, setShowConnectionPanel] = useState(false); + const [focusFilter, setFocusFilter] = useState<FocusFilter>("all"); + const [focusedPreferencesLoaded, setFocusedPreferencesLoaded] = useState(false); + const [agentsLoadedOnce, setAgentsLoadedOnce] = useState(false); + const [didAttemptGatewayConnect, setDidAttemptGatewayConnect] = useState(false); + const [heartbeatTick, setHeartbeatTick] = useState(0); const stateRef = useRef(state); - const viewportRef = useRef<HTMLDivElement | null>(null); - const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); - // flowInstance removed (zoom controls live in the bottom-right ReactFlow Controls). + const focusFilterTouchedRef = useRef(false); + const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]); + const [gatewayModelsError, setGatewayModelsError] = useState<string | null>(null); + const [gatewayConfigSnapshot, setGatewayConfigSnapshot] = + useState<GatewayModelPolicySnapshot | null>(null); + const [createAgentBusy, setCreateAgentBusy] = useState(false); + const [createAgentModalOpen, setCreateAgentModalOpen] = useState(false); + const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null); + const [mobilePane, setMobilePane] = useState<MobilePane>("chat"); + const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null); + const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null); + const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false); + const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality"); + const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(null); + const [pendingExecApprovalsByAgentId, setPendingExecApprovalsByAgentId] = useState< + Record<string, PendingExecApproval[]> + >({}); + const [unscopedPendingExecApprovals, setUnscopedPendingExecApprovals] = useState< + PendingExecApproval[] + >([]); + const pendingExecApprovalsByAgentIdRef = useRef(pendingExecApprovalsByAgentId); + const unscopedPendingExecApprovalsRef = useRef(unscopedPendingExecApprovals); + const specialUpdateRef = useRef<Map<string, string>>(new Map()); + const seenCronEventIdsRef = useRef<Set<string>>(new Set()); + const preferredSelectedAgentIdRef = useRef<string | null>(null); + const runtimeEventHandlerRef = useRef<ReturnType<typeof createGatewayRuntimeEventHandler> | null>( + null + ); + const enqueueConfigMutationRef = useRef< + (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => Promise<void> + >((input) => Promise.reject(new Error(`Config mutation queue not ready for "${input.kind}".`))); + const approvalPausedRunIdByAgentRef = useRef<Map<string, string>>(new Map()); - const tiles = useMemo(() => project?.tiles ?? [], [project?.tiles]); + const agents = state.agents; + const selectedAgent = useMemo(() => getSelectedAgent(state), [state]); + const filteredAgents = useMemo( + () => getFilteredAgents(state, focusFilter), + [focusFilter, state] + ); + const focusedAgent = useMemo(() => { + if (filteredAgents.length === 0) return null; + const selectedInFilter = selectedAgent + ? filteredAgents.find((entry) => entry.agentId === selectedAgent.agentId) + : null; + return selectedInFilter ?? filteredAgents[0] ?? null; + }, [filteredAgents, selectedAgent]); + const focusedAgentId = focusedAgent?.agentId ?? null; + const focusedAgentRunning = focusedAgent?.status === "running"; + const focusedAgentStopDisabledReason = useMemo(() => { + if (!focusedAgent) return null; + if (focusedAgent.status !== "running") return null; + const lastMessage = focusedAgent.lastUserMessage?.trim() ?? ""; + if (!lastMessage || !isHeartbeatPrompt(lastMessage)) return null; + return "This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon)."; + }, [focusedAgent]); + const inspectSidebarAgentId = inspectSidebar?.agentId ?? null; + const inspectSidebarTab = inspectSidebar?.tab ?? null; + const effectiveSettingsTab: SettingsRouteTab = inspectSidebarTab ?? "personality"; + useEffect(() => { + setSettingsSidebarItem(effectiveSettingsTab); + }, [effectiveSettingsTab]); + const inspectSidebarAgent = useMemo(() => { + if (!inspectSidebarAgentId) return null; + return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null; + }, [agents, inspectSidebarAgentId]); + useEffect(() => { + setSystemInitialSkillKey(null); + }, [inspectSidebarAgentId]); + useEffect(() => { + if (effectiveSettingsTab !== "system") { + setSystemInitialSkillKey(null); + } + }, [effectiveSettingsTab]); + const settingsAgentPermissionsDraft = useMemo(() => { + if (!inspectSidebarAgent) return null; + const baseConfig = + gatewayConfigSnapshot?.config && + typeof gatewayConfigSnapshot.config === "object" && + !Array.isArray(gatewayConfigSnapshot.config) + ? (gatewayConfigSnapshot.config as Record<string, unknown>) + : undefined; + const list = readConfigAgentList(baseConfig); + const configEntry = list.find((entry) => entry.id === inspectSidebarAgent.agentId) ?? null; + const toolsRaw = + configEntry && typeof (configEntry as Record<string, unknown>).tools === "object" + ? ((configEntry as Record<string, unknown>).tools as unknown) + : null; + const tools = + toolsRaw && typeof toolsRaw === "object" && !Array.isArray(toolsRaw) + ? (toolsRaw as Record<string, unknown>) + : null; + return resolveAgentPermissionsDraft({ + agent: inspectSidebarAgent, + existingTools: tools, + }); + }, [gatewayConfigSnapshot, inspectSidebarAgent]); + const settingsAgentSkillsAllowlist = useMemo(() => { + if (!inspectSidebarAgent) return undefined; + const baseConfig = + gatewayConfigSnapshot?.config && + typeof gatewayConfigSnapshot.config === "object" && + !Array.isArray(gatewayConfigSnapshot.config) + ? (gatewayConfigSnapshot.config as Record<string, unknown>) + : undefined; + const list = readConfigAgentList(baseConfig); + const configEntry = list.find((entry) => entry.id === inspectSidebarAgent.agentId) ?? null; + const raw = configEntry?.skills; + if (!Array.isArray(raw)) return undefined; + return raw + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + }, [gatewayConfigSnapshot, inspectSidebarAgent]); + const settingsDefaultAgentId = useMemo(() => { + const baseConfig = + gatewayConfigSnapshot?.config && + typeof gatewayConfigSnapshot.config === "object" && + !Array.isArray(gatewayConfigSnapshot.config) + ? (gatewayConfigSnapshot.config as Record<string, unknown>) + : undefined; + return resolveDefaultConfigAgentId(baseConfig); + }, [gatewayConfigSnapshot]); + const settingsSkillScopeWarning = useMemo(() => { + if (!inspectSidebarAgent) return null; + if (inspectSidebarAgent.agentId === settingsDefaultAgentId) { + return "Setup actions are shared across agents. Installs run in this shared workspace."; + } + return `Setup actions are shared across agents. Installs currently run in ${settingsDefaultAgentId} (shared workspace), not ${inspectSidebarAgent.agentId}.`; + }, [inspectSidebarAgent, settingsDefaultAgentId]); + const focusedPendingExecApprovals = useMemo(() => { + if (!focusedAgentId) return unscopedPendingExecApprovals; + const scoped = pendingExecApprovalsByAgentId[focusedAgentId] ?? []; + return mergePendingApprovalsForFocusedAgent({ + scopedApprovals: scoped, + unscopedApprovals: unscopedPendingExecApprovals, + }); + }, [focusedAgentId, pendingExecApprovalsByAgentId, unscopedPendingExecApprovals]); + const suggestedCreateAgentName = useMemo(() => { + try { + return resolveNextNewAgentName(state.agents); + } catch { + return "New Agent"; + } + }, [state.agents]); + const faviconSeed = useMemo(() => { + const firstAgent = agents[0]; + const seed = firstAgent?.avatarSeed ?? firstAgent?.agentId ?? ""; + return seed.trim() || null; + }, [agents]); + const faviconHref = useMemo( + () => (faviconSeed ? buildAvatarDataUrl(faviconSeed) : null), + [faviconSeed] + ); + const errorMessage = state.error ?? gatewayModelsError; + const runningAgentCount = useMemo( + () => agents.filter((agent) => agent.status === "running").length, + [agents] + ); + const hasRunningAgents = runningAgentCount > 0; + const isLocalGateway = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]); + const controlUiUrl = useMemo( + () => resolveControlUiUrl({ gatewayUrl, configSnapshot: gatewayConfigSnapshot }), + [gatewayConfigSnapshot, gatewayUrl] + ); + const settingsHeaderModel = (inspectSidebarAgent?.model ?? "").trim() || "Default"; + const settingsHeaderThinkingRaw = (inspectSidebarAgent?.thinkingLevel ?? "").trim() || "low"; + const settingsHeaderThinking = + settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1); + const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem; - const computeNewTilePosition = useCallback( - (tileSize: { width: number; height: number }) => { - if (!project) { - return { x: 80, y: 200 }; + useEffect(() => { + const selector = 'link[data-agent-favicon="true"]'; + const existing = document.querySelector(selector) as HTMLLinkElement | null; + if (!faviconHref) { + existing?.remove(); + return; + } + if (existing) { + if (existing.href !== faviconHref) { + existing.href = faviconHref; } + return; + } + const link = document.createElement("link"); + link.rel = "icon"; + link.type = "image/svg+xml"; + link.href = faviconHref; + link.setAttribute("data-agent-favicon", "true"); + document.head.appendChild(link); + }, [faviconHref]); - if (viewportSize.width === 0 || viewportSize.height === 0) { - const offset = project.tiles.length * 36; - return { x: 80 + offset, y: 200 + offset }; - } - const safeTop = 140; - const edgePadding = 24; - const step = 80; - const maxRings = 12; - const zoom = state.canvas.zoom; - - const effectiveSize = { - width: MIN_TILE_SIZE.width, - height: Math.max(tileSize.height, MIN_TILE_SIZE.height), - }; - - const minCenterY = safeTop + (effectiveSize.height * zoom) / 2; - const screenCenter = { - x: viewportSize.width / 2, - y: Math.max(viewportSize.height / 2, minCenterY), - }; - const worldCenter = screenToWorld(state.canvas, screenCenter); - const base = { - x: worldCenter.x - effectiveSize.width / 2, - y: worldCenter.y - effectiveSize.height / 2, - }; - - const rectsOverlap = ( - a: { x: number; y: number; width: number; height: number }, - b: { x: number; y: number; width: number; height: number }, - padding = 0 - ) => { - const ax = a.x - padding; - const ay = a.y - padding; - const aw = a.width + padding * 2; - const ah = a.height + padding * 2; - const bx = b.x; - const by = b.y; - const bw = b.width; - const bh = b.height; - return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; - }; - - const candidateFits = (candidate: { x: number; y: number }) => { - const screen = worldToScreen(state.canvas, candidate); - const tileWidth = effectiveSize.width * zoom; - const tileHeight = effectiveSize.height * zoom; - return ( - screen.x >= edgePadding && - screen.y >= safeTop && - screen.x + tileWidth <= viewportSize.width - edgePadding && - screen.y + tileHeight <= viewportSize.height - edgePadding - ); - }; - - const candidateOverlaps = (candidate: { x: number; y: number }) => { - const rect = { - x: candidate.x, - y: candidate.y, - width: effectiveSize.width, - height: effectiveSize.height, - }; - return project.tiles.some((tile) => - rectsOverlap( - rect, - { - x: tile.position.x, - y: tile.position.y, - width: MIN_TILE_SIZE.width, - height: Math.max(tile.size.height, MIN_TILE_SIZE.height), - }, - 24 - ) - ); - }; - - for (let ring = 0; ring <= maxRings; ring += 1) { - for (let dx = -ring; dx <= ring; dx += 1) { - for (let dy = -ring; dy <= ring; dy += 1) { - if (ring > 0 && Math.abs(dx) !== ring && Math.abs(dy) !== ring) { - continue; - } - const candidate = { - x: base.x + dx * step, - y: base.y + dy * step, - }; - if (!candidateFits(candidate)) continue; - if (!candidateOverlaps(candidate)) return candidate; - } - } - } + const resolveCronJobForAgent = useCallback((jobs: CronJobSummary[], agentId: string) => { + return resolveLatestCronJobForAgent(jobs, agentId); + }, []); + + const specialLatestUpdate = useMemo(() => { + return createSpecialLatestUpdateOperation({ + callGateway: (method, params) => client.call(method, params), + listCronJobs: () => listCronJobs(client, { includeDisabled: true }), + resolveCronJobForAgent, + formatCronJobDisplay, + dispatchUpdateAgent: (agentId, patch) => { + dispatch({ type: "updateAgent", agentId, patch }); + }, + isDisconnectLikeError: isGatewayDisconnectLikeError, + logError: (message) => console.error(message), + }); + }, [client, dispatch, resolveCronJobForAgent]); + + const refreshHeartbeatLatestUpdate = useCallback(() => { + const agents = stateRef.current.agents; + specialLatestUpdate.refreshHeartbeat(agents); + }, [specialLatestUpdate]); + + const loadAgents = useCallback(async () => { + if (status !== "connected") return; + setLoading(true); + try { + const commands = await runStudioBootstrapLoadOperation({ + client, + gatewayUrl, + cachedConfigSnapshot: gatewayConfigSnapshot, + loadStudioSettings: settingsCoordinator.loadSettings.bind(settingsCoordinator), + isDisconnectLikeError: isGatewayDisconnectLikeError, + preferredSelectedAgentId: preferredSelectedAgentIdRef.current, + hasCurrentSelection: Boolean(stateRef.current.selectedAgentId), + logError: (message, error) => console.error(message, error), + }); + executeStudioBootstrapLoadCommands({ + commands, + setGatewayConfigSnapshot, + hydrateAgents, + dispatchUpdateAgent: (agentId, patch) => { + dispatch({ type: "updateAgent", agentId, patch }); + }, + setError, + }); + } finally { + setLoading(false); + setAgentsLoadedOnce(true); + } + }, [ + client, + dispatch, + hydrateAgents, + setError, + setLoading, + gatewayUrl, + gatewayConfigSnapshot, + settingsCoordinator, + status, + ]); - return base; + const enqueueConfigMutationFromRef = useCallback( + (mutation: { kind: ConfigMutationKind; label: string; run: () => Promise<void> }) => { + return enqueueConfigMutationRef.current(mutation); }, - [project, state.canvas, viewportSize] + [] ); + const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ + client, + status, + settingsRouteActive, + inspectSidebarAgentId, + gatewayConfigSnapshot, + setGatewayConfigSnapshot, + setGatewayModels, + setGatewayModelsError, + enqueueConfigMutation: enqueueConfigMutationFromRef, + loadAgents, + isDisconnectLikeError: isGatewayDisconnectLikeError, + }); + + const settingsMutationController = useAgentSettingsMutationController({ + client, + status, + isLocalGateway, + agents, + hasCreateBlock: Boolean(createAgentBlock), + enqueueConfigMutation: enqueueConfigMutationFromRef, + gatewayConfigSnapshot, + settingsRouteActive, + inspectSidebarAgentId, + inspectSidebarTab, + loadAgents, + refreshGatewayConfigSnapshot, + clearInspectSidebar: () => { + setInspectSidebar(null); + }, + setInspectSidebarCapabilities: (agentId) => { + setInspectSidebar((current) => { + if (current?.agentId === agentId) return current; + return { agentId, tab: "capabilities" }; + }); + }, + dispatchUpdateAgent: (agentId, patch) => { + dispatch({ + type: "updateAgent", + agentId, + patch, + }); + }, + setMobilePaneChat: () => { + setMobilePane("chat"); + }, + setError, + }); + + const hasRenameMutationBlock = settingsMutationController.hasRenameMutationBlock; + const hasDeleteMutationBlock = settingsMutationController.hasDeleteMutationBlock; + const restartingMutationBlock = settingsMutationController.restartingMutationBlock; + const hasRestartBlockInProgress = Boolean( + settingsMutationController.hasRestartBlockInProgress || + (createAgentBlock && createAgentBlock.phase !== "queued") + ); + + const { + enqueueConfigMutation, + queuedCount: queuedConfigMutationCount, + queuedBlockedByRunningAgents, + activeConfigMutation, + } = useConfigMutationQueue({ + status, + hasRunningAgents, + hasRestartBlockInProgress, + }); + enqueueConfigMutationRef.current = enqueueConfigMutation; + useEffect(() => { stateRef.current = state; }, [state]); useEffect(() => { - const node = viewportRef.current; - if (!node) return; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const { width, height } = entry.contentRect; - setViewportSize({ width, height }); + pendingExecApprovalsByAgentIdRef.current = pendingExecApprovalsByAgentId; + }, [pendingExecApprovalsByAgentId]); + + useEffect(() => { + unscopedPendingExecApprovalsRef.current = unscopedPendingExecApprovals; + }, [unscopedPendingExecApprovals]); + + useEffect(() => { + if (status === "connected") return; + setAgentsLoadedOnce(false); + }, [gatewayUrl, status]); + + useEffect(() => { + let cancelled = false; + const key = gatewayUrl.trim(); + if (!key) { + preferredSelectedAgentIdRef.current = null; + setFocusedPreferencesLoaded(true); + return; + } + setFocusedPreferencesLoaded(false); + focusFilterTouchedRef.current = false; + preferredSelectedAgentIdRef.current = null; + const loadFocusedPreferences = async () => { + const commands = await runStudioFocusedPreferenceLoadOperation({ + gatewayUrl, + loadStudioSettings: settingsCoordinator.loadSettings.bind(settingsCoordinator), + isFocusFilterTouched: () => focusFilterTouchedRef.current, + }); + if (cancelled) return; + executeStudioFocusedPreferenceLoadCommands({ + commands, + setFocusedPreferencesLoaded, + setPreferredSelectedAgentId: (agentId) => { + preferredSelectedAgentIdRef.current = agentId; + }, + setFocusFilter, + logError: (message, error) => console.error(message, error), + }); + }; + void loadFocusedPreferences(); + return () => { + cancelled = true; + }; + }, [gatewayUrl, settingsCoordinator]); + + useEffect(() => { + return () => { + void settingsCoordinator.flushPending(); + }; + }, [settingsCoordinator]); + + useEffect(() => { + const commands = runStudioFocusFilterPersistenceOperation({ + gatewayUrl, + focusFilterTouched: focusFilterTouchedRef.current, + focusFilter, }); - observer.observe(node); - return () => observer.disconnect(); - }, []); + executeStudioFocusedPatchCommands({ + commands, + schedulePatch: settingsCoordinator.schedulePatch.bind(settingsCoordinator), + }); + }, [focusFilter, gatewayUrl, settingsCoordinator]); - const handleNewAgent = useCallback(async () => { - if (!project) return; - const name = createRandomAgentName(); - const result = await createTile(project.id, name, "coding"); - if (!result) return; - - const nextPosition = computeNewTilePosition(result.tile.size); - dispatch({ - type: "updateTile", - projectId: project.id, - tileId: result.tile.id, - patch: { position: nextPosition }, + useEffect(() => { + const commands = runStudioFocusedSelectionPersistenceOperation({ + gatewayUrl, + status, + focusedPreferencesLoaded, + agentsLoadedOnce, + selectedAgentId: state.selectedAgentId, + }); + executeStudioFocusedPatchCommands({ + commands, + schedulePatch: settingsCoordinator.schedulePatch.bind(settingsCoordinator), }); - dispatch({ type: "selectTile", tileId: result.tile.id }); - }, [computeNewTilePosition, createTile, dispatch, project]); + }, [ + agentsLoadedOnce, + focusedPreferencesLoaded, + gatewayUrl, + settingsCoordinator, + status, + state.selectedAgentId, + ]); - const loadTileHistory = useCallback( - async (projectId: string, tileId: string) => { - const currentProject = stateRef.current.projects.find( - (entry) => entry.id === projectId - ); - const tile = currentProject?.tiles.find((entry) => entry.id === tileId); - const sessionKey = tile?.sessionKey?.trim(); - if (!tile || !sessionKey) return; - if (historyInFlightRef.current.has(sessionKey)) return; - - historyInFlightRef.current.add(sessionKey); - try { - const result = await client.call<ChatHistoryResult>("chat.history", { - sessionKey, - limit: 200, - }); - const { lines, lastAssistant, lastRole } = buildHistoryLines( - result.messages ?? [] - ); - if (lines.length === 0) return; - const currentLines = tile.outputLines; - const mergedLines = mergeHistoryWithPending(lines, currentLines); - const isSame = - mergedLines.length === currentLines.length && - mergedLines.every((line, index) => line === currentLines[index]); - if (isSame) { - if (!tile.runId && tile.status === "running" && lastRole === "assistant") { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { status: "idle", runId: null, streamText: null, thinkingTrace: null }, - }); - } - return; - } - const patch: Partial<AgentTile> = { - outputLines: mergedLines, - lastResult: lastAssistant ?? null, - }; - if (!tile.runId && tile.status === "running" && lastRole === "assistant") { - patch.status = "idle"; - patch.runId = null; - patch.streamText = null; - patch.thinkingTrace = null; - } - dispatch({ - type: "updateTile", - projectId, - tileId, - patch, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to load chat history."; - console.error(msg); - } finally { - historyInFlightRef.current.delete(sessionKey); - } + useEffect(() => { + if (status !== "connected" || !focusedPreferencesLoaded) return; + if (restartingMutationBlock && restartingMutationBlock.phase !== "queued") return; + if (createAgentBlock && createAgentBlock.phase !== "queued") return; + void loadAgents(); + }, [ + createAgentBlock, + focusedPreferencesLoaded, + gatewayUrl, + loadAgents, + restartingMutationBlock, + status, + ]); + + useEffect(() => { + if (status === "disconnected") { + setLoading(false); + } + }, [setLoading, status]); + + useEffect(() => { + const nowMs = Date.now(); + const delayMs = planPendingPruneDelay({ + pendingState: { + approvalsByAgentId: pendingExecApprovalsByAgentId, + unscopedApprovals: unscopedPendingExecApprovals, + }, + nowMs, + graceMs: PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS, + }); + if (delayMs === null) return; + const timerId = window.setTimeout(() => { + const pendingState = planPrunedPendingState({ + pendingState: { + approvalsByAgentId: pendingExecApprovalsByAgentIdRef.current, + unscopedApprovals: unscopedPendingExecApprovalsRef.current, + }, + nowMs: Date.now(), + graceMs: PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS, + }); + pendingExecApprovalsByAgentIdRef.current = pendingState.approvalsByAgentId; + unscopedPendingExecApprovalsRef.current = pendingState.unscopedApprovals; + setPendingExecApprovalsByAgentId(pendingState.approvalsByAgentId); + setUnscopedPendingExecApprovals(pendingState.unscopedApprovals); + }, delayMs); + return () => { + window.clearTimeout(timerId); + }; + }, [pendingExecApprovalsByAgentId, unscopedPendingExecApprovals]); + + useEffect(() => { + const patches = planAwaitingUserInputPatches({ + agents, + approvalsByAgentId: pendingExecApprovalsByAgentId, + }); + for (const patch of patches) { + dispatch({ + type: "updateAgent", + agentId: patch.agentId, + patch: { awaitingUserInput: patch.awaitingUserInput }, + }); + } + }, [agents, dispatch, pendingExecApprovalsByAgentId]); + + useEffect(() => { + for (const agent of agents) { + const lastMessage = agent.lastUserMessage?.trim() ?? ""; + const kind = resolveLatestUpdateKind(lastMessage); + const key = agent.agentId; + const marker = kind === "heartbeat" ? `${lastMessage}:${heartbeatTick}` : lastMessage; + const previous = specialUpdateRef.current.get(key); + if (previous === marker) continue; + specialUpdateRef.current.set(key, marker); + void specialLatestUpdate.update(agent.agentId, agent, lastMessage); + } + }, [agents, heartbeatTick, specialLatestUpdate]); + + const { + loadSummarySnapshot, + loadAgentHistory, + loadMoreAgentHistory, + clearHistoryInFlight, + } = useRuntimeSyncController({ + client, + status, + agents, + focusedAgentId, + focusedAgentRunning, + dispatch, + clearRunTracking: (runId) => { + runtimeEventHandlerRef.current?.clearRunTracking(runId); }, - [client, dispatch] + isDisconnectLikeError: isGatewayDisconnectLikeError, + }); + + const { + stopBusyAgentId, + flushPendingDraft, + handleDraftChange, + handleSend, + removeQueuedMessage, + handleNewSession, + handleStopRun, + queueLivePatch, + clearPendingLivePatch, + } = useChatInteractionController({ + client, + status, + agents, + dispatch, + setError, + getAgents: () => stateRef.current.agents, + clearRunTracking: (runId) => { + runtimeEventHandlerRef.current?.clearRunTracking(runId); + }, + clearHistoryInFlight, + clearSpecialUpdateMarker: (agentId) => { + specialUpdateRef.current.delete(agentId); + }, + clearSpecialLatestUpdateInFlight: (agentId) => { + specialLatestUpdate.clearInFlight(agentId); + }, + setInspectSidebarNull: () => { + setInspectSidebar(null); + }, + setMobilePaneChat: () => { + setMobilePane("chat"); + }, + }); + + const handleFocusFilterChange = useCallback( + (next: FocusFilter) => { + flushPendingDraft(focusedAgent?.agentId ?? null); + focusFilterTouchedRef.current = true; + setFocusFilter(next); + }, + [flushPendingDraft, focusedAgent] ); - const startHistoryPolling = useCallback( - (projectId: string, tileId: string) => { - const pollKey = `${projectId}:${tileId}`; - const existing = historyPollsRef.current.get(pollKey); - if (existing) { - window.clearTimeout(existing); - historyPollsRef.current.delete(pollKey); - } + const { + handleBackToChat, + handleSettingsRouteTabChange, + handleOpenAgentSettingsRoute, + handleFleetSelectAgent, + } = useSettingsRouteController({ + settingsRouteActive, + settingsRouteAgentId, + status, + agentsLoadedOnce, + selectedAgentId: state.selectedAgentId, + focusedAgentId: focusedAgent?.agentId ?? null, + personalityHasUnsavedChanges, + activeTab: effectiveSettingsTab, + inspectSidebar, + agents, + flushPendingDraft, + dispatchSelectAgent: (agentId) => { + dispatch({ type: "selectAgent", agentId }); + }, + setInspectSidebar, + setMobilePaneChat: () => { + setMobilePane("chat"); + }, + setPersonalityHasUnsavedChanges, + push: router.push, + replace: router.replace, + confirmDiscard: () => window.confirm("Discard changes?"), + }); + const handleOpenSystemSkillSetup = useCallback( + (skillKey?: string) => { + const normalized = skillKey?.trim() ?? ""; + setSystemInitialSkillKey(normalized.length > 0 ? normalized : null); + setSettingsSidebarItem("system"); + handleSettingsRouteTabChange("system"); + }, + [handleSettingsRouteTabChange] + ); - let attempts = 0; - const maxAttempts = 40; - const poll = async () => { - historyPollsRef.current.delete(pollKey); - attempts += 1; - await loadTileHistory(projectId, tileId); - const currentProject = stateRef.current.projects.find( - (entry) => entry.id === projectId - ); - const tile = currentProject?.tiles.find((entry) => entry.id === tileId); - if (!tile || tile.status !== "running") { - return; - } - if (attempts >= maxAttempts) { - return; - } - const timeoutId = window.setTimeout(poll, 1000); - historyPollsRef.current.set(pollKey, timeoutId); - }; + const handleOpenCreateAgentModal = useCallback(() => { + if (createAgentBusy) return; + if (createAgentBlock) return; + if (restartingMutationBlock) return; + setCreateAgentModalError(null); + setCreateAgentModalOpen(true); + }, [createAgentBlock, createAgentBusy, restartingMutationBlock]); - const timeoutId = window.setTimeout(poll, 1000); - historyPollsRef.current.set(pollKey, timeoutId); + const persistAvatarSeed = useCallback( + (agentId: string, avatarSeed: string) => { + const resolvedAgentId = agentId.trim(); + const resolvedAvatarSeed = avatarSeed.trim(); + const key = gatewayUrl.trim(); + if (!resolvedAgentId || !resolvedAvatarSeed || !key) return; + settingsCoordinator.schedulePatch( + { + avatars: { + [key]: { + [resolvedAgentId]: resolvedAvatarSeed, + }, + }, + }, + 0 + ); }, - [loadTileHistory] + [gatewayUrl, settingsCoordinator] ); - const handleSend = useCallback( - async (tileId: string, sessionKey: string, message: string) => { - if (!project) return; - const trimmed = message.trim(); - if (!trimmed) return; - const isResetCommand = /^\/(reset|new)(\s|$)/i.test(trimmed); - const runId = crypto.randomUUID(); - const tile = project.tiles.find((entry) => entry.id === tileId); - if (!tile) { - dispatch({ - type: "appendOutput", - projectId: project.id, - tileId, - line: "Error: Tile not found.", - }); - return; - } - if (isResetCommand) { - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { outputLines: [], streamText: null, thinkingTrace: null, lastResult: null }, - }); - } - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { status: "running", runId, streamText: "", thinkingTrace: null, draft: "" }, - }); - dispatch({ - type: "appendOutput", - projectId: project.id, - tileId, - line: `> ${trimmed}`, - }); - try { - if (!sessionKey) { - throw new Error("Missing session key for tile."); - } - if (!tile.sessionSettingsSynced) { - await client.call("sessions.patch", { - key: sessionKey, - model: tile.model ?? null, - thinkingLevel: tile.thinkingLevel ?? null, - }); - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { sessionSettingsSynced: true }, - }); + const handleCreateAgentSubmit = useCallback( + async (payload: AgentCreateModalSubmitPayload) => { + await runCreateAgentMutationLifecycle( + { + payload, + status, + hasCreateBlock: Boolean(createAgentBlock), + hasRenameBlock: hasRenameMutationBlock, + hasDeleteBlock: hasDeleteMutationBlock, + createAgentBusy, + }, + { + enqueueConfigMutation, + createAgent: async (name, avatarSeed) => { + const created = await createGatewayAgent({ client, name }); + if (avatarSeed) { + persistAvatarSeed(created.id, avatarSeed); + } + flushPendingDraft(focusedAgent?.agentId ?? null); + focusFilterTouchedRef.current = true; + setFocusFilter("all"); + dispatch({ type: "selectAgent", agentId: created.id }); + return { id: created.id }; + }, + setQueuedBlock: ({ agentName, startedAt }) => { + const queuedCreateBlock = buildQueuedMutationBlock({ + kind: "create-agent", + agentId: "", + agentName, + startedAt, + }); + setCreateAgentBlock({ + agentName: queuedCreateBlock.agentName, + phase: "queued", + startedAt: queuedCreateBlock.startedAt, + }); + }, + setCreatingBlock: (agentName) => { + setCreateAgentBlock((current) => { + if (!current || current.agentName !== agentName) return current; + return { ...current, phase: "creating" }; + }); + }, + onCompletion: async (completion) => { + const commands = await runCreateAgentBootstrapOperation({ + completion, + focusedAgentId: focusedAgent?.agentId ?? null, + loadAgents, + findAgentById: (agentId) => + stateRef.current.agents.find((entry) => entry.agentId === agentId) ?? null, + applyDefaultPermissions: async ({ agentId, sessionKey }) => { + await applyCreateAgentBootstrapPermissions({ + client, + agentId, + sessionKey, + draft: { ...CREATE_AGENT_DEFAULT_PERMISSIONS }, + loadAgents, + }); + }, + refreshGatewayConfigSnapshot, + }); + executeCreateAgentBootstrapCommands({ + commands, + setCreateAgentModalError, + setGlobalError: setError, + setCreateAgentBlock: (value) => { + setCreateAgentBlock(value); + }, + setCreateAgentModalOpen, + flushPendingDraft, + selectAgent: (agentId) => { + dispatch({ type: "selectAgent", agentId }); + }, + setInspectSidebarCapabilities: (agentId) => { + setInspectSidebar({ agentId, tab: "capabilities" }); + }, + setMobilePaneChat: () => { + setMobilePane("chat"); + }, + }); + }, + setCreateAgentModalError, + setCreateAgentBusy, + clearCreateBlock: () => { + setCreateAgentBlock(null); + }, + onError: setError, } - await client.call("chat.send", { - sessionKey, - message: buildProjectMessage(project, trimmed), - deliver: false, - idempotencyKey: runId, - }); - startHistoryPolling(project.id, tileId); - } catch (err) { - const msg = err instanceof Error ? err.message : "Gateway error"; - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { status: "error", runId: null, streamText: null, thinkingTrace: null }, - }); - dispatch({ - type: "appendOutput", - projectId: project.id, - tileId, - line: `Error: ${msg}`, - }); - } + ); }, - [client, dispatch, project, startHistoryPolling] + [ + client, + createAgentBusy, + createAgentBlock, + dispatch, + enqueueConfigMutation, + flushPendingDraft, + focusedAgent, + hasDeleteMutationBlock, + hasRenameMutationBlock, + loadAgents, + persistAvatarSeed, + refreshGatewayConfigSnapshot, + setError, + status, + ] ); useEffect(() => { - const polls = historyPollsRef.current; - return () => { - for (const timeoutId of polls.values()) { - window.clearTimeout(timeoutId); - } - polls.clear(); + if (!createAgentBlock || createAgentBlock.phase === "queued") return; + const maxWaitMs = 90_000; + const timeoutNow = isCreateBlockTimedOut({ + block: createAgentBlock, + nowMs: Date.now(), + maxWaitMs, + }); + const handleTimeout = () => { + setCreateAgentBlock(null); + setCreateAgentModalOpen(false); + void loadAgents(); + setError("Agent creation timed out."); }; - }, []); - - useEffect(() => { - if (status !== "connected") return; - if (!project) return; - const tilesToLoad = project.tiles.filter( - (tile) => tile.outputLines.length === 0 && tile.sessionKey?.trim() - ); - if (tilesToLoad.length === 0) return; - let cancelled = false; - const loadHistory = async () => { - for (const tile of tilesToLoad) { - if (cancelled) return; - await loadTileHistory(project.id, tile.id); + if (timeoutNow) { + handleTimeout(); + return; + } + const elapsed = Date.now() - createAgentBlock.startedAt; + const remaining = Math.max(0, maxWaitMs - elapsed); + const timeoutId = window.setTimeout(() => { + if ( + !isCreateBlockTimedOut({ + block: createAgentBlock, + nowMs: Date.now(), + maxWaitMs, + }) + ) { + return; } - }; - void loadHistory(); + handleTimeout(); + }, remaining); return () => { - cancelled = true; + window.clearTimeout(timeoutId); }; - }, [loadTileHistory, project, status]); + }, [createAgentBlock, loadAgents, setError]); - const handleModelChange = useCallback( - async (tileId: string, sessionKey: string, value: string | null) => { - if (!project) return; - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { model: value, sessionSettingsSynced: false }, + const handleSessionSettingChange = useCallback( + async ( + agentId: string, + sessionKey: string, + field: "model" | "thinkingLevel", + value: string | null + ) => { + await applySessionSettingMutation({ + agents: stateRef.current.agents, + dispatch, + client, + agentId, + sessionKey, + field, + value, }); - try { - await client.call("sessions.patch", { - key: sessionKey, - model: value ?? null, - }); - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { sessionSettingsSynced: true }, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to set model."; - dispatch({ - type: "appendOutput", - projectId: project.id, - tileId, - line: `Model update failed: ${msg}`, - }); - } }, - [client, dispatch, project] + [client, dispatch] + ); + + const handleModelChange = useCallback( + async (agentId: string, sessionKey: string, value: string | null) => { + await handleSessionSettingChange(agentId, sessionKey, "model", value); + }, + [handleSessionSettingChange] ); const handleThinkingChange = useCallback( - async (tileId: string, sessionKey: string, value: string | null) => { - if (!project) return; + async (agentId: string, sessionKey: string, value: string | null) => { + await handleSessionSettingChange(agentId, sessionKey, "thinkingLevel", value); + }, + [handleSessionSettingChange] + ); + + + const handleToolCallingToggle = useCallback( + (agentId: string, enabled: boolean) => { dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { thinkingLevel: value, sessionSettingsSynced: false }, + type: "updateAgent", + agentId, + patch: { toolCallingEnabled: enabled }, }); - try { - await client.call("sessions.patch", { - key: sessionKey, - thinkingLevel: value ?? null, - }); - dispatch({ - type: "updateTile", - projectId: project.id, - tileId, - patch: { sessionSettingsSynced: true }, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to set thinking level."; - dispatch({ - type: "appendOutput", - projectId: project.id, - tileId, - line: `Thinking update failed: ${msg}`, - }); - } }, - [client, dispatch, project] + [dispatch] ); - useEffect(() => { - return client.onEvent((event: EventFrame) => { - if (event.event !== "chat") return; - const payload = event.payload as ChatEventPayload | undefined; - if (!payload?.sessionKey) return; - const match = findTileBySessionKey(state.projects, payload.sessionKey); - if (!match) return; - - const project = state.projects.find((entry) => entry.id === match.projectId); - const tile = project?.tiles.find((entry) => entry.id === match.tileId); - const role = - payload.message && typeof payload.message === "object" - ? (payload.message as Record<string, unknown>).role - : null; - if (role === "user") { - return; - } - const nextTextRaw = extractText(payload.message); - const nextText = nextTextRaw ? stripUiMetadata(nextTextRaw) : null; - const nextThinking = extractThinking(payload.message); - if (payload.state === "delta") { - if (typeof nextTextRaw === "string" && UI_METADATA_PREFIX_RE.test(nextTextRaw.trim())) { - return; - } - if (nextThinking) { - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { thinkingTrace: nextThinking, status: "running" }, - }); - } - if (typeof nextText === "string") { - dispatch({ - type: "setStream", - projectId: match.projectId, - tileId: match.tileId, - value: nextText, - }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "running" }, - }); - } - return; - } + const handleThinkingTracesToggle = useCallback( + (agentId: string, enabled: boolean) => { + dispatch({ + type: "updateAgent", + agentId, + patch: { showThinkingTraces: enabled }, + }); + }, + [dispatch] + ); - if (payload.state === "final") { - const thinkingText = nextThinking ?? tile?.thinkingTrace ?? null; - const thinkingLine = thinkingText ? formatThinkingMarkdown(thinkingText) : ""; - if (thinkingLine) { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: thinkingLine, - }); - } - if (typeof nextText === "string") { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: nextText, + const handleResolveExecApproval = useCallback( + async (approvalId: string, decision: ExecApprovalDecision) => { + await runResolveExecApprovalOperation({ + client, + approvalId, + decision, + getAgents: () => stateRef.current.agents, + getPendingState: () => ({ + approvalsByAgentId: pendingExecApprovalsByAgentIdRef.current, + unscopedApprovals: unscopedPendingExecApprovalsRef.current, + }), + setPendingExecApprovalsByAgentId: (next) => { + setPendingExecApprovalsByAgentId((current) => { + const resolved = typeof next === "function" ? next(current) : next; + pendingExecApprovalsByAgentIdRef.current = resolved; + return resolved; }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { lastResult: nextText }, + }, + setUnscopedPendingExecApprovals: (next) => { + setUnscopedPendingExecApprovals((current) => { + const resolved = typeof next === "function" ? next(current) : next; + unscopedPendingExecApprovalsRef.current = resolved; + return resolved; }); - } - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); - return; - } - - if (payload.state === "aborted") { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: "Run aborted.", - }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); - return; - } - - if (payload.state === "error") { - dispatch({ - type: "appendOutput", - projectId: match.projectId, - tileId: match.tileId, - line: payload.errorMessage ? `Error: ${payload.errorMessage}` : "Run error.", - }); - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { streamText: null, thinkingTrace: null }, - }); - } - }); - }, [client, dispatch, state.projects]); - - useEffect(() => { - return client.onEvent((event: EventFrame) => { - if (event.event !== "agent") return; - const payload = event.payload as AgentEventPayload | undefined; - if (!payload?.runId) return; - const directMatch = payload.sessionKey - ? findTileBySessionKey(state.projects, payload.sessionKey) - : null; - const match = directMatch ?? findTileByRunId(state.projects, payload.runId); - if (!match) return; - if (payload.stream !== "lifecycle") return; - const project = state.projects.find((entry) => entry.id === match.projectId); - const tile = project?.tiles.find((entry) => entry.id === match.tileId); - if (!tile) return; - const phase = typeof payload.data?.phase === "string" ? payload.data.phase : ""; - if (phase === "start") { - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "running", runId: payload.runId }, - }); - return; - } - if (phase === "end") { - if (tile.runId && tile.runId !== payload.runId) return; - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "idle", runId: null, streamText: null, thinkingTrace: null }, - }); - return; - } - if (phase === "error") { - if (tile.runId && tile.runId !== payload.runId) return; - dispatch({ - type: "updateTile", - projectId: match.projectId, - tileId: match.tileId, - patch: { status: "error", runId: null, streamText: null, thinkingTrace: null }, - }); - } - }); - }, [client, dispatch, state.projects]); - - // Zoom controls are available in the bottom-right of the canvas. - - const handleProjectCreate = useCallback(async () => { - if (!projectName.trim()) { - setProjectWarnings(["Workspace name is required."]); - return; - } - const result = await createProject(projectName.trim()); - if (!result) return; - setProjectWarnings(result.warnings); - setProjectName(""); - setShowProjectForm(false); - }, [createProject, projectName]); - - const handleProjectOpen = useCallback(async () => { - if (!projectPath.trim()) { - setOpenProjectWarnings(["Workspace path is required."]); - return; - } - const result = await openProject(projectPath.trim()); - if (!result) return; - setOpenProjectWarnings(result.warnings); - setProjectPath(""); - setShowOpenProjectForm(false); - }, [openProject, projectPath]); - - const handleProjectDelete = useCallback(async () => { - if (!project) return; - const confirmation = window.prompt( - `Type DELETE ${project.name} to confirm workspace deletion.` - ); - if (confirmation !== `DELETE ${project.name}`) { - return; - } - const result = await deleteProject(project.id); - if (result?.warnings.length) { - window.alert(result.warnings.join("\n")); - } - }, [deleteProject, project]); + }, + requestHistoryRefresh: (agentId) => loadAgentHistory(agentId), + pausedRunIdByAgentId: approvalPausedRunIdByAgentRef.current, + dispatch, + isDisconnectLikeError: isGatewayDisconnectLikeError, + logWarn: (message, error) => console.warn(message, error), + clearRunTracking: (runId) => runtimeEventHandlerRef.current?.clearRunTracking(runId), + }); + }, + [client, dispatch, loadAgentHistory] + ); - const handleCreateDiscordChannel = useCallback(async () => { - if (!project) return; - if (!state.selectedTileId) { - window.alert("Select an agent tile first."); - return; - } - const tile = project.tiles.find((entry) => entry.id === state.selectedTileId); - if (!tile) { - window.alert("Selected agent not found."); - return; - } - try { - const result = await createProjectDiscordChannel(project.id, { - agentId: tile.agentId, - agentName: tile.name, + const pauseRunForExecApproval = useCallback( + async (approval: PendingExecApproval, preferredAgentId?: string | null) => { + await runPauseRunForExecApprovalOperation({ + status, + client, + approval, + preferredAgentId: preferredAgentId ?? null, + getAgents: () => stateRef.current.agents, + pausedRunIdByAgentId: approvalPausedRunIdByAgentRef.current, + isDisconnectLikeError: isGatewayDisconnectLikeError, + logWarn: (message, error) => console.warn(message, error), }); - const notice = `Created Discord channel #${result.channelName} for ${tile.name}.`; - if (result.warnings.length) { - window.alert(`${notice}\n${result.warnings.join("\n")}`); - } else { - window.alert(notice); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to create Discord channel."; - window.alert(message); - } - }, [project, state.selectedTileId]); - - const handleTileDelete = useCallback( - async (tileId: string) => { - if (!project) return; - const result = await deleteTile(project.id, tileId); - if (result?.warnings.length) { - window.alert(result.warnings.join("\n")); - } }, - [deleteTile, project] + [client, status] ); - const handleAvatarShuffle = useCallback( - async (tileId: string) => { - if (!project) return; - const avatarSeed = crypto.randomUUID(); - const result = await updateTile(project.id, tileId, { avatarSeed }); - if (!result) return; - if ("error" in result) { - window.alert(result.error); - return; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } + const handleGatewayEventIngress = useCallback( + (event: EventFrame) => { + runGatewayEventIngressOperation({ + event, + getAgents: () => stateRef.current.agents, + getPendingState: () => ({ + approvalsByAgentId: pendingExecApprovalsByAgentIdRef.current, + unscopedApprovals: unscopedPendingExecApprovalsRef.current, + }), + pausedRunIdByAgentId: approvalPausedRunIdByAgentRef.current, + seenCronDedupeKeys: seenCronEventIdsRef.current, + nowMs: Date.now(), + replacePendingState: (pendingState) => { + if ( + pendingState.approvalsByAgentId !== + pendingExecApprovalsByAgentIdRef.current + ) { + pendingExecApprovalsByAgentIdRef.current = + pendingState.approvalsByAgentId; + setPendingExecApprovalsByAgentId(pendingState.approvalsByAgentId); + } + if ( + pendingState.unscopedApprovals !== + unscopedPendingExecApprovalsRef.current + ) { + unscopedPendingExecApprovalsRef.current = + pendingState.unscopedApprovals; + setUnscopedPendingExecApprovals(pendingState.unscopedApprovals); + } + }, + pauseRunForApproval: (approval, commandPreferredAgentId) => + pauseRunForExecApproval(approval, commandPreferredAgentId), + dispatch, + recordCronDedupeKey: (dedupeKey) => seenCronEventIdsRef.current.add(dedupeKey), + }); }, - [project, updateTile] + [dispatch, pauseRunForExecApproval] ); - const handleNameShuffle = useCallback( - async (tileId: string) => { - if (!project) return; - const name = createRandomAgentName(); - const result = await renameTile(project.id, tileId, normalizeAgentName(name)); - if (!result) return; - if ("error" in result) { - window.alert(result.error); - return; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } + useEffect(() => { + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => status, + getAgents: () => stateRef.current.agents, + dispatch, + queueLivePatch, + clearPendingLivePatch, + loadSummarySnapshot, + requestHistoryRefresh: ({ agentId }) => loadAgentHistory(agentId), + refreshHeartbeatLatestUpdate, + bumpHeartbeatTick: () => setHeartbeatTick((prev) => prev + 1), + setTimeout: (fn, delayMs) => window.setTimeout(fn, delayMs), + clearTimeout: (id) => window.clearTimeout(id), + isDisconnectLikeError: isGatewayDisconnectLikeError, + logWarn: (message, meta) => console.warn(message, meta), + shouldSuppressRunAbortedLine: ({ agentId, runId, stopReason }) => { + if (stopReason !== "rpc") return false; + const normalizedRunId = runId?.trim() ?? ""; + if (!normalizedRunId) return false; + const pausedRunId = approvalPausedRunIdByAgentRef.current.get(agentId)?.trim() ?? ""; + return pausedRunId.length > 0 && pausedRunId === normalizedRunId; + }, + updateSpecialLatestUpdate: (agentId, agent, message) => { + void specialLatestUpdate.update(agentId, agent, message); + }, + }); + runtimeEventHandlerRef.current = handler; + const unsubscribe = client.onEvent((event: EventFrame) => { + handler.handleEvent(event); + handleGatewayEventIngress(event); + }); + return () => { + runtimeEventHandlerRef.current = null; + handler.dispose(); + unsubscribe(); + }; + }, [ + client, + dispatch, + loadAgentHistory, + loadSummarySnapshot, + clearPendingLivePatch, + queueLivePatch, + refreshHeartbeatLatestUpdate, + specialLatestUpdate, + handleGatewayEventIngress, + status, + ]); + + const handleAvatarShuffle = useCallback( + async (agentId: string) => { + const avatarSeed = randomUUID(); + dispatch({ + type: "updateAgent", + agentId, + patch: { avatarSeed }, + }); + persistAvatarSeed(agentId, avatarSeed); }, - [project, renameTile] + [dispatch, persistAvatarSeed] ); - return ( - <div className="relative h-screen w-screen overflow-hidden"> - <CanvasFlow - tiles={tiles} - projectId={project?.id ?? null} - transform={state.canvas} - viewportRef={viewportRef} - selectedTileId={state.selectedTileId} - canSend={status === "connected"} - onSelectTile={(id) => dispatch({ type: "selectTile", tileId: id })} - onMoveTile={(id, position) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { position }, - }) - : null - } - onResizeTile={(id, size) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { - size: { - width: MIN_TILE_SIZE.width, - height: Math.max(size.height, MIN_TILE_SIZE.height), - }, - }, - }) - : null - } - onDeleteTile={handleTileDelete} - onRenameTile={(id, name) => { - if (!project) return Promise.resolve(false); - return renameTile(project.id, id, name).then((result) => { - if (!result) return false; - if ("error" in result) { - window.alert(result.error); - return false; - } - if (result.warnings.length > 0) { - window.alert(result.warnings.join("\n")); - } - return true; - }); - }} - onDraftChange={(id, value) => - project - ? dispatch({ - type: "updateTile", - projectId: project.id, - tileId: id, - patch: { draft: value }, - }) - : null + const connectionPanelVisible = showConnectionPanel; + const hasAnyAgents = agents.length > 0; + const configMutationStatusLine = activeConfigMutation + ? `Applying config change: ${activeConfigMutation.label}` + : queuedConfigMutationCount > 0 + ? queuedBlockedByRunningAgents + ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish` + : status !== "connected" + ? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection` + : `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}` + : null; + const createBlockStatusLine = createAgentBlock + ? createAgentBlock.phase === "queued" + ? "Waiting for active runs to finish" + : createAgentBlock.phase === "creating" + ? "Submitting config change" + : null + : null; + const restartingMutationStatusLine = resolveConfigMutationStatusLine({ + block: restartingMutationBlock + ? { + phase: restartingMutationBlock.phase, + sawDisconnect: restartingMutationBlock.sawDisconnect, } - onSend={handleSend} - onModelChange={handleModelChange} - onThinkingChange={handleThinkingChange} - onAvatarShuffle={handleAvatarShuffle} - onNameShuffle={handleNameShuffle} - onUpdateTransform={(patch) => dispatch({ type: "setCanvas", patch })} - /> - - <div className="pointer-events-none absolute inset-0 z-10 flex flex-col gap-4 p-6"> - <div className="pointer-events-auto mx-auto w-full max-w-6xl"> + : null, + status, + }); + const restartingMutationModalTestId = restartingMutationBlock + ? restartingMutationBlock.kind === "delete-agent" + ? "agent-delete-restart-modal" + : "agent-rename-restart-modal" + : null; + const restartingMutationAriaLabel = restartingMutationBlock + ? restartingMutationBlock.kind === "delete-agent" + ? "Deleting agent and restarting gateway" + : "Renaming agent and restarting gateway" + : null; + const restartingMutationHeading = restartingMutationBlock + ? restartingMutationBlock.kind === "delete-agent" + ? "Agent delete in progress" + : "Agent rename in progress" + : null; + + useEffect(() => { + if (status === "connecting") { + setDidAttemptGatewayConnect(true); + } + }, [status]); + + useEffect(() => { + if (gatewayError) { + setDidAttemptGatewayConnect(true); + } + }, [gatewayError]); + + if (!agentsLoadedOnce && (!didAttemptGatewayConnect || status === "connecting")) { + return ( + <div className="relative min-h-screen w-screen overflow-hidden bg-background"> + <div className="flex min-h-screen items-center justify-center px-6"> + <div className="glass-panel ui-panel w-full max-w-md px-6 py-6 text-center"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + OpenClaw Studio + </div> + <div className="mt-3 text-sm text-muted-foreground"> + {status === "connecting" ? "Connecting to gateway…" : "Booting Studio…"} + </div> + </div> + </div> + </div> + ); + } + + if (status === "disconnected" && !agentsLoadedOnce && didAttemptGatewayConnect) { + return ( + <div className="relative min-h-screen w-screen overflow-hidden bg-background"> + <div className="relative z-10 flex h-screen flex-col"> <HeaderBar - projects={state.projects.map((entry) => ({ id: entry.id, name: entry.name }))} - activeProjectId={state.activeProjectId} status={status} - onProjectChange={(projectId) => - dispatch({ - type: "setActiveProject", - projectId: projectId.trim() ? projectId : null, - }) - } - onCreateProject={() => { - setProjectWarnings([]); - setOpenProjectWarnings([]); - setShowOpenProjectForm(false); - setShowProjectForm((prev) => !prev); - }} - onOpenProject={() => { - setProjectWarnings([]); - setOpenProjectWarnings([]); - setShowProjectForm(false); - setShowOpenProjectForm((prev) => !prev); - }} - onDeleteProject={handleProjectDelete} - onNewAgent={handleNewAgent} - onCreateDiscordChannel={handleCreateDiscordChannel} - canCreateDiscordChannel={Boolean(project && project.tiles.length > 0)} + onConnectionSettings={() => setShowConnectionPanel(true)} /> + <div className="flex min-h-0 flex-1 flex-col gap-4 px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4 md:px-6 md:pb-6 md:pt-4"> + {settingsRouteActive ? ( + <div className="w-full"> + <button + type="button" + className="ui-btn-secondary px-3 py-1.5 font-mono text-[10px] font-semibold tracking-[0.06em]" + onClick={handleBackToChat} + > + Back to chat + </button> + </div> + ) : null} + <GatewayConnectScreen + gatewayUrl={gatewayUrl} + token={token} + localGatewayDefaults={localGatewayDefaults} + status={status} + error={gatewayError} + onGatewayUrlChange={setGatewayUrl} + onTokenChange={setToken} + onUseLocalDefaults={useLocalGatewayDefaults} + onConnect={() => void connect()} + /> + </div> + </div> + </div> + ); + } + + if (status === "connected" && !agentsLoadedOnce) { + return ( + <div className="relative min-h-screen w-screen overflow-hidden bg-background"> + <div className="flex min-h-screen items-center justify-center px-6"> + <div className="glass-panel ui-panel w-full max-w-md px-6 py-6 text-center"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> + OpenClaw Studio + </div> + <div className="mt-3 text-sm text-muted-foreground">Loading agents…</div> + </div> </div> + </div> + ); + } - {state.loading ? ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="glass-panel px-6 py-6 text-slate-700">Loading workspaces…</div> + return ( + <div className="relative min-h-screen w-screen overflow-hidden bg-background"> + {state.loading ? ( + <div className="pointer-events-none fixed bottom-4 left-0 right-0 z-50 flex justify-center px-3"> + <div className="glass-panel ui-card px-6 py-3 font-mono text-[11px] tracking-[0.08em] text-muted-foreground"> + Loading agents… </div> - ) : null} - - {showProjectForm ? ( - <div className="pointer-events-auto mx-auto w-full max-w-5xl"> - <div className="glass-panel px-6 py-6"> - <div className="flex flex-col gap-4"> - <div className="grid gap-4"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"> - Workspace name - <input - className="h-11 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none" - value={projectName} - onChange={(event) => setProjectName(event.target.value)} - /> - </label> - </div> - <div className="flex flex-wrap items-center gap-3"> - <button - className="rounded-full bg-[var(--accent)] px-5 py-2 text-sm font-semibold text-white" - type="button" - onClick={handleProjectCreate} - > - Create Workspace - </button> + </div> + ) : null} + <div className="relative z-10 flex h-screen flex-col"> + <HeaderBar + status={status} + onConnectionSettings={() => setShowConnectionPanel(true)} + /> + <div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3"> + {connectionPanelVisible ? ( + <div className="pointer-events-none fixed inset-x-0 top-12 z-[140] flex justify-center px-3 sm:px-4 md:px-5"> + <div className="glass-panel pointer-events-auto w-full max-w-4xl !bg-card px-4 py-4 sm:px-6 sm:py-6"> + <ConnectionPanel + gatewayUrl={gatewayUrl} + token={token} + status={status} + error={gatewayError} + onGatewayUrlChange={setGatewayUrl} + onTokenChange={setToken} + onConnect={() => void connect()} + onDisconnect={disconnect} + onClose={() => setShowConnectionPanel(false)} + /> + </div> + </div> + ) : null} + + {errorMessage ? ( + <div className="w-full"> + <div className="ui-alert-danger rounded-md px-4 py-2 text-sm"> + {errorMessage} + </div> + </div> + ) : null} + {configMutationStatusLine ? ( + <div className="w-full"> + <div className="ui-card px-4 py-2 font-mono text-[11px] tracking-[0.07em] text-muted-foreground"> + {configMutationStatusLine} + </div> + </div> + ) : null} + + {settingsRouteActive ? ( + <div + className="ui-panel ui-depth-workspace flex min-h-0 flex-1 overflow-hidden" + data-testid="agent-settings-route-panel" + > + <aside className="w-[240px] shrink-0 border-r border-border/60"> + <div className="border-b border-border/60 px-4 py-3"> <button - className="rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" type="button" - onClick={() => setShowProjectForm(false)} + className="ui-btn-secondary w-full px-3 py-1.5 font-mono text-[10px] font-semibold tracking-[0.06em]" + onClick={handleBackToChat} > - Cancel + Back to chat </button> </div> - {projectWarnings.length > 0 ? ( - <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700"> - {projectWarnings.join(" ")} + <nav className="py-3"> + {( + [ + { id: "personality", label: "Behavior" }, + { id: "capabilities", label: "Capabilities" }, + { id: "skills", label: "Skills" }, + { id: "system", label: "System setup" }, + { id: "automations", label: "Automations" }, + { id: "advanced", label: "Advanced" }, + ] as const + ).map((entry) => { + const active = activeSettingsSidebarItem === entry.id; + return ( + <button + key={entry.id} + type="button" + className={`relative w-full px-5 py-3 text-left text-sm transition ${ + active + ? "bg-surface-2/55 font-medium text-foreground" + : "font-normal text-muted-foreground hover:bg-surface-2/35 hover:text-foreground" + }`} + onClick={() => { + setSettingsSidebarItem(entry.id); + handleSettingsRouteTabChange(entry.id); + }} + > + {active ? ( + <span + className="absolute inset-y-2 left-0 w-0.5 rounded-r bg-primary" + aria-hidden="true" + /> + ) : null} + {entry.label} + </button> + ); + })} + </nav> + </aside> + <div className="flex min-h-0 flex-1 flex-col overflow-hidden"> + <div className="flex items-start justify-between border-b border-border/60 px-6 py-4"> + <div> + <div className="text-lg font-semibold text-foreground"> + {inspectSidebarAgent?.name ?? settingsRouteAgentId ?? "Agent settings"} + </div> + <div className="mt-1 font-mono text-[11px] text-muted-foreground"> + Model: {settingsHeaderModel}{" "} + <span className="mx-2 text-border">|</span> + Thinking: {settingsHeaderThinking} + </div> </div> - ) : null} - </div> - </div> - </div> - ) : null} - - {showOpenProjectForm ? ( - <div className="pointer-events-auto mx-auto w-full max-w-5xl"> - <div className="glass-panel px-6 py-6"> - <div className="flex flex-col gap-4"> - <div className="grid gap-4"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-500"> - Workspace path - <input - className="h-11 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none" - value={projectPath} - onChange={(event) => setProjectPath(event.target.value)} - placeholder="/Users/you/repos/my-workspace" + <div className="rounded-md border border-border/70 bg-surface-1 px-3 py-1 font-mono text-[11px] text-muted-foreground"> + [{personalityHasUnsavedChanges ? "Unsaved" : "Saved ✓"}] + </div> + </div> + <div className="min-h-0 flex-1 overflow-hidden"> + {inspectSidebarAgent ? ( + effectiveSettingsTab === "personality" ? ( + <AgentBrainPanel + client={client} + agents={agents} + selectedAgentId={inspectSidebarAgent.agentId} + onUnsavedChangesChange={setPersonalityHasUnsavedChanges} + /> + ) : ( + <div className="h-full overflow-y-auto px-6 py-6"> + <div className="mx-auto w-full max-w-[920px]"> + <AgentSettingsPanel + key={`${inspectSidebarAgent.agentId}:${effectiveSettingsTab}`} + mode={ + effectiveSettingsTab === "automations" + ? "automations" + : effectiveSettingsTab === "skills" + ? "skills" + : effectiveSettingsTab === "system" + ? "system" + : effectiveSettingsTab === "advanced" + ? "advanced" + : "capabilities" + } + showHeader={false} + agent={inspectSidebarAgent} + onClose={handleBackToChat} + permissionsDraft={settingsAgentPermissionsDraft ?? undefined} + onUpdateAgentPermissions={(draft) => + settingsMutationController.handleUpdateAgentPermissions( + inspectSidebarAgent.agentId, + draft + ) + } + onDelete={() => + settingsMutationController.handleDeleteAgent(inspectSidebarAgent.agentId) + } + canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID} + onToolCallingToggle={(enabled) => + handleToolCallingToggle(inspectSidebarAgent.agentId, enabled) + } + onThinkingTracesToggle={(enabled) => + handleThinkingTracesToggle(inspectSidebarAgent.agentId, enabled) + } + skillsReport={settingsMutationController.settingsSkillsReport} + skillsLoading={settingsMutationController.settingsSkillsLoading} + skillsError={settingsMutationController.settingsSkillsError} + skillsBusy={settingsMutationController.settingsSkillsBusy} + skillsBusyKey={settingsMutationController.settingsSkillsBusyKey} + skillMessages={settingsMutationController.settingsSkillMessages} + skillApiKeyDrafts={settingsMutationController.settingsSkillApiKeyDrafts} + defaultAgentScopeWarning={settingsSkillScopeWarning} + systemInitialSkillKey={systemInitialSkillKey} + onSystemInitialSkillHandled={() => { + setSystemInitialSkillKey(null); + }} + skillsAllowlist={settingsAgentSkillsAllowlist} + onSetSkillEnabled={(skillName, enabled) => + settingsMutationController.handleSetSkillEnabled( + inspectSidebarAgent.agentId, + skillName, + enabled + ) + } + onOpenSystemSetup={handleOpenSystemSkillSetup} + onInstallSkill={(skillKey, name, installId) => + settingsMutationController.handleInstallSkill( + inspectSidebarAgent.agentId, + skillKey, + name, + installId + ) + } + onRemoveSkill={(skill) => + settingsMutationController.handleRemoveSkill( + inspectSidebarAgent.agentId, + skill + ) + } + onSkillApiKeyChange={(skillKey, value) => + settingsMutationController.handleSkillApiKeyDraftChange(skillKey, value) + } + onSaveSkillApiKey={(skillKey) => + settingsMutationController.handleSaveSkillApiKey( + inspectSidebarAgent.agentId, + skillKey + ) + } + onSetSkillGlobalEnabled={(skillKey, enabled) => + settingsMutationController.handleSetSkillGlobalEnabled( + inspectSidebarAgent.agentId, + skillKey, + enabled + ) + } + cronJobs={settingsMutationController.settingsCronJobs} + cronLoading={settingsMutationController.settingsCronLoading} + cronError={settingsMutationController.settingsCronError} + cronCreateBusy={settingsMutationController.cronCreateBusy} + cronRunBusyJobId={settingsMutationController.cronRunBusyJobId} + cronDeleteBusyJobId={settingsMutationController.cronDeleteBusyJobId} + onCreateCronJob={(draft) => + settingsMutationController.handleCreateCronJob(inspectSidebarAgent.agentId, draft) + } + onRunCronJob={(jobId) => + settingsMutationController.handleRunCronJob(inspectSidebarAgent.agentId, jobId) + } + onDeleteCronJob={(jobId) => + settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId) + } + controlUiUrl={controlUiUrl} + /> + </div> + </div> + ) + ) : ( + <EmptyStatePanel + title="Agent not found." + description="Back to chat and select an available agent." + fillHeight + className="items-center p-6 text-center text-sm" /> - </label> + )} </div> - <div className="flex flex-wrap items-center gap-3"> + </div> + </div> + ) : ( + <div className="flex min-h-0 flex-1 flex-col gap-4 xl:flex-row"> + <div className="glass-panel ui-panel p-2 xl:hidden" data-testid="mobile-pane-toggle"> + <div className="ui-segment grid-cols-2"> <button - className="rounded-full bg-[var(--accent)] px-5 py-2 text-sm font-semibold text-white" type="button" - onClick={handleProjectOpen} + className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]" + data-active={mobilePane === "fleet" ? "true" : "false"} + onClick={() => setMobilePane("fleet")} > - Open Workspace + Fleet </button> <button - className="rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" type="button" - onClick={() => setShowOpenProjectForm(false)} + className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]" + data-active={mobilePane === "chat" ? "true" : "false"} + onClick={() => setMobilePane("chat")} > - Cancel + Chat </button> </div> - {openProjectWarnings.length > 0 ? ( - <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-700"> - {openProjectWarnings.join(" ")} + </div> + <div + className={`${mobilePane === "fleet" ? "block" : "hidden"} min-h-0 xl:block xl:min-h-0`} + > + <FleetSidebar + agents={filteredAgents} + selectedAgentId={focusedAgent?.agentId ?? state.selectedAgentId} + filter={focusFilter} + onFilterChange={handleFocusFilterChange} + onCreateAgent={() => { + handleOpenCreateAgentModal(); + }} + createDisabled={status !== "connected" || createAgentBusy || state.loading} + createBusy={createAgentBusy} + onSelectAgent={handleFleetSelectAgent} + /> + </div> + <div + className={`${mobilePane === "chat" ? "flex" : "hidden"} ui-panel ui-depth-workspace min-h-0 flex-1 overflow-hidden xl:flex`} + data-testid="focused-agent-panel" + > + {focusedAgent ? ( + <div className="flex min-h-0 flex-1 flex-col"> + <div className="min-h-0 flex-1"> + <AgentChatPanel + agent={focusedAgent} + isSelected={false} + canSend={status === "connected"} + models={gatewayModels} + stopBusy={stopBusyAgentId === focusedAgent.agentId} + stopDisabledReason={focusedAgentStopDisabledReason} + onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)} + onOpenSettings={() => handleOpenAgentSettingsRoute(focusedAgent.agentId)} + onRename={(name) => + settingsMutationController.handleRenameAgent(focusedAgent.agentId, name) + } + onNewSession={() => handleNewSession(focusedAgent.agentId)} + onModelChange={(value) => + handleModelChange(focusedAgent.agentId, focusedAgent.sessionKey, value) + } + onThinkingChange={(value) => + handleThinkingChange(focusedAgent.agentId, focusedAgent.sessionKey, value) + } + onToolCallingToggle={(enabled) => + handleToolCallingToggle(focusedAgent.agentId, enabled) + } + onThinkingTracesToggle={(enabled) => + handleThinkingTracesToggle(focusedAgent.agentId, enabled) + } + onDraftChange={(value) => handleDraftChange(focusedAgent.agentId, value)} + onSend={(message) => + handleSend(focusedAgent.agentId, focusedAgent.sessionKey, message) + } + onRemoveQueuedMessage={(index) => + removeQueuedMessage(focusedAgent.agentId, index) + } + onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)} + onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)} + pendingExecApprovals={focusedPendingExecApprovals} + onResolveExecApproval={(id, decision) => { + void handleResolveExecApproval(id, decision); + }} + /> + </div> </div> - ) : null} + ) : ( + <EmptyStatePanel + title={hasAnyAgents ? "No agents match this filter." : "No agents available."} + description={ + hasAnyAgents + ? undefined + : status === "connected" + ? "Use New Agent in the sidebar to add your first agent." + : "Connect to your gateway to load agents into the studio." + } + fillHeight + className="items-center p-6 text-center text-sm" + /> + )} </div> </div> - </div> - ) : null} - - {state.error ? ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700"> - {state.error} + )} + </div> + </div> + {createAgentModalOpen ? ( + <AgentCreateModal + open={createAgentModalOpen} + suggestedName={suggestedCreateAgentName} + busy={createAgentBusy} + submitError={createAgentModalError} + onClose={() => { + if (createAgentBusy) return; + setCreateAgentModalError(null); + setCreateAgentModalOpen(false); + }} + onSubmit={(payload) => { + void handleCreateAgentSubmit(payload); + }} + /> + ) : null} + {createAgentBlock && createAgentBlock.phase !== "queued" ? ( + <div + className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80" + data-testid="agent-create-restart-modal" + role="dialog" + aria-modal="true" + aria-label="Creating agent" + > + <div className="ui-panel w-full max-w-md p-6"> + <div className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + Agent create in progress + </div> + <div className="mt-2 text-base font-semibold text-foreground"> + {createAgentBlock.agentName} + </div> + <div className="mt-3 text-sm text-muted-foreground"> + Studio is temporarily locked until creation finishes. </div> + {createBlockStatusLine ? ( + <div className="ui-card mt-4 px-3 py-2 font-mono text-[11px] tracking-[0.06em] text-foreground"> + {createBlockStatusLine} + </div> + ) : null} </div> - ) : null} - - {project ? null : ( - <div className="pointer-events-auto mx-auto w-full max-w-4xl"> - <div className="glass-panel px-6 py-8 text-slate-600"> - Create a workspace to begin. + </div> + ) : null} + {restartingMutationBlock && restartingMutationBlock.phase !== "queued" ? ( + <div + className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80" + data-testid={restartingMutationModalTestId ?? undefined} + role="dialog" + aria-modal="true" + aria-label={restartingMutationAriaLabel ?? undefined} + > + <div className="ui-panel w-full max-w-md p-6"> + <div className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + {restartingMutationHeading} </div> + <div className="mt-2 text-base font-semibold text-foreground"> + {restartingMutationBlock.agentName} + </div> + <div className="mt-3 text-sm text-muted-foreground"> + Studio is temporarily locked until the gateway restarts. + </div> + {restartingMutationStatusLine ? ( + <div className="ui-card mt-4 px-3 py-2 font-mono text-[11px] tracking-[0.06em] text-foreground"> + {restartingMutationStatusLine} + </div> + ) : null} </div> - )} - </div> + </div> + ) : null} </div> ); }; export default function Home() { return ( - <AgentCanvasProvider> - <AgentCanvasPage /> - </AgentCanvasProvider> + <Suspense> + <AgentStoreProvider> + <AgentStudioPage /> + </AgentStoreProvider> + </Suspense> ); } diff --git a/src/app/styles/markdown.css b/src/app/styles/markdown.css index 776f1f50..6281e9a1 100644 --- a/src/app/styles/markdown.css +++ b/src/app/styles/markdown.css @@ -1,7 +1,8 @@ .agent-markdown { display: block; - white-space: pre-wrap; + white-space: normal; word-break: break-word; + line-height: 1.7; } .agent-markdown p { @@ -14,7 +15,7 @@ .agent-markdown pre, .agent-markdown blockquote, .agent-markdown table { - margin-top: 0.35rem; + margin-top: 1rem; } .agent-markdown h1, @@ -23,7 +24,13 @@ .agent-markdown h4, .agent-markdown h5, .agent-markdown h6 { - margin: 0 0 0.25rem 0; + margin: 1.1rem 0 0.5rem 0; + font-weight: 700; + letter-spacing: 0.01em; +} + +.agent-markdown :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; } .agent-markdown h1 + p, @@ -44,7 +51,7 @@ .agent-markdown h4 + ol, .agent-markdown h5 + ol, .agent-markdown h6 + ol { - margin-top: 0.15rem; + margin-top: 0.25rem; } .agent-markdown ul, @@ -52,36 +59,89 @@ padding-left: 1.1rem; } +.agent-markdown ul { + list-style-type: disc; +} + +.agent-markdown ol { + list-style-type: decimal; +} + +.agent-markdown ul ul { + list-style-type: circle; + padding-left: 1.2rem; +} + +.agent-markdown ul ul ul { + list-style-type: square; +} + +.agent-markdown ol ol { + list-style-type: lower-alpha; + padding-left: 1.3rem; +} + +.agent-markdown ol ol ol { + list-style-type: lower-roman; +} + +.agent-markdown ul ol { + list-style-type: decimal; + padding-left: 1.3rem; +} + +.agent-markdown ol ul { + list-style-type: disc; + padding-left: 1.2rem; +} + .agent-markdown li { - margin-top: 0.15rem; + margin-top: 0.25rem; +} + +.agent-markdown li > p { + margin: 0; } .agent-markdown code { - font-family: var(--font-code), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: 0.85em; - background: rgba(15, 23, 42, 0.08); - padding: 0.1rem 0.25rem; - border-radius: 0.25rem; + font-size: 0.82em; + overflow-wrap: anywhere; + word-break: break-word; + background: color-mix(in oklch, var(--surface-3) 92%, transparent); + padding: 0.14rem 0.28rem; + border-radius: 0.3125rem; + border: 1px solid color-mix(in oklch, var(--surface-selected-border) 88%, transparent); } .agent-markdown pre { - background: rgba(15, 23, 42, 0.06); - border-radius: 0.5rem; - padding: 0.6rem 0.75rem; + background: color-mix(in oklch, var(--surface-2) 94%, transparent); + border-radius: 0.4rem; + padding: 0.75rem 0.85rem; + border: 1px solid color-mix(in oklch, var(--surface-selected-border) 82%, transparent); overflow-x: auto; + line-height: 1.64; } .agent-markdown pre code { background: transparent; padding: 0; font-size: 0.85em; + border: 0; + border-radius: 0; +} + +.agent-tool-markdown pre { + max-height: 70px; + overflow: auto; + padding: 0.5rem 0.6rem; } .agent-markdown blockquote { - border-left: 3px solid rgba(15, 23, 42, 0.2); - padding-left: 0.75rem; - color: rgba(15, 23, 42, 0.7); + border-left: 3px solid var(--surface-selected-border); + padding-left: 0.7rem; + color: var(--muted-foreground); } .agent-markdown table { @@ -92,12 +152,45 @@ .agent-markdown th, .agent-markdown td { - border: 1px solid rgba(15, 23, 42, 0.15); - padding: 0.3rem 0.5rem; + border: 1px solid var(--border); + padding: 0.45rem 0.65rem; text-align: left; } .agent-markdown a { - color: var(--accent-strong); + color: color-mix(in oklch, var(--action-bg) 68%, var(--foreground)); text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +.agent-markdown img { + max-width: 100%; + height: auto; + display: block; + border-radius: 0.5rem; + border: 1px solid var(--border); +} + +.dark .agent-markdown code { + background: color-mix(in oklch, var(--surface-1) 80%, var(--surface-0)); + border-color: color-mix(in oklch, var(--chat-assistant-border) 88%, var(--surface-selected-border)); +} + +.dark .agent-markdown pre { + background: color-mix(in oklch, var(--surface-0) 82%, var(--surface-1)); + border-color: color-mix(in oklch, var(--chat-assistant-border) 86%, transparent); + box-shadow: inset 0 1px 0 var(--elev-overlay-1); +} + +.dark .ui-chat-assistant-card .agent-markdown :is(p + p, ul, ol, pre, blockquote, table) { + margin-top: 1.35rem; +} + +.dark .ui-chat-assistant-card .agent-markdown :is(h1, h2, h3, h4, h5, h6) { + margin: 1.35rem 0 0.68rem 0; +} + +.dark .ui-chat-assistant-card .agent-markdown li + li { + margin-top: 0.42rem; } diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx new file mode 100644 index 00000000..4601c762 --- /dev/null +++ b/src/components/theme-toggle.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Moon, Sun } from "lucide-react"; + +const THEME_STORAGE_KEY = "theme"; + +type ThemeMode = "light" | "dark"; + +const getPreferredTheme = (): ThemeMode => { + if (typeof window === "undefined") return "light"; + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? "dark" : "light"; +}; + +const applyTheme = (mode: ThemeMode) => { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle("dark", mode === "dark"); +}; + +export const ThemeToggle = () => { + // Keep SSR + initial hydration stable ("light") to avoid markup mismatch. + const [theme, setTheme] = useState<ThemeMode>("light"); + + useEffect(() => { + const preferred = getPreferredTheme(); + // eslint-disable-next-line react-hooks/set-state-in-effect + setTheme(preferred); + applyTheme(preferred); + }, []); + + const toggleTheme = () => { + setTheme((current) => { + const next: ThemeMode = current === "dark" ? "light" : "dark"; + if (typeof window !== "undefined") { + window.localStorage.setItem(THEME_STORAGE_KEY, next); + } + applyTheme(next); + return next; + }); + }; + + const isDark = theme === "dark"; + + return ( + <button + type="button" + onClick={toggleTheme} + aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} + className="ui-btn-icon ui-btn-icon-xs" + > + {isDark ? <Sun className="h-3 w-3" /> : <Moon className="h-3 w-3" />} + </button> + ); +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx deleted file mode 100644 index 915ea2a0..00000000 --- a/src/components/ui/button.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps<typeof buttonVariants> & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - return ( - <Comp - data-slot="button" - data-variant={variant} - data-size={size} - className={cn(buttonVariants({ variant, size, className }))} - {...props} - /> - ) -} - -export { Button, buttonVariants } diff --git a/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts new file mode 100644 index 00000000..b8b77f96 --- /dev/null +++ b/src/features/agents/approvals/execApprovalControlLoopWorkflow.ts @@ -0,0 +1,239 @@ +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + applyApprovalIngressEffects, + deriveAwaitingUserInputPatches, + derivePendingApprovalPruneDelayMs, + prunePendingApprovalState, + resolveApprovalAutoResumeDispatch, + resolveApprovalAutoResumePreflight, + type ApprovalPendingState, + type AwaitingUserInputPatch, +} from "@/features/agents/approvals/execApprovalRuntimeCoordinator"; +import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy"; +import { + resolveGatewayEventIngressDecision, + type CronTranscriptIntent, +} from "@/features/agents/state/gatewayEventIngressWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +export type ExecApprovalPendingSnapshot = ApprovalPendingState; + +export type ExecApprovalIngressCommand = + | { kind: "replacePendingState"; pendingState: ApprovalPendingState } + | { + kind: "pauseRunForApproval"; + approval: PendingExecApproval; + preferredAgentId: string | null; + } + | { kind: "markActivity"; agentId: string } + | { kind: "recordCronDedupeKey"; dedupeKey: string } + | { kind: "appendCronTranscript"; intent: CronTranscriptIntent }; + +export type PauseRunIntent = + | { kind: "skip"; reason: string } + | { kind: "pause"; agentId: string; sessionKey: string; runId: string }; + +export type AutoResumeIntent = + | { kind: "skip"; reason: string } + | { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string }; + +const resolvePauseTargetAgent = (params: { + approval: PendingExecApproval; + preferredAgentId: string | null | undefined; + agents: AgentState[]; +}): AgentState | null => { + const preferredAgentId = params.preferredAgentId?.trim() ?? ""; + if (preferredAgentId) { + const match = + params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null; + if (match) return match; + } + + const approvalSessionKey = params.approval.sessionKey?.trim() ?? ""; + if (!approvalSessionKey) return null; + + return ( + params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ?? + null + ); +}; + +export const planPausedRunMapCleanup = (params: { + pausedRunIdByAgentId: ReadonlyMap<string, string>; + agents: AgentState[]; +}): string[] => { + const staleAgentIds: string[] = []; + for (const [agentId, trackedRunId] of params.pausedRunIdByAgentId.entries()) { + const trackedAgent = params.agents.find((agent) => agent.agentId === agentId) ?? null; + const currentRunId = trackedAgent?.runId?.trim() ?? ""; + if (!currentRunId || currentRunId !== trackedRunId) { + staleAgentIds.push(agentId); + } + } + return staleAgentIds; +}; + +export const planPauseRunIntent = (params: { + approval: PendingExecApproval; + preferredAgentId?: string | null; + agents: AgentState[]; + pausedRunIdByAgentId: ReadonlyMap<string, string>; +}): PauseRunIntent => { + const agent = resolvePauseTargetAgent({ + approval: params.approval, + preferredAgentId: params.preferredAgentId, + agents: params.agents, + }); + if (!agent) { + return { kind: "skip", reason: "missing-agent" }; + } + + const runId = agent.runId?.trim() ?? ""; + if (!runId) { + return { kind: "skip", reason: "missing-run-id" }; + } + + const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null; + const shouldPause = shouldPauseRunForPendingExecApproval({ + agent, + approval: params.approval, + pausedRunId, + }); + if (!shouldPause) { + return { kind: "skip", reason: "pause-policy-denied" }; + } + + const sessionKey = agent.sessionKey.trim(); + if (!sessionKey) { + return { kind: "skip", reason: "missing-session-key" }; + } + + return { + kind: "pause", + agentId: agent.agentId, + sessionKey, + runId, + }; +}; + +export const planAutoResumeIntent = (params: { + approval: PendingExecApproval; + targetAgentId: string; + pendingState: ApprovalPendingState; + pausedRunIdByAgentId: ReadonlyMap<string, string>; + agents: AgentState[]; +}): AutoResumeIntent => { + const preflight = resolveApprovalAutoResumePreflight({ + approval: params.approval, + targetAgentId: params.targetAgentId, + pendingState: params.pendingState, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }); + + if (preflight.kind !== "resume") { + return { kind: "skip", reason: preflight.reason }; + } + + const dispatchIntent = resolveApprovalAutoResumeDispatch({ + targetAgentId: preflight.targetAgentId, + pausedRunId: preflight.pausedRunId, + agents: params.agents, + }); + + if (dispatchIntent.kind !== "resume") { + return { kind: "skip", reason: dispatchIntent.reason }; + } + + return { + kind: "resume", + targetAgentId: dispatchIntent.targetAgentId, + pausedRunId: dispatchIntent.pausedRunId, + sessionKey: dispatchIntent.sessionKey, + }; +}; + +export const planIngressCommands = (params: { + event: EventFrame; + agents: AgentState[]; + pendingState: ApprovalPendingState; + pausedRunIdByAgentId: ReadonlyMap<string, string>; + seenCronDedupeKeys: ReadonlySet<string>; + nowMs: number; +}): ExecApprovalIngressCommand[] => { + const ingressDecision = resolveGatewayEventIngressDecision({ + event: params.event, + agents: params.agents, + seenCronDedupeKeys: params.seenCronDedupeKeys, + nowMs: params.nowMs, + }); + + const approvalIngress = applyApprovalIngressEffects({ + pendingState: params.pendingState, + approvalEffects: ingressDecision.approvalEffects, + agents: params.agents, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }); + + const commands: ExecApprovalIngressCommand[] = []; + if ( + approvalIngress.pendingState.approvalsByAgentId !== params.pendingState.approvalsByAgentId || + approvalIngress.pendingState.unscopedApprovals !== params.pendingState.unscopedApprovals + ) { + commands.push({ + kind: "replacePendingState", + pendingState: approvalIngress.pendingState, + }); + } + + for (const pauseRequest of approvalIngress.pauseRequests) { + commands.push({ + kind: "pauseRunForApproval", + approval: pauseRequest.approval, + preferredAgentId: pauseRequest.preferredAgentId, + }); + } + + for (const agentId of approvalIngress.markActivityAgentIds) { + commands.push({ kind: "markActivity", agentId }); + } + + if (ingressDecision.cronDedupeKeyToRecord) { + commands.push({ + kind: "recordCronDedupeKey", + dedupeKey: ingressDecision.cronDedupeKeyToRecord, + }); + } + + if (ingressDecision.cronTranscriptIntent) { + commands.push({ + kind: "appendCronTranscript", + intent: ingressDecision.cronTranscriptIntent, + }); + } + + return commands; +}; + +export const planPendingPruneDelay = (params: { + pendingState: ApprovalPendingState; + nowMs: number; + graceMs: number; +}): number | null => { + return derivePendingApprovalPruneDelayMs(params); +}; + +export const planPrunedPendingState = (params: { + pendingState: ApprovalPendingState; + nowMs: number; + graceMs: number; +}): ApprovalPendingState => { + return prunePendingApprovalState(params).pendingState; +}; + +export const planAwaitingUserInputPatches = (params: { + agents: AgentState[]; + approvalsByAgentId: Record<string, PendingExecApproval[]>; +}): AwaitingUserInputPatch[] => { + return deriveAwaitingUserInputPatches(params); +}; diff --git a/src/features/agents/approvals/execApprovalEvents.ts b/src/features/agents/approvals/execApprovalEvents.ts new file mode 100644 index 00000000..f9115eea --- /dev/null +++ b/src/features/agents/approvals/execApprovalEvents.ts @@ -0,0 +1,106 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import type { ExecApprovalDecision } from "@/features/agents/approvals/types"; + +type RequestedPayload = { + id: string; + request: { + command: string; + cwd: string | null; + host: string | null; + security: string | null; + ask: string | null; + agentId: string | null; + resolvedPath: string | null; + sessionKey: string | null; + }; + createdAtMs: number; + expiresAtMs: number; +}; + +type ResolvedPayload = { + id: string; + decision: ExecApprovalDecision; + resolvedBy: string | null; + ts: number; +}; + +const asRecord = (value: unknown): Record<string, unknown> | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record<string, unknown>) + : null; + +const asNonEmptyString = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const asOptionalString = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + +const asPositiveTimestamp = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; + +export const parseExecApprovalRequested = (event: EventFrame): RequestedPayload | null => { + if (event.type !== "event" || event.event !== "exec.approval.requested") return null; + const payload = asRecord(event.payload); + if (!payload) return null; + const id = asNonEmptyString(payload.id); + const request = asRecord(payload.request); + const createdAtMs = asPositiveTimestamp(payload.createdAtMs); + const expiresAtMs = asPositiveTimestamp(payload.expiresAtMs); + if (!id || !request || !createdAtMs || !expiresAtMs) return null; + const command = asNonEmptyString(request.command); + if (!command) return null; + return { + id, + request: { + command, + cwd: asOptionalString(request.cwd), + host: asOptionalString(request.host), + security: asOptionalString(request.security), + ask: asOptionalString(request.ask), + agentId: asOptionalString(request.agentId), + resolvedPath: asOptionalString(request.resolvedPath), + sessionKey: asOptionalString(request.sessionKey), + }, + createdAtMs, + expiresAtMs, + }; +}; + +export const parseExecApprovalResolved = (event: EventFrame): ResolvedPayload | null => { + if (event.type !== "event" || event.event !== "exec.approval.resolved") return null; + const payload = asRecord(event.payload); + if (!payload) return null; + const id = asNonEmptyString(payload.id); + const decisionRaw = asNonEmptyString(payload.decision); + const ts = asPositiveTimestamp(payload.ts); + if (!id || !decisionRaw || !ts) return null; + if (decisionRaw !== "allow-once" && decisionRaw !== "allow-always" && decisionRaw !== "deny") { + return null; + } + return { + id, + decision: decisionRaw, + resolvedBy: asOptionalString(payload.resolvedBy), + ts, + }; +}; + +export const resolveExecApprovalAgentId = (params: { + requested: RequestedPayload; + agents: AgentState[]; +}): string | null => { + const requestedAgentId = params.requested.request.agentId; + if (requestedAgentId) { + return requestedAgentId; + } + const requestedSessionKey = params.requested.request.sessionKey; + if (!requestedSessionKey) return null; + const matchedBySession = params.agents.find( + (agent) => agent.sessionKey.trim() === requestedSessionKey + ); + return matchedBySession?.agentId ?? null; +}; diff --git a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts new file mode 100644 index 00000000..2899cb2e --- /dev/null +++ b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts @@ -0,0 +1,127 @@ +import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types"; +import { + parseExecApprovalRequested, + parseExecApprovalResolved, + resolveExecApprovalAgentId, +} from "@/features/agents/approvals/execApprovalEvents"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import { GatewayResponseError } from "@/lib/gateway/errors"; + +export type ExecApprovalEventEffects = { + scopedUpserts: Array<{ agentId: string; approval: PendingExecApproval }>; + unscopedUpserts: PendingExecApproval[]; + removals: string[]; + markActivityAgentIds: string[]; +}; + +export type ExecApprovalFollowUpIntent = { + shouldSend: boolean; + agentId: string | null; + sessionKey: string | null; + message: string | null; +}; + +const EMPTY_EVENT_EFFECTS: ExecApprovalEventEffects = { + scopedUpserts: [], + unscopedUpserts: [], + removals: [], + markActivityAgentIds: [], +}; + +const NO_FOLLOW_UP_INTENT: ExecApprovalFollowUpIntent = { + shouldSend: false, + agentId: null, + sessionKey: null, + message: null, +}; + +export const resolveExecApprovalEventEffects = (params: { + event: EventFrame; + agents: AgentState[]; +}): ExecApprovalEventEffects | null => { + const requested = parseExecApprovalRequested(params.event); + if (requested) { + const resolvedAgentId = resolveExecApprovalAgentId({ + requested, + agents: params.agents, + }); + const approval: PendingExecApproval = { + id: requested.id, + agentId: resolvedAgentId, + sessionKey: requested.request.sessionKey, + command: requested.request.command, + cwd: requested.request.cwd, + host: requested.request.host, + security: requested.request.security, + ask: requested.request.ask, + resolvedPath: requested.request.resolvedPath, + createdAtMs: requested.createdAtMs, + expiresAtMs: requested.expiresAtMs, + resolving: false, + error: null, + }; + if (!resolvedAgentId) { + return { + ...EMPTY_EVENT_EFFECTS, + unscopedUpserts: [approval], + }; + } + return { + ...EMPTY_EVENT_EFFECTS, + scopedUpserts: [{ agentId: resolvedAgentId, approval }], + markActivityAgentIds: [resolvedAgentId], + }; + } + + const resolved = parseExecApprovalResolved(params.event); + if (!resolved) { + return null; + } + return { + ...EMPTY_EVENT_EFFECTS, + removals: [resolved.id], + }; +}; + +export const resolveExecApprovalFollowUpIntent = (params: { + decision: ExecApprovalDecision; + approval: PendingExecApproval | null; + agents: AgentState[]; + followUpMessage: string; +}): ExecApprovalFollowUpIntent => { + if (params.decision !== "allow-once" && params.decision !== "allow-always") { + return NO_FOLLOW_UP_INTENT; + } + if (!params.approval) { + return NO_FOLLOW_UP_INTENT; + } + const scopedAgentId = params.approval.agentId?.trim() ?? ""; + const sessionAgentId = + params.approval.sessionKey?.trim() + ? (params.agents.find( + (agent) => agent.sessionKey.trim() === params.approval?.sessionKey?.trim() + )?.agentId ?? "") + : ""; + const targetAgentId = scopedAgentId || sessionAgentId; + if (!targetAgentId) { + return NO_FOLLOW_UP_INTENT; + } + const targetSessionKey = + params.approval.sessionKey?.trim() || + params.agents.find((agent) => agent.agentId === targetAgentId)?.sessionKey?.trim() || + ""; + const followUpMessage = params.followUpMessage.trim(); + if (!targetSessionKey || !followUpMessage) { + return NO_FOLLOW_UP_INTENT; + } + return { + shouldSend: true, + agentId: targetAgentId, + sessionKey: targetSessionKey, + message: followUpMessage, + }; +}; + +export const shouldTreatExecApprovalResolveErrorAsUnknownId = (error: unknown): boolean => + error instanceof GatewayResponseError && /unknown approval id/i.test(error.message); diff --git a/src/features/agents/approvals/execApprovalPausePolicy.ts b/src/features/agents/approvals/execApprovalPausePolicy.ts new file mode 100644 index 00000000..d63db476 --- /dev/null +++ b/src/features/agents/approvals/execApprovalPausePolicy.ts @@ -0,0 +1,32 @@ +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import type { AgentState } from "@/features/agents/state/store"; + +const normalizeExecAsk = ( + value: string | null | undefined +): "off" | "on-miss" | "always" | null => { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized; + } + return null; +}; + +export const shouldPauseRunForPendingExecApproval = (params: { + agent: AgentState | null; + approval: PendingExecApproval; + pausedRunId: string | null; +}): boolean => { + const agent = params.agent; + if (!agent) return false; + if (agent.status !== "running") return false; + + const runId = agent.runId?.trim() ?? ""; + if (!runId) return false; + if (params.pausedRunId === runId) return false; + + const approvalAsk = normalizeExecAsk(params.approval.ask); + const agentAsk = normalizeExecAsk(agent.sessionExecAsk); + const effectiveAsk = approvalAsk ?? agentAsk; + return effectiveAsk === "always"; +}; diff --git a/src/features/agents/approvals/execApprovalResolveOperation.ts b/src/features/agents/approvals/execApprovalResolveOperation.ts new file mode 100644 index 00000000..7463370d --- /dev/null +++ b/src/features/agents/approvals/execApprovalResolveOperation.ts @@ -0,0 +1,155 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { ExecApprovalDecision, PendingExecApproval } from "@/features/agents/approvals/types"; +import { + removePendingApprovalEverywhere, + updatePendingApprovalById, +} from "@/features/agents/approvals/pendingStore"; +import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +type SetState<T> = (next: T | ((current: T) => T)) => void; + +export const resolveExecApprovalViaStudio = async (params: { + client: GatewayClientLike; + approvalId: string; + decision: ExecApprovalDecision; + getAgents: () => AgentState[]; + getLatestAgent: (agentId: string) => AgentState | null; + getPendingState: () => { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; + }; + setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>; + setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>; + requestHistoryRefresh: (agentId: string) => Promise<void> | void; + onAllowResolved?: (params: { + approval: PendingExecApproval; + targetAgentId: string; + }) => Promise<void> | void; + onAllowed?: (params: { approval: PendingExecApproval; targetAgentId: string }) => Promise<void> | void; + isDisconnectLikeError: (error: unknown) => boolean; + shouldTreatUnknownId?: (error: unknown) => boolean; + logWarn?: (message: string, error: unknown) => void; +}): Promise<void> => { + const id = params.approvalId.trim(); + if (!id) return; + + const resolvePendingApproval = ( + approvalId: string, + state: { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; + } + ): PendingExecApproval | null => { + for (const approvals of Object.values(state.approvalsByAgentId)) { + const found = approvals.find((approval) => approval.id === approvalId); + if (found) return found; + } + return state.unscopedApprovals.find((approval) => approval.id === approvalId) ?? null; + }; + + const resolveApprovalTargetAgentId = (approval: PendingExecApproval | null): string | null => { + if (!approval) return null; + const scopedAgentId = approval.agentId?.trim() ?? ""; + if (scopedAgentId) return scopedAgentId; + const scopedSessionKey = approval.sessionKey?.trim() ?? ""; + if (!scopedSessionKey) return null; + const matched = params + .getAgents() + .find((agent) => agent.sessionKey.trim() === scopedSessionKey); + return matched?.agentId ?? null; + }; + + const snapshot = params.getPendingState(); + const approval = resolvePendingApproval(id, snapshot); + + const removeLocalApproval = (approvalId: string) => { + params.setPendingExecApprovalsByAgentId((current) => { + return removePendingApprovalEverywhere({ + approvalsByAgentId: current, + unscopedApprovals: [], + approvalId, + }).approvalsByAgentId; + }); + params.setUnscopedPendingExecApprovals((current) => { + return removePendingApprovalEverywhere({ + approvalsByAgentId: {}, + unscopedApprovals: current, + approvalId, + }).unscopedApprovals; + }); + }; + + const setLocalApprovalState = (resolving: boolean, error: string | null) => { + params.setPendingExecApprovalsByAgentId((current) => { + let changed = false; + const next: Record<string, PendingExecApproval[]> = {}; + for (const [agentId, approvals] of Object.entries(current)) { + const updated = updatePendingApprovalById(approvals, id, (approval) => ({ + ...approval, + resolving, + error, + })); + if (updated !== approvals) { + changed = true; + } + if (updated.length > 0) { + next[agentId] = updated; + } + } + return changed ? next : current; + }); + params.setUnscopedPendingExecApprovals((current) => + updatePendingApprovalById(current, id, (approval) => ({ + ...approval, + resolving, + error, + })) + ); + }; + + setLocalApprovalState(true, null); + + try { + await params.client.call("exec.approval.resolve", { id, decision: params.decision }); + removeLocalApproval(id); + + if (params.decision !== "allow-once" && params.decision !== "allow-always") { + return; + } + + if (!approval) return; + const targetAgentId = resolveApprovalTargetAgentId(approval); + if (!targetAgentId) return; + await params.onAllowResolved?.({ approval, targetAgentId }); + + const latest = params.getLatestAgent(targetAgentId); + const activeRunId = latest?.runId?.trim() ?? ""; + if (activeRunId) { + try { + await params.client.call("agent.wait", { runId: activeRunId, timeoutMs: 15_000 }); + } catch (waitError) { + if (!params.isDisconnectLikeError(waitError)) { + (params.logWarn ?? ((message, error) => console.warn(message, error)))( + "Failed to wait for run after exec approval resolve.", + waitError + ); + } + } + } + + await params.requestHistoryRefresh(targetAgentId); + await params.onAllowed?.({ approval, targetAgentId }); + } catch (err) { + const shouldTreatUnknownId = params.shouldTreatUnknownId ?? shouldTreatExecApprovalResolveErrorAsUnknownId; + if (shouldTreatUnknownId(err)) { + removeLocalApproval(id); + return; + } + const message = err instanceof Error ? err.message : "Failed to resolve exec approval."; + setLocalApprovalState(false, message); + } +}; diff --git a/src/features/agents/approvals/execApprovalRunControlOperation.ts b/src/features/agents/approvals/execApprovalRunControlOperation.ts new file mode 100644 index 00000000..e3aa76ab --- /dev/null +++ b/src/features/agents/approvals/execApprovalRunControlOperation.ts @@ -0,0 +1,288 @@ +import type { + ExecApprovalDecision, + PendingExecApproval, +} from "@/features/agents/approvals/types"; +import type { + ExecApprovalIngressCommand, + ExecApprovalPendingSnapshot, +} from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation"; +import { + planApprovalIngressRunControl, + planAutoResumeRunControl, + planPauseRunControl, +} from "@/features/agents/approvals/execApprovalRunControlWorkflow"; +import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; +import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract"; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +type RunControlDispatchAction = + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string; transcript?: Record<string, unknown> } + | { type: "markActivity"; agentId: string; at?: number }; + +type RunControlDispatch = (action: RunControlDispatchAction) => void; + +type SetState<T> = (next: T | ((current: T) => T)) => void; + +const AUTO_RESUME_FOLLOW_UP_MESSAGE = `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`; +export const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000; + +export async function runPauseRunForExecApprovalOperation(params: { + status: string; + client: GatewayClientLike; + approval: PendingExecApproval; + preferredAgentId?: string | null; + getAgents: () => AgentState[]; + pausedRunIdByAgentId: Map<string, string>; + isDisconnectLikeError: (error: unknown) => boolean; + logWarn?: (message: string, error: unknown) => void; +}): Promise<void> { + if (params.status !== "connected") return; + + const plan = planPauseRunControl({ + approval: params.approval, + preferredAgentId: params.preferredAgentId ?? null, + agents: params.getAgents(), + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }); + for (const agentId of plan.stalePausedAgentIds) { + params.pausedRunIdByAgentId.delete(agentId); + } + if (plan.pauseIntent.kind !== "pause") { + return; + } + + params.pausedRunIdByAgentId.set(plan.pauseIntent.agentId, plan.pauseIntent.runId); + try { + await params.client.call("chat.abort", { + sessionKey: plan.pauseIntent.sessionKey, + }); + } catch (error) { + params.pausedRunIdByAgentId.delete(plan.pauseIntent.agentId); + if (!params.isDisconnectLikeError(error)) { + (params.logWarn ?? ((message, err) => console.warn(message, err)))( + "Failed to pause run for pending exec approval.", + error + ); + } + } +} + +export async function runExecApprovalAutoResumeOperation(params: { + client: GatewayClientLike; + dispatch: RunControlDispatch; + approval: PendingExecApproval; + targetAgentId: string; + getAgents: () => AgentState[]; + getPendingState: () => ExecApprovalPendingSnapshot; + pausedRunIdByAgentId: Map<string, string>; + isDisconnectLikeError: (error: unknown) => boolean; + logWarn?: (message: string, error: unknown) => void; + clearRunTracking?: (runId: string) => void; + sendChatMessage?: typeof sendChatMessageViaStudio; + now?: () => number; +}): Promise<void> { + const sendChatMessage = params.sendChatMessage ?? sendChatMessageViaStudio; + const pendingState = params.getPendingState(); + const prePlan = planAutoResumeRunControl({ + approval: params.approval, + targetAgentId: params.targetAgentId, + pendingState, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + agents: params.getAgents(), + }); + if (prePlan.preWaitIntent.kind !== "resume") { + return; + } + + const preWaitIntent = prePlan.preWaitIntent; + params.pausedRunIdByAgentId.delete(preWaitIntent.targetAgentId); + params.dispatch({ + type: "updateAgent", + agentId: preWaitIntent.targetAgentId, + patch: { + status: "running", + runId: preWaitIntent.pausedRunId, + lastActivityAt: (params.now ?? (() => Date.now()))(), + }, + }); + + try { + await params.client.call("agent.wait", { + runId: preWaitIntent.pausedRunId, + timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS, + }); + } catch (error) { + if (!params.isDisconnectLikeError(error)) { + (params.logWarn ?? ((message, err) => console.warn(message, err)))( + "Failed waiting for paused run before auto-resume.", + error + ); + } + } + + const postPlan = planAutoResumeRunControl({ + approval: params.approval, + targetAgentId: preWaitIntent.targetAgentId, + pendingState, + pausedRunIdByAgentId: new Map([[preWaitIntent.targetAgentId, preWaitIntent.pausedRunId]]), + agents: params.getAgents(), + }); + if (postPlan.postWaitIntent.kind !== "resume") { + return; + } + + await sendChatMessage({ + client: params.client, + dispatch: params.dispatch, + getAgent: (agentId) => params.getAgents().find((entry) => entry.agentId === agentId) ?? null, + agentId: postPlan.postWaitIntent.targetAgentId, + sessionKey: postPlan.postWaitIntent.sessionKey, + message: AUTO_RESUME_FOLLOW_UP_MESSAGE, + clearRunTracking: params.clearRunTracking, + echoUserMessage: false, + }); +} + +export async function runResolveExecApprovalOperation(params: { + client: GatewayClientLike; + approvalId: string; + decision: ExecApprovalDecision; + getAgents: () => AgentState[]; + getPendingState: () => ExecApprovalPendingSnapshot; + setPendingExecApprovalsByAgentId: SetState<Record<string, PendingExecApproval[]>>; + setUnscopedPendingExecApprovals: SetState<PendingExecApproval[]>; + requestHistoryRefresh: (agentId: string) => Promise<void> | void; + pausedRunIdByAgentId: Map<string, string>; + dispatch: RunControlDispatch; + isDisconnectLikeError: (error: unknown) => boolean; + logWarn?: (message: string, error: unknown) => void; + clearRunTracking?: (runId: string) => void; + resolveExecApproval?: typeof resolveExecApprovalViaStudio; + runAutoResume?: typeof runExecApprovalAutoResumeOperation; +}): Promise<void> { + const resolveExecApproval = params.resolveExecApproval ?? resolveExecApprovalViaStudio; + const runAutoResume = params.runAutoResume ?? runExecApprovalAutoResumeOperation; + + await resolveExecApproval({ + client: params.client, + approvalId: params.approvalId, + decision: params.decision, + getAgents: params.getAgents, + getLatestAgent: (agentId) => + params.getAgents().find((entry) => entry.agentId === agentId) ?? null, + getPendingState: params.getPendingState, + setPendingExecApprovalsByAgentId: params.setPendingExecApprovalsByAgentId, + setUnscopedPendingExecApprovals: params.setUnscopedPendingExecApprovals, + requestHistoryRefresh: params.requestHistoryRefresh, + onAllowed: async ({ approval, targetAgentId }) => { + await runAutoResume({ + client: params.client, + dispatch: params.dispatch, + approval, + targetAgentId, + getAgents: params.getAgents, + getPendingState: params.getPendingState, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + isDisconnectLikeError: params.isDisconnectLikeError, + logWarn: params.logWarn, + clearRunTracking: params.clearRunTracking, + }); + }, + isDisconnectLikeError: params.isDisconnectLikeError, + logWarn: params.logWarn, + }); +} + +export function executeExecApprovalIngressCommands(params: { + commands: ExecApprovalIngressCommand[]; + replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void; + pauseRunForApproval: ( + approval: PendingExecApproval, + preferredAgentId: string | null + ) => Promise<void> | void; + dispatch: RunControlDispatch; + recordCronDedupeKey: (dedupeKey: string) => void; +}): void { + for (const command of params.commands) { + if (command.kind === "replacePendingState") { + params.replacePendingState(command.pendingState); + continue; + } + if (command.kind === "pauseRunForApproval") { + void params.pauseRunForApproval(command.approval, command.preferredAgentId); + continue; + } + if (command.kind === "markActivity") { + params.dispatch({ + type: "markActivity", + agentId: command.agentId, + }); + continue; + } + if (command.kind === "recordCronDedupeKey") { + params.recordCronDedupeKey(command.dedupeKey); + continue; + } + + const intent = command.intent; + params.dispatch({ + type: "appendOutput", + agentId: intent.agentId, + line: intent.line, + transcript: { + source: "runtime-agent", + role: "assistant", + kind: "assistant", + sessionKey: intent.sessionKey, + timestampMs: intent.timestampMs, + entryId: intent.dedupeKey, + confirmed: true, + }, + }); + params.dispatch({ + type: "markActivity", + agentId: intent.agentId, + at: intent.activityAtMs ?? undefined, + }); + } +} + +export function runGatewayEventIngressOperation(params: { + event: EventFrame; + getAgents: () => AgentState[]; + getPendingState: () => ExecApprovalPendingSnapshot; + pausedRunIdByAgentId: ReadonlyMap<string, string>; + seenCronDedupeKeys: ReadonlySet<string>; + nowMs: number; + replacePendingState: (nextPendingState: ExecApprovalPendingSnapshot) => void; + pauseRunForApproval: ( + approval: PendingExecApproval, + preferredAgentId: string | null + ) => Promise<void> | void; + dispatch: RunControlDispatch; + recordCronDedupeKey: (dedupeKey: string) => void; +}): ExecApprovalIngressCommand[] { + const commands = planApprovalIngressRunControl({ + event: params.event, + agents: params.getAgents(), + pendingState: params.getPendingState(), + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + seenCronDedupeKeys: params.seenCronDedupeKeys, + nowMs: params.nowMs, + }); + executeExecApprovalIngressCommands({ + commands, + replacePendingState: params.replacePendingState, + pauseRunForApproval: params.pauseRunForApproval, + dispatch: params.dispatch, + recordCronDedupeKey: params.recordCronDedupeKey, + }); + return commands; +} diff --git a/src/features/agents/approvals/execApprovalRunControlWorkflow.ts b/src/features/agents/approvals/execApprovalRunControlWorkflow.ts new file mode 100644 index 00000000..8e43887f --- /dev/null +++ b/src/features/agents/approvals/execApprovalRunControlWorkflow.ts @@ -0,0 +1,95 @@ +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + planAutoResumeIntent, + planIngressCommands, + planPausedRunMapCleanup, + planPauseRunIntent, + type ExecApprovalIngressCommand, + type ExecApprovalPendingSnapshot, +} from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; + +type GatewayEventFrame = Parameters<typeof planIngressCommands>[0]["event"]; + +export type PauseRunControlPlan = { + stalePausedAgentIds: string[]; + pauseIntent: ReturnType<typeof planPauseRunIntent>; +}; + +export type AutoResumeRunControlPlan = { + preWaitIntent: ReturnType<typeof planAutoResumeIntent>; + postWaitIntent: ReturnType<typeof planAutoResumeIntent>; +}; + +export function planPauseRunControl(params: { + approval: PendingExecApproval; + preferredAgentId: string | null; + agents: AgentState[]; + pausedRunIdByAgentId: ReadonlyMap<string, string>; +}): PauseRunControlPlan { + return { + stalePausedAgentIds: planPausedRunMapCleanup({ + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + agents: params.agents, + }), + pauseIntent: planPauseRunIntent({ + approval: params.approval, + preferredAgentId: params.preferredAgentId, + agents: params.agents, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }), + }; +} + +export function planAutoResumeRunControl(params: { + approval: PendingExecApproval; + targetAgentId: string; + pendingState: ExecApprovalPendingSnapshot; + pausedRunIdByAgentId: ReadonlyMap<string, string>; + agents: AgentState[]; +}): AutoResumeRunControlPlan { + const preWaitIntent = planAutoResumeIntent({ + approval: params.approval, + targetAgentId: params.targetAgentId, + pendingState: params.pendingState, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + agents: params.agents, + }); + if (preWaitIntent.kind !== "resume") { + return { + preWaitIntent, + postWaitIntent: preWaitIntent, + }; + } + + return { + preWaitIntent, + postWaitIntent: planAutoResumeIntent({ + approval: params.approval, + targetAgentId: preWaitIntent.targetAgentId, + pendingState: params.pendingState, + pausedRunIdByAgentId: new Map([ + [preWaitIntent.targetAgentId, preWaitIntent.pausedRunId], + ]), + agents: params.agents, + }), + }; +} + +export function planApprovalIngressRunControl(params: { + event: GatewayEventFrame; + agents: AgentState[]; + pendingState: ExecApprovalPendingSnapshot; + pausedRunIdByAgentId: ReadonlyMap<string, string>; + seenCronDedupeKeys: ReadonlySet<string>; + nowMs: number; +}): ExecApprovalIngressCommand[] { + return planIngressCommands({ + event: params.event, + agents: params.agents, + pendingState: params.pendingState, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + seenCronDedupeKeys: params.seenCronDedupeKeys, + nowMs: params.nowMs, + }); +} diff --git a/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts b/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts new file mode 100644 index 00000000..3d44f1d7 --- /dev/null +++ b/src/features/agents/approvals/execApprovalRuntimeCoordinator.ts @@ -0,0 +1,300 @@ +import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + nextPendingApprovalPruneDelayMs, + pruneExpiredPendingApprovals, + pruneExpiredPendingApprovalsMap, + removePendingApprovalById, + removePendingApprovalByIdMap, + removePendingApprovalEverywhere, + upsertPendingApproval, +} from "@/features/agents/approvals/pendingStore"; +import type { AgentState } from "@/features/agents/state/store"; + +export type ApprovalPendingState = { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; +}; + +export type ApprovalPauseRequest = { + approval: PendingExecApproval; + preferredAgentId: string | null; +}; + +export type ApprovalIngressResult = { + pendingState: ApprovalPendingState; + pauseRequests: ApprovalPauseRequest[]; + markActivityAgentIds: string[]; +}; + +export type AwaitingUserInputPatch = { + agentId: string; + awaitingUserInput: boolean; +}; + +export type AutoResumePreflightIntent = + | { kind: "skip"; reason: "missing-paused-run" | "blocking-pending-approvals" } + | { kind: "resume"; targetAgentId: string; pausedRunId: string }; + +export type AutoResumeDispatchIntent = + | { kind: "skip"; reason: "missing-paused-run" | "missing-agent" | "run-replaced" | "missing-session-key" } + | { kind: "resume"; targetAgentId: string; pausedRunId: string; sessionKey: string }; + +const resolveAgentForPauseRequest = (params: { + approval: PendingExecApproval; + preferredAgentId: string | null; + agents: AgentState[]; +}): AgentState | null => { + const preferredAgentId = params.preferredAgentId?.trim() ?? ""; + if (preferredAgentId) { + const match = params.agents.find((agent) => agent.agentId === preferredAgentId) ?? null; + if (match) return match; + } + const approvalSessionKey = params.approval.sessionKey?.trim() ?? ""; + if (!approvalSessionKey) return null; + return ( + params.agents.find((agent) => agent.sessionKey.trim() === approvalSessionKey) ?? null + ); +}; + +const shouldQueuePauseRequest = (params: { + approval: PendingExecApproval; + preferredAgentId: string | null; + agents: AgentState[]; + pausedRunIdByAgentId: ReadonlyMap<string, string>; +}): boolean => { + const agent = resolveAgentForPauseRequest(params); + if (!agent) return false; + const pausedRunId = params.pausedRunIdByAgentId.get(agent.agentId) ?? null; + return shouldPauseRunForPendingExecApproval({ + agent, + approval: params.approval, + pausedRunId, + }); +}; + +export const applyApprovalIngressEffects = (params: { + pendingState: ApprovalPendingState; + approvalEffects: ExecApprovalEventEffects | null; + agents: AgentState[]; + pausedRunIdByAgentId: ReadonlyMap<string, string>; +}): ApprovalIngressResult => { + const effects = params.approvalEffects; + if (!effects) { + return { + pendingState: params.pendingState, + pauseRequests: [], + markActivityAgentIds: [], + }; + } + + let approvalsByAgentId = params.pendingState.approvalsByAgentId; + let unscopedApprovals = params.pendingState.unscopedApprovals; + const pauseRequests: ApprovalPauseRequest[] = []; + + for (const approvalId of effects.removals) { + const removed = removePendingApprovalEverywhere({ + approvalsByAgentId, + unscopedApprovals, + approvalId, + }); + approvalsByAgentId = removed.approvalsByAgentId; + unscopedApprovals = removed.unscopedApprovals; + } + + for (const scopedUpsert of effects.scopedUpserts) { + approvalsByAgentId = removePendingApprovalByIdMap( + approvalsByAgentId, + scopedUpsert.approval.id + ); + const existing = approvalsByAgentId[scopedUpsert.agentId] ?? []; + const upserted = upsertPendingApproval(existing, scopedUpsert.approval); + if (upserted !== existing) { + approvalsByAgentId = { + ...approvalsByAgentId, + [scopedUpsert.agentId]: upserted, + }; + } + unscopedApprovals = removePendingApprovalById( + unscopedApprovals, + scopedUpsert.approval.id + ); + if ( + shouldQueuePauseRequest({ + approval: scopedUpsert.approval, + preferredAgentId: scopedUpsert.agentId, + agents: params.agents, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }) + ) { + pauseRequests.push({ + approval: scopedUpsert.approval, + preferredAgentId: scopedUpsert.agentId, + }); + } + } + + for (const unscopedUpsert of effects.unscopedUpserts) { + approvalsByAgentId = removePendingApprovalByIdMap( + approvalsByAgentId, + unscopedUpsert.id + ); + const withoutExisting = removePendingApprovalById( + unscopedApprovals, + unscopedUpsert.id + ); + unscopedApprovals = upsertPendingApproval(withoutExisting, unscopedUpsert); + if ( + shouldQueuePauseRequest({ + approval: unscopedUpsert, + preferredAgentId: null, + agents: params.agents, + pausedRunIdByAgentId: params.pausedRunIdByAgentId, + }) + ) { + pauseRequests.push({ + approval: unscopedUpsert, + preferredAgentId: null, + }); + } + } + + return { + pendingState: { + approvalsByAgentId, + unscopedApprovals, + }, + pauseRequests, + markActivityAgentIds: effects.markActivityAgentIds, + }; +}; + +export const deriveAwaitingUserInputPatches = (params: { + agents: AgentState[]; + approvalsByAgentId: Record<string, PendingExecApproval[]>; +}): AwaitingUserInputPatch[] => { + const pendingCountsByAgentId = new Map<string, number>(); + for (const [agentId, approvals] of Object.entries(params.approvalsByAgentId)) { + if (approvals.length <= 0) continue; + pendingCountsByAgentId.set(agentId, approvals.length); + } + + const patches: AwaitingUserInputPatch[] = []; + for (const agent of params.agents) { + const awaitingUserInput = (pendingCountsByAgentId.get(agent.agentId) ?? 0) > 0; + if (agent.awaitingUserInput === awaitingUserInput) continue; + patches.push({ + agentId: agent.agentId, + awaitingUserInput, + }); + } + return patches; +}; + +export const derivePendingApprovalPruneDelayMs = (params: { + pendingState: ApprovalPendingState; + nowMs: number; + graceMs: number; +}): number | null => { + return nextPendingApprovalPruneDelayMs({ + approvalsByAgentId: params.pendingState.approvalsByAgentId, + unscopedApprovals: params.pendingState.unscopedApprovals, + nowMs: params.nowMs, + graceMs: params.graceMs, + }); +}; + +export const prunePendingApprovalState = (params: { + pendingState: ApprovalPendingState; + nowMs: number; + graceMs: number; +}): { pendingState: ApprovalPendingState } => { + return { + pendingState: { + approvalsByAgentId: pruneExpiredPendingApprovalsMap( + params.pendingState.approvalsByAgentId, + { + nowMs: params.nowMs, + graceMs: params.graceMs, + } + ), + unscopedApprovals: pruneExpiredPendingApprovals( + params.pendingState.unscopedApprovals, + { + nowMs: params.nowMs, + graceMs: params.graceMs, + } + ), + }, + }; +}; + +export const resolveApprovalAutoResumePreflight = (params: { + approval: PendingExecApproval; + targetAgentId: string; + pendingState: ApprovalPendingState; + pausedRunIdByAgentId: ReadonlyMap<string, string>; +}): AutoResumePreflightIntent => { + const pausedRunId = params.pausedRunIdByAgentId.get(params.targetAgentId)?.trim() ?? ""; + if (!pausedRunId) { + return { kind: "skip", reason: "missing-paused-run" }; + } + + const scopedPending = ( + params.pendingState.approvalsByAgentId[params.targetAgentId] ?? [] + ).some((pendingApproval) => pendingApproval.id !== params.approval.id); + + const targetSessionKey = params.approval.sessionKey?.trim() ?? ""; + const unscopedPending = params.pendingState.unscopedApprovals.some((pendingApproval) => { + if (pendingApproval.id === params.approval.id) return false; + const pendingAgentId = pendingApproval.agentId?.trim() ?? ""; + if (pendingAgentId && pendingAgentId === params.targetAgentId) return true; + if (!targetSessionKey) return false; + return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey; + }); + + if (scopedPending || unscopedPending) { + return { kind: "skip", reason: "blocking-pending-approvals" }; + } + + return { + kind: "resume", + targetAgentId: params.targetAgentId, + pausedRunId, + }; +}; + +export const resolveApprovalAutoResumeDispatch = (params: { + targetAgentId: string; + pausedRunId: string; + agents: AgentState[]; +}): AutoResumeDispatchIntent => { + const pausedRunId = params.pausedRunId.trim(); + if (!pausedRunId) { + return { kind: "skip", reason: "missing-paused-run" }; + } + + const latest = + params.agents.find((agent) => agent.agentId === params.targetAgentId) ?? null; + if (!latest) { + return { kind: "skip", reason: "missing-agent" }; + } + + const latestRunId = latest.runId?.trim() ?? ""; + if (latest.status === "running" && latestRunId && latestRunId !== pausedRunId) { + return { kind: "skip", reason: "run-replaced" }; + } + + const sessionKey = latest.sessionKey.trim(); + if (!sessionKey) { + return { kind: "skip", reason: "missing-session-key" }; + } + + return { + kind: "resume", + targetAgentId: params.targetAgentId, + pausedRunId, + sessionKey, + }; +}; diff --git a/src/features/agents/approvals/pendingStore.ts b/src/features/agents/approvals/pendingStore.ts new file mode 100644 index 00000000..bb94b193 --- /dev/null +++ b/src/features/agents/approvals/pendingStore.ts @@ -0,0 +1,155 @@ +import type { PendingExecApproval } from "@/features/agents/approvals/types"; + +export const upsertPendingApproval = ( + approvals: PendingExecApproval[], + nextApproval: PendingExecApproval +): PendingExecApproval[] => { + const index = approvals.findIndex((entry) => entry.id === nextApproval.id); + if (index < 0) { + return [nextApproval, ...approvals]; + } + const next = [...approvals]; + next[index] = nextApproval; + return next; +}; + +export const mergePendingApprovalsForFocusedAgent = (params: { + scopedApprovals: PendingExecApproval[]; + unscopedApprovals: PendingExecApproval[]; +}): PendingExecApproval[] => { + if (params.scopedApprovals.length === 0) return params.unscopedApprovals; + if (params.unscopedApprovals.length === 0) return params.scopedApprovals; + const merged = [...params.unscopedApprovals]; + const seen = new Map<string, number>(); + for (let index = 0; index < merged.length; index += 1) { + seen.set(merged[index]!.id, index); + } + for (const approval of params.scopedApprovals) { + const existingIndex = seen.get(approval.id); + if (existingIndex === undefined) { + seen.set(approval.id, merged.length); + merged.push(approval); + continue; + } + merged[existingIndex] = approval; + } + return merged; +}; + +export const updatePendingApprovalById = ( + approvals: PendingExecApproval[], + approvalId: string, + updater: (approval: PendingExecApproval) => PendingExecApproval +): PendingExecApproval[] => { + let changed = false; + const next = approvals.map((approval) => { + if (approval.id !== approvalId) return approval; + changed = true; + return updater(approval); + }); + return changed ? next : approvals; +}; + +export const removePendingApprovalById = ( + approvals: PendingExecApproval[], + approvalId: string +): PendingExecApproval[] => approvals.filter((approval) => approval.id !== approvalId); + +export const removePendingApprovalEverywhere = (params: { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; + approvalId: string; +}): { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; +} => { + const hasScoped = Object.values(params.approvalsByAgentId).some((approvals) => + approvals.some((approval) => approval.id === params.approvalId) + ); + const hasUnscoped = params.unscopedApprovals.some( + (approval) => approval.id === params.approvalId + ); + if (!hasScoped && !hasUnscoped) { + return { + approvalsByAgentId: params.approvalsByAgentId, + unscopedApprovals: params.unscopedApprovals, + }; + } + return { + approvalsByAgentId: hasScoped + ? removePendingApprovalByIdMap(params.approvalsByAgentId, params.approvalId) + : params.approvalsByAgentId, + unscopedApprovals: hasUnscoped + ? removePendingApprovalById(params.unscopedApprovals, params.approvalId) + : params.unscopedApprovals, + }; +}; + +export const removePendingApprovalByIdMap = ( + approvalsByAgentId: Record<string, PendingExecApproval[]>, + approvalId: string +): Record<string, PendingExecApproval[]> => { + let changed = false; + const next: Record<string, PendingExecApproval[]> = {}; + for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) { + const filtered = removePendingApprovalById(approvals, approvalId); + if (filtered.length !== approvals.length) { + changed = true; + } + if (filtered.length > 0) { + next[agentId] = filtered; + } + } + return changed ? next : approvalsByAgentId; +}; + +export const pruneExpiredPendingApprovals = ( + approvals: PendingExecApproval[], + params: { nowMs: number; graceMs: number } +): PendingExecApproval[] => { + const cutoff = params.nowMs - params.graceMs; + return approvals.filter((approval) => approval.expiresAtMs >= cutoff); +}; + +export const pruneExpiredPendingApprovalsMap = ( + approvalsByAgentId: Record<string, PendingExecApproval[]>, + params: { nowMs: number; graceMs: number } +): Record<string, PendingExecApproval[]> => { + let changed = false; + const next: Record<string, PendingExecApproval[]> = {}; + for (const [agentId, approvals] of Object.entries(approvalsByAgentId)) { + const filtered = pruneExpiredPendingApprovals(approvals, params); + if (filtered.length !== approvals.length) { + changed = true; + } + if (filtered.length > 0) { + next[agentId] = filtered; + } + } + return changed ? next : approvalsByAgentId; +}; + +export const nextPendingApprovalPruneDelayMs = (params: { + approvalsByAgentId: Record<string, PendingExecApproval[]>; + unscopedApprovals: PendingExecApproval[]; + nowMs: number; + graceMs: number; +}): number | null => { + let earliestExpiresMs = Number.POSITIVE_INFINITY; + for (const approvals of Object.values(params.approvalsByAgentId)) { + for (const approval of approvals) { + if (approval.expiresAtMs < earliestExpiresMs) { + earliestExpiresMs = approval.expiresAtMs; + } + } + } + for (const approval of params.unscopedApprovals) { + if (approval.expiresAtMs < earliestExpiresMs) { + earliestExpiresMs = approval.expiresAtMs; + } + } + if (!Number.isFinite(earliestExpiresMs)) { + return null; + } + return Math.max(0, earliestExpiresMs + params.graceMs - params.nowMs); +}; diff --git a/src/features/agents/approvals/types.ts b/src/features/agents/approvals/types.ts new file mode 100644 index 00000000..78eb9f77 --- /dev/null +++ b/src/features/agents/approvals/types.ts @@ -0,0 +1,17 @@ +export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export type PendingExecApproval = { + id: string; + agentId: string | null; + sessionKey: string | null; + command: string; + cwd: string | null; + host: string | null; + security: string | null; + ask: string | null; + resolvedPath: string | null; + createdAtMs: number; + expiresAtMs: number; + resolving: boolean; + error: string | null; +}; diff --git a/src/features/agents/components/AgentAvatar.tsx b/src/features/agents/components/AgentAvatar.tsx new file mode 100644 index 00000000..22321235 --- /dev/null +++ b/src/features/agents/components/AgentAvatar.tsx @@ -0,0 +1,43 @@ +import Image from "next/image"; +import { useMemo } from "react"; + +import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; + +type AgentAvatarProps = { + seed: string; + name: string; + avatarUrl?: string | null; + size?: number; + isSelected?: boolean; +}; + +export const AgentAvatar = ({ + seed, + name, + avatarUrl, + size = 112, + isSelected = false, +}: AgentAvatarProps) => { + const src = useMemo(() => { + const trimmed = avatarUrl?.trim(); + if (trimmed) return trimmed; + return buildAvatarDataUrl(seed); + }, [avatarUrl, seed]); + + return ( + <div + className={`flex items-center justify-center overflow-hidden rounded-full border border-border/80 bg-card transition-transform duration-300 ${isSelected ? "agent-avatar-selected scale-[1.02]" : ""}`} + style={{ width: size, height: size }} + > + <Image + className="pointer-events-none h-full w-full select-none" + src={src} + alt={`Avatar for ${name}`} + width={size} + height={size} + unoptimized + draggable={false} + /> + </div> + ); +}; diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx new file mode 100644 index 00000000..63c721ab --- /dev/null +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -0,0 +1,1563 @@ +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, + type MutableRefObject, + type ReactNode, +} from "react"; +import type { AgentState as AgentRecord } from "@/features/agents/state/store"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Check, ChevronRight, Clock, Cog, Pencil, Shuffle, Trash2, X } from "lucide-react"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; +import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; +import { isNearBottom } from "@/lib/dom"; +import { AgentAvatar } from "./AgentAvatar"; +import type { + ExecApprovalDecision, + PendingExecApproval, +} from "@/features/agents/approvals/types"; +import { + buildAgentChatRenderBlocks, + buildFinalAgentChatItems, + summarizeToolLabel, + type AssistantTraceEvent, + type AgentChatItem, +} from "./chatItems"; + +const formatChatTimestamp = (timestampMs: number): string => { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }).format(new Date(timestampMs)); +}; + +const formatDurationLabel = (durationMs: number): string => { + const seconds = durationMs / 1000; + if (!Number.isFinite(seconds) || seconds <= 0) return "0.0s"; + if (seconds < 10) return `${seconds.toFixed(1)}s`; + return `${Math.round(seconds)}s`; +}; + +const SPINE_LEFT = "left-[15px]"; +const ASSISTANT_GUTTER_CLASS = "pl-[44px]"; +const ASSISTANT_MAX_WIDTH_DEFAULT_CLASS = "max-w-[68ch]"; +const ASSISTANT_MAX_WIDTH_EXPANDED_CLASS = "max-w-[1120px]"; +const CHAT_TOP_THRESHOLD_PX = 8; +const EMPTY_CHAT_INTRO_MESSAGES = [ + "How can I help you today?", + "What should we accomplish today?", + "Ready when you are. What do you want to tackle?", + "What are we working on today?", + "I'm here and ready. What's the plan?", +]; + +const stableStringHash = (value: string): number => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +}; + +const resolveEmptyChatIntroMessage = (agentId: string, sessionEpoch: number | undefined): string => { + if (EMPTY_CHAT_INTRO_MESSAGES.length === 0) return "How can I help you today?"; + const normalizedEpoch = + typeof sessionEpoch === "number" && Number.isFinite(sessionEpoch) + ? Math.max(0, Math.trunc(sessionEpoch)) + : 0; + const offset = stableStringHash(agentId) % EMPTY_CHAT_INTRO_MESSAGES.length; + const index = (offset + normalizedEpoch) % EMPTY_CHAT_INTRO_MESSAGES.length; + return EMPTY_CHAT_INTRO_MESSAGES[index]; +}; + +const looksLikePath = (value: string): boolean => { + if (!value) return false; + if (/(^|[\s(])(?:[A-Za-z]:\\|~\/|\/)/.test(value)) return true; + if (/(^|[\s(])(src|app|packages|components)\//.test(value)) return true; + if (/(^|[\s(])[\w.-]+\.(ts|tsx|js|jsx|json|md|py|go|rs|java|kt|rb|sh|yaml|yml)\b/.test(value)) { + return true; + } + return false; +}; + +const isStructuredMarkdown = (text: string): boolean => { + if (!text) return false; + if (/```/.test(text)) return true; + if (/^\s*#{1,6}\s+/m.test(text)) return true; + if (/^\s*[-*+]\s+/m.test(text)) return true; + if (/^\s*\d+\.\s+/m.test(text)) return true; + if (/^\s*\|.+\|\s*$/m.test(text)) return true; + if (looksLikePath(text) && text.split("\n").filter(Boolean).length >= 3) return true; + return false; +}; + +const resolveAssistantMaxWidthClass = (text: string | null | undefined): string => { + const value = (text ?? "").trim(); + if (!value) return ASSISTANT_MAX_WIDTH_DEFAULT_CLASS; + if (isStructuredMarkdown(value)) return ASSISTANT_MAX_WIDTH_EXPANDED_CLASS; + const nonEmptyLines = value.split("\n").filter((line) => line.trim().length > 0); + const shortLineCount = nonEmptyLines.filter((line) => line.trim().length <= 44).length; + if (nonEmptyLines.length >= 10 && shortLineCount / Math.max(1, nonEmptyLines.length) >= 0.65) { + return ASSISTANT_MAX_WIDTH_EXPANDED_CLASS; + } + return ASSISTANT_MAX_WIDTH_DEFAULT_CLASS; +}; + +type AgentChatPanelProps = { + agent: AgentRecord; + isSelected: boolean; + canSend: boolean; + models: GatewayModelChoice[]; + stopBusy: boolean; + stopDisabledReason?: string | null; + onLoadMoreHistory: () => void; + onOpenSettings: () => void; + onRename?: (name: string) => Promise<boolean>; + onNewSession?: () => Promise<void> | void; + onModelChange: (value: string | null) => void; + onThinkingChange: (value: string | null) => void; + onToolCallingToggle?: (enabled: boolean) => void; + onThinkingTracesToggle?: (enabled: boolean) => void; + onDraftChange: (value: string) => void; + onSend: (message: string) => void; + onRemoveQueuedMessage?: (index: number) => void; + onStopRun: () => void; + onAvatarShuffle: () => void; + pendingExecApprovals?: PendingExecApproval[]; + onResolveExecApproval?: (id: string, decision: ExecApprovalDecision) => void; +}; + +const formatApprovalExpiry = (timestampMs: number): string => { + if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "Unknown"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestampMs)); +}; + +const ExecApprovalCard = memo(function ExecApprovalCard({ + approval, + onResolve, +}: { + approval: PendingExecApproval; + onResolve?: (id: string, decision: ExecApprovalDecision) => void; +}) { + const disabled = approval.resolving || !onResolve; + return ( + <div + className={`w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} ui-badge-approval self-start rounded-md px-3 py-2 shadow-2xs`} + data-testid={`exec-approval-card-${approval.id}`} + > + <div className="type-meta"> + Exec approval required + </div> + <div className="mt-2 rounded-md bg-surface-3 px-2 py-1.5 shadow-2xs"> + <div className="font-mono text-[10px] font-semibold text-foreground">{approval.command}</div> + </div> + <div className="mt-2 grid gap-1 text-[11px] text-muted-foreground sm:grid-cols-2"> + <div>Host: {approval.host ?? "unknown"}</div> + <div>Expires: {formatApprovalExpiry(approval.expiresAtMs)}</div> + {approval.cwd ? <div className="sm:col-span-2">CWD: {approval.cwd}</div> : null} + </div> + {approval.error ? ( + <div className="ui-alert-danger mt-2 rounded-md px-2 py-1 text-[11px] shadow-2xs"> + {approval.error} + </div> + ) : null} + <div className="mt-2 flex flex-wrap gap-2"> + <button + type="button" + className="rounded-md border border-border/70 bg-surface-3 px-2.5 py-1 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60" + onClick={() => onResolve?.(approval.id, "allow-once")} + disabled={disabled} + aria-label={`Allow once for exec approval ${approval.id}`} + > + Allow once + </button> + <button + type="button" + className="rounded-md border border-border/70 bg-surface-3 px-2.5 py-1 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60" + onClick={() => onResolve?.(approval.id, "allow-always")} + disabled={disabled} + aria-label={`Always allow for exec approval ${approval.id}`} + > + Always allow + </button> + <button + type="button" + className="ui-btn-danger rounded-md px-2.5 py-1 font-mono text-[12px] font-medium tracking-[0.02em] transition disabled:cursor-not-allowed disabled:opacity-60" + onClick={() => onResolve?.(approval.id, "deny")} + disabled={disabled} + aria-label={`Deny exec approval ${approval.id}`} + > + Deny + </button> + </div> + </div> + ); +}); + +const ToolCallDetails = memo(function ToolCallDetails({ + line, + className, +}: { + line: string; + className?: string; +}) { + const { summaryText, body, inlineOnly } = summarizeToolLabel(line); + const [open, setOpen] = useState(false); + const resolvedClassName = + className ?? + `w-full ${ASSISTANT_MAX_WIDTH_EXPANDED_CLASS} ${ASSISTANT_GUTTER_CLASS} self-start rounded-md bg-surface-3 px-2 py-1 text-[10px] text-muted-foreground shadow-2xs`; + if (inlineOnly) { + return ( + <div className={resolvedClassName}> + <div className="font-mono text-[10px] font-semibold tracking-[0.11em]">{summaryText}</div> + </div> + ); + } + return ( + <details open={open} className={resolvedClassName}> + <summary + className="cursor-pointer select-none font-mono text-[10px] font-semibold tracking-[0.11em]" + onClick={(event) => { + event.preventDefault(); + setOpen((current) => !current); + }} + > + {summaryText} + </summary> + {open && body ? ( + <div className="agent-markdown agent-tool-markdown mt-1 text-foreground"> + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {rewriteMediaLinesToMarkdown(body)} + </ReactMarkdown> + </div> + ) : null} + </details> + ); +}); + +const ThinkingDetailsRow = memo(function ThinkingDetailsRow({ + events, + thinkingText, + toolLines = [], + durationMs, + showTyping, +}: { + events?: AssistantTraceEvent[]; + thinkingText?: string | null; + toolLines?: string[]; + durationMs?: number; + showTyping?: boolean; +}) { + const [open, setOpen] = useState(false); + const traceEvents = (() => { + if (events && events.length > 0) return events; + const normalizedThinkingText = thinkingText?.trim() ?? ""; + const next: AssistantTraceEvent[] = []; + if (normalizedThinkingText) { + next.push({ kind: "thinking", text: normalizedThinkingText }); + } + for (const line of toolLines) { + next.push({ kind: "tool", text: line }); + } + return next; + })(); + if (traceEvents.length === 0) return null; + return ( + <details + open={open} + className="ui-chat-thinking group rounded-md px-2 py-1.5 text-[10px] shadow-2xs" + > + <summary + className="flex cursor-pointer list-none items-center gap-2 opacity-65 [&::-webkit-details-marker]:hidden" + onClick={(event) => { + event.preventDefault(); + setOpen((current) => !current); + }} + > + <ChevronRight className="h-3 w-3 shrink-0 transition group-open:rotate-90" /> + <span className="flex min-w-0 items-center gap-2"> + <span className="font-mono text-[10px] font-medium tracking-[0.02em]"> + Thinking (internal) + </span> + {typeof durationMs === "number" ? ( + <span className="inline-flex items-center gap-1 font-mono text-[10px] font-medium tracking-[0.02em] text-muted-foreground/80"> + <Clock className="h-3 w-3" /> + {formatDurationLabel(durationMs)} + </span> + ) : null} + {showTyping ? ( + <span className="typing-dots" aria-hidden="true"> + <span /> + <span /> + <span /> + </span> + ) : null} + </span> + </summary> + {open ? ( + <div className="mt-2 space-y-2 pl-5"> + {traceEvents.map((event, index) => + event.kind === "thinking" ? ( + <div + key={`thinking-event-${index}-${event.text.slice(0, 48)}`} + className="agent-markdown min-w-0 text-foreground/85" + > + <ReactMarkdown remarkPlugins={[remarkGfm]}>{event.text}</ReactMarkdown> + </div> + ) : ( + <ToolCallDetails + key={`thinking-tool-${index}-${event.text.slice(0, 48)}`} + line={event.text} + className="rounded-md border border-border/45 bg-surface-2/65 px-2 py-1 text-[10px] text-muted-foreground/90 shadow-2xs" + /> + ) + )} + </div> + ) : null} + </details> + ); +}); + +const UserMessageCard = memo(function UserMessageCard({ + text, + timestampMs, +}: { + text: string; + timestampMs?: number; +}) { + return ( + <div className="ui-chat-user-card w-full max-w-[70ch] self-end overflow-hidden rounded-[var(--radius-small)] bg-[color:var(--chat-user-bg)]"> + <div className="flex items-center justify-between gap-3 bg-[color:var(--chat-user-header-bg)] px-3 py-2 dark:px-3.5 dark:py-2.5"> + <div className="type-meta min-w-0 truncate font-mono text-foreground/90"> + You + </div> + {typeof timestampMs === "number" ? ( + <time className="type-meta shrink-0 rounded-md bg-surface-3 px-2 py-0.5 font-mono text-muted-foreground/70"> + {formatChatTimestamp(timestampMs)} + </time> + ) : null} + </div> + <div className="agent-markdown type-body px-3 py-3 text-foreground dark:px-3.5 dark:py-3.5"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> + </div> + </div> + ); +}); + +const AssistantMessageCard = memo(function AssistantMessageCard({ + avatarSeed, + avatarUrl, + name, + timestampMs, + thinkingEvents, + thinkingText, + thinkingToolLines, + thinkingDurationMs, + contentText, + streaming, +}: { + avatarSeed: string; + avatarUrl: string | null; + name: string; + timestampMs?: number; + thinkingEvents?: AssistantTraceEvent[]; + thinkingText?: string | null; + thinkingToolLines?: string[]; + thinkingDurationMs?: number; + contentText?: string | null; + streaming?: boolean; +}) { + const resolvedTimestamp = typeof timestampMs === "number" ? timestampMs : null; + const hasThinking = Boolean( + (thinkingEvents?.length ?? 0) > 0 || + thinkingText?.trim() || + (thinkingToolLines?.length ?? 0) > 0 + ); + const widthClass = hasThinking + ? ASSISTANT_MAX_WIDTH_EXPANDED_CLASS + : resolveAssistantMaxWidthClass(contentText); + const hasContent = Boolean(contentText?.trim()); + const compactStreamingIndicator = Boolean(streaming && !hasThinking && !hasContent); + + return ( + <div className="w-full self-start"> + <div className={`relative w-full ${widthClass} ${ASSISTANT_GUTTER_CLASS}`}> + <div className="absolute left-[4px] top-[2px]"> + <AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} /> + </div> + <div className="flex items-center justify-between gap-3 py-0.5"> + <div className="type-meta min-w-0 truncate font-mono text-foreground/90"> + {name} + </div> + {resolvedTimestamp !== null ? ( + <time className="type-meta shrink-0 rounded-md bg-surface-3 px-2 py-0.5 font-mono text-muted-foreground/90"> + {formatChatTimestamp(resolvedTimestamp)} + </time> + ) : null} + </div> + + {compactStreamingIndicator ? ( + <div + className="mt-2 inline-flex items-center gap-2 rounded-md bg-surface-3 px-3 py-2 text-[10px] text-muted-foreground/80 shadow-2xs" + role="status" + aria-live="polite" + data-testid="agent-typing-indicator" + > + <span className="font-mono text-[10px] font-medium tracking-[0.02em]"> + Thinking + </span> + <span className="typing-dots" aria-hidden="true"> + <span /> + <span /> + <span /> + </span> + </div> + ) : ( + <div className="mt-2 space-y-3 dark:space-y-5"> + {streaming && !hasThinking ? ( + <div + className="flex items-center gap-2 text-[10px] text-muted-foreground/80" + role="status" + aria-live="polite" + data-testid="agent-typing-indicator" + > + <span className="font-mono text-[10px] font-medium tracking-[0.02em]"> + Thinking + </span> + <span className="typing-dots" aria-hidden="true"> + <span /> + <span /> + <span /> + </span> + </div> + ) : null} + + {hasThinking ? ( + <ThinkingDetailsRow + events={thinkingEvents} + thinkingText={thinkingText} + toolLines={thinkingToolLines ?? []} + durationMs={thinkingDurationMs} + showTyping={streaming} + /> + ) : null} + + {contentText ? ( + <div className="ui-chat-assistant-card"> + {streaming ? ( + (() => { + if (!contentText.includes("MEDIA:")) { + return ( + <div className="whitespace-pre-wrap break-words text-foreground"> + {contentText} + </div> + ); + } + const rewritten = rewriteMediaLinesToMarkdown(contentText); + if (!rewritten.includes("![](")) { + return ( + <div className="whitespace-pre-wrap break-words text-foreground"> + {contentText} + </div> + ); + } + return ( + <div className="agent-markdown text-foreground"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{rewritten}</ReactMarkdown> + </div> + ); + })() + ) : ( + <div className="agent-markdown text-foreground"> + <ReactMarkdown remarkPlugins={[remarkGfm]}> + {rewriteMediaLinesToMarkdown(contentText)} + </ReactMarkdown> + </div> + )} + </div> + ) : null} + </div> + )} + </div> + </div> + ); +}); + +const AssistantIntroCard = memo(function AssistantIntroCard({ + avatarSeed, + avatarUrl, + name, + title, +}: { + avatarSeed: string; + avatarUrl: string | null; + name: string; + title: string; +}) { + return ( + <div className="w-full self-start"> + <div className={`relative w-full ${ASSISTANT_MAX_WIDTH_DEFAULT_CLASS} ${ASSISTANT_GUTTER_CLASS}`}> + <div className="absolute left-[4px] top-[2px]"> + <AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} /> + </div> + <div className="flex items-center justify-between gap-3 py-0.5"> + <div className="type-meta min-w-0 truncate font-mono text-foreground/90"> + {name} + </div> + </div> + <div className="ui-chat-assistant-card mt-2"> + <div className="text-[14px] leading-[1.65] text-foreground">{title}</div> + <div className="mt-2 font-mono text-[10px] tracking-[0.03em] text-muted-foreground/80"> + Try describing a task, bug, or question to get started. + </div> + </div> + </div> + </div> + ); +}); + +const AgentChatFinalItems = memo(function AgentChatFinalItems({ + agentId, + name, + avatarSeed, + avatarUrl, + chatItems, + running, + runStartedAt, +}: { + agentId: string; + name: string; + avatarSeed: string; + avatarUrl: string | null; + chatItems: AgentChatItem[]; + running: boolean; + runStartedAt: number | null; +}) { + const blocks = buildAgentChatRenderBlocks(chatItems); + + return ( + <> + {blocks.map((block, index) => { + if (block.kind === "user") { + return ( + <UserMessageCard + key={`chat-${agentId}-user-${index}`} + text={block.text} + timestampMs={block.timestampMs} + /> + ); + } + const streaming = running && index === blocks.length - 1 && !block.text; + return ( + <AssistantMessageCard + key={`chat-${agentId}-assistant-${index}`} + avatarSeed={avatarSeed} + avatarUrl={avatarUrl} + name={name} + timestampMs={block.timestampMs ?? (streaming ? runStartedAt ?? undefined : undefined)} + thinkingEvents={block.traceEvents} + thinkingDurationMs={block.thinkingDurationMs} + contentText={block.text} + streaming={streaming} + /> + ); + })} + </> + ); +}); + +const AgentChatTranscript = memo(function AgentChatTranscript({ + agentId, + name, + avatarSeed, + avatarUrl, + status, + historyMaybeTruncated, + historyFetchedCount, + historyFetchLimit, + onLoadMoreHistory, + chatItems, + liveThinkingText, + liveAssistantText, + showTypingIndicator, + outputLineCount, + liveAssistantCharCount, + liveThinkingCharCount, + runStartedAt, + scrollToBottomNextOutputRef, + pendingExecApprovals, + onResolveExecApproval, + emptyStateTitle, +}: { + agentId: string; + name: string; + avatarSeed: string; + avatarUrl: string | null; + status: AgentRecord["status"]; + historyMaybeTruncated: boolean; + historyFetchedCount: number | null; + historyFetchLimit: number | null; + onLoadMoreHistory: () => void; + chatItems: AgentChatItem[]; + liveThinkingText: string; + liveAssistantText: string; + showTypingIndicator: boolean; + outputLineCount: number; + liveAssistantCharCount: number; + liveThinkingCharCount: number; + runStartedAt: number | null; + scrollToBottomNextOutputRef: MutableRefObject<boolean>; + pendingExecApprovals: PendingExecApproval[]; + onResolveExecApproval?: (id: string, decision: ExecApprovalDecision) => void; + emptyStateTitle: string; +}) { + const chatRef = useRef<HTMLDivElement | null>(null); + const chatBottomRef = useRef<HTMLDivElement | null>(null); + const scrollFrameRef = useRef<number | null>(null); + const pinnedRef = useRef(true); + const [isPinned, setIsPinned] = useState(true); + const [isAtTop, setIsAtTop] = useState(false); + const [nowMs, setNowMs] = useState<number | null>(null); + + const scrollChatToBottom = useCallback(() => { + if (!chatRef.current) return; + if (chatBottomRef.current) { + chatBottomRef.current.scrollIntoView({ block: "end" }); + return; + } + chatRef.current.scrollTop = chatRef.current.scrollHeight; + }, []); + + const setPinned = useCallback((nextPinned: boolean) => { + if (pinnedRef.current === nextPinned) return; + pinnedRef.current = nextPinned; + setIsPinned(nextPinned); + }, []); + + const updatePinnedFromScroll = useCallback(() => { + const el = chatRef.current; + if (!el) return; + const nextAtTop = el.scrollTop <= CHAT_TOP_THRESHOLD_PX; + setIsAtTop((current) => (current === nextAtTop ? current : nextAtTop)); + setPinned( + isNearBottom( + { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }, + 48 + ) + ); + }, [setPinned]); + + const scheduleScrollToBottom = useCallback(() => { + if (scrollFrameRef.current !== null) return; + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + scrollChatToBottom(); + }); + }, [scrollChatToBottom]); + + useEffect(() => { + updatePinnedFromScroll(); + }, [updatePinnedFromScroll]); + + const showJumpToLatest = + !isPinned && (outputLineCount > 0 || liveAssistantCharCount > 0 || liveThinkingCharCount > 0); + + useEffect(() => { + const shouldForceScroll = scrollToBottomNextOutputRef.current; + if (shouldForceScroll) { + scrollToBottomNextOutputRef.current = false; + scheduleScrollToBottom(); + return; + } + + if (pinnedRef.current) { + scheduleScrollToBottom(); + return; + } + }, [ + liveAssistantCharCount, + liveThinkingCharCount, + outputLineCount, + pendingExecApprovals.length, + scheduleScrollToBottom, + scrollToBottomNextOutputRef, + ]); + + useEffect(() => { + return () => { + if (scrollFrameRef.current !== null) { + cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = null; + } + }; + }, []); + + const showLiveAssistantCard = + status === "running" && Boolean(liveThinkingText || liveAssistantText || showTypingIndicator); + const hasApprovals = pendingExecApprovals.length > 0; + const hasTranscriptContent = chatItems.length > 0 || hasApprovals; + + useEffect(() => { + if (status !== "running" || typeof runStartedAt !== "number" || !showLiveAssistantCard) { + return; + } + + const timeoutId = window.setTimeout(() => { + setNowMs(Date.now()); + }, 0); + const intervalId = window.setInterval(() => setNowMs(Date.now()), 250); + + return () => { + window.clearTimeout(timeoutId); + window.clearInterval(intervalId); + }; + }, [runStartedAt, showLiveAssistantCard, status]); + + return ( + <div className="relative flex-1 overflow-hidden"> + <div + ref={chatRef} + data-testid="agent-chat-scroll" + className={`ui-chat-scroll ui-chat-scroll-borderless h-full overflow-auto p-4 dark:p-6 sm:p-5 dark:sm:p-7 ${showJumpToLatest ? "pb-20" : ""}`} + onScroll={() => updatePinnedFromScroll()} + onWheel={(event) => { + event.stopPropagation(); + }} + onWheelCapture={(event) => { + event.stopPropagation(); + }} + > + <div className="relative flex flex-col gap-6 dark:gap-8 text-[14px] leading-[1.65] text-foreground"> + <div aria-hidden className={`pointer-events-none absolute ${SPINE_LEFT} top-0 bottom-0 w-px bg-border/20`} /> + {historyMaybeTruncated && isAtTop ? ( + <div className="-mx-1 flex items-center justify-between gap-3 rounded-md bg-surface-2 px-3 py-2 shadow-2xs"> + <div className="type-meta min-w-0 truncate font-mono text-muted-foreground"> + Showing most recent {typeof historyFetchedCount === "number" ? historyFetchedCount : "?"} messages + {typeof historyFetchLimit === "number" ? ` (limit ${historyFetchLimit})` : ""} + </div> + <button + type="button" + className="shrink-0 rounded-md border border-border/70 bg-surface-3 px-3 py-1.5 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2" + onClick={onLoadMoreHistory} + > + Load more + </button> + </div> + ) : null} + {!hasTranscriptContent ? ( + <AssistantIntroCard + avatarSeed={avatarSeed} + avatarUrl={avatarUrl} + name={name} + title={emptyStateTitle} + /> + ) : ( + <> + <AgentChatFinalItems + agentId={agentId} + name={name} + avatarSeed={avatarSeed} + avatarUrl={avatarUrl} + chatItems={chatItems} + running={status === "running"} + runStartedAt={runStartedAt} + /> + {showLiveAssistantCard ? ( + <AssistantMessageCard + avatarSeed={avatarSeed} + avatarUrl={avatarUrl} + name={name} + timestampMs={runStartedAt ?? undefined} + thinkingText={liveThinkingText || null} + thinkingDurationMs={ + typeof runStartedAt === "number" && typeof nowMs === "number" + ? Math.max(0, nowMs - runStartedAt) + : undefined + } + contentText={liveAssistantText || null} + streaming={status === "running"} + /> + ) : null} + {pendingExecApprovals.map((approval) => ( + <ExecApprovalCard + key={approval.id} + approval={approval} + onResolve={onResolveExecApproval} + /> + ))} + <div ref={chatBottomRef} /> + </> + )} + </div> + </div> + + {showJumpToLatest ? ( + <button + type="button" + className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-md border border-border/70 bg-card px-3 py-1.5 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground shadow-xs transition hover:bg-surface-2" + onClick={() => { + setPinned(true); + scrollChatToBottom(); + }} + aria-label="Jump to latest" + > + Jump to latest + </button> + ) : null} + </div> + ); +}); + +const noopToggle = () => {}; +const InlineHoverTooltip = ({ + text, + children, +}: { + text: string; + children: ReactNode; +}) => { + return ( + <div className="group/tooltip relative inline-flex"> + {children} + <span + role="tooltip" + className="pointer-events-none absolute -top-7 left-1/2 z-20 w-max max-w-none -translate-x-1/2 whitespace-nowrap rounded-md border border-border/70 bg-card px-2 py-1 font-mono text-[10px] text-foreground opacity-0 shadow-sm transition-opacity duration-150 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100" + > + {text} + </span> + </div> + ); +}; + +const AgentChatComposer = memo(function AgentChatComposer({ + value, + onChange, + onKeyDown, + onSend, + onStop, + canSend, + stopBusy, + stopDisabledReason, + running, + sendDisabled, + queuedMessages, + onRemoveQueuedMessage, + inputRef, + modelOptions, + modelValue, + allowThinking, + thinkingValue, + onModelChange, + onThinkingChange, + toolCallingEnabled, + showThinkingTraces, + onToolCallingToggle, + onThinkingTracesToggle, +}: { + value: string; + onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void; + onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void; + onSend: () => void; + onStop: () => void; + canSend: boolean; + stopBusy: boolean; + stopDisabledReason?: string | null; + running: boolean; + sendDisabled: boolean; + queuedMessages: string[]; + onRemoveQueuedMessage?: (index: number) => void; + inputRef: (el: HTMLTextAreaElement | HTMLInputElement | null) => void; + modelOptions: { value: string; label: string }[]; + modelValue: string; + allowThinking: boolean; + thinkingValue: string; + onModelChange: (value: string | null) => void; + onThinkingChange: (value: string | null) => void; + toolCallingEnabled: boolean; + showThinkingTraces: boolean; + onToolCallingToggle: (enabled: boolean) => void; + onThinkingTracesToggle: (enabled: boolean) => void; +}) { + const stopReason = stopDisabledReason?.trim() ?? ""; + const stopDisabled = !canSend || stopBusy || Boolean(stopReason); + const stopAriaLabel = stopReason ? `Stop unavailable: ${stopReason}` : "Stop"; + const modelSelectedLabel = useMemo(() => { + if (modelOptions.length === 0) return "No models found"; + return modelOptions.find((option) => option.value === modelValue)?.label ?? modelValue; + }, [modelOptions, modelValue]); + const modelSelectWidthCh = Math.max(11, Math.min(44, modelSelectedLabel.length + 6)); + const thinkingSelectedLabel = useMemo(() => { + switch (thinkingValue) { + case "off": + return "Off"; + case "minimal": + return "Minimal"; + case "low": + return "Low"; + case "medium": + return "Medium"; + case "high": + return "High"; + case "xhigh": + return "XHigh"; + default: + return "Default"; + } + }, [thinkingValue]); + const thinkingSelectWidthCh = Math.max(9, Math.min(22, thinkingSelectedLabel.length + 6)); + return ( + <div className="rounded-2xl border border-border/65 bg-surface-2/45 px-3 py-2"> + {queuedMessages.length > 0 ? ( + <div + className={`mb-2 grid items-start gap-2 ${ + running ? "grid-cols-[minmax(0,1fr)_auto_auto]" : "grid-cols-[minmax(0,1fr)_auto]" + }`} + > + <div + className="min-w-0 max-w-full space-y-1 overflow-hidden" + data-testid="queued-messages-bar" + aria-label="Queued messages" + > + {queuedMessages.map((queuedMessage, index) => ( + <div + key={`${index}-${queuedMessage}`} + className="flex w-full min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border border-border/70 bg-card/80 px-2 py-1 text-[11px] text-foreground" + > + <span className="font-mono text-[10px] uppercase tracking-[0.06em] text-muted-foreground"> + Queued + </span> + <span + className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" + title={queuedMessage} + > + {queuedMessage} + </span> + <button + type="button" + className="inline-flex h-4 w-4 flex-none items-center justify-center rounded-sm text-muted-foreground transition hover:bg-surface-2 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" + aria-label={`Remove queued message ${index + 1}`} + onClick={() => onRemoveQueuedMessage?.(index)} + disabled={!onRemoveQueuedMessage} + > + <Trash2 className="h-3 w-3" /> + </button> + </div> + ))} + </div> + {running ? ( + <button + type="button" + aria-hidden="true" + tabIndex={-1} + disabled + className="invisible rounded-md border border-border/70 bg-surface-3 px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground" + > + {stopBusy ? "Stopping" : "Stop"} + </button> + ) : null} + <button + type="button" + aria-hidden="true" + tabIndex={-1} + disabled + className="ui-btn-primary ui-btn-send invisible px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em]" + > + Send + </button> + </div> + ) : null} + <div className="flex items-end gap-2"> + <textarea + ref={inputRef} + rows={1} + value={value} + className="chat-composer-input min-h-[28px] flex-1 resize-none border-0 bg-transparent px-0 py-1 text-[15px] leading-6 text-foreground outline-none shadow-none transition placeholder:text-muted-foreground/65 focus:outline-none focus-visible:outline-none focus-visible:ring-0" + onChange={onChange} + onKeyDown={onKeyDown} + placeholder="type a message" + /> + {running ? ( + <span className="inline-flex" title={stopReason || undefined}> + <button + className="rounded-md border border-border/70 bg-surface-3 px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground" + type="button" + onClick={onStop} + disabled={stopDisabled} + aria-label={stopAriaLabel} + > + {stopBusy ? "Stopping" : "Stop"} + </button> + </span> + ) : null} + <button + className="ui-btn-primary ui-btn-send px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + type="button" + onClick={onSend} + disabled={sendDisabled} + > + Send + </button> + </div> + <div className="mt-1 flex items-center justify-between gap-2"> + <div className="flex min-w-0 items-center gap-2"> + <InlineHoverTooltip text="Choose model"> + <select + className="ui-input ui-control-important h-6 min-w-0 rounded-md px-1.5 text-[10px] font-semibold text-foreground" + aria-label="Model" + value={modelValue} + style={{ width: `${modelSelectWidthCh}ch` }} + onChange={(event) => { + const nextValue = event.target.value.trim(); + onModelChange(nextValue ? nextValue : null); + event.currentTarget.blur(); + }} + > + {modelOptions.length === 0 ? ( + <option value="">No models found</option> + ) : null} + {modelOptions.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </InlineHoverTooltip> + {allowThinking ? ( + <InlineHoverTooltip text="Select reasoning effort"> + <select + className="ui-input ui-control-important h-6 rounded-md px-1.5 text-[10px] font-semibold text-foreground" + aria-label="Thinking" + value={thinkingValue} + style={{ width: `${thinkingSelectWidthCh}ch` }} + onChange={(event) => { + const nextValue = event.target.value.trim(); + onThinkingChange(nextValue ? nextValue : null); + }} + > + <option value="">Default</option> + <option value="off">Off</option> + <option value="minimal">Minimal</option> + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + <option value="xhigh">XHigh</option> + </select> + </InlineHoverTooltip> + ) : null} + </div> + <div className="ml-auto flex items-center gap-1.5 text-[10px] text-muted-foreground"> + <span className="font-mono tracking-[0.02em]">Show</span> + <button + type="button" + role="switch" + aria-label="Show tool calls" + aria-checked={toolCallingEnabled} + className={`inline-flex h-5 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${ + toolCallingEnabled + ? "border-primary/45 bg-primary/14 text-foreground" + : "border-border/70 bg-surface-2/40 text-muted-foreground hover:text-foreground" + }`} + onClick={() => onToolCallingToggle(!toolCallingEnabled)} + > + Tools + </button> + <button + type="button" + role="switch" + aria-label="Show thinking" + aria-checked={showThinkingTraces} + className={`inline-flex h-5 items-center rounded-sm border px-1.5 font-mono text-[10px] tracking-[0.01em] transition ${ + showThinkingTraces + ? "border-primary/45 bg-primary/14 text-foreground" + : "border-border/70 bg-surface-2/40 text-muted-foreground hover:text-foreground" + }`} + onClick={() => onThinkingTracesToggle(!showThinkingTraces)} + > + Thinking + </button> + </div> + </div> + </div> + ); +}); + +export const AgentChatPanel = ({ + agent, + isSelected, + canSend, + models, + stopBusy, + stopDisabledReason = null, + onLoadMoreHistory, + onOpenSettings, + onRename, + onNewSession, + onModelChange, + onThinkingChange, + onToolCallingToggle = noopToggle, + onThinkingTracesToggle = noopToggle, + onDraftChange, + onSend, + onRemoveQueuedMessage, + onStopRun, + onAvatarShuffle, + pendingExecApprovals = [], + onResolveExecApproval, +}: AgentChatPanelProps) => { + const [draftValue, setDraftValue] = useState(agent.draft); + const [newSessionBusy, setNewSessionBusy] = useState(false); + const [renameEditing, setRenameEditing] = useState(false); + const [renameSaving, setRenameSaving] = useState(false); + const [renameDraft, setRenameDraft] = useState(agent.name); + const [renameError, setRenameError] = useState<string | null>(null); + const draftRef = useRef<HTMLTextAreaElement | null>(null); + const renameInputRef = useRef<HTMLInputElement | null>(null); + const renameEditorRef = useRef<HTMLDivElement | null>(null); + const scrollToBottomNextOutputRef = useRef(false); + const plainDraftRef = useRef(agent.draft); + const draftIdentityRef = useRef<{ agentId: string; sessionKey: string }>({ + agentId: agent.agentId, + sessionKey: agent.sessionKey, + }); + const pendingResizeFrameRef = useRef<number | null>(null); + + const resizeDraft = useCallback(() => { + const el = draftRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + el.style.overflowY = el.scrollHeight > el.clientHeight ? "auto" : "hidden"; + }, []); + + const handleDraftRef = useCallback((el: HTMLTextAreaElement | HTMLInputElement | null) => { + draftRef.current = el instanceof HTMLTextAreaElement ? el : null; + }, []); + + useEffect(() => { + const previousIdentity = draftIdentityRef.current; + const identityChanged = + previousIdentity.agentId !== agent.agentId || + previousIdentity.sessionKey !== agent.sessionKey; + if (identityChanged) { + draftIdentityRef.current = { + agentId: agent.agentId, + sessionKey: agent.sessionKey, + }; + plainDraftRef.current = agent.draft; + setDraftValue(agent.draft); + return; + } + if (agent.draft === plainDraftRef.current) return; + if (agent.draft.length !== 0) return; + plainDraftRef.current = ""; + setDraftValue(""); + }, [agent.agentId, agent.draft, agent.sessionKey]); + + useEffect(() => { + setRenameEditing(false); + setRenameSaving(false); + setRenameError(null); + setRenameDraft(agent.name); + }, [agent.agentId, agent.name]); + + useEffect(() => { + if (!renameEditing) return; + const frameId = requestAnimationFrame(() => { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + }); + return () => { + cancelAnimationFrame(frameId); + }; + }, [renameEditing]); + + useEffect(() => { + if (pendingResizeFrameRef.current !== null) { + cancelAnimationFrame(pendingResizeFrameRef.current); + } + pendingResizeFrameRef.current = requestAnimationFrame(() => { + pendingResizeFrameRef.current = null; + resizeDraft(); + }); + return () => { + if (pendingResizeFrameRef.current !== null) { + cancelAnimationFrame(pendingResizeFrameRef.current); + pendingResizeFrameRef.current = null; + } + }; + }, [resizeDraft, draftValue]); + + const handleSend = useCallback( + (message: string) => { + if (!canSend) return; + const trimmed = message.trim(); + if (!trimmed) return; + plainDraftRef.current = ""; + setDraftValue(""); + onDraftChange(""); + scrollToBottomNextOutputRef.current = true; + onSend(trimmed); + }, + [canSend, onDraftChange, onSend] + ); + + const chatItems = useMemo( + () => + buildFinalAgentChatItems({ + outputLines: agent.outputLines, + showThinkingTraces: agent.showThinkingTraces, + toolCallingEnabled: agent.toolCallingEnabled, + }), + [agent.outputLines, agent.showThinkingTraces, agent.toolCallingEnabled] + ); + const running = agent.status === "running"; + const renderBlocks = useMemo(() => buildAgentChatRenderBlocks(chatItems), [chatItems]); + const hasActiveStreamingTailInTranscript = + running && renderBlocks.length > 0 && !renderBlocks[renderBlocks.length - 1].text; + const liveAssistantText = + running && agent.streamText ? normalizeAssistantDisplayText(agent.streamText) : ""; + const liveThinkingText = + running && agent.showThinkingTraces && agent.thinkingTrace ? agent.thinkingTrace.trim() : ""; + const hasVisibleLiveThinking = Boolean(liveThinkingText.trim()); + const showTypingIndicator = + running && + !hasVisibleLiveThinking && + !liveAssistantText && + !hasActiveStreamingTailInTranscript; + + const modelOptions = useMemo( + () => + models.map((entry) => { + const key = `${entry.provider}/${entry.id}`; + const alias = typeof entry.name === "string" ? entry.name.trim() : ""; + return { + value: key, + label: !alias || alias === key ? key : alias, + reasoning: entry.reasoning, + }; + }), + [models] + ); + const modelValue = agent.model ?? ""; + const modelOptionsWithFallback = + modelValue && !modelOptions.some((option) => option.value === modelValue) + ? [{ value: modelValue, label: modelValue, reasoning: undefined }, ...modelOptions] + : modelOptions; + const selectedModel = modelOptionsWithFallback.find((option) => option.value === modelValue); + const allowThinking = selectedModel?.reasoning !== false; + + const avatarSeed = agent.avatarSeed ?? agent.agentId; + const emptyStateTitle = useMemo( + () => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch), + [agent.agentId, agent.sessionEpoch] + ); + const sendDisabled = !canSend || !draftValue.trim(); + + const handleComposerChange = useCallback( + (event: ChangeEvent<HTMLTextAreaElement>) => { + const value = event.target.value; + plainDraftRef.current = value; + setDraftValue(value); + onDraftChange(value); + }, + [onDraftChange] + ); + + const handleComposerKeyDown = useCallback( + (event: KeyboardEvent<HTMLTextAreaElement>) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key !== "Enter" || event.shiftKey) return; + if (event.defaultPrevented) return; + event.preventDefault(); + handleSend(draftValue); + }, + [draftValue, handleSend] + ); + + const handleComposerSend = useCallback(() => { + handleSend(draftValue); + }, [draftValue, handleSend]); + + const beginRename = useCallback(() => { + if (!onRename) return; + setRenameEditing(true); + setRenameDraft(agent.name); + setRenameError(null); + }, [agent.name, onRename]); + + const cancelRename = useCallback(() => { + if (renameSaving) return; + setRenameEditing(false); + setRenameDraft(agent.name); + setRenameError(null); + }, [agent.name, renameSaving]); + + useEffect(() => { + if (!renameEditing) return; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (renameEditorRef.current?.contains(target)) return; + cancelRename(); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + }; + }, [cancelRename, renameEditing]); + + const submitRename = useCallback(async () => { + if (!onRename || renameSaving) return; + const nextName = renameDraft.trim(); + const currentName = agent.name.trim(); + if (!nextName) { + setRenameError("Agent name is required."); + return; + } + if (nextName === currentName) { + setRenameEditing(false); + setRenameError(null); + setRenameDraft(agent.name); + return; + } + setRenameSaving(true); + setRenameError(null); + try { + const ok = await onRename(nextName); + if (!ok) { + setRenameError("Failed to rename agent."); + return; + } + setRenameEditing(false); + setRenameDraft(nextName); + } finally { + setRenameSaving(false); + } + }, [agent.name, onRename, renameDraft, renameSaving]); + + const handleRenameInputKeyDown = useCallback( + (event: KeyboardEvent<HTMLInputElement>) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitRename(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + }, + [cancelRename, submitRename] + ); + + const handleNewSession = useCallback(async () => { + if (!onNewSession || newSessionBusy || !canSend) return; + setNewSessionBusy(true); + try { + await onNewSession(); + } finally { + setNewSessionBusy(false); + } + }, [canSend, newSessionBusy, onNewSession]); + + const newSessionDisabled = newSessionBusy || !canSend || !onNewSession; + + return ( + <div data-agent-panel className="group fade-up relative flex h-full w-full flex-col"> + <div className="px-3 pt-2 sm:px-4 sm:pt-3"> + <div className="flex items-start justify-between gap-4"> + <div className="flex min-w-0 items-start gap-3"> + <div className="group/avatar relative"> + <AgentAvatar + seed={avatarSeed} + name={agent.name} + avatarUrl={agent.avatarUrl ?? null} + size={84} + isSelected={isSelected} + /> + <button + className="nodrag ui-btn-icon ui-btn-icon-xs agent-avatar-shuffle-btn absolute bottom-0.5 right-0.5" + type="button" + aria-label="Shuffle avatar" + data-testid="agent-avatar-shuffle" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onAvatarShuffle(); + }} + > + <Shuffle className="h-2.5 w-2.5" /> + </button> + </div> + + <div className="min-w-0 flex-1"> + <div className="flex min-w-0 items-center gap-2"> + <div className="min-w-0 w-[clamp(11rem,34vw,16rem)]"> + {renameEditing ? ( + <div ref={renameEditorRef} className="flex h-8 items-center gap-1.5"> + <input + ref={renameInputRef} + className="ui-input agent-rename-input h-8 min-w-0 flex-1 rounded-md px-2 text-[12px] font-semibold text-foreground" + aria-label="Edit agent name" + data-testid="agent-rename-input" + value={renameDraft} + disabled={renameSaving} + onChange={(event) => { + setRenameDraft(event.target.value); + if (renameError) setRenameError(null); + }} + onKeyDown={handleRenameInputKeyDown} + /> + <button + className="ui-btn-icon ui-btn-icon-sm agent-rename-control" + type="button" + aria-label="Save agent name" + data-testid="agent-rename-save" + onClick={() => { + void submitRename(); + }} + disabled={renameSaving} + > + <Check className="h-3.5 w-3.5" /> + </button> + <button + className="ui-btn-icon ui-btn-icon-sm agent-rename-control" + type="button" + aria-label="Cancel agent rename" + data-testid="agent-rename-cancel" + onClick={cancelRename} + disabled={renameSaving} + > + <X className="h-3.5 w-3.5" /> + </button> + </div> + ) : ( + <div className="flex h-8 min-w-0 items-center gap-1.5"> + <div className="type-agent-name min-w-0 truncate text-foreground"> + {agent.name} + </div> + {onRename ? ( + <button + className="ui-btn-icon ui-btn-icon-xs agent-rename-control shrink-0" + type="button" + aria-label="Rename agent" + data-testid="agent-rename-toggle" + onClick={beginRename} + > + <Pencil className="h-3 w-3" /> + </button> + ) : null} + </div> + )} + </div> + </div> + {renameError ? ( + <div className="ui-text-danger mt-1 text-[11px]">{renameError}</div> + ) : null} + </div> + </div> + + <div className="mt-0.5 flex items-center gap-2"> + <button + className="nodrag ui-btn-primary px-2.5 py-1.5 font-mono text-[11px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + type="button" + data-testid="agent-new-session-toggle" + aria-label="Start new session" + title="Start new session" + onClick={() => { + void handleNewSession(); + }} + disabled={newSessionDisabled} + > + {newSessionBusy ? "Starting..." : "New session"} + </button> + <button + className="nodrag ui-btn-icon" + type="button" + data-testid="agent-settings-toggle" + aria-label="Open behavior" + title="Behavior" + onClick={onOpenSettings} + > + <Cog className="h-4 w-4" /> + </button> + </div> + </div> + </div> + + <div className="mt-3 flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4"> + <AgentChatTranscript + agentId={agent.agentId} + name={agent.name} + avatarSeed={avatarSeed} + avatarUrl={agent.avatarUrl ?? null} + status={agent.status} + historyMaybeTruncated={agent.historyMaybeTruncated} + historyFetchedCount={agent.historyFetchedCount} + historyFetchLimit={agent.historyFetchLimit} + onLoadMoreHistory={onLoadMoreHistory} + chatItems={chatItems} + liveThinkingText={liveThinkingText} + liveAssistantText={liveAssistantText} + showTypingIndicator={showTypingIndicator} + outputLineCount={agent.outputLines.length} + liveAssistantCharCount={liveAssistantText.length} + liveThinkingCharCount={liveThinkingText.length} + runStartedAt={agent.runStartedAt} + scrollToBottomNextOutputRef={scrollToBottomNextOutputRef} + pendingExecApprovals={pendingExecApprovals} + onResolveExecApproval={onResolveExecApproval} + emptyStateTitle={emptyStateTitle} + /> + + <div className="mt-3"> + <AgentChatComposer + value={draftValue} + inputRef={handleDraftRef} + onChange={handleComposerChange} + onKeyDown={handleComposerKeyDown} + onSend={handleComposerSend} + onStop={onStopRun} + canSend={canSend} + stopBusy={stopBusy} + stopDisabledReason={stopDisabledReason} + running={running} + sendDisabled={sendDisabled} + queuedMessages={agent.queuedMessages ?? []} + onRemoveQueuedMessage={onRemoveQueuedMessage} + modelOptions={modelOptionsWithFallback.map((option) => ({ + value: option.value, + label: option.label, + }))} + modelValue={modelValue} + allowThinking={allowThinking} + thinkingValue={agent.thinkingLevel ?? ""} + onModelChange={onModelChange} + onThinkingChange={onThinkingChange} + toolCallingEnabled={agent.toolCallingEnabled} + showThinkingTraces={agent.showThinkingTraces} + onToolCallingToggle={onToolCallingToggle} + onThinkingTracesToggle={onThinkingTracesToggle} + /> + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/AgentCreateModal.tsx b/src/features/agents/components/AgentCreateModal.tsx new file mode 100644 index 00000000..b7a490b9 --- /dev/null +++ b/src/features/agents/components/AgentCreateModal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { Shuffle } from "lucide-react"; +import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; +import { AgentAvatar } from "@/features/agents/components/AgentAvatar"; +import { randomUUID } from "@/lib/uuid"; + +type AgentCreateModalProps = { + open: boolean; + suggestedName: string; + busy?: boolean; + submitError?: string | null; + onClose: () => void; + onSubmit: (payload: AgentCreateModalSubmitPayload) => Promise<void> | void; +}; + +const fieldClassName = + "ui-input w-full rounded-md px-3 py-2 text-xs text-foreground outline-none"; +const labelClassName = + "font-mono text-[11px] font-semibold tracking-[0.05em] text-muted-foreground"; + +const resolveInitialName = (suggestedName: string): string => { + const trimmed = suggestedName.trim(); + if (!trimmed) return "New Agent"; + return trimmed; +}; + +const AgentCreateModalContent = ({ + suggestedName, + busy, + submitError, + onClose, + onSubmit, +}: Omit<AgentCreateModalProps, "open">) => { + const [name, setName] = useState(() => resolveInitialName(suggestedName)); + const [avatarSeed, setAvatarSeed] = useState(() => randomUUID()); + + const canSubmit = name.trim().length > 0; + + const handleSubmit = () => { + if (!canSubmit || busy) return; + const trimmedName = name.trim(); + if (!trimmedName) return; + void onSubmit({ name: trimmedName, avatarSeed }); + }; + + return ( + <div + className="fixed inset-0 z-[120] flex items-center justify-center bg-background/80 p-4" + role="dialog" + aria-modal="true" + aria-label="Create agent" + onClick={busy ? undefined : onClose} + > + <form + className="ui-panel w-full max-w-2xl shadow-xs" + onSubmit={(event) => { + event.preventDefault(); + handleSubmit(); + }} + onClick={(event) => event.stopPropagation()} + data-testid="agent-create-modal" + > + <div className="flex items-center justify-between border-b border-border/35 px-6 py-6"> + <div> + <div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground"> + New agent + </div> + <div className="mt-1 text-base font-semibold text-foreground">Launch agent</div> + <div className="mt-1 text-xs text-muted-foreground">Name it and activate immediately.</div> + </div> + <button + type="button" + className="ui-btn-ghost px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60" + onClick={onClose} + disabled={busy} + > + Close + </button> + </div> + + <div className="grid gap-4 px-6 py-5"> + <label className={labelClassName}> + Name + <input + aria-label="Agent name" + value={name} + onChange={(event) => setName(event.target.value)} + className={`mt-1 ${fieldClassName}`} + placeholder="My agent" + /> + </label> + <div className="-mt-2 text-[11px] text-muted-foreground"> + You can rename this agent from the main chat header. + </div> + <div className="grid justify-items-center gap-2 border-t border-border/40 pt-3"> + <div className={labelClassName}>Choose avatar</div> + <AgentAvatar + seed={avatarSeed} + name={name.trim() || "New Agent"} + size={64} + isSelected + /> + <button + type="button" + aria-label="Shuffle avatar selection" + className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground" + onClick={() => setAvatarSeed(randomUUID())} + disabled={busy} + > + <Shuffle className="h-3.5 w-3.5" /> + Shuffle + </button> + </div> + + {submitError ? ( + <div className="ui-alert-danger rounded-md px-3 py-2 text-xs"> + {submitError} + </div> + ) : null} + </div> + + <div className="flex items-center justify-between border-t border-border/45 px-6 pb-4 pt-5"> + <div className="text-[11px] text-muted-foreground">Authority can be configured after launch.</div> + <button + type="submit" + className="ui-btn-primary px-3 py-1.5 font-mono text-[11px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + disabled={!canSubmit || busy} + > + {busy ? "Launching..." : "Launch agent"} + </button> + </div> + </form> + </div> + ); +}; + +export const AgentCreateModal = ({ + open, + suggestedName, + busy = false, + submitError = null, + onClose, + onSubmit, +}: AgentCreateModalProps) => { + if (!open) return null; + return ( + <AgentCreateModalContent + suggestedName={suggestedName} + busy={busy} + submitError={submitError} + onClose={onClose} + onSubmit={onSubmit} + /> + ); +}; diff --git a/src/features/agents/components/AgentInspectPanels.tsx b/src/features/agents/components/AgentInspectPanels.tsx new file mode 100644 index 00000000..0e8a0a96 --- /dev/null +++ b/src/features/agents/components/AgentInspectPanels.tsx @@ -0,0 +1,1551 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + AlertTriangle, + Bell, + CalendarDays, + ExternalLink, + ListChecks, + Play, + Sun, + Trash2, + X, + ChevronRight, +} from "lucide-react"; + +import type { AgentState } from "@/features/agents/state/store"; +import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder"; +import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { SkillStatusReport } from "@/lib/skills/types"; +import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles"; +import { + resolveExecutionRoleFromAgent, + resolvePresetDefaultsForRole, + type AgentPermissionsDraft, +} from "@/features/agents/operations/agentPermissionsOperation"; +import { AgentSkillsPanel } from "@/features/agents/components/AgentSkillsPanel"; +import { SystemSkillsPanel } from "@/features/agents/components/SystemSkillsPanel"; +import { + AGENT_FILE_NAMES, + type AgentFileName, + createAgentFilesState, + isAgentFileName, +} from "@/lib/agents/agentFiles"; +import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder"; + +const AgentInspectHeader = ({ + label, + title, + onClose, + closeTestId, + closeDisabled, +}: { + label?: string; + title?: string; + onClose: () => void; + closeTestId: string; + closeDisabled?: boolean; +}) => { + const normalizedLabel = label?.trim() ?? ""; + const normalizedTitle = title?.trim() ?? ""; + const hasLabel = normalizedLabel.length > 0; + const hasTitle = normalizedTitle.length > 0; + if (!hasLabel && !hasTitle) { + return null; + } + return ( + <div className="flex items-center justify-between pl-4 pr-2 pb-3 pt-2"> + <div> + {hasLabel ? ( + <div className="font-mono text-[9px] font-medium tracking-[0.04em] text-muted-foreground/58"> + {normalizedLabel} + </div> + ) : null} + {hasTitle ? ( + <div + className={ + hasLabel + ? "text-[1.45rem] font-semibold leading-[1.05] tracking-[0.01em] text-foreground" + : "font-mono text-[12px] font-semibold tracking-[0.05em] text-foreground" + } + > + {normalizedTitle} + </div> + ) : null} + </div> + <button + className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/55 transition hover:bg-surface-2 hover:text-muted-foreground/85" + type="button" + data-testid={closeTestId} + aria-label="Close panel" + disabled={closeDisabled} + onClick={onClose} + > + <X className="h-4 w-4" aria-hidden="true" /> + </button> + </div> + ); +}; + +type AgentSettingsPanelProps = { + agent: AgentState; + mode?: "capabilities" | "skills" | "system" | "automations" | "advanced"; + showHeader?: boolean; + onClose: () => void; + permissionsDraft?: AgentPermissionsDraft; + onUpdateAgentPermissions?: (draft: AgentPermissionsDraft) => Promise<void> | void; + onDelete: () => void; + canDelete?: boolean; + onToolCallingToggle: (enabled: boolean) => void; + onThinkingTracesToggle: (enabled: boolean) => void; + cronJobs: CronJobSummary[]; + cronLoading: boolean; + cronError: string | null; + cronRunBusyJobId: string | null; + cronDeleteBusyJobId: string | null; + onRunCronJob: (jobId: string) => Promise<void> | void; + onDeleteCronJob: (jobId: string) => Promise<void> | void; + cronCreateBusy?: boolean; + onCreateCronJob?: (draft: CronCreateDraft) => Promise<void> | void; + controlUiUrl?: string | null; + skillsReport?: SkillStatusReport | null; + skillsLoading?: boolean; + skillsError?: string | null; + skillsBusy?: boolean; + skillsBusyKey?: string | null; + skillMessages?: Record<string, { kind: "success" | "error"; message: string }>; + skillApiKeyDrafts?: Record<string, string>; + defaultAgentScopeWarning?: string | null; + systemInitialSkillKey?: string | null; + onSystemInitialSkillHandled?: () => void; + skillsAllowlist?: string[] | undefined; + onSetSkillEnabled?: (skillName: string, enabled: boolean) => Promise<void> | void; + onOpenSystemSetup?: (skillKey?: string) => void; + onSetSkillGlobalEnabled?: (skillKey: string, enabled: boolean) => Promise<void> | void; + onInstallSkill?: (skillKey: string, name: string, installId: string) => Promise<void> | void; + onRemoveSkill?: ( + skill: { skillKey: string; source: string; baseDir: string } + ) => Promise<void> | void; + onSkillApiKeyChange?: (skillKey: string, value: string) => Promise<void> | void; + onSaveSkillApiKey?: (skillKey: string) => Promise<void> | void; +}; + +const formatCronStateLine = (job: CronJobSummary): string | null => { + if (typeof job.state.runningAtMs === "number" && Number.isFinite(job.state.runningAtMs)) { + return "Running now"; + } + if (typeof job.state.nextRunAtMs === "number" && Number.isFinite(job.state.nextRunAtMs)) { + return `Next: ${new Date(job.state.nextRunAtMs).toLocaleString()}`; + } + if (typeof job.state.lastRunAtMs === "number" && Number.isFinite(job.state.lastRunAtMs)) { + const status = job.state.lastStatus ? `${job.state.lastStatus} ` : ""; + return `Last: ${status}${new Date(job.state.lastRunAtMs).toLocaleString()}`.trim(); + } + return null; +}; + +const getFirstLinePreview = (value: string, maxChars: number): string => { + const firstLine = + value + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + if (!firstLine) return ""; + if (firstLine.length <= maxChars) return firstLine; + return `${firstLine.slice(0, maxChars)}...`; +}; + +type CronTemplateOption = { + id: CronCreateTemplateId; + title: string; + description: string; + icon: typeof Sun; +}; + +const CRON_TEMPLATE_OPTIONS: CronTemplateOption[] = [ + { + id: "morning-brief", + title: "Morning Brief", + description: "Daily status summary with overnight updates.", + icon: Sun, + }, + { + id: "reminder", + title: "Reminder", + description: "A timed nudge for a specific event or task.", + icon: Bell, + }, + { + id: "weekly-review", + title: "Weekly Review", + description: "Recurring synthesis across a longer time window.", + icon: CalendarDays, + }, + { + id: "inbox-triage", + title: "Inbox Triage", + description: "Regular sorting and summarizing of incoming updates.", + icon: ListChecks, + }, + { + id: "custom", + title: "Custom", + description: "Start from a blank flow and choose each setting.", + icon: ListChecks, + }, +]; + +const TIMED_AUTOMATION_STEP_META: Array<{ title: string; indicator: string }> = [ + { title: "Choose type", indicator: "Type" }, + { title: "Define function", indicator: "Function" }, + { title: "Set timing", indicator: "Timing" }, + { title: "Review and create", indicator: "Review" }, +]; + +const resolveLocalTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +const createInitialCronDraft = (): CronCreateDraft => ({ + templateId: "morning-brief", + name: "", + taskText: "", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + everyAtTime: "09:00", + everyTimeZone: resolveLocalTimeZone(), + deliveryMode: "none", + deliveryChannel: "last", +}); + +const arePermissionsDraftEqual = (a: AgentPermissionsDraft, b: AgentPermissionsDraft): boolean => + a.commandMode === b.commandMode && + a.webAccess === b.webAccess && + a.fileTools === b.fileTools; + +const applyTemplateDefaults = (templateId: CronCreateTemplateId, current: CronCreateDraft): CronCreateDraft => { + const nextTimeZone = (current.everyTimeZone ?? "").trim() || resolveLocalTimeZone(); + const base = { + ...createInitialCronDraft(), + deliveryMode: current.deliveryMode ?? "none", + deliveryChannel: current.deliveryChannel || "last", + deliveryTo: current.deliveryTo, + advancedSessionTarget: current.advancedSessionTarget, + advancedWakeMode: current.advancedWakeMode, + everyTimeZone: nextTimeZone, + } satisfies CronCreateDraft; + + if (templateId === "morning-brief") { + return { + ...base, + templateId, + name: "Morning brief", + taskText: "Summarize overnight updates and priorities.", + scheduleKind: "every", + everyAmount: 1, + everyUnit: "days", + everyAtTime: "07:00", + }; + } + if (templateId === "reminder") { + return { + ...base, + templateId, + name: "Reminder", + taskText: "Reminder: follow up on today's priority task.", + scheduleKind: "at", + scheduleAt: "", + }; + } + if (templateId === "weekly-review") { + return { + ...base, + templateId, + name: "Weekly review", + taskText: "Summarize wins, blockers, and next-week priorities.", + scheduleKind: "every", + everyAmount: 7, + everyUnit: "days", + everyAtTime: "09:00", + }; + } + if (templateId === "inbox-triage") { + return { + ...base, + templateId, + name: "Inbox triage", + taskText: "Triage unread updates and surface the top actions.", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + }; + } + return { + ...base, + templateId: "custom", + name: "", + taskText: "", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + }; +}; + +export const AgentSettingsPanel = ({ + agent, + mode = "capabilities", + showHeader = true, + onClose, + permissionsDraft, + onUpdateAgentPermissions = () => {}, + onDelete, + canDelete = true, + onToolCallingToggle, + onThinkingTracesToggle, + cronJobs, + cronLoading, + cronError, + cronRunBusyJobId, + cronDeleteBusyJobId, + onRunCronJob, + onDeleteCronJob, + cronCreateBusy = false, + onCreateCronJob = () => {}, + controlUiUrl = null, + skillsReport = null, + skillsLoading = false, + skillsError = null, + skillsBusy = false, + skillsBusyKey = null, + skillMessages = {}, + skillApiKeyDrafts = {}, + defaultAgentScopeWarning = null, + systemInitialSkillKey = null, + onSystemInitialSkillHandled = () => {}, + skillsAllowlist, + onSetSkillEnabled = () => {}, + onOpenSystemSetup = () => {}, + onSetSkillGlobalEnabled = () => {}, + onInstallSkill = () => {}, + onRemoveSkill = () => {}, + onSkillApiKeyChange = () => {}, + onSaveSkillApiKey = () => {}, +}: AgentSettingsPanelProps) => { + const initialPermissionsDraft = + permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent)); + const [permissionsBaselineValue, setPermissionsBaselineValue] = + useState<AgentPermissionsDraft>(initialPermissionsDraft); + const [permissionsDraftValue, setPermissionsDraftValue] = + useState<AgentPermissionsDraft>(initialPermissionsDraft); + const [permissionsSaving, setPermissionsSaving] = useState(false); + const [permissionsSaveState, setPermissionsSaveState] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); + const [permissionsSaveError, setPermissionsSaveError] = useState<string | null>(null); + const permissionsSaveTimerRef = useRef<number | null>(null); + const permissionsDraftAgentIdRef = useRef(agent.agentId); + const [expandedCronJobIds, setExpandedCronJobIds] = useState<Set<string>>(() => new Set()); + const [cronCreateOpen, setCronCreateOpen] = useState(false); + const [cronCreateStep, setCronCreateStep] = useState(0); + const [cronCreateError, setCronCreateError] = useState<string | null>(null); + const [cronDraft, setCronDraft] = useState<CronCreateDraft>(createInitialCronDraft); + + const resolvedExecutionRole = useMemo(() => resolveExecutionRoleFromAgent(agent), [agent]); + const resolvedPermissionsDraft = useMemo( + () => permissionsDraft ?? resolvePresetDefaultsForRole(resolvedExecutionRole), + [permissionsDraft, resolvedExecutionRole] + ); + const permissionsDirty = useMemo( + () => !arePermissionsDraftEqual(permissionsDraftValue, permissionsBaselineValue), + [permissionsBaselineValue, permissionsDraftValue] + ); + + useEffect(() => { + const agentChanged = permissionsDraftAgentIdRef.current !== agent.agentId; + permissionsDraftAgentIdRef.current = agent.agentId; + setPermissionsBaselineValue(resolvedPermissionsDraft); + if (!agentChanged && (permissionsSaving || permissionsDirty)) { + return; + } + setPermissionsDraftValue(resolvedPermissionsDraft); + setPermissionsSaveState("idle"); + setPermissionsSaveError(null); + setPermissionsSaving(false); + }, [agent.agentId, permissionsDirty, permissionsSaving, resolvedPermissionsDraft]); + + const runPermissionsSave = useCallback(async (draft: AgentPermissionsDraft) => { + if (permissionsSaving) return; + setPermissionsSaving(true); + setPermissionsSaveState("saving"); + setPermissionsSaveError(null); + try { + await onUpdateAgentPermissions(draft); + setPermissionsSaveState("saved"); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save permissions."; + setPermissionsSaveState("error"); + setPermissionsSaveError(message); + } finally { + setPermissionsSaving(false); + } + }, [onUpdateAgentPermissions, permissionsSaving]); + + useEffect(() => { + return () => { + if (permissionsSaveTimerRef.current !== null) { + window.clearTimeout(permissionsSaveTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!permissionsDirty) return; + if (permissionsSaving) return; + if (permissionsSaveTimerRef.current !== null) { + window.clearTimeout(permissionsSaveTimerRef.current); + } + setPermissionsSaveState("idle"); + permissionsSaveTimerRef.current = window.setTimeout(() => { + permissionsSaveTimerRef.current = null; + void runPermissionsSave(permissionsDraftValue); + }, 450); + return () => { + if (permissionsSaveTimerRef.current !== null) { + window.clearTimeout(permissionsSaveTimerRef.current); + permissionsSaveTimerRef.current = null; + } + }; + }, [permissionsDirty, permissionsDraftValue, permissionsSaving, runPermissionsSave]); + + const openCronCreate = () => { + setCronCreateOpen(true); + setCronCreateStep(0); + setCronCreateError(null); + setCronDraft(createInitialCronDraft()); + }; + + const closeCronCreate = () => { + setCronCreateOpen(false); + setCronCreateStep(0); + setCronCreateError(null); + setCronDraft(createInitialCronDraft()); + }; + + const updateCronDraft = (patch: Partial<CronCreateDraft>) => { + setCronDraft((prev) => ({ ...prev, ...patch })); + }; + + const selectCronTemplate = (templateId: CronCreateTemplateId) => { + setCronDraft((prev) => applyTemplateDefaults(templateId, prev)); + }; + + const canMoveToScheduleStep = cronDraft.name.trim().length > 0 && cronDraft.taskText.trim().length > 0; + const canMoveToReviewStep = + cronDraft.scheduleKind === "every" + ? Number.isFinite(cronDraft.everyAmount) && + (cronDraft.everyAmount ?? 0) > 0 && + (cronDraft.everyUnit !== "days" || + ((cronDraft.everyAtTime ?? "").trim().length > 0 && + (cronDraft.everyTimeZone ?? "").trim().length > 0)) + : (cronDraft.scheduleAt ?? "").trim().length > 0; + const canSubmitCronCreate = canMoveToScheduleStep && canMoveToReviewStep; + + const submitCronCreate = async () => { + if (cronCreateBusy || !canSubmitCronCreate) { + return; + } + setCronCreateError(null); + const payload: CronCreateDraft = { + templateId: cronDraft.templateId, + name: cronDraft.name.trim(), + taskText: cronDraft.taskText.trim(), + scheduleKind: cronDraft.scheduleKind, + ...(typeof cronDraft.everyAmount === "number" ? { everyAmount: cronDraft.everyAmount } : {}), + ...(cronDraft.everyUnit ? { everyUnit: cronDraft.everyUnit } : {}), + ...(cronDraft.everyUnit === "days" && cronDraft.everyAtTime + ? { everyAtTime: cronDraft.everyAtTime } + : {}), + ...(cronDraft.everyUnit === "days" && cronDraft.everyTimeZone + ? { everyTimeZone: cronDraft.everyTimeZone } + : {}), + ...(cronDraft.scheduleAt ? { scheduleAt: cronDraft.scheduleAt } : {}), + ...(cronDraft.deliveryMode ? { deliveryMode: cronDraft.deliveryMode } : {}), + ...(cronDraft.deliveryChannel ? { deliveryChannel: cronDraft.deliveryChannel } : {}), + ...(cronDraft.deliveryTo ? { deliveryTo: cronDraft.deliveryTo } : {}), + ...(cronDraft.advancedSessionTarget + ? { advancedSessionTarget: cronDraft.advancedSessionTarget } + : {}), + ...(cronDraft.advancedWakeMode ? { advancedWakeMode: cronDraft.advancedWakeMode } : {}), + }; + try { + await onCreateCronJob(payload); + closeCronCreate(); + } catch (err) { + setCronCreateError(err instanceof Error ? err.message : "Failed to create automation."); + } + }; + + const moveCronCreateBack = () => { + setCronCreateStep((prev) => Math.max(0, prev - 1)); + }; + + const moveCronCreateNext = () => { + if (cronCreateStep === 0) { + setCronCreateStep(1); + return; + } + if (cronCreateStep === 1 && canMoveToScheduleStep) { + setCronCreateStep(2); + return; + } + if (cronCreateStep === 2 && canMoveToReviewStep) { + setCronCreateStep(3); + } + }; + + const panelLabel = + mode === "advanced" + ? "Advanced" + : mode === "skills" + ? "Skills" + : mode === "system" + ? "System setup" + : ""; + const canOpenControlUi = typeof controlUiUrl === "string" && controlUiUrl.trim().length > 0; + const timedAutomationStepMeta = + TIMED_AUTOMATION_STEP_META[cronCreateStep] ?? + TIMED_AUTOMATION_STEP_META[TIMED_AUTOMATION_STEP_META.length - 1]; + + return ( + <div + className="agent-inspect-panel" + data-testid="agent-settings-panel" + style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }} + > + {showHeader ? ( + <AgentInspectHeader + label={panelLabel} + title={agent.name} + onClose={onClose} + closeTestId="agent-settings-close" + /> + ) : null} + + <div className="flex flex-col gap-0 px-5 pb-5"> + {mode === "capabilities" ? ( + <> + <section + className="sidebar-section" + data-testid="agent-settings-permissions" + > + <div className="mt-2 flex flex-col gap-8"> + <div className="px-1 py-1"> + <div className="sidebar-copy flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-medium text-foreground/88">Run commands</span> + <div + className="ui-segment ui-segment-command-mode mt-2 grid-cols-3" + role="group" + aria-label="Run commands" + > + {( + [ + { id: "off", label: "Off" }, + { id: "ask", label: "Ask" }, + { id: "auto", label: "Auto" }, + ] as const + ).map((option) => { + const selected = permissionsDraftValue.commandMode === option.id; + return ( + <button + key={option.id} + type="button" + aria-label={`Run commands ${option.label.toLowerCase()}`} + aria-pressed={selected} + className="ui-segment-item px-3 py-2.5 text-center font-mono text-[11px] font-semibold tracking-[0.04em]" + data-active={selected ? "true" : "false"} + onClick={() => + setPermissionsDraftValue((current) => ({ + ...current, + commandMode: option.id, + })) + } + > + {option.label} + </button> + ); + })} + </div> + </div> + </div> + <div className="ui-settings-row flex min-h-[68px] items-center justify-between gap-6 px-4 py-3"> + <div className="flex items-center gap-3"> + <button + type="button" + role="switch" + aria-label="Web access" + aria-checked={permissionsDraftValue.webAccess} + className={`ui-switch self-center ${permissionsDraftValue.webAccess ? "ui-switch--on" : ""}`} + onClick={() => + setPermissionsDraftValue((current) => ({ + ...current, + webAccess: !current.webAccess, + })) + } + > + <span className="ui-switch-thumb" /> + </button> + <div className="sidebar-copy flex flex-col"> + <span className="text-[11px] font-medium text-foreground/88">Web access</span> + <span className="text-[10px] text-muted-foreground/70"> + Allows this agent to fetch live web results. + </span> + </div> + </div> + <ChevronRight className="h-4 w-4 text-muted-foreground/55" aria-hidden="true" /> + </div> + <div className="ui-settings-row flex min-h-[68px] items-center justify-between gap-6 px-4 py-3"> + <div className="flex items-center gap-3"> + <button + type="button" + role="switch" + aria-label="File tools" + aria-checked={permissionsDraftValue.fileTools} + className={`ui-switch self-center ${permissionsDraftValue.fileTools ? "ui-switch--on" : ""}`} + onClick={() => + setPermissionsDraftValue((current) => ({ + ...current, + fileTools: !current.fileTools, + })) + } + > + <span className="ui-switch-thumb" /> + </button> + <div className="sidebar-copy flex flex-col"> + <span className="text-[11px] font-medium text-foreground/88">File tools</span> + <span className="text-[10px] text-muted-foreground/70"> + Lets this agent read and edit files in its workspace. + </span> + </div> + </div> + <ChevronRight className="h-4 w-4 text-muted-foreground/55" aria-hidden="true" /> + </div> + <div className="ui-settings-row flex min-h-[68px] items-center justify-between gap-6 px-4 py-3"> + <div className="flex items-center gap-3"> + <button + type="button" + role="switch" + aria-label="Browser automation" + aria-checked="false" + className="ui-switch self-center" + disabled + > + <span className="ui-switch-thumb" /> + </button> + <div className="sidebar-copy flex flex-col"> + <span className="text-[11px] font-medium text-foreground/88">Browser automation</span> + <span className="text-[10px] text-muted-foreground/70">Coming soon</span> + </div> + </div> + <ChevronRight className="h-4 w-4 text-muted-foreground/55" aria-hidden="true" /> + </div> + </div> + <div className="sidebar-copy mt-3 text-[11px] text-muted-foreground"> + {permissionsSaveState === "saving" ? "Saving..." : null} + {permissionsSaveState === "saved" ? "Saved." : null} + {permissionsSaveState === "error" && permissionsSaveError ? ( + <span> + Couldn't save. {permissionsSaveError}{" "} + <button + type="button" + className="underline underline-offset-2" + onClick={() => { + void runPermissionsSave(permissionsDraftValue); + }} + > + Retry + </button> + </span> + ) : null} + </div> + {permissionsSaveState === "error" && !permissionsSaveError ? ( + <div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs"> + Couldn't save permissions. + </div> + ) : null} + </section> + </> + ) : null} + + {mode === "skills" ? ( + <AgentSkillsPanel + skillsReport={skillsReport} + skillsLoading={skillsLoading} + skillsError={skillsError} + skillsBusy={skillsBusy} + skillsBusyKey={skillsBusyKey} + skillsAllowlist={skillsAllowlist} + onSetSkillEnabled={onSetSkillEnabled} + onOpenSystemSetup={onOpenSystemSetup} + /> + ) : null} + + {mode === "system" ? ( + <SystemSkillsPanel + skillsReport={skillsReport} + skillsLoading={skillsLoading} + skillsError={skillsError} + skillsBusy={skillsBusy} + skillsBusyKey={skillsBusyKey} + skillMessages={skillMessages} + skillApiKeyDrafts={skillApiKeyDrafts} + defaultAgentScopeWarning={defaultAgentScopeWarning} + initialSkillKey={systemInitialSkillKey} + onInitialSkillKeyHandled={onSystemInitialSkillHandled} + onSetSkillGlobalEnabled={onSetSkillGlobalEnabled} + onInstallSkill={onInstallSkill} + onRemoveSkill={onRemoveSkill} + onSkillApiKeyChange={onSkillApiKeyChange} + onSaveSkillApiKey={onSaveSkillApiKey} + /> + ) : null} + + {mode === "automations" ? ( + <section + className="sidebar-section" + data-testid="agent-settings-cron" + > + <div className="flex items-center justify-between gap-2"> + <h3 className="sidebar-section-title">Timed automations</h3> + {!cronLoading && !cronError && cronJobs.length > 0 ? ( + <button + className="sidebar-btn-ghost px-2.5 py-1.5 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60" + type="button" + onClick={openCronCreate} + > + Create + </button> + ) : null} + </div> + {cronLoading ? ( + <div className="mt-3 text-[11px] text-muted-foreground">Loading timed automations...</div> + ) : null} + {!cronLoading && cronError ? ( + <div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs"> + {cronError} + </div> + ) : null} + {!cronLoading && !cronError && cronJobs.length === 0 ? ( + <div className="sidebar-card mt-3 flex flex-col items-center justify-center gap-4 px-5 py-6 text-center"> + <CalendarDays + className="h-4 w-4 text-muted-foreground/70" + aria-hidden="true" + data-testid="cron-empty-icon" + /> + <div className="sidebar-copy text-[11px] text-muted-foreground/82"> + No timed automations for this agent. + </div> + <button + className="sidebar-btn-primary mt-2 w-auto min-w-[116px] self-center px-4 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60" + type="button" + onClick={openCronCreate} + > + Create + </button> + </div> + ) : null} + {!cronLoading && !cronError && cronJobs.length > 0 ? ( + <div className="mt-3 flex flex-col gap-3"> + {cronJobs.map((job) => { + const runBusy = cronRunBusyJobId === job.id; + const deleteBusy = cronDeleteBusyJobId === job.id; + const busy = runBusy || deleteBusy; + const scheduleText = formatCronSchedule(job.schedule); + const payloadText = formatCronPayload(job.payload).trim(); + const payloadPreview = getFirstLinePreview(payloadText, 160); + const payloadExpandable = + payloadText.length > payloadPreview.length || payloadText.split("\n").length > 1; + const expanded = expandedCronJobIds.has(job.id); + const stateLine = formatCronStateLine(job); + return ( + <div key={job.id} className="group/cron ui-card flex items-start justify-between gap-2 px-4 py-3"> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> + <div className="min-w-0 flex-1 truncate font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground"> + {job.name} + </div> + {!job.enabled ? ( + <div className="shrink-0 rounded-md bg-muted/50 px-2 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] text-muted-foreground shadow-2xs"> + Disabled + </div> + ) : null} + </div> + <div className="mt-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + Frequency + </span> + <div className="break-words">{scheduleText}</div> + </div> + {stateLine ? ( + <div className="mt-1 break-words text-[11px] text-muted-foreground"> + {stateLine} + </div> + ) : null} + {payloadText ? ( + <div className="mt-1 text-[11px] text-muted-foreground"> + <div className="flex items-center justify-between gap-2"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"> + Task + </span> + {payloadExpandable ? ( + <button + className="ui-btn-secondary shrink-0 min-h-0 px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.06em] text-muted-foreground" + type="button" + onClick={() => { + setExpandedCronJobIds((prev) => { + const next = new Set(prev); + if (next.has(job.id)) { + next.delete(job.id); + } else { + next.add(job.id); + } + return next; + }); + }} + > + {expanded ? "Less" : "More"} + </button> + ) : null} + </div> + <div className="mt-0.5 whitespace-pre-wrap break-words" title={payloadText}> + {expanded ? payloadText : payloadPreview || payloadText} + </div> + </div> + ) : null} + </div> + <div className="flex items-center gap-1 opacity-0 transition group-focus-within/cron:opacity-100 group-hover/cron:opacity-100"> + <button + className="ui-btn-icon h-7 w-7 disabled:cursor-not-allowed disabled:opacity-60" + type="button" + aria-label={`Run timed automation ${job.name} now`} + onClick={() => { + void onRunCronJob(job.id); + }} + disabled={busy} + > + <Play className="h-3.5 w-3.5" /> + </button> + <button + className="ui-btn-icon ui-btn-icon-danger h-7 w-7 bg-transparent disabled:cursor-not-allowed disabled:opacity-60" + type="button" + aria-label={`Delete timed automation ${job.name}`} + onClick={() => { + void onDeleteCronJob(job.id); + }} + disabled={busy} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + </div> + </div> + ); + })} + </div> + ) : null} + <section + className="sidebar-section" + data-testid="agent-settings-heartbeat-coming-soon" + > + <h3 className="sidebar-section-title">Heartbeats</h3> + <div className="mt-3 text-[11px] text-muted-foreground"> + Heartbeat automation controls are coming soon. + </div> + </section> + </section> + ) : null} + + {mode === "advanced" ? ( + <> + <section className="sidebar-section mt-8" data-testid="agent-settings-control-ui"> + <h3 className="sidebar-section-title ui-text-danger">Danger Zone</h3> + <div className="ui-alert-danger mt-3 rounded-md px-3 py-3 text-[11px]"> + <div className="flex items-start gap-2"> + <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" /> + <div className="space-y-1"> + <div className="font-medium">Advanced users only.</div> + <div>Open the full OpenClaw Control UI outside Studio.</div> + <div>Changes there can break agent behavior or put Studio out of sync.</div> + </div> + </div> + </div> + {canOpenControlUi ? ( + <a + className="sidebar-btn-primary ui-btn-danger mt-3 inline-flex items-center justify-center gap-1.5 px-3 py-2.5 text-center font-mono text-[10px] font-semibold tracking-[0.06em]" + href={controlUiUrl ?? undefined} + target="_blank" + rel="noreferrer" + > + Open Full Control UI + <ExternalLink className="h-3 w-3" aria-hidden="true" /> + </a> + ) : ( + <> + <button + className="sidebar-btn-primary ui-btn-danger mt-3 inline-flex px-3 py-2.5 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-65" + type="button" + disabled + > + Open Full Control UI + </button> + <div className="mt-2 text-[10px] text-muted-foreground/70"> + Control UI link unavailable for this gateway. + </div> + </> + )} + </section> + + {canDelete ? ( + <section className="sidebar-section mt-8"> + <div className="text-[11px] text-muted-foreground/68"> + Removes the agent from the gateway config and deletes its scheduled automations. + </div> + <button + className="sidebar-btn-ghost ui-btn-danger mt-3 inline-flex px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em]" + type="button" + onClick={onDelete} + > + Delete agent + </button> + </section> + ) : ( + <section className="sidebar-section mt-8"> + <h3 className="sidebar-section-title">System agent</h3> + <div className="mt-3 text-[11px] text-muted-foreground"> + The main agent is reserved and cannot be deleted. + </div> + </section> + )} + </> + ) : null} + </div> + {cronCreateOpen ? ( + <div + className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4" + role="dialog" + aria-modal="true" + aria-label="Create automation" + onClick={closeCronCreate} + > + <div + className="ui-panel w-full max-w-2xl bg-card shadow-xs" + onClick={(event) => event.stopPropagation()} + > + <div className="flex items-start justify-between gap-3 px-6 py-5"> + <div className="min-w-0"> + <div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80"> + Timed automation composer + </div> + <div className="mt-1 text-base font-semibold text-foreground">{timedAutomationStepMeta.title}</div> + </div> + <button + type="button" + className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]" + onClick={closeCronCreate} + > + Close + </button> + </div> + <div className="space-y-4 px-5 py-5"> + {cronCreateError ? ( + <div className="ui-alert-danger rounded-md px-3 py-2 text-xs"> + {cronCreateError} + </div> + ) : null} + {cronCreateStep === 0 ? ( + <div className="space-y-3"> + <div className="text-sm text-muted-foreground"> + Pick a template to start quickly, or choose Custom. + </div> + <div className="grid gap-2 sm:grid-cols-2"> + {CRON_TEMPLATE_OPTIONS.map((option) => { + const active = option.id === cronDraft.templateId; + const Icon = option.icon; + return ( + <button + key={option.id} + type="button" + aria-label={option.title} + className={`ui-card px-3 py-3 text-left transition ${ + active + ? "ui-selected" + : "bg-surface-2/60 hover:bg-surface-3/90" + }`} + onClick={() => selectCronTemplate(option.id)} + > + <div className="flex items-center gap-2"> + <Icon className="h-4 w-4 text-foreground" /> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground"> + {option.title} + </div> + </div> + <div className="mt-1 text-[11px] text-muted-foreground">{option.description}</div> + </button> + ); + })} + </div> + </div> + ) : null} + {cronCreateStep === 1 ? ( + <div className="space-y-3"> + <div className="text-sm text-muted-foreground"> + Name this automation and describe what it should do. + </div> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Automation name + </span> + <input + aria-label="Automation name" + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.name} + onChange={(event) => updateCronDraft({ name: event.target.value })} + /> + </label> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Task + </span> + <textarea + aria-label="Task" + className="min-h-28 rounded-md border border-border bg-surface-3 px-3 py-2 text-sm text-foreground outline-none" + value={cronDraft.taskText} + onChange={(event) => updateCronDraft({ taskText: event.target.value })} + /> + </label> + </div> + ) : null} + {cronCreateStep === 2 ? ( + <div className="space-y-3"> + <div className="text-sm text-muted-foreground">Choose when this should run.</div> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Schedule type + </span> + <select + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.scheduleKind} + onChange={(event) => + updateCronDraft({ scheduleKind: event.target.value as CronCreateDraft["scheduleKind"] }) + } + > + <option value="every">Every</option> + <option value="at">One time</option> + </select> + </label> + {cronDraft.scheduleKind === "every" ? ( + <div className="grid gap-2 sm:grid-cols-2"> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Every + </span> + <input + type="number" + min={1} + step={1} + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={String(cronDraft.everyAmount ?? 30)} + onChange={(event) => + updateCronDraft({ + everyAmount: Number.parseInt(event.target.value, 10) || 0, + }) + } + /> + </label> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Unit + </span> + <select + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.everyUnit ?? "minutes"} + onChange={(event) => + updateCronDraft({ + everyUnit: event.target.value as CronCreateDraft["everyUnit"], + }) + } + > + <option value="minutes">Minutes</option> + <option value="hours">Hours</option> + <option value="days">Days</option> + </select> + </label> + {cronDraft.everyUnit === "days" ? ( + <> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Time of day + </span> + <input + type="time" + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.everyAtTime ?? "09:00"} + onChange={(event) => updateCronDraft({ everyAtTime: event.target.value })} + /> + </label> + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Timezone + </span> + <input + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.everyTimeZone ?? resolveLocalTimeZone()} + onChange={(event) => updateCronDraft({ everyTimeZone: event.target.value })} + /> + </label> + </> + ) : null} + </div> + ) : null} + {cronDraft.scheduleKind === "at" ? ( + <label className="flex flex-col gap-1 text-[11px] text-muted-foreground"> + <span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em]"> + Run at + </span> + <input + type="datetime-local" + className="h-10 rounded-md border border-border bg-surface-3 px-3 text-sm text-foreground outline-none" + value={cronDraft.scheduleAt ?? ""} + onChange={(event) => updateCronDraft({ scheduleAt: event.target.value })} + /> + </label> + ) : null} + </div> + ) : null} + {cronCreateStep === 3 ? ( + <div className="space-y-3 text-sm text-muted-foreground"> + <div>Review details before creating this automation.</div> + <div className="ui-card px-3 py-2"> + <div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground"> + {cronDraft.name || "Untitled automation"} + </div> + <div className="mt-1 text-[11px]">{cronDraft.taskText || "No task provided."}</div> + <div className="mt-2 text-[11px]"> + Schedule:{" "} + {cronDraft.scheduleKind === "every" + ? `Every ${cronDraft.everyAmount ?? 0} ${cronDraft.everyUnit ?? "minutes"}${ + cronDraft.everyUnit === "days" + ? ` at ${cronDraft.everyAtTime ?? ""} (${cronDraft.everyTimeZone ?? resolveLocalTimeZone()})` + : "" + }` + : `At ${cronDraft.scheduleAt ?? ""}`} + </div> + </div> + </div> + ) : null} + </div> + <div className="flex items-center justify-between gap-2 border-t border-border/50 px-5 pb-4 pt-5"> + <div className="text-[11px] text-muted-foreground"> + {timedAutomationStepMeta.indicator} · Step {cronCreateStep + 1} of 4 + </div> + <div className="flex items-center gap-2"> + <button + type="button" + className="sidebar-btn-ghost px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60" + onClick={moveCronCreateBack} + disabled={cronCreateStep === 0 || cronCreateBusy} + > + Back + </button> + {cronCreateStep < 3 ? ( + <button + type="button" + className="sidebar-btn-ghost px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:opacity-60" + onClick={moveCronCreateNext} + disabled={ + cronCreateBusy || + (cronCreateStep === 1 && !canMoveToScheduleStep) || + (cronCreateStep === 2 && !canMoveToReviewStep) + } + > + Next + </button> + ) : null} + {cronCreateStep === 3 ? ( + <button + type="button" + className="sidebar-btn-primary px-3 py-2 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + onClick={() => { + void submitCronCreate(); + }} + disabled={cronCreateBusy || !canSubmitCronCreate} + > + Create automation + </button> + ) : null} + </div> + </div> + </div> + </div> + ) : null} + </div> + ); +}; + +type AgentFilesState = ReturnType<typeof createAgentFilesState>; + +type UseAgentFilesEditorResult = { + agentFiles: AgentFilesState; + agentFilesLoading: boolean; + agentFilesSaving: boolean; + agentFilesDirty: boolean; + agentFilesError: string | null; + setAgentFileContent: (name: AgentFileName, value: string) => void; + saveAgentFiles: () => Promise<boolean>; + discardAgentFileChanges: () => void; +}; + +const useAgentFilesEditor = (params: { + client: GatewayClient | null | undefined; + agentId: string | null | undefined; +}): UseAgentFilesEditorResult => { + const { client, agentId } = params; + const [agentFiles, setAgentFiles] = useState(createAgentFilesState); + const [agentFilesLoading, setAgentFilesLoading] = useState(false); + const [agentFilesSaving, setAgentFilesSaving] = useState(false); + const [agentFilesDirty, setAgentFilesDirty] = useState(false); + const [agentFilesError, setAgentFilesError] = useState<string | null>(null); + const savedAgentFilesRef = useRef<AgentFilesState>(createAgentFilesState()); + + const cloneAgentFilesState = useCallback((source: AgentFilesState): AgentFilesState => { + const next = createAgentFilesState(); + for (const name of AGENT_FILE_NAMES) { + next[name] = { ...source[name] }; + } + return next; + }, []); + + const loadAgentFiles = useCallback(async () => { + setAgentFilesLoading(true); + setAgentFilesError(null); + try { + const trimmedAgentId = agentId?.trim(); + if (!trimmedAgentId) { + const emptyState = createAgentFilesState(); + savedAgentFilesRef.current = emptyState; + setAgentFiles(emptyState); + setAgentFilesDirty(false); + setAgentFilesError("Agent ID is missing for this agent."); + return; + } + if (!client) { + setAgentFilesError("Gateway client is not available."); + return; + } + const results = await Promise.all( + AGENT_FILE_NAMES.map(async (name) => { + const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name }); + return { name, content: file.content, exists: file.exists }; + }) + ); + const nextState = createAgentFilesState(); + for (const file of results) { + if (!isAgentFileName(file.name)) continue; + nextState[file.name] = { + content: file.content ?? "", + exists: Boolean(file.exists), + }; + } + savedAgentFilesRef.current = nextState; + setAgentFiles(nextState); + setAgentFilesDirty(false); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load agent files."; + setAgentFilesError(message); + } finally { + setAgentFilesLoading(false); + } + }, [agentId, client]); + + const saveAgentFiles = useCallback(async () => { + setAgentFilesSaving(true); + setAgentFilesError(null); + try { + const trimmedAgentId = agentId?.trim(); + if (!trimmedAgentId) { + setAgentFilesError("Agent ID is missing for this agent."); + return false; + } + if (!client) { + setAgentFilesError("Gateway client is not available."); + return false; + } + await Promise.all( + AGENT_FILE_NAMES.map(async (name) => { + await writeGatewayAgentFile({ + client, + agentId: trimmedAgentId, + name, + content: agentFiles[name].content, + }); + }) + ); + const nextState = createAgentFilesState(); + for (const name of AGENT_FILE_NAMES) { + nextState[name] = { + content: agentFiles[name].content, + exists: true, + }; + } + savedAgentFilesRef.current = nextState; + setAgentFiles(nextState); + setAgentFilesDirty(false); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save agent files."; + setAgentFilesError(message); + return false; + } finally { + setAgentFilesSaving(false); + } + }, [agentFiles, agentId, client]); + + const setAgentFileContent = useCallback((name: AgentFileName, value: string) => { + if (!isAgentFileName(name)) return; + setAgentFiles((prev) => ({ + ...prev, + [name]: { ...prev[name], content: value }, + })); + setAgentFilesDirty(true); + }, []); + + const discardAgentFileChanges = useCallback(() => { + setAgentFiles(cloneAgentFilesState(savedAgentFilesRef.current)); + setAgentFilesDirty(false); + setAgentFilesError(null); + }, [cloneAgentFilesState]); + + useEffect(() => { + void loadAgentFiles(); + }, [loadAgentFiles]); + + return { + agentFiles, + agentFilesLoading, + agentFilesSaving, + agentFilesDirty, + agentFilesError, + setAgentFileContent, + saveAgentFiles, + discardAgentFileChanges, + }; +}; + +type AgentBrainPanelProps = { + client: GatewayClient; + agents: AgentState[]; + selectedAgentId: string | null; + onUnsavedChangesChange?: (dirty: boolean) => void; +}; + +const AgentBrainPanelSection = ({ + title, + children, +}: { + title: string; + children: ReactNode; +}) => ( + <section className="space-y-3 border-t border-border/55 pt-8 first:border-t-0 first:pt-0"> + <h3 className="text-sm font-medium text-foreground">{title}</h3> + {children} + </section> +); + +export const AgentBrainPanel = ({ + client, + agents, + selectedAgentId, + onUnsavedChangesChange, +}: AgentBrainPanelProps) => { + const selectedAgent = useMemo( + () => + selectedAgentId + ? agents.find((entry) => entry.agentId === selectedAgentId) ?? null + : null, + [agents, selectedAgentId] + ); + + const { + agentFiles, + agentFilesLoading, + agentFilesSaving, + agentFilesDirty, + agentFilesError, + setAgentFileContent, + saveAgentFiles, + discardAgentFileChanges, + } = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null }); + const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]); + + const setIdentityField = useCallback( + (field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => { + const nextDraft = parsePersonalityFiles(agentFiles); + nextDraft.identity[field] = value; + const serialized = serializePersonalityFiles(nextDraft); + setAgentFileContent("IDENTITY.md", serialized["IDENTITY.md"]); + }, + [agentFiles, setAgentFileContent] + ); + + const handleSave = useCallback(async () => { + if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return; + await saveAgentFiles(); + }, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]); + + useEffect(() => { + onUnsavedChangesChange?.(agentFilesDirty); + }, [agentFilesDirty, onUnsavedChangesChange]); + + useEffect(() => { + return () => { + onUnsavedChangesChange?.(false); + }; + }, [onUnsavedChangesChange]); + + return ( + <div + className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden" + data-testid="agent-personality-panel" + style={{ position: "relative", left: "auto", top: "auto", width: "100%", height: "100%" }} + > + <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-6"> + <section className="mx-auto flex w-full max-w-[920px] min-h-0 flex-col" data-testid="agent-personality-files"> + {agentFilesError ? ( + <div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs"> + {agentFilesError} + </div> + ) : null} + + <div className="mb-6 flex items-center justify-end gap-2"> + <button + type="button" + className="ui-btn-secondary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:opacity-50" + disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty} + onClick={discardAgentFileChanges} + > + Discard + </button> + <button + type="button" + className="ui-btn-primary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty} + onClick={() => { + void handleSave(); + }} + > + Save + </button> + </div> + + <div className="space-y-8 pb-8"> + <AgentBrainPanelSection title="Persona"> + <textarea + aria-label="Persona" + className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none" + value={agentFiles["SOUL.md"].content} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setAgentFileContent("SOUL.md", event.target.value); + }} + /> + </AgentBrainPanelSection> + + <AgentBrainPanelSection title="Directives"> + <textarea + aria-label="Directives" + className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none" + value={agentFiles["AGENTS.md"].content} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setAgentFileContent("AGENTS.md", event.target.value); + }} + /> + </AgentBrainPanelSection> + + <AgentBrainPanelSection title="Context"> + <textarea + aria-label="Context" + className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none" + value={agentFiles["USER.md"].content} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setAgentFileContent("USER.md", event.target.value); + }} + /> + </AgentBrainPanelSection> + + <section className="space-y-3 border-t border-border/55 pt-8"> + <h3 className="text-sm font-medium text-foreground">Identity</h3> + <div className="grid gap-4 sm:grid-cols-2"> + <label className="flex flex-col gap-2 text-xs text-muted-foreground"> + Name + <input + className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none" + value={draft.identity.name} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setIdentityField("name", event.target.value); + }} + /> + </label> + <label className="flex flex-col gap-2 text-xs text-muted-foreground"> + Creature + <input + className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none" + value={draft.identity.creature} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setIdentityField("creature", event.target.value); + }} + /> + </label> + <label className="flex flex-col gap-2 text-xs text-muted-foreground"> + Vibe + <input + className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none" + value={draft.identity.vibe} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setIdentityField("vibe", event.target.value); + }} + /> + </label> + <label className="flex flex-col gap-2 text-xs text-muted-foreground"> + Emoji + <input + className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none" + value={draft.identity.emoji} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setIdentityField("emoji", event.target.value); + }} + /> + </label> + </div> + <label className="flex flex-col gap-2 text-xs text-muted-foreground"> + Avatar + <input + className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none" + value={draft.identity.avatar} + disabled={agentFilesLoading || agentFilesSaving} + onChange={(event) => { + setIdentityField("avatar", event.target.value); + }} + /> + </label> + </section> + </div> + </section> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/AgentSkillsPanel.tsx b/src/features/agents/components/AgentSkillsPanel.tsx new file mode 100644 index 00000000..3e3fa395 --- /dev/null +++ b/src/features/agents/components/AgentSkillsPanel.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import type { SkillStatusReport } from "@/lib/skills/types"; +import { + buildAgentSkillsAllowlistSet, + buildSkillMissingDetails, + deriveAgentSkillDisplayState, + deriveAgentSkillsAccessMode, + deriveSkillReadinessState, + type AgentSkillDisplayState, +} from "@/lib/skills/presentation"; + +type SkillRowFilter = "all" | AgentSkillDisplayState; + +type AgentSkillsPanelProps = { + skillsReport?: SkillStatusReport | null; + skillsLoading?: boolean; + skillsError?: string | null; + skillsBusy?: boolean; + skillsBusyKey?: string | null; + skillsAllowlist?: string[] | undefined; + onSetSkillEnabled: (skillName: string, enabled: boolean) => Promise<void> | void; + onOpenSystemSetup: (skillKey?: string) => void; +}; + +const FILTERS: Array<{ id: SkillRowFilter; label: string }> = [ + { id: "all", label: "All" }, + { id: "ready", label: "Ready" }, + { id: "setup-required", label: "Setup required" }, + { id: "not-supported", label: "Not supported" }, +]; + +const DISPLAY_LABELS: Record<AgentSkillDisplayState, string> = { + ready: "Ready", + "setup-required": "Setup required", + "not-supported": "Not supported", +}; + +const DISPLAY_CLASSES: Record<AgentSkillDisplayState, string> = { + ready: "ui-badge-status-running", + "setup-required": "ui-badge-status-error", + "not-supported": "ui-badge-status-error", +}; + +const resolveHint = ( + skill: SkillStatusReport["skills"][number], + displayState: AgentSkillDisplayState +): string | null => { + if (displayState === "ready") { + return null; + } + if (displayState === "not-supported") { + if (skill.blockedByAllowlist) { + return "Blocked by bundled skills policy."; + } + return buildSkillMissingDetails(skill).find((line) => line.startsWith("Requires OS:")) ?? "Not supported."; + } + const readiness = deriveSkillReadinessState(skill); + if (readiness === "disabled-globally") { + return "Disabled globally. Enable it in System setup."; + } + return buildSkillMissingDetails(skill)[0] ?? "Requires setup in System setup."; +}; + +export const AgentSkillsPanel = ({ + skillsReport = null, + skillsLoading = false, + skillsError = null, + skillsBusy = false, + skillsBusyKey = null, + skillsAllowlist, + onSetSkillEnabled, + onOpenSystemSetup, +}: AgentSkillsPanelProps) => { + const [skillsFilter, setSkillsFilter] = useState(""); + const [rowFilter, setRowFilter] = useState<SkillRowFilter>("all"); + + const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]); + const accessMode = deriveAgentSkillsAccessMode(skillsAllowlist); + const allowlistSet = useMemo(() => buildAgentSkillsAllowlistSet(skillsAllowlist), [skillsAllowlist]); + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + + const rows = useMemo(() => { + return skillEntries.map((skill) => { + const normalizedName = skill.name.trim(); + const allowed = + accessMode === "all" ? true : accessMode === "none" ? false : allowlistSet.has(normalizedName); + const readiness = deriveSkillReadinessState(skill); + return { + skill, + allowed, + displayState: deriveAgentSkillDisplayState(readiness), + }; + }); + }, [accessMode, allowlistSet, skillEntries]); + + const searchedRows = useMemo(() => { + const query = skillsFilter.trim().toLowerCase(); + if (!query) { + return rows; + } + return rows.filter((entry) => + [entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey] + .join(" ") + .toLowerCase() + .includes(query) + ); + }, [rows, skillsFilter]); + + const filteredRows = useMemo(() => { + if (rowFilter === "all") { + return searchedRows; + } + return searchedRows.filter((entry) => entry.displayState === rowFilter); + }, [rowFilter, searchedRows]); + + const filterCounts = useMemo( + () => + searchedRows.reduce( + (counts, entry) => { + counts.all += 1; + counts[entry.displayState] += 1; + return counts; + }, + { + all: 0, + ready: 0, + "setup-required": 0, + "not-supported": 0, + } satisfies Record<SkillRowFilter, number> + ), + [searchedRows] + ); + + const enabledCount = useMemo( + () => rows.reduce((count, entry) => count + (entry.allowed ? 1 : 0), 0), + [rows] + ); + + return ( + <section className="sidebar-section" data-testid="agent-settings-skills"> + <div className="flex items-center justify-between gap-3"> + <h3 className="sidebar-section-title">Skills</h3> + <div className="font-mono text-[10px] text-muted-foreground"> + {enabledCount}/{skillEntries.length} + </div> + </div> + <div className="mt-2 text-[11px] text-muted-foreground">Skill access controls apply to this agent.</div> + {accessMode === "selected" ? ( + <div className="mt-2 text-[10px] text-muted-foreground/80"> + This agent is using selected skills only. + </div> + ) : null} + <div className="mt-3"> + <input + value={skillsFilter} + onChange={(event) => setSkillsFilter(event.target.value)} + placeholder="Search skills" + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border" + aria-label="Search skills" + /> + </div> + <div className="mt-2 flex flex-wrap gap-1"> + {FILTERS.map((filter) => { + const selected = rowFilter === filter.id; + return ( + <button + key={filter.id} + type="button" + className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65" + data-active={selected ? "true" : "false"} + disabled={skillsLoading} + onClick={() => { + setRowFilter(filter.id); + }} + > + {filter.label} ({filterCounts[filter.id]}) + </button> + ); + })} + </div> + {skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null} + {!skillsLoading && skillsError ? ( + <div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div> + ) : null} + {!skillsLoading && !skillsError && filteredRows.length === 0 ? ( + <div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div> + ) : null} + {!skillsLoading && !skillsError && filteredRows.length > 0 ? ( + <div className="mt-3 flex flex-col gap-2"> + {filteredRows.map((entry) => { + const statusLabel = DISPLAY_LABELS[entry.displayState]; + const statusClassName = DISPLAY_CLASSES[entry.displayState]; + const canConfigureInSystem = entry.displayState === "setup-required"; + const switchDisabled = anySkillBusy || entry.displayState === "not-supported"; + return ( + <div + key={`${entry.skill.source}:${entry.skill.skillKey}`} + className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between" + > + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-2"> + <span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span> + <span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground"> + {entry.skill.source} + </span> + <span + className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${statusClassName}`} + > + {statusLabel} + </span> + </div> + <div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div> + {entry.displayState !== "ready" ? ( + <div className="mt-1 text-[10px] text-muted-foreground/80"> + {resolveHint(entry.skill, entry.displayState)} + </div> + ) : null} + </div> + <div className="flex w-full items-center justify-between gap-2 sm:w-[240px] sm:justify-end"> + <button + type="button" + role="switch" + aria-label={`Skill ${entry.skill.name}`} + aria-checked={entry.allowed} + className={`ui-switch self-start ${entry.allowed ? "ui-switch--on" : ""}`} + disabled={switchDisabled} + onClick={() => { + void onSetSkillEnabled(entry.skill.name, !entry.allowed); + }} + > + <span className="ui-switch-thumb" /> + </button> + {canConfigureInSystem ? ( + <button + type="button" + className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold" + onClick={() => { + onOpenSystemSetup(entry.skill.skillKey); + }} + > + Open System Setup + </button> + ) : null} + </div> + </div> + ); + })} + </div> + ) : null} + </section> + ); +}; diff --git a/src/features/agents/components/AgentSkillsSetupModal.tsx b/src/features/agents/components/AgentSkillsSetupModal.tsx new file mode 100644 index 00000000..a250aa5c --- /dev/null +++ b/src/features/agents/components/AgentSkillsSetupModal.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useEffect } from "react"; + +import type { SkillStatusEntry } from "@/lib/skills/types"; +import { + buildSkillMissingDetails, + canRemoveSkill, + deriveSkillReadinessState, + resolvePreferredInstallOption, +} from "@/lib/skills/presentation"; + +type SkillSetupMessage = { kind: "success" | "error"; message: string }; + +type AgentSkillsSetupModalProps = { + skill: SkillStatusEntry | null; + skillsBusy: boolean; + skillsBusyKey: string | null; + skillMessage: SkillSetupMessage | null; + apiKeyDraft: string; + defaultAgentScopeWarning?: string | null; + onClose: () => void; + onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void; + onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void; + onRemoveSkill: ( + skill: { skillKey: string; source: string; baseDir: string } + ) => Promise<void> | void; + onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void; + onSaveSkillApiKey: (skillKey: string) => Promise<void> | void; +}; + +const READINESS_LABELS = { + ready: "Ready", + "needs-setup": "Needs setup", + unavailable: "Unavailable", + "disabled-globally": "Disabled globally", +} as const; + +const READINESS_CLASSES = { + ready: "ui-badge-status-running", + "needs-setup": "ui-badge-status-error", + unavailable: "ui-badge-status-error", + "disabled-globally": "ui-badge-status-error", +} as const; + +export const AgentSkillsSetupModal = ({ + skill, + skillsBusy, + skillsBusyKey, + skillMessage, + apiKeyDraft, + defaultAgentScopeWarning = null, + onClose, + onInstallSkill, + onSetSkillGlobalEnabled, + onRemoveSkill, + onSkillApiKeyChange, + onSaveSkillApiKey, +}: AgentSkillsSetupModalProps) => { + useEffect(() => { + if (!skill) { + return; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose, skill]); + + if (!skill) { + return null; + } + + const readiness = deriveSkillReadinessState(skill); + const readinessLabel = READINESS_LABELS[readiness]; + const readinessClassName = READINESS_CLASSES[readiness]; + const missingDetails = buildSkillMissingDetails(skill); + const installOption = resolvePreferredInstallOption(skill); + const canDeleteSkill = canRemoveSkill(skill); + const busyForSkill = skillsBusyKey === skill.skillKey; + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + const trimmedApiKey = apiKeyDraft.trim(); + + return ( + <div + className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 p-4" + role="dialog" + aria-modal="true" + aria-label={`Setup ${skill.name}`} + onClick={onClose} + > + <div + className="ui-panel w-full max-w-2xl bg-card shadow-xs" + onClick={(event) => event.stopPropagation()} + > + <div className="flex items-start justify-between gap-3 px-6 py-5"> + <div className="min-w-0"> + <div className="text-[11px] font-medium tracking-[0.01em] text-muted-foreground/80"> + System setup + </div> + <div className="mt-1 flex flex-wrap items-center gap-2"> + <span className="text-base font-semibold text-foreground">{skill.name}</span> + <span + className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`} + > + {readinessLabel} + </span> + </div> + <div className="mt-2 text-[10px] text-muted-foreground/80"> + Changes affect all agents on this gateway. + </div> + </div> + <button + type="button" + className="sidebar-btn-ghost px-3 font-mono text-[10px] font-semibold tracking-[0.06em]" + onClick={onClose} + > + Close + </button> + </div> + <div className="space-y-3 px-6 pb-3 text-[11px] text-muted-foreground"> + {defaultAgentScopeWarning ? ( + <div className="rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/80"> + {defaultAgentScopeWarning} + </div> + ) : null} + <div>{skill.description}</div> + {skill.blockedByAllowlist ? ( + <div className="text-[10px] text-muted-foreground/80"> + Blocked by bundled skills policy (`skills.allowBundled`). + </div> + ) : null} + {missingDetails.map((line) => ( + <div key={`${skill.skillKey}:${line}`} className="text-[10px] text-muted-foreground/80"> + {line} + </div> + ))} + {skillMessage ? ( + <div + className={`text-[10px] ${skillMessage.kind === "error" ? "ui-text-danger" : "ui-text-success"}`} + > + {skillMessage.message} + </div> + ) : null} + <div className="space-y-2 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3"> + {installOption ? ( + <button + type="button" + className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy} + onClick={() => { + void onInstallSkill(skill.skillKey, skill.name, installOption.id); + }} + > + {busyForSkill ? "Working..." : installOption.label} + </button> + ) : null} + <button + type="button" + className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy} + onClick={() => { + void onSetSkillGlobalEnabled(skill.skillKey, skill.disabled); + }} + > + {busyForSkill + ? "Working..." + : skill.disabled + ? "Enable globally" + : "Disable globally"} + </button> + {skill.primaryEnv ? ( + <> + <input + type="password" + value={apiKeyDraft} + onChange={(event) => { + void onSkillApiKeyChange(skill.skillKey, event.target.value); + }} + disabled={anySkillBusy} + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[10px] text-foreground outline-none transition focus:border-border" + placeholder={`Set ${skill.primaryEnv}`} + aria-label={`API key for ${skill.name}`} + /> + <button + type="button" + className="ui-btn-secondary w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy || trimmedApiKey.length === 0} + onClick={() => { + if (trimmedApiKey.length === 0) { + return; + } + void onSaveSkillApiKey(skill.skillKey); + }} + > + {busyForSkill ? "Working..." : `Save ${skill.primaryEnv}`} + </button> + </> + ) : null} + {canDeleteSkill ? ( + <button + type="button" + className="ui-btn-secondary ui-btn-danger w-full px-3 py-2 text-[10px] font-medium disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy} + onClick={() => { + const approved = window.confirm( + `Remove ${skill.name} from the gateway? This affects all agents.` + ); + if (!approved) { + return; + } + void onRemoveSkill({ + skillKey: skill.skillKey, + source: skill.source, + baseDir: skill.baseDir, + }); + onClose(); + }} + > + Remove skill from gateway + </button> + ) : null} + </div> + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx new file mode 100644 index 00000000..553098bf --- /dev/null +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -0,0 +1,94 @@ +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import { X } from "lucide-react"; +import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; + +type ConnectionPanelProps = { + gatewayUrl: string; + token: string; + status: GatewayStatus; + error: string | null; + onGatewayUrlChange: (value: string) => void; + onTokenChange: (value: string) => void; + onConnect: () => void; + onDisconnect: () => void; + onClose?: () => void; +}; + +export const ConnectionPanel = ({ + gatewayUrl, + token, + status, + error, + onGatewayUrlChange, + onTokenChange, + onConnect, + onDisconnect, + onClose, +}: ConnectionPanelProps) => { + const isConnected = status === "connected"; + const isConnecting = status === "connecting"; + + return ( + <div className="fade-up-delay flex flex-col gap-3"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="flex flex-wrap items-center gap-3"> + <span + className={`ui-chip inline-flex items-center px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`} + data-status={status} + > + {resolveGatewayStatusLabel(status)} + </span> + <button + className="ui-btn-secondary px-4 py-2 text-xs font-semibold tracking-[0.05em] text-foreground disabled:cursor-not-allowed disabled:opacity-60" + type="button" + onClick={isConnected ? onDisconnect : onConnect} + disabled={isConnecting || !gatewayUrl.trim()} + > + {isConnected ? "Disconnect" : "Connect"} + </button> + </div> + {onClose ? ( + <button + className="ui-btn-ghost inline-flex items-center gap-1 px-3 py-2 text-xs font-semibold tracking-[0.05em] text-foreground" + type="button" + onClick={onClose} + data-testid="gateway-connection-close" + aria-label="Close gateway connection panel" + > + <X className="h-3.5 w-3.5" /> + Close + </button> + ) : null} + </div> + <div className="grid gap-3 lg:grid-cols-[1.4fr_1fr]"> + <label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + Upstream gateway URL + <input + className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none" + type="text" + value={gatewayUrl} + onChange={(event) => onGatewayUrlChange(event.target.value)} + placeholder="ws://localhost:18789" + spellCheck={false} + /> + </label> + <label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + Upstream token + <input + className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none" + type="password" + value={token} + onChange={(event) => onTokenChange(event.target.value)} + placeholder="gateway token" + spellCheck={false} + /> + </label> + </div> + {error ? ( + <p className="ui-alert-danger rounded-md px-4 py-2 text-sm"> + {error} + </p> + ) : null} + </div> + ); +}; diff --git a/src/features/agents/components/EmptyStatePanel.tsx b/src/features/agents/components/EmptyStatePanel.tsx new file mode 100644 index 00000000..0668e021 --- /dev/null +++ b/src/features/agents/components/EmptyStatePanel.tsx @@ -0,0 +1,58 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + +type EmptyStatePanelProps = { + title: string; + label?: string; + description?: string; + detail?: string; + fillHeight?: boolean; + compact?: boolean; + className?: string; +}; + +export const EmptyStatePanel = ({ + title, + label, + description, + detail, + fillHeight = false, + compact = false, + className, +}: EmptyStatePanelProps) => { + return ( + <div + className={cn( + "ui-card text-muted-foreground", + fillHeight ? "flex h-full w-full flex-col justify-center" : "", + className + )} + > + {label ? ( + <p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + {label} + </p> + ) : null} + <p + className={cn( + "console-title mt-2 text-2xl leading-none text-foreground sm:text-3xl", + compact ? "mt-0 text-xs font-medium tracking-normal text-muted-foreground sm:text-xs" : "" + )} + > + {title} + </p> + {description ? ( + <p className={cn("mt-3 text-sm text-muted-foreground", compact ? "mt-1 text-xs" : "")}> + {description} + </p> + ) : null} + {detail ? ( + <p className="ui-input mt-3 rounded-md px-4 py-2 font-mono text-[11px] text-muted-foreground/90"> + {detail} + </p> + ) : null} + </div> + ); +}; diff --git a/src/features/agents/components/FleetSidebar.tsx b/src/features/agents/components/FleetSidebar.tsx new file mode 100644 index 00000000..ff3f48b6 --- /dev/null +++ b/src/features/agents/components/FleetSidebar.tsx @@ -0,0 +1,173 @@ +import type { AgentState, FocusFilter } from "@/features/agents/state/store"; +import { useLayoutEffect, useMemo, useRef } from "react"; +import { AgentAvatar } from "./AgentAvatar"; +import { + NEEDS_APPROVAL_BADGE_CLASS, + resolveAgentStatusBadgeClass, + resolveAgentStatusLabel, +} from "./colorSemantics"; +import { EmptyStatePanel } from "./EmptyStatePanel"; + +type FleetSidebarProps = { + agents: AgentState[]; + selectedAgentId: string | null; + filter: FocusFilter; + onFilterChange: (next: FocusFilter) => void; + onSelectAgent: (agentId: string) => void; + onCreateAgent: () => void; + createDisabled?: boolean; + createBusy?: boolean; +}; + +const FILTER_OPTIONS: Array<{ value: FocusFilter; label: string; testId: string }> = [ + { value: "all", label: "All", testId: "fleet-filter-all" }, + { value: "running", label: "Running", testId: "fleet-filter-running" }, + { value: "approvals", label: "Approvals", testId: "fleet-filter-approvals" }, +]; + +export const FleetSidebar = ({ + agents, + selectedAgentId, + filter, + onFilterChange, + onSelectAgent, + onCreateAgent, + createDisabled = false, + createBusy = false, +}: FleetSidebarProps) => { + const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); + const previousTopByAgentIdRef = useRef<Map<string, number>>(new Map()); + const scrollContainerRef = useRef<HTMLDivElement | null>(null); + + const agentOrderKey = useMemo(() => agents.map((agent) => agent.agentId).join("|"), [agents]); + + useLayoutEffect(() => { + const scroller = scrollContainerRef.current; + if (!scroller) return; + const scrollerRect = scroller.getBoundingClientRect(); + + const getTopInScrollContent = (node: HTMLElement) => + node.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop; + + const nextTopByAgentId = new Map<string, number>(); + const agentIds = agentOrderKey.length === 0 ? [] : agentOrderKey.split("|"); + for (const agentId of agentIds) { + const node = rowRefs.current.get(agentId); + if (!node) continue; + const nextTop = getTopInScrollContent(node); + nextTopByAgentId.set(agentId, nextTop); + const previousTop = previousTopByAgentIdRef.current.get(agentId); + if (typeof previousTop !== "number") continue; + const deltaY = previousTop - nextTop; + if (Math.abs(deltaY) < 0.5) continue; + if (typeof node.animate !== "function") continue; + node.animate( + [{ transform: `translateY(${deltaY}px)` }, { transform: "translateY(0px)" }], + { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)" } + ); + } + previousTopByAgentIdRef.current = nextTopByAgentId; + }, [agentOrderKey]); + + return ( + <aside + className="glass-panel fade-up-delay ui-panel ui-depth-sidepanel relative flex h-full w-full min-w-72 flex-col gap-3 bg-sidebar p-3 xl:max-w-[320px] xl:border-r xl:border-sidebar-border" + data-testid="fleet-sidebar" + > + <div className="flex items-center justify-between gap-2 px-1"> + <p className="console-title type-page-title text-foreground">Agents ({agents.length})</p> + <button + type="button" + data-testid="fleet-new-agent-button" + className="ui-btn-primary px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground" + onClick={onCreateAgent} + disabled={createDisabled || createBusy} + > + {createBusy ? "Creating..." : "New agent"} + </button> + </div> + + <div className="ui-segment ui-segment-fleet-filter grid-cols-3"> + {FILTER_OPTIONS.map((option) => { + const active = filter === option.value; + return ( + <button + key={option.value} + type="button" + data-testid={option.testId} + aria-pressed={active} + className="ui-segment-item px-2 py-1 font-mono text-[12px] font-medium tracking-[0.02em]" + data-active={active ? "true" : "false"} + onClick={() => onFilterChange(option.value)} + > + {option.label} + </button> + ); + })} + </div> + + <div ref={scrollContainerRef} className="ui-scroll min-h-0 flex-1 overflow-auto"> + {agents.length === 0 ? ( + <EmptyStatePanel title="No agents available." compact className="p-3 text-xs" /> + ) : ( + <div className="flex flex-col gap-2.5"> + {agents.map((agent) => { + const selected = selectedAgentId === agent.agentId; + const avatarSeed = agent.avatarSeed ?? agent.agentId; + return ( + <button + key={agent.agentId} + ref={(node) => { + if (node) { + rowRefs.current.set(agent.agentId, node); + return; + } + rowRefs.current.delete(agent.agentId); + }} + type="button" + data-testid={`fleet-agent-row-${agent.agentId}`} + className={`group relative ui-card flex w-full items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-colors ${ + selected + ? "ui-card-selected" + : "hover:bg-surface-2/45" + }`} + onClick={() => onSelectAgent(agent.agentId)} + > + <span + aria-hidden="true" + className={`ui-card-select-indicator ${selected ? "opacity-100" : "opacity-0 group-hover:opacity-35"}`} + /> + <AgentAvatar + seed={avatarSeed} + name={agent.name} + avatarUrl={agent.avatarUrl ?? null} + size={42} + isSelected={selected} + /> + <div className="min-w-0 flex-1"> + <p className="type-secondary-heading truncate text-foreground"> + {agent.name} + </p> + <div className="mt-1.5 flex flex-wrap items-center gap-2"> + <span + className={`ui-badge ${resolveAgentStatusBadgeClass(agent.status)}`} + data-status={agent.status} + > + {resolveAgentStatusLabel(agent.status)} + </span> + {agent.awaitingUserInput ? ( + <span className={`ui-badge ${NEEDS_APPROVAL_BADGE_CLASS}`} data-status="approval"> + Needs approval + </span> + ) : null} + </div> + </div> + </button> + ); + })} + </div> + )} + </div> + </aside> + ); +}; diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx new file mode 100644 index 00000000..42545e27 --- /dev/null +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -0,0 +1,236 @@ +import { useMemo, useState } from "react"; +import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react"; +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; +import type { StudioGatewaySettings } from "@/lib/studio/settings"; + +type GatewayConnectScreenProps = { + gatewayUrl: string; + token: string; + localGatewayDefaults: StudioGatewaySettings | null; + status: GatewayStatus; + error: string | null; + onGatewayUrlChange: (value: string) => void; + onTokenChange: (value: string) => void; + onUseLocalDefaults: () => void; + onConnect: () => void; +}; + +const resolveLocalGatewayPort = (gatewayUrl: string): number => { + try { + const parsed = new URL(gatewayUrl); + const port = Number(parsed.port); + if (Number.isFinite(port) && port > 0) return port; + } catch {} + return 18789; +}; + +export const GatewayConnectScreen = ({ + gatewayUrl, + token, + localGatewayDefaults, + status, + error, + onGatewayUrlChange, + onTokenChange, + onUseLocalDefaults, + onConnect, +}: GatewayConnectScreenProps) => { + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle"); + const [showToken, setShowToken] = useState(false); + const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]); + const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]); + const localGatewayCommand = useMemo( + () => `npx openclaw gateway run --bind loopback --port ${localPort} --verbose`, + [localPort] + ); + const localGatewayCommandPnpm = useMemo( + () => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`, + [localPort] + ); + const statusCopy = useMemo(() => { + if (status === "connecting" && isLocal) { + return `Local gateway detected on port ${localPort}. Connecting…`; + } + if (status === "connecting") { + return "Connecting to remote gateway…"; + } + if (isLocal) { + return "No local gateway found."; + } + return "Not connected to a gateway."; + }, [isLocal, localPort, status]); + const connectDisabled = status === "connecting"; + const connectLabel = connectDisabled ? "Connecting…" : "Connect"; + const statusDotClass = + status === "connected" + ? "ui-dot-status-connected" + : status === "connecting" + ? "ui-dot-status-connecting" + : "ui-dot-status-disconnected"; + + const copyLocalCommand = async () => { + try { + await navigator.clipboard.writeText(localGatewayCommand); + setCopyStatus("copied"); + window.setTimeout(() => setCopyStatus("idle"), 1200); + } catch { + setCopyStatus("failed"); + window.setTimeout(() => setCopyStatus("idle"), 1800); + } + }; + + const commandField = ( + <div className="space-y-1.5"> + <div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2"> + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px]"> + {localGatewayCommand} + </code> + <button + type="button" + className="ui-btn-icon ui-command-copy h-7 w-7 shrink-0" + onClick={copyLocalCommand} + aria-label="Copy local gateway command" + title="Copy command" + > + {copyStatus === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} + </button> + </div> + {copyStatus === "copied" ? ( + <p className="text-xs text-muted-foreground">Copied</p> + ) : copyStatus === "failed" ? ( + <p className="ui-text-danger text-xs">Could not copy command.</p> + ) : ( + <p className="text-xs leading-snug text-muted-foreground"> + In a source checkout, use <span className="font-mono">{localGatewayCommandPnpm}</span>. + </p> + )} + </div> + ); + + const remoteForm = ( + <div className="mt-2.5 flex flex-col gap-3"> + <label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80"> + Upstream URL + <input + className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none" + type="text" + value={gatewayUrl} + onChange={(event) => onGatewayUrlChange(event.target.value)} + placeholder="wss://your-gateway.example.com" + spellCheck={false} + /> + </label> + + <div className="space-y-0.5 text-xs text-muted-foreground/90"> + <p className="font-medium text-foreground/85">Using Tailscale?</p> + <p> + URL: <span className="font-mono">wss://<your-tailnet-host></span> + </p> + </div> + + <label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/80"> + Upstream token + <div className="relative"> + <input + className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none" + type={showToken ? "text" : "password"} + value={token} + onChange={(event) => onTokenChange(event.target.value)} + placeholder="gateway token" + spellCheck={false} + /> + <button + type="button" + className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground" + aria-label={showToken ? "Hide token" : "Show token"} + onClick={() => setShowToken((prev) => !prev)} + > + {showToken ? ( + <EyeOff className="h-4 w-4 transition-transform duration-150" /> + ) : ( + <Eye className="h-4 w-4 transition-transform duration-150" /> + )} + </button> + </div> + </label> + + <button + type="button" + className="ui-btn-primary mt-1 h-11 w-full px-4 text-xs font-semibold tracking-[0.05em] disabled:cursor-not-allowed disabled:opacity-60" + onClick={onConnect} + disabled={connectDisabled || !gatewayUrl.trim()} + > + {connectLabel} + </button> + + {status === "connecting" ? ( + <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> + <Loader2 className="h-3.5 w-3.5 animate-spin" /> + Connecting… + </p> + ) : null} + {error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null} + </div> + ); + + return ( + <div className="mx-auto flex min-h-0 w-full max-w-[820px] flex-1 flex-col gap-5"> + <div className="ui-card px-4 py-2"> + <div className="flex items-center gap-2"> + {status === "connecting" ? ( + <Loader2 className="h-4 w-4 animate-spin text-[color:var(--status-connecting-fg)]" /> + ) : ( + <span + className={`h-2.5 w-2.5 ${statusDotClass}`} + /> + )} + <p className="text-sm font-semibold text-foreground">{statusCopy}</p> + </div> + </div> + + <div className="ui-card px-4 py-5 sm:px-6"> + <div> + <p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground"> + Remote gateway (recommended) + </p> + <p className="mt-2 text-sm text-foreground/85">Default: enter your URL and token to connect.</p> + </div> + {remoteForm} + </div> + + <div className="ui-card px-4 py-4 sm:px-6 sm:py-5"> + <div className="space-y-1.5"> + <p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground"> + Run locally (optional) + </p> + <p className="text-sm text-foreground/85"> + Start a local gateway process on this machine, then connect. + </p> + </div> + <div className="mt-3 space-y-3"> + {commandField} + {localGatewayDefaults ? ( + <div className="ui-input rounded-md px-3 py-3"> + <div className="space-y-2"> + <p className="text-xs text-muted-foreground"> + Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>. + </p> + <p className="font-mono text-[11px] text-foreground/85"> + {localGatewayDefaults.url} + </p> + <button + type="button" + className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em] text-foreground" + onClick={onUseLocalDefaults} + > + Use local defaults + </button> + </div> + </div> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/HeaderBar.tsx b/src/features/agents/components/HeaderBar.tsx new file mode 100644 index 00000000..c82b0022 --- /dev/null +++ b/src/features/agents/components/HeaderBar.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { ThemeToggle } from "@/components/theme-toggle"; +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; +import { Plug } from "lucide-react"; +import { resolveGatewayStatusBadgeClass } from "./colorSemantics"; + +type HeaderBarProps = { + status: GatewayStatus; + onConnectionSettings: () => void; + showConnectionSettings?: boolean; +}; + +export const HeaderBar = ({ + status, + onConnectionSettings, + showConnectionSettings = true, +}: HeaderBarProps) => { + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (!menuRef.current) return; + if (menuRef.current.contains(event.target as Node)) return; + setMenuOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [menuOpen]); + + return ( + <div className="ui-topbar relative z-[180]"> + <div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-4 md:px-5"> + <div aria-hidden="true" /> + <p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground"> + OpenClaw Studio + </p> + <div className="flex items-center justify-end gap-1"> + {status === "connecting" ? ( + <span + className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`} + data-testid="gateway-connecting-indicator" + data-status="connecting" + > + Connecting + </span> + ) : null} + <ThemeToggle /> + {showConnectionSettings ? ( + <div className="relative z-[210]" ref={menuRef}> + <button + type="button" + className="ui-btn-icon ui-btn-icon-xs" + data-testid="studio-menu-toggle" + aria-haspopup="menu" + aria-expanded={menuOpen} + onClick={() => setMenuOpen((prev) => !prev)} + > + <Plug className="h-3.5 w-3.5" /> + <span className="sr-only">Open studio menu</span> + </button> + {menuOpen ? ( + <div className="ui-card ui-menu-popover absolute right-0 top-9 z-[260] min-w-44 p-1"> + <button + className="ui-btn-ghost w-full justify-start border-transparent px-3 py-2 text-left text-xs font-medium tracking-normal text-foreground" + type="button" + onClick={() => { + onConnectionSettings(); + setMenuOpen(false); + }} + data-testid="gateway-settings-toggle" + > + Gateway connection + </button> + </div> + ) : null} + </div> + ) : null} + </div> + </div> + </div> + ); +}; diff --git a/src/features/agents/components/SystemSkillsPanel.tsx b/src/features/agents/components/SystemSkillsPanel.tsx new file mode 100644 index 00000000..cf54be25 --- /dev/null +++ b/src/features/agents/components/SystemSkillsPanel.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { AgentSkillsSetupModal } from "@/features/agents/components/AgentSkillsSetupModal"; +import { + buildSkillMissingDetails, + deriveSkillReadinessState, + type SkillReadinessState, +} from "@/lib/skills/presentation"; +import type { SkillStatusReport } from "@/lib/skills/types"; + +type SkillSetupMessage = { kind: "success" | "error"; message: string }; + +type ReadinessFilter = "all" | SkillReadinessState; + +type SystemSkillsPanelProps = { + skillsReport?: SkillStatusReport | null; + skillsLoading?: boolean; + skillsError?: string | null; + skillsBusy?: boolean; + skillsBusyKey?: string | null; + skillMessages?: Record<string, SkillSetupMessage>; + skillApiKeyDrafts?: Record<string, string>; + defaultAgentScopeWarning?: string | null; + initialSkillKey?: string | null; + onInitialSkillKeyHandled?: () => void; + onSetSkillGlobalEnabled: (skillKey: string, enabled: boolean) => Promise<void> | void; + onInstallSkill: (skillKey: string, name: string, installId: string) => Promise<void> | void; + onRemoveSkill: ( + skill: { skillKey: string; source: string; baseDir: string } + ) => Promise<void> | void; + onSkillApiKeyChange: (skillKey: string, value: string) => Promise<void> | void; + onSaveSkillApiKey: (skillKey: string) => Promise<void> | void; +}; + +const READINESS_FILTERS: Array<{ id: ReadinessFilter; label: string }> = [ + { id: "all", label: "All" }, + { id: "ready", label: "Ready" }, + { id: "needs-setup", label: "Needs setup" }, + { id: "unavailable", label: "Unavailable" }, + { id: "disabled-globally", label: "Disabled globally" }, +]; + +const READINESS_LABELS = { + ready: "Ready", + "needs-setup": "Needs setup", + unavailable: "Unavailable", + "disabled-globally": "Disabled globally", +} as const; + +const READINESS_CLASSES = { + ready: "ui-badge-status-running", + "needs-setup": "ui-badge-status-error", + unavailable: "ui-badge-status-error", + "disabled-globally": "ui-badge-status-error", +} as const; + +const resolveReadinessHint = ( + skill: SkillStatusReport["skills"][number], + readiness: SkillReadinessState +): string | null => { + if (readiness === "ready") { + return null; + } + if (readiness === "disabled-globally") { + return "Disabled globally for all agents."; + } + if (readiness === "unavailable") { + if (skill.blockedByAllowlist) { + return "Blocked by bundled skills policy."; + } + return buildSkillMissingDetails(skill)[0] ?? "Unavailable on this system."; + } + return buildSkillMissingDetails(skill)[0] ?? "Requires setup."; +}; + +export const SystemSkillsPanel = ({ + skillsReport = null, + skillsLoading = false, + skillsError = null, + skillsBusy = false, + skillsBusyKey = null, + skillMessages = {}, + skillApiKeyDrafts = {}, + defaultAgentScopeWarning = null, + initialSkillKey = null, + onInitialSkillKeyHandled, + onSetSkillGlobalEnabled, + onInstallSkill, + onRemoveSkill, + onSkillApiKeyChange, + onSaveSkillApiKey, +}: SystemSkillsPanelProps) => { + const [skillsFilter, setSkillsFilter] = useState(""); + const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>("all"); + const [setupSkillKey, setSetupSkillKey] = useState<string | null>(null); + + const skillEntries = useMemo(() => skillsReport?.skills ?? [], [skillsReport]); + const anySkillBusy = skillsBusy || Boolean(skillsBusyKey); + const requestedInitialSkillKey = useMemo(() => { + const candidate = initialSkillKey?.trim() ?? ""; + if (!candidate) { + return null; + } + return skillEntries.some((entry) => entry.skillKey === candidate) ? candidate : null; + }, [initialSkillKey, skillEntries]); + + const rows = useMemo( + () => + skillEntries.map((skill) => ({ + skill, + readiness: deriveSkillReadinessState(skill), + })), + [skillEntries] + ); + + const searchedRows = useMemo(() => { + const query = skillsFilter.trim().toLowerCase(); + if (!query) { + return rows; + } + return rows.filter((entry) => + [entry.skill.name, entry.skill.description, entry.skill.source, entry.skill.skillKey] + .join(" ") + .toLowerCase() + .includes(query) + ); + }, [rows, skillsFilter]); + + const filteredRows = useMemo(() => { + if (readinessFilter === "all") { + return searchedRows; + } + return searchedRows.filter((entry) => entry.readiness === readinessFilter); + }, [readinessFilter, searchedRows]); + + const readinessCounts = useMemo( + () => + searchedRows.reduce( + (counts, entry) => { + counts.all += 1; + counts[entry.readiness] += 1; + return counts; + }, + { + all: 0, + ready: 0, + "needs-setup": 0, + unavailable: 0, + "disabled-globally": 0, + } satisfies Record<ReadinessFilter, number> + ), + [searchedRows] + ); + + const setupQueue = useMemo( + () => + rows.filter( + (entry) => entry.readiness === "needs-setup" || entry.readiness === "disabled-globally" + ), + [rows] + ); + + const selectedSkillKey = setupSkillKey ?? requestedInitialSkillKey; + const selectedSetupSkill = selectedSkillKey + ? skillEntries.find((entry) => entry.skillKey === selectedSkillKey) ?? null + : null; + + return ( + <section className="sidebar-section" data-testid="agent-settings-system-skills"> + <div className="flex items-center justify-between gap-3"> + <h3 className="sidebar-section-title">System skill setup</h3> + <div className="font-mono text-[10px] text-muted-foreground">{skillEntries.length}</div> + </div> + <div className="mt-2 text-[11px] text-muted-foreground"> + Changes here affect all agents on this gateway. + </div> + {defaultAgentScopeWarning ? ( + <div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-2 text-[10px] text-muted-foreground/82"> + {defaultAgentScopeWarning} + </div> + ) : null} + {setupQueue.length > 0 ? ( + <div className="mt-3 rounded-md border border-border/60 bg-surface-1/65 px-3 py-3"> + <div className="text-[10px] font-semibold text-foreground/85">Needs setup ({setupQueue.length})</div> + <div className="mt-2 flex flex-col gap-2"> + {setupQueue.slice(0, 5).map((entry) => ( + <div + key={`setup-queue:${entry.skill.skillKey}`} + className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground/85" + > + <span className="truncate">{entry.skill.name}</span> + <button + type="button" + className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy} + onClick={() => { + onInitialSkillKeyHandled?.(); + setSetupSkillKey(entry.skill.skillKey); + }} + > + Set up + </button> + </div> + ))} + </div> + </div> + ) : null} + <div className="mt-3"> + <input + value={skillsFilter} + onChange={(event) => setSkillsFilter(event.target.value)} + placeholder="Search skills" + className="w-full rounded-md border border-border/60 bg-surface-1 px-3 py-2 text-[11px] text-foreground outline-none transition focus:border-border" + aria-label="Search skills" + /> + </div> + <div className="mt-2 flex flex-wrap gap-1"> + {READINESS_FILTERS.map((filter) => { + const selected = readinessFilter === filter.id; + return ( + <button + key={filter.id} + type="button" + className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65" + data-active={selected ? "true" : "false"} + disabled={skillsLoading} + onClick={() => { + setReadinessFilter(filter.id); + }} + > + {filter.label} ({readinessCounts[filter.id]}) + </button> + ); + })} + </div> + {skillsLoading ? <div className="mt-3 text-[11px] text-muted-foreground">Loading skills...</div> : null} + {!skillsLoading && skillsError ? ( + <div className="ui-alert-danger mt-3 rounded-md px-3 py-2 text-xs">{skillsError}</div> + ) : null} + {!skillsLoading && !skillsError && filteredRows.length === 0 ? ( + <div className="mt-3 text-[11px] text-muted-foreground">No matching skills.</div> + ) : null} + {!skillsLoading && !skillsError && filteredRows.length > 0 ? ( + <div className="mt-3 flex flex-col gap-2"> + {filteredRows.map((entry) => { + const readinessLabel = READINESS_LABELS[entry.readiness]; + const readinessClassName = READINESS_CLASSES[entry.readiness]; + const message = skillMessages[entry.skill.skillKey] ?? null; + return ( + <div + key={`${entry.skill.source}:${entry.skill.skillKey}`} + className="ui-settings-row flex min-h-[68px] flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start sm:justify-between" + > + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-2"> + <span className="truncate text-[11px] font-medium text-foreground/88">{entry.skill.name}</span> + <span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[9px] text-muted-foreground"> + {entry.skill.source} + </span> + <span + className={`rounded border px-1.5 py-0.5 font-mono text-[10px] font-semibold ${readinessClassName}`} + > + {readinessLabel} + </span> + </div> + <div className="mt-1 text-[10px] text-muted-foreground/70">{entry.skill.description}</div> + {entry.readiness !== "ready" ? ( + <div className="mt-1 text-[10px] text-muted-foreground/80"> + {resolveReadinessHint(entry.skill, entry.readiness)} + </div> + ) : null} + {message ? ( + <div + className={`mt-1 text-[10px] ${message.kind === "error" ? "ui-text-danger" : "ui-text-success"}`} + > + {message.message} + </div> + ) : null} + </div> + <div className="flex w-full items-center justify-end gap-2 sm:w-[210px]"> + <button + type="button" + className="ui-btn-secondary px-2 py-1 text-[9px] font-semibold disabled:cursor-not-allowed disabled:opacity-65" + disabled={anySkillBusy} + onClick={() => { + onInitialSkillKeyHandled?.(); + setSetupSkillKey(entry.skill.skillKey); + }} + > + Configure + </button> + </div> + </div> + ); + })} + </div> + ) : null} + <AgentSkillsSetupModal + skill={selectedSetupSkill} + skillsBusy={skillsBusy} + skillsBusyKey={skillsBusyKey} + skillMessage={selectedSetupSkill ? skillMessages[selectedSetupSkill.skillKey] ?? null : null} + apiKeyDraft={selectedSetupSkill ? skillApiKeyDrafts[selectedSetupSkill.skillKey] ?? "" : ""} + defaultAgentScopeWarning={defaultAgentScopeWarning} + onClose={() => { + onInitialSkillKeyHandled?.(); + setSetupSkillKey(null); + }} + onInstallSkill={onInstallSkill} + onSetSkillGlobalEnabled={onSetSkillGlobalEnabled} + onRemoveSkill={onRemoveSkill} + onSkillApiKeyChange={onSkillApiKeyChange} + onSaveSkillApiKey={onSaveSkillApiKey} + /> + </section> + ); +}; diff --git a/src/features/agents/components/chatItems.ts b/src/features/agents/components/chatItems.ts new file mode 100644 index 00000000..6b5851ac --- /dev/null +++ b/src/features/agents/components/chatItems.ts @@ -0,0 +1,493 @@ +import { + formatThinkingMarkdown, + isToolMarkdown, + isMetaMarkdown, + isTraceMarkdown, + parseToolMarkdown, + parseMetaMarkdown, + stripTraceMarkdown, +} from "@/lib/text/message-extract"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; + +type ItemMeta = { + role: "user" | "assistant"; + timestampMs: number; + thinkingDurationMs?: number; +}; + +export type AgentChatItem = + | { kind: "user"; text: string; timestampMs?: number } + | { kind: "assistant"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number } + | { kind: "tool"; text: string; timestampMs?: number } + | { kind: "thinking"; text: string; live?: boolean; timestampMs?: number; thinkingDurationMs?: number }; + +export type AssistantTraceEvent = + | { kind: "thinking"; text: string } + | { kind: "tool"; text: string }; + +export type AgentChatRenderBlock = + | { kind: "user"; text: string; timestampMs?: number } + | { + kind: "assistant"; + text: string | null; + timestampMs?: number; + thinkingDurationMs?: number; + traceEvents: AssistantTraceEvent[]; + }; + +export type BuildAgentChatItemsInput = { + outputLines: string[]; + streamText: string | null; + liveThinkingTrace: string; + showThinkingTraces: boolean; + toolCallingEnabled: boolean; +}; + +const normalizeUserDisplayText = (value: string): string => { + return value.replace(/\s+/g, " ").trim(); +}; + +const normalizeThinkingDisplayText = (value: string): string => { + const markdown = formatThinkingMarkdown(value); + const normalized = stripTraceMarkdown(markdown).trim(); + return normalized; +}; + +export const buildFinalAgentChatItems = ({ + outputLines, + showThinkingTraces, + toolCallingEnabled, +}: Pick< + BuildAgentChatItemsInput, + "outputLines" | "showThinkingTraces" | "toolCallingEnabled" +>): AgentChatItem[] => { + const items: AgentChatItem[] = []; + let currentMeta: ItemMeta | null = null; + const appendThinking = (text: string) => { + const normalized = text.trim(); + if (!normalized) return; + const previous = items[items.length - 1]; + if (!previous || previous.kind !== "thinking") { + items.push({ + kind: "thinking", + text: normalized, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}), + }); + return; + } + if (previous.text === normalized) { + return; + } + if (normalized.startsWith(previous.text)) { + previous.text = normalized; + return; + } + if (previous.text.startsWith(normalized)) { + return; + } + previous.text = `${previous.text}\n\n${normalized}`; + }; + + for (const line of outputLines) { + if (!line) continue; + if (isMetaMarkdown(line)) { + const parsed = parseMetaMarkdown(line); + if (parsed) { + currentMeta = { + role: parsed.role, + timestampMs: parsed.timestamp, + ...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}), + }; + } + continue; + } + if (isTraceMarkdown(line)) { + if (!showThinkingTraces) continue; + const text = stripTraceMarkdown(line).trim(); + if (!text) continue; + appendThinking(text); + continue; + } + if (isToolMarkdown(line)) { + if (!toolCallingEnabled) continue; + items.push({ + kind: "tool", + text: line, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}), + }); + continue; + } + const trimmed = line.trim(); + if (trimmed.startsWith(">")) { + const text = trimmed.replace(/^>\s?/, "").trim(); + if (text) { + const normalized = normalizeUserDisplayText(text); + const currentTimestamp = + currentMeta?.role === "user" ? currentMeta.timestampMs : undefined; + const previous = items[items.length - 1]; + if (previous?.kind === "user") { + const previousNormalized = normalizeUserDisplayText(previous.text); + const previousTimestamp = previous.timestampMs; + const shouldCollapse = + previousNormalized === normalized && + ((typeof previousTimestamp === "number" && + typeof currentTimestamp === "number" && + previousTimestamp === currentTimestamp) || + (previousTimestamp === undefined && + typeof currentTimestamp === "number")); + if ( + shouldCollapse + ) { + previous.text = normalized; + if (typeof currentTimestamp === "number") { + previous.timestampMs = currentTimestamp; + } + if (currentMeta?.role === "user") { + currentMeta = null; + } + continue; + } + } + items.push({ + kind: "user", + text: normalized, + ...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}), + }); + if (currentMeta?.role === "user") { + currentMeta = null; + } + } + continue; + } + const normalizedAssistant = normalizeAssistantDisplayText(line); + if (!normalizedAssistant) continue; + items.push({ + kind: "assistant", + text: normalizedAssistant, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}), + }); + } + return items; +}; + +export const buildAgentChatItems = ({ + outputLines, + streamText, + liveThinkingTrace, + showThinkingTraces, + toolCallingEnabled, +}: BuildAgentChatItemsInput): AgentChatItem[] => { + const items: AgentChatItem[] = []; + let currentMeta: ItemMeta | null = null; + const appendThinking = (text: string, live?: boolean) => { + const normalized = text.trim(); + if (!normalized) return; + const previous = items[items.length - 1]; + if (!previous || previous.kind !== "thinking") { + items.push({ + kind: "thinking", + text: normalized, + live, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}), + }); + return; + } + if (previous.text === normalized) { + if (live) previous.live = true; + return; + } + if (normalized.startsWith(previous.text)) { + previous.text = normalized; + if (live) previous.live = true; + return; + } + if (previous.text.startsWith(normalized)) { + if (live) previous.live = true; + return; + } + previous.text = `${previous.text}\n\n${normalized}`; + if (live) previous.live = true; + }; + + for (const line of outputLines) { + if (!line) continue; + if (isMetaMarkdown(line)) { + const parsed = parseMetaMarkdown(line); + if (parsed) { + currentMeta = { + role: parsed.role, + timestampMs: parsed.timestamp, + ...(typeof parsed.thinkingDurationMs === "number" ? { thinkingDurationMs: parsed.thinkingDurationMs } : {}), + }; + } + continue; + } + if (isTraceMarkdown(line)) { + if (!showThinkingTraces) continue; + const text = stripTraceMarkdown(line).trim(); + if (!text) continue; + appendThinking(text); + continue; + } + if (isToolMarkdown(line)) { + if (!toolCallingEnabled) continue; + items.push({ + kind: "tool", + text: line, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs } : {}), + }); + continue; + } + const trimmed = line.trim(); + if (trimmed.startsWith(">")) { + const text = trimmed.replace(/^>\s?/, "").trim(); + if (text) { + const currentTimestamp = + currentMeta?.role === "user" ? currentMeta.timestampMs : undefined; + items.push({ + kind: "user", + text: normalizeUserDisplayText(text), + ...(typeof currentTimestamp === "number" ? { timestampMs: currentTimestamp } : {}), + }); + if (currentMeta?.role === "user") { + currentMeta = null; + } + } + continue; + } + const normalizedAssistant = normalizeAssistantDisplayText(line); + if (!normalizedAssistant) continue; + items.push({ + kind: "assistant", + text: normalizedAssistant, + ...(currentMeta ? { timestampMs: currentMeta.timestampMs, thinkingDurationMs: currentMeta.thinkingDurationMs } : {}), + }); + } + + const liveStream = streamText?.trim(); + + if (showThinkingTraces) { + const normalizedLiveThinking = normalizeThinkingDisplayText(liveThinkingTrace); + if (normalizedLiveThinking) { + appendThinking(normalizedLiveThinking, true); + } + } + + if (liveStream) { + const normalizedStream = normalizeAssistantDisplayText(liveStream); + if (normalizedStream) { + items.push({ kind: "assistant", text: normalizedStream, live: true }); + } + } + + return items; +}; + +const mergeIncrementalText = (existing: string, next: string): string => { + if (existing === next) return existing; + if (next.startsWith(existing)) return next; + if (existing.startsWith(next)) return existing; + return `${existing}\n\n${next}`; +}; + +const appendThinkingTraceEvent = (events: AssistantTraceEvent[], text: string) => { + const normalized = text.trim(); + if (!normalized) return; + const previous = events[events.length - 1]; + if (!previous || previous.kind !== "thinking") { + events.push({ kind: "thinking", text: normalized }); + return; + } + previous.text = mergeIncrementalText(previous.text, normalized); +}; + +const hasMismatchedTimestamps = ( + left?: number, + right?: number +): boolean => { + if (typeof left !== "number" || typeof right !== "number") return false; + return left !== right; +}; + +export const buildAgentChatRenderBlocks = ( + chatItems: AgentChatItem[] +): AgentChatRenderBlock[] => { + const blocks: AgentChatRenderBlock[] = []; + let currentAssistant: Extract<AgentChatRenderBlock, { kind: "assistant" }> | null = null; + + const flushAssistant = () => { + if (!currentAssistant) return; + if (currentAssistant.text || currentAssistant.traceEvents.length > 0) { + blocks.push(currentAssistant); + } + currentAssistant = null; + }; + + const ensureAssistant = (meta?: { + timestampMs?: number; + thinkingDurationMs?: number; + }) => { + if (!currentAssistant) { + currentAssistant = { + kind: "assistant", + text: null, + traceEvents: [], + ...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}), + ...(typeof meta?.thinkingDurationMs === "number" + ? { thinkingDurationMs: meta.thinkingDurationMs } + : {}), + }; + return currentAssistant; + } + if ( + currentAssistant.text && + hasMismatchedTimestamps(currentAssistant.timestampMs, meta?.timestampMs) + ) { + flushAssistant(); + currentAssistant = { + kind: "assistant", + text: null, + traceEvents: [], + ...(typeof meta?.timestampMs === "number" ? { timestampMs: meta.timestampMs } : {}), + ...(typeof meta?.thinkingDurationMs === "number" + ? { thinkingDurationMs: meta.thinkingDurationMs } + : {}), + }; + return currentAssistant; + } + if ( + typeof currentAssistant.timestampMs !== "number" && + typeof meta?.timestampMs === "number" + ) { + currentAssistant.timestampMs = meta.timestampMs; + } + if (typeof meta?.thinkingDurationMs === "number") { + currentAssistant.thinkingDurationMs = meta.thinkingDurationMs; + } + return currentAssistant; + }; + + for (const item of chatItems) { + if (item.kind === "user") { + flushAssistant(); + blocks.push({ kind: "user", text: item.text, timestampMs: item.timestampMs }); + continue; + } + + if (item.kind === "thinking") { + const assistant = ensureAssistant({ + timestampMs: item.timestampMs, + thinkingDurationMs: item.thinkingDurationMs, + }); + appendThinkingTraceEvent(assistant.traceEvents, item.text); + continue; + } + + if (item.kind === "tool") { + const assistant = ensureAssistant({ timestampMs: item.timestampMs }); + assistant.traceEvents.push({ kind: "tool", text: item.text }); + continue; + } + + const assistant = ensureAssistant({ + timestampMs: item.timestampMs, + thinkingDurationMs: item.thinkingDurationMs, + }); + const normalized = item.text.trim(); + if (!normalized) continue; + assistant.text = + typeof assistant.text === "string" + ? mergeIncrementalText(assistant.text, normalized) + : normalized; + } + + flushAssistant(); + return blocks; +}; + +const stripTrailingToolCallId = ( + label: string +): { toolLabel: string; toolCallId: string | null } => { + const trimmed = label.trim(); + const match = trimmed.match(/^(.*?)\s*\(([^)]+)\)\s*$/); + if (!match) return { toolLabel: trimmed, toolCallId: null }; + const toolLabel = (match[1] ?? "").trim(); + const toolCallId = (match[2] ?? "").trim(); + return { toolLabel: toolLabel || trimmed, toolCallId: toolCallId || null }; +}; + +const toDisplayToolName = (label: string): string => { + const cleaned = label.trim(); + if (!cleaned) return "tool"; + const segments = cleaned.split(/[.:/]/).map((s) => s.trim()).filter(Boolean); + return segments[segments.length - 1] ?? cleaned; +}; + +const truncateInline = (value: string, maxChars: number): string => { + const cleaned = value.replace(/\s+/g, " ").trim(); + if (cleaned.length <= maxChars) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +}; + +const extractToolMetaLine = (body: string): string | null => { + const trimmed = body.trim(); + if (!trimmed) return null; + const [firstLine] = trimmed.split(/\r?\n/, 1); + const meta = (firstLine ?? "").trim(); + if (!meta) return null; + if (meta.startsWith("```")) return null; + return meta; +}; + +const extractFirstCodeBlockLine = (body: string): string | null => { + const match = body.match(/```[a-zA-Z0-9_-]*\r?\n([^\r\n]+)\r?\n/); + const line = (match?.[1] ?? "").trim(); + return line ? truncateInline(line, 96) : null; +}; + +const extractToolArgSummary = (body: string): string | null => { + const matchers: Array<[RegExp, (m: RegExpMatchArray) => string | null]> = [ + [/"command"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)], + [/"file_path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)], + [/"filePath"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)], + [/"path"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)], + [/"url"\s*:\s*"([^"]+)"/, (m) => (m[1] ? m[1] : null)], + ]; + for (const [re, toSummary] of matchers) { + const m = body.match(re); + const summary = m ? toSummary(m) : null; + if (summary) return truncateInline(summary, 96); + } + return null; +}; + +export const summarizeToolLabel = ( + line: string +): { summaryText: string; body: string; inlineOnly?: boolean } => { + const parsed = parseToolMarkdown(line); + const { toolLabel } = stripTrailingToolCallId(parsed.label); + const toolName = toDisplayToolName(toolLabel).toUpperCase(); + const metaLine = parsed.kind === "result" ? extractToolMetaLine(parsed.body) : null; + const argSummary = parsed.kind === "call" ? extractToolArgSummary(parsed.body) : null; + const toolIsRead = toolName === "READ"; + if (toolIsRead && parsed.kind === "call" && argSummary) { + return { + summaryText: `read ${argSummary}`, + body: "", + inlineOnly: true, + }; + } + const suffix = metaLine ?? argSummary; + const toolIsExec = toolName === "EXEC"; + const execSummary = + parsed.kind === "call" + ? argSummary + : metaLine ?? extractFirstCodeBlockLine(parsed.body); + const summaryText = toolIsExec + ? (execSummary ?? metaLine ?? toolName) + : (suffix ? `${toolName} · ${suffix}` : toolName); + return { + summaryText, + body: parsed.body, + }; +}; diff --git a/src/features/agents/components/colorSemantics.ts b/src/features/agents/components/colorSemantics.ts new file mode 100644 index 00000000..c1b21c48 --- /dev/null +++ b/src/features/agents/components/colorSemantics.ts @@ -0,0 +1,38 @@ +import type { AgentStatus } from "@/features/agents/state/store"; +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; + +export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = { + idle: "Idle", + running: "Running", + error: "Error", +}; + +export const AGENT_STATUS_BADGE_CLASS: Record<AgentStatus, string> = { + idle: "ui-badge-status-idle", + running: "ui-badge-status-running", + error: "ui-badge-status-error", +}; + +export const GATEWAY_STATUS_LABEL: Record<GatewayStatus, string> = { + disconnected: "Disconnected", + connecting: "Connecting", + connected: "Connected", +}; + +export const GATEWAY_STATUS_BADGE_CLASS: Record<GatewayStatus, string> = { + disconnected: "ui-badge-status-disconnected", + connecting: "ui-badge-status-connecting", + connected: "ui-badge-status-connected", +}; + +export const NEEDS_APPROVAL_BADGE_CLASS = "ui-badge-approval"; + +export const resolveAgentStatusBadgeClass = (status: AgentStatus): string => + AGENT_STATUS_BADGE_CLASS[status]; + +export const resolveGatewayStatusBadgeClass = (status: GatewayStatus): string => + GATEWAY_STATUS_BADGE_CLASS[status]; + +export const resolveAgentStatusLabel = (status: AgentStatus): string => AGENT_STATUS_LABEL[status]; + +export const resolveGatewayStatusLabel = (status: GatewayStatus): string => GATEWAY_STATUS_LABEL[status]; diff --git a/src/features/agents/creation/types.ts b/src/features/agents/creation/types.ts new file mode 100644 index 00000000..31ae46f6 --- /dev/null +++ b/src/features/agents/creation/types.ts @@ -0,0 +1,4 @@ +export type AgentCreateModalSubmitPayload = { + name: string; + avatarSeed?: string; +}; diff --git a/src/features/agents/operations/agentFleetHydration.ts b/src/features/agents/operations/agentFleetHydration.ts new file mode 100644 index 00000000..142b0877 --- /dev/null +++ b/src/features/agents/operations/agentFleetHydration.ts @@ -0,0 +1,180 @@ +import { buildAgentMainSessionKey, isSameSessionKey } from "@/lib/gateway/GatewayClient"; +import { type GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import { type StudioSettings } from "@/lib/studio/settings"; +import { + type SummaryPreviewSnapshot, + type SummarySnapshotPatch, + type SummaryStatusSnapshot, +} from "@/features/agents/state/runtimeEventBridge"; +import type { AgentStoreSeed } from "@/features/agents/state/store"; +import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation"; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +type AgentsListResult = { + defaultId: string; + mainKey: string; + scope?: string; + agents: Array<{ + id: string; + name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; + }>; +}; + +type SessionsListEntry = { + key: string; + updatedAt?: number | null; + displayName?: string; + origin?: { label?: string | null; provider?: string | null } | null; + thinkingLevel?: string; + modelProvider?: string; + model?: string; + execHost?: string | null; + execSecurity?: string | null; + execAsk?: string | null; +}; + +type SessionsListResult = { + sessions?: SessionsListEntry[]; +}; + +type ExecApprovalsSnapshot = { + file?: { + agents?: Record<string, { security?: string | null; ask?: string | null }>; + }; +}; + +export type HydrateAgentFleetResult = { + seeds: AgentStoreSeed[]; + sessionCreatedAgentIds: string[]; + sessionSettingsSyncedAgentIds: string[]; + summaryPatches: SummarySnapshotPatch[]; + suggestedSelectedAgentId: string | null; + configSnapshot: GatewayModelPolicySnapshot | null; +}; + +export async function hydrateAgentFleetFromGateway(params: { + client: GatewayClientLike; + gatewayUrl: string; + cachedConfigSnapshot: GatewayModelPolicySnapshot | null; + loadStudioSettings: () => Promise<StudioSettings | null>; + isDisconnectLikeError: (err: unknown) => boolean; + logError?: (message: string, error: unknown) => void; +}): Promise<HydrateAgentFleetResult> { + const logError = params.logError ?? ((message, error) => console.error(message, error)); + + let configSnapshot = params.cachedConfigSnapshot; + if (!configSnapshot) { + try { + configSnapshot = (await params.client.call( + "config.get", + {} + )) as GatewayModelPolicySnapshot; + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to load gateway config while loading agents.", err); + } + } + } + + const gatewayKey = params.gatewayUrl.trim(); + let settings: StudioSettings | null = null; + if (gatewayKey) { + try { + settings = await params.loadStudioSettings(); + } catch (err) { + logError("Failed to load studio settings while loading agents.", err); + } + } + + let execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; + try { + execApprovalsSnapshot = (await params.client.call( + "exec.approvals.get", + {} + )) as ExecApprovalsSnapshot; + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to load exec approvals while loading agents.", err); + } + } + + const agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult; + const mainKey = agentsResult.mainKey?.trim() || "main"; + + const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>(); + await Promise.all( + agentsResult.agents.map(async (agent) => { + try { + const expectedMainKey = buildAgentMainSessionKey(agent.id, mainKey); + const sessions = (await params.client.call("sessions.list", { + agentId: agent.id, + includeGlobal: false, + includeUnknown: false, + search: expectedMainKey, + limit: 4, + })) as SessionsListResult; + const entries = Array.isArray(sessions.sessions) ? sessions.sessions : []; + const mainEntry = + entries.find((entry) => isSameSessionKey(entry.key ?? "", expectedMainKey)) ?? null; + mainSessionKeyByAgent.set(agent.id, mainEntry); + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to list sessions while resolving agent session.", err); + } + mainSessionKeyByAgent.set(agent.id, null); + } + }) + ); + + let statusSummary: SummaryStatusSnapshot | null = null; + let previewResult: SummaryPreviewSnapshot | null = null; + try { + const sessionKeys = Array.from( + new Set( + agentsResult.agents + .filter((agent) => Boolean(mainSessionKeyByAgent.get(agent.id))) + .map((agent) => buildAgentMainSessionKey(agent.id, mainKey)) + .filter((key) => key.trim().length > 0) + ) + ).slice(0, 64); + if (sessionKeys.length > 0) { + const snapshot = await Promise.all([ + params.client.call("status", {}) as Promise<SummaryStatusSnapshot>, + params.client.call("sessions.preview", { + keys: sessionKeys, + limit: 8, + maxChars: 240, + }) as Promise<SummaryPreviewSnapshot>, + ]); + statusSummary = snapshot[0] ?? null; + previewResult = snapshot[1] ?? null; + } + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to load initial summary snapshot.", err); + } + } + + const derived = deriveHydrateAgentFleetResult({ + gatewayUrl: params.gatewayUrl, + configSnapshot: configSnapshot ?? null, + settings, + execApprovalsSnapshot, + agentsResult, + mainSessionByAgentId: mainSessionKeyByAgent, + statusSummary, + previewResult, + }); + + return derived; +} diff --git a/src/features/agents/operations/agentFleetHydrationDerivation.ts b/src/features/agents/operations/agentFleetHydrationDerivation.ts new file mode 100644 index 00000000..45fa2f2b --- /dev/null +++ b/src/features/agents/operations/agentFleetHydrationDerivation.ts @@ -0,0 +1,316 @@ +import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient"; +import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import { resolveAgentAvatarSeed, type StudioSettings } from "@/lib/studio/settings"; +import { + buildSummarySnapshotPatches, + type SummaryPreviewSnapshot, + type SummarySnapshotAgent, + type SummarySnapshotPatch, + type SummaryStatusSnapshot, +} from "@/features/agents/state/runtimeEventBridge"; +import type { AgentStoreSeed } from "@/features/agents/state/store"; + +type AgentsListResult = { + defaultId: string; + mainKey: string; + scope?: string; + agents: Array<{ + id: string; + name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; + }>; +}; + +type SessionsListEntry = { + key: string; + updatedAt?: number | null; + displayName?: string; + origin?: { label?: string | null; provider?: string | null } | null; + thinkingLevel?: string; + modelProvider?: string; + model?: string; + execHost?: string | null; + execSecurity?: string | null; + execAsk?: string | null; +}; + +type ExecHost = "sandbox" | "gateway" | "node"; +type ExecSecurity = "deny" | "allowlist" | "full"; +type ExecAsk = "off" | "on-miss" | "always"; + +type ExecApprovalsSnapshot = { + file?: { + agents?: Record<string, { security?: string | null; ask?: string | null }>; + }; +}; + +type ExecPolicyEntry = { + security?: ExecSecurity; + ask?: ExecAsk; +}; + +type SandboxMode = "off" | "non-main" | "all"; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const resolveAgentSandboxMode = ( + agentId: string, + snapshot: GatewayModelPolicySnapshot | null +): SandboxMode | null => { + const resolvedAgentId = agentId.trim(); + if (!resolvedAgentId) return null; + const configRaw = snapshot?.config as unknown; + if (!isRecord(configRaw)) return null; + const agents = isRecord(configRaw.agents) ? configRaw.agents : null; + const list = Array.isArray(agents?.list) ? agents?.list : []; + for (const entryRaw of list) { + if (!isRecord(entryRaw)) continue; + const id = typeof entryRaw.id === "string" ? entryRaw.id.trim() : ""; + if (!id || id !== resolvedAgentId) continue; + const sandbox = isRecord(entryRaw.sandbox) ? entryRaw.sandbox : null; + const modeRaw = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : ""; + if (modeRaw === "off" || modeRaw === "non-main" || modeRaw === "all") { + return modeRaw; + } + return null; + } + return null; +}; + +const normalizeExecHost = (raw: string | null | undefined): ExecHost | undefined => { + if (typeof raw !== "string") return undefined; + const normalized = raw.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") { + return normalized; + } + return undefined; +}; + +const normalizeExecSecurity = (raw: string | null | undefined): ExecSecurity | undefined => { + if (typeof raw !== "string") return undefined; + const normalized = raw.trim().toLowerCase(); + if (normalized === "deny" || normalized === "allowlist" || normalized === "full") { + return normalized; + } + return undefined; +}; + +const normalizeExecAsk = (raw: string | null | undefined): ExecAsk | undefined => { + if (typeof raw !== "string") return undefined; + const normalized = raw.trim().toLowerCase(); + if (normalized === "off" || normalized === "on-miss" || normalized === "always") { + return normalized; + } + return undefined; +}; + +const resolveAgentName = (agent: AgentsListResult["agents"][number]) => { + const fromList = typeof agent.name === "string" ? agent.name.trim() : ""; + if (fromList) return fromList; + const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : ""; + if (fromIdentity) return fromIdentity; + return agent.id; +}; + +const resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => { + const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null; + if (typeof candidate !== "string") return null; + const trimmed = candidate.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed; + if (trimmed.startsWith("data:image/")) return trimmed; + return null; +}; + +const resolveDefaultModelForAgent = ( + agentId: string, + snapshot: GatewayModelPolicySnapshot | null +): string | null => { + const resolvedAgentId = agentId.trim(); + if (!resolvedAgentId) return null; + const defaults = snapshot?.config?.agents?.defaults; + const modelAliases = defaults?.models; + const agentEntry = + snapshot?.config?.agents?.list?.find((entry) => entry?.id?.trim() === resolvedAgentId) ?? + null; + const agentModel = agentEntry?.model; + let raw: string | null = null; + if (typeof agentModel === "string") { + raw = agentModel; + } else if (agentModel && typeof agentModel === "object") { + raw = agentModel.primary ?? null; + } + if (!raw) { + const defaultModel = defaults?.model; + if (typeof defaultModel === "string") { + raw = defaultModel; + } else if (defaultModel && typeof defaultModel === "object") { + raw = defaultModel.primary ?? null; + } + } + if (!raw) return null; + return resolveConfiguredModelKey(raw, modelAliases); +}; + +export type DeriveFleetHydrationInput = { + gatewayUrl: string; + configSnapshot: GatewayModelPolicySnapshot | null; + settings: StudioSettings | null; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + agentsResult: AgentsListResult; + mainSessionByAgentId: Map<string, SessionsListEntry | null>; + statusSummary: SummaryStatusSnapshot | null; + previewResult: SummaryPreviewSnapshot | null; +}; + +export type DerivedHydrateAgentFleetResult = { + seeds: AgentStoreSeed[]; + sessionCreatedAgentIds: string[]; + sessionSettingsSyncedAgentIds: string[]; + summaryPatches: SummarySnapshotPatch[]; + suggestedSelectedAgentId: string | null; + configSnapshot: GatewayModelPolicySnapshot | null; +}; + +export const deriveHydrateAgentFleetResult = ( + input: DeriveFleetHydrationInput +): DerivedHydrateAgentFleetResult => { + const execPolicyByAgentId = new Map<string, ExecPolicyEntry>(); + const execAgents = input.execApprovalsSnapshot?.file?.agents ?? {}; + for (const [agentId, entry] of Object.entries(execAgents)) { + const normalizedSecurity = normalizeExecSecurity(entry?.security); + const normalizedAsk = normalizeExecAsk(entry?.ask); + if (!normalizedSecurity && !normalizedAsk) continue; + execPolicyByAgentId.set(agentId, { + security: normalizedSecurity, + ask: normalizedAsk, + }); + } + + const mainKey = input.agentsResult.mainKey?.trim() || "main"; + const gatewayKey = input.gatewayUrl.trim(); + + const needsSessionSettingsSync = new Set<string>(); + const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => { + const persistedSeed = + input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null; + const avatarSeed = persistedSeed ?? agent.id; + const avatarUrl = resolveAgentAvatarUrl(agent); + const name = resolveAgentName(agent); + const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null; + const modelProvider = + typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : ""; + const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : ""; + const model = + modelProvider && modelId + ? `${modelProvider}/${modelId}` + : resolveDefaultModelForAgent(agent.id, input.configSnapshot); + const thinkingLevel = + typeof mainSession?.thinkingLevel === "string" ? mainSession.thinkingLevel : null; + const sessionExecHost = normalizeExecHost(mainSession?.execHost); + const sessionExecSecurity = normalizeExecSecurity(mainSession?.execSecurity); + const sessionExecAsk = normalizeExecAsk(mainSession?.execAsk); + const policy = execPolicyByAgentId.get(agent.id); + const sandboxMode = resolveAgentSandboxMode(agent.id, input.configSnapshot); + const resolvedExecSecurity = sessionExecSecurity ?? policy?.security; + const resolvedExecAsk = sessionExecAsk ?? policy?.ask; + const shouldForceSandboxExecHost = + sandboxMode === "all" && + Boolean(sessionExecHost || resolvedExecSecurity || resolvedExecAsk); + const resolvedExecHost = shouldForceSandboxExecHost + ? "sandbox" + : sessionExecHost ?? + (resolvedExecSecurity || resolvedExecAsk ? "gateway" : undefined); + const expectsExecOverrides = Boolean( + resolvedExecHost || resolvedExecSecurity || resolvedExecAsk + ); + const hasMatchingExecOverrides = + sessionExecHost === resolvedExecHost && + sessionExecSecurity === resolvedExecSecurity && + sessionExecAsk === resolvedExecAsk; + if (expectsExecOverrides && !hasMatchingExecOverrides) { + needsSessionSettingsSync.add(agent.id); + } + return { + agentId: agent.id, + name, + sessionKey: buildAgentMainSessionKey(agent.id, mainKey), + avatarSeed, + avatarUrl, + model, + thinkingLevel, + sessionExecHost: resolvedExecHost, + sessionExecSecurity: resolvedExecSecurity, + sessionExecAsk: resolvedExecAsk, + }; + }); + + const sessionCreatedAgentIds: string[] = []; + const sessionSettingsSyncedAgentIds: string[] = []; + for (const seed of seeds) { + const mainSession = input.mainSessionByAgentId.get(seed.agentId) ?? null; + if (!mainSession) continue; + sessionCreatedAgentIds.push(seed.agentId); + if (!needsSessionSettingsSync.has(seed.agentId)) { + sessionSettingsSyncedAgentIds.push(seed.agentId); + } + } + + let summaryPatches: SummarySnapshotPatch[] = []; + let suggestedSelectedAgentId: string | null = null; + if (input.statusSummary && input.previewResult) { + const activeAgents: SummarySnapshotAgent[] = []; + for (const seed of seeds) { + const mainSession = input.mainSessionByAgentId.get(seed.agentId) ?? null; + if (!mainSession) continue; + activeAgents.push({ + agentId: seed.agentId, + sessionKey: seed.sessionKey, + status: "idle", + }); + } + const sessionKeys = Array.from( + new Set(activeAgents.map((agent) => agent.sessionKey).filter((key) => key.trim().length > 0)) + ).slice(0, 64); + if (sessionKeys.length > 0) { + summaryPatches = buildSummarySnapshotPatches({ + agents: activeAgents, + statusSummary: input.statusSummary, + previewResult: input.previewResult, + }); + + const assistantAtByAgentId = new Map<string, number>(); + for (const entry of summaryPatches) { + if (typeof entry.patch.lastAssistantMessageAt === "number") { + assistantAtByAgentId.set(entry.agentId, entry.patch.lastAssistantMessageAt); + } + } + + let bestAgentId: string | null = seeds[0]?.agentId ?? null; + let bestTs = bestAgentId ? assistantAtByAgentId.get(bestAgentId) ?? 0 : 0; + for (const seed of seeds) { + const ts = assistantAtByAgentId.get(seed.agentId) ?? 0; + if (ts <= bestTs) continue; + bestTs = ts; + bestAgentId = seed.agentId; + } + suggestedSelectedAgentId = bestAgentId; + } + } + + return { + seeds, + sessionCreatedAgentIds, + sessionSettingsSyncedAgentIds, + summaryPatches, + suggestedSelectedAgentId, + configSnapshot: input.configSnapshot ?? null, + }; +}; diff --git a/src/features/agents/operations/agentPermissionsOperation.ts b/src/features/agents/operations/agentPermissionsOperation.ts new file mode 100644 index 00000000..7fb59a15 --- /dev/null +++ b/src/features/agents/operations/agentPermissionsOperation.ts @@ -0,0 +1,419 @@ +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient"; +import { + readGatewayAgentExecApprovals, + upsertGatewayAgentExecApprovals, +} from "@/lib/gateway/execApprovals"; +import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; + +export type ExecutionRoleId = "conservative" | "collaborative" | "autonomous"; +export type CommandModeId = "off" | "ask" | "auto"; + +export type AgentPermissionsDraft = { + commandMode: CommandModeId; + webAccess: boolean; + fileTools: boolean; +}; + +export type ToolGroupState = { + runtime: boolean | null; + web: boolean | null; + fs: boolean | null; + usesAllow: boolean; +}; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const coerceStringArray = (value: unknown): string[] | null => { + if (!Array.isArray(value)) return null; + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +}; + +export const resolveExecutionRoleFromAgent = (agent: { + sessionExecSecurity?: "deny" | "allowlist" | "full"; + sessionExecAsk?: "off" | "on-miss" | "always"; +}): ExecutionRoleId => { + if (agent.sessionExecSecurity === "full" && agent.sessionExecAsk === "off") { + return "autonomous"; + } + if ( + agent.sessionExecSecurity === "allowlist" || + agent.sessionExecAsk === "always" || + agent.sessionExecAsk === "on-miss" + ) { + return "collaborative"; + } + return "conservative"; +}; + +export const resolveRoleForCommandMode = (mode: CommandModeId): ExecutionRoleId => { + if (mode === "auto") return "autonomous"; + if (mode === "ask") return "collaborative"; + return "conservative"; +}; + +export const resolveCommandModeFromRole = (role: ExecutionRoleId): CommandModeId => { + if (role === "autonomous") return "auto"; + if (role === "collaborative") return "ask"; + return "off"; +}; + +export const resolvePresetDefaultsForRole = (role: ExecutionRoleId): AgentPermissionsDraft => { + const commandMode = resolveCommandModeFromRole(role); + if (role === "conservative") { + return { + commandMode, + webAccess: false, + fileTools: false, + }; + } + return { + commandMode, + webAccess: true, + fileTools: true, + }; +}; + +export const resolveEffectivePermissionsSummary = (draft: AgentPermissionsDraft): string => { + const commandLabel = + draft.commandMode === "auto" + ? "Commands: Auto" + : draft.commandMode === "ask" + ? "Commands: Ask" + : "Commands: Off"; + const webLabel = draft.webAccess ? "Web: On" : "Web: Off"; + const fileLabel = draft.fileTools ? "File tools: On" : "File tools: Off"; + return `${commandLabel} | ${webLabel} | ${fileLabel}`; +}; + +export const isPermissionsCustom = (params: { + role: ExecutionRoleId; + draft: AgentPermissionsDraft; +}): boolean => { + const defaults = resolvePresetDefaultsForRole(params.role); + return ( + defaults.commandMode !== params.draft.commandMode || + defaults.webAccess !== params.draft.webAccess || + defaults.fileTools !== params.draft.fileTools + ); +}; + +const resolveGroupState = (params: { + group: "group:runtime" | "group:web" | "group:fs"; + allowed: Set<string>; + denied: Set<string>; +}): boolean | null => { + if (params.denied.has(params.group)) return false; + if (params.allowed.has(params.group)) return true; + return null; +}; + +export const resolveToolGroupStateFromConfigEntry = (existingTools: unknown): ToolGroupState => { + const tools = isRecord(existingTools) ? existingTools : null; + const existingAllow = coerceStringArray(tools?.allow); + const existingAlsoAllow = coerceStringArray(tools?.alsoAllow); + const existingDeny = coerceStringArray(tools?.deny) ?? []; + const usesAllow = existingAllow !== null; + const allowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []); + const denied = new Set(existingDeny); + + return { + runtime: resolveGroupState({ group: "group:runtime", allowed, denied }), + web: resolveGroupState({ group: "group:web", allowed, denied }), + fs: resolveGroupState({ group: "group:fs", allowed, denied }), + usesAllow, + }; +}; + +export const resolveAgentPermissionsDraft = (params: { + agent: { + sessionExecSecurity?: "deny" | "allowlist" | "full"; + sessionExecAsk?: "off" | "on-miss" | "always"; + }; + existingTools: unknown; +}): AgentPermissionsDraft => { + const role = resolveExecutionRoleFromAgent(params.agent); + const defaults = resolvePresetDefaultsForRole(role); + const groupState = resolveToolGroupStateFromConfigEntry(params.existingTools); + + return { + commandMode: defaults.commandMode, + webAccess: groupState.web ?? defaults.webAccess, + fileTools: groupState.fs ?? defaults.fileTools, + }; +}; + +export function resolveExecApprovalsPolicyForRole(params: { + role: ExecutionRoleId; + allowlist: Array<{ pattern: string }>; +}): + | { + security: "full" | "allowlist"; + ask: "off" | "always"; + allowlist: Array<{ pattern: string }>; + } + | null { + if (params.role === "conservative") return null; + if (params.role === "autonomous") { + return { security: "full", ask: "off", allowlist: params.allowlist }; + } + return { security: "allowlist", ask: "always", allowlist: params.allowlist }; +} + +export function resolveToolGroupOverrides(params: { + existingTools: unknown; + runtimeEnabled: boolean; + webEnabled: boolean; + fsEnabled: boolean; +}): { tools: { allow?: string[]; alsoAllow?: string[]; deny?: string[] } } { + const tools = isRecord(params.existingTools) ? params.existingTools : null; + + const existingAllow = coerceStringArray(tools?.allow); + const existingAlsoAllow = coerceStringArray(tools?.alsoAllow); + const existingDeny = coerceStringArray(tools?.deny) ?? []; + + const usesAllow = existingAllow !== null; + const allowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []); + const denied = new Set(existingDeny); + + const applyGroup = (group: "group:runtime" | "group:web" | "group:fs", enabled: boolean) => { + if (enabled) { + allowed.add(group); + denied.delete(group); + return; + } + allowed.delete(group); + denied.add(group); + }; + + applyGroup("group:runtime", params.runtimeEnabled); + applyGroup("group:web", params.webEnabled); + applyGroup("group:fs", params.fsEnabled); + + const allowedList = Array.from(allowed); + const denyList = Array.from(denied).filter((entry) => !allowed.has(entry)); + + return { + tools: usesAllow + ? { allow: allowedList, deny: denyList } + : { alsoAllow: allowedList, deny: denyList }, + }; +} + +export function resolveSessionExecSettingsForRole(params: { + role: ExecutionRoleId; + sandboxMode: string; +}): { + execHost: "sandbox" | "gateway" | null; + execSecurity: "deny" | "allowlist" | "full"; + execAsk: "off" | "always"; +} { + if (params.role === "conservative") { + return { execHost: null, execSecurity: "deny", execAsk: "off" }; + } + + const normalizedMode = params.sandboxMode.trim().toLowerCase(); + const execHost = normalizedMode === "all" ? "sandbox" : "gateway"; + if (params.role === "autonomous") { + return { execHost, execSecurity: "full", execAsk: "off" }; + } + return { execHost, execSecurity: "allowlist", execAsk: "always" }; +} + +export function resolveRuntimeToolOverridesForRole(params: { + role: ExecutionRoleId; + existingTools: unknown; +}): { tools: { allow?: string[]; alsoAllow?: string[]; deny?: string[] } } { + const tools = isRecord(params.existingTools) ? params.existingTools : null; + + const existingAllow = coerceStringArray(tools?.allow); + const existingAlsoAllow = coerceStringArray(tools?.alsoAllow); + const existingDeny = coerceStringArray(tools?.deny) ?? []; + + const usesAllow = existingAllow !== null; + const baseAllowed = new Set(usesAllow ? existingAllow : existingAlsoAllow ?? []); + const deny = new Set(existingDeny); + + if (params.role === "conservative") { + baseAllowed.delete("group:runtime"); + deny.add("group:runtime"); + } else { + baseAllowed.add("group:runtime"); + deny.delete("group:runtime"); + } + + const allowedList = Array.from(baseAllowed); + const denyList = Array.from(deny).filter((entry) => !baseAllowed.has(entry)); + + return { + tools: usesAllow + ? { allow: allowedList, deny: denyList } + : { alsoAllow: allowedList, deny: denyList }, + }; +} + +type AgentRuntimeConfigContext = { + sandboxMode: string; + tools: Record<string, unknown> | null; +}; + +const resolveAgentRuntimeConfigContext = async (params: { + client: GatewayClient; + agentId: string; +}): Promise<AgentRuntimeConfigContext> => { + const snapshot = await params.client.call<{ config?: unknown }>("config.get", {}); + const baseConfig = + snapshot.config && typeof snapshot.config === "object" && !Array.isArray(snapshot.config) + ? (snapshot.config as Record<string, unknown>) + : undefined; + + const list = readConfigAgentList(baseConfig); + const configEntry = list.find((entry) => entry.id === params.agentId) ?? null; + + const sandboxRaw = + configEntry && typeof (configEntry as Record<string, unknown>).sandbox === "object" + ? ((configEntry as Record<string, unknown>).sandbox as unknown) + : null; + const sandbox = + sandboxRaw && typeof sandboxRaw === "object" && !Array.isArray(sandboxRaw) + ? (sandboxRaw as Record<string, unknown>) + : null; + const sandboxMode = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : ""; + + const toolsRaw = + configEntry && typeof (configEntry as Record<string, unknown>).tools === "object" + ? ((configEntry as Record<string, unknown>).tools as unknown) + : null; + const tools = + toolsRaw && typeof toolsRaw === "object" && !Array.isArray(toolsRaw) + ? (toolsRaw as Record<string, unknown>) + : null; + + return { + sandboxMode, + tools, + }; +}; + +const upsertExecApprovalsPolicyForRole = async (params: { + client: GatewayClient; + agentId: string; + role: ExecutionRoleId; +}) => { + const existingPolicy = await readGatewayAgentExecApprovals({ + client: params.client, + agentId: params.agentId, + }); + const allowlist = existingPolicy?.allowlist ?? []; + const nextPolicy = resolveExecApprovalsPolicyForRole({ role: params.role, allowlist }); + + await upsertGatewayAgentExecApprovals({ + client: params.client, + agentId: params.agentId, + policy: nextPolicy, + }); +}; + +export async function updateAgentPermissionsViaStudio(params: { + client: GatewayClient; + agentId: string; + sessionKey: string; + draft: AgentPermissionsDraft; + loadAgents?: () => Promise<void>; +}): Promise<void> { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + + const role = resolveRoleForCommandMode(params.draft.commandMode); + await upsertExecApprovalsPolicyForRole({ + client: params.client, + agentId, + role, + }); + const runtimeConfigContext = await resolveAgentRuntimeConfigContext({ + client: params.client, + agentId, + }); + + const toolOverrides = resolveToolGroupOverrides({ + existingTools: runtimeConfigContext.tools, + runtimeEnabled: role !== "conservative", + webEnabled: params.draft.webAccess, + fsEnabled: params.draft.fileTools, + }); + + await updateGatewayAgentOverrides({ + client: params.client, + agentId, + overrides: toolOverrides, + }); + + const execSettings = resolveSessionExecSettingsForRole({ + role, + sandboxMode: runtimeConfigContext.sandboxMode, + }); + await syncGatewaySessionSettings({ + client: params.client, + sessionKey: params.sessionKey, + execHost: execSettings.execHost, + execSecurity: execSettings.execSecurity, + execAsk: execSettings.execAsk, + }); + + if (params.loadAgents) { + await params.loadAgents(); + } +} + +export async function updateExecutionRoleViaStudio(params: { + client: GatewayClient; + agentId: string; + sessionKey: string; + role: ExecutionRoleId; + loadAgents: () => Promise<void>; +}): Promise<void> { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + + await upsertExecApprovalsPolicyForRole({ + client: params.client, + agentId, + role: params.role, + }); + const runtimeConfigContext = await resolveAgentRuntimeConfigContext({ + client: params.client, + agentId, + }); + + const toolOverrides = resolveRuntimeToolOverridesForRole({ + role: params.role, + existingTools: runtimeConfigContext.tools, + }); + await updateGatewayAgentOverrides({ + client: params.client, + agentId, + overrides: toolOverrides, + }); + + const execSettings = resolveSessionExecSettingsForRole({ + role: params.role, + sandboxMode: runtimeConfigContext.sandboxMode, + }); + await syncGatewaySessionSettings({ + client: params.client, + sessionKey: params.sessionKey, + execHost: execSettings.execHost, + execSecurity: execSettings.execSecurity, + execAsk: execSettings.execAsk, + }); + + await params.loadAgents(); +} diff --git a/src/features/agents/operations/agentReconcileOperation.ts b/src/features/agents/operations/agentReconcileOperation.ts new file mode 100644 index 00000000..eff7e6bd --- /dev/null +++ b/src/features/agents/operations/agentReconcileOperation.ts @@ -0,0 +1,126 @@ +import type { AgentState } from "@/features/agents/state/store"; +import { + buildReconcileTerminalPatch, + resolveReconcileEligibility, + resolveReconcileWaitOutcome, +} from "@/features/agents/operations/fleetLifecycleWorkflow"; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +export type ReconcileCommand = + | { kind: "clearRunTracking"; runId: string } + | { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> } + | { kind: "requestHistoryRefresh"; agentId: string } + | { kind: "logInfo"; message: string } + | { kind: "logWarn"; message: string; error: unknown }; + +type ReconcileDispatchAction = { + type: "updateAgent"; + agentId: string; + patch: Partial<AgentState>; +}; + +export const executeAgentReconcileCommands = (params: { + commands: ReconcileCommand[]; + dispatch: (action: ReconcileDispatchAction) => void; + clearRunTracking: (runId: string) => void; + requestHistoryRefresh: (agentId: string) => void; + logInfo: (message: string) => void; + logWarn: (message: string, error: unknown) => void; +}) => { + for (const command of params.commands) { + if (command.kind === "clearRunTracking") { + params.clearRunTracking(command.runId); + continue; + } + if (command.kind === "dispatchUpdateAgent") { + params.dispatch({ + type: "updateAgent", + agentId: command.agentId, + patch: command.patch, + }); + continue; + } + if (command.kind === "requestHistoryRefresh") { + params.requestHistoryRefresh(command.agentId); + continue; + } + if (command.kind === "logInfo") { + params.logInfo(command.message); + continue; + } + if (command.kind === "logWarn") { + params.logWarn(command.message, command.error); + } + } +}; + +export const runAgentReconcileOperation = async (params: { + client: GatewayClientLike; + agents: AgentState[]; + getLatestAgent: (agentId: string) => AgentState | null; + claimRunId: (runId: string) => boolean; + releaseRunId: (runId: string) => void; + isDisconnectLikeError: (error: unknown) => boolean; +}): Promise<ReconcileCommand[]> => { + const commands: ReconcileCommand[] = []; + + for (const agent of params.agents) { + const eligibility = resolveReconcileEligibility({ + status: agent.status, + sessionCreated: agent.sessionCreated, + runId: agent.runId, + }); + if (!eligibility.shouldCheck) continue; + + const runId = agent.runId?.trim() ?? ""; + if (!runId) continue; + + if (!params.claimRunId(runId)) continue; + + try { + const result = (await params.client.call("agent.wait", { + runId, + timeoutMs: 1, + })) as { status?: unknown }; + const outcome = resolveReconcileWaitOutcome(result?.status); + if (!outcome) { + continue; + } + + const latest = params.getLatestAgent(agent.agentId); + if (!latest || latest.runId !== runId || latest.status !== "running") { + continue; + } + + commands.push({ kind: "clearRunTracking", runId }); + commands.push({ + kind: "dispatchUpdateAgent", + agentId: agent.agentId, + patch: buildReconcileTerminalPatch({ outcome }), + }); + commands.push({ + kind: "logInfo", + message: `[agent-reconcile] ${agent.agentId} run ${runId} resolved as ${outcome}.`, + }); + commands.push({ + kind: "requestHistoryRefresh", + agentId: agent.agentId, + }); + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + commands.push({ + kind: "logWarn", + message: "Failed to reconcile running agent.", + error: err, + }); + } + } finally { + params.releaseRunId(runId); + } + } + + return commands; +}; diff --git a/src/features/agents/operations/agentSettingsMutationWorkflow.ts b/src/features/agents/operations/agentSettingsMutationWorkflow.ts new file mode 100644 index 00000000..10af4a9f --- /dev/null +++ b/src/features/agents/operations/agentSettingsMutationWorkflow.ts @@ -0,0 +1,175 @@ +import { + resolveMutationStartGuard, + type MutationStartGuardResult, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; + +export const RESERVED_MAIN_AGENT_ID = "main"; + +type GuardedActionKind = + | "delete-agent" + | "rename-agent" + | "update-agent-permissions" + | "use-all-skills" + | "disable-all-skills" + | "set-skills-allowlist" + | "set-skill-enabled" + | "set-skill-global-enabled" + | "install-skill" + | "remove-skill" + | "save-skill-api-key"; +type CronActionKind = "run-cron-job" | "delete-cron-job"; + +export type AgentSettingsMutationRequest = + | { kind: GuardedActionKind; agentId: string; skillName?: string; skillKey?: string } + | { kind: "create-cron-job"; agentId: string } + | { kind: CronActionKind; agentId: string; jobId: string }; + +export type AgentSettingsMutationContext = { + status: "connected" | "connecting" | "disconnected"; + hasCreateBlock: boolean; + hasRenameBlock: boolean; + hasDeleteBlock: boolean; + cronCreateBusy: boolean; + cronRunBusyJobId: string | null; + cronDeleteBusyJobId: string | null; +}; + +export type AgentSettingsMutationDenyReason = + | "start-guard-deny" + | "reserved-main-delete" + | "cron-action-busy" + | "missing-agent-id" + | "missing-job-id" + | "missing-skill-name" + | "missing-skill-key"; + +export type AgentSettingsMutationDecision = + | { + kind: "allow"; + normalizedAgentId: string; + normalizedJobId?: string; + } + | { + kind: "deny"; + reason: AgentSettingsMutationDenyReason; + message: string | null; + guardReason?: Exclude<MutationStartGuardResult, { kind: "allow" }>["reason"]; + }; + +const normalizeId = (value: string) => value.trim(); + +const isGuardedAction = ( + kind: AgentSettingsMutationRequest["kind"] +): kind is GuardedActionKind => + kind === "delete-agent" || + kind === "rename-agent" || + kind === "update-agent-permissions" || + kind === "use-all-skills" || + kind === "disable-all-skills" || + kind === "set-skills-allowlist" || + kind === "set-skill-enabled" || + kind === "set-skill-global-enabled" || + kind === "install-skill" || + kind === "remove-skill" || + kind === "save-skill-api-key"; + +const isCronActionBusy = (context: AgentSettingsMutationContext) => + context.cronCreateBusy || + Boolean(context.cronRunBusyJobId?.trim()) || + Boolean(context.cronDeleteBusyJobId?.trim()); + +export const planAgentSettingsMutation = ( + request: AgentSettingsMutationRequest, + context: AgentSettingsMutationContext +): AgentSettingsMutationDecision => { + const normalizedAgentId = normalizeId(request.agentId); + if (!normalizedAgentId) { + return { + kind: "deny", + reason: "missing-agent-id", + message: null, + }; + } + + if (isGuardedAction(request.kind)) { + const startGuard = resolveMutationStartGuard({ + status: context.status, + hasCreateBlock: context.hasCreateBlock, + hasRenameBlock: context.hasRenameBlock, + hasDeleteBlock: context.hasDeleteBlock, + }); + if (startGuard.kind === "deny") { + return { + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: startGuard.reason, + }; + } + } + + if (request.kind === "delete-agent" && normalizedAgentId === RESERVED_MAIN_AGENT_ID) { + return { + kind: "deny", + reason: "reserved-main-delete", + message: "The main agent cannot be deleted.", + }; + } + + if (request.kind === "run-cron-job" || request.kind === "delete-cron-job") { + const normalizedJobId = normalizeId(request.jobId); + if (!normalizedJobId) { + return { + kind: "deny", + reason: "missing-job-id", + message: null, + }; + } + + if (isCronActionBusy(context)) { + return { + kind: "deny", + reason: "cron-action-busy", + message: null, + }; + } + + return { + kind: "allow", + normalizedAgentId, + normalizedJobId, + }; + } + + if (request.kind === "set-skill-enabled") { + const normalizedSkillName = normalizeId(request.skillName ?? ""); + if (!normalizedSkillName) { + return { + kind: "deny", + reason: "missing-skill-name", + message: null, + }; + } + } + + if ( + request.kind === "set-skill-global-enabled" || + request.kind === "install-skill" || + request.kind === "remove-skill" || + request.kind === "save-skill-api-key" + ) { + const normalizedSkillKey = normalizeId(request.skillKey ?? ""); + if (!normalizedSkillKey) { + return { + kind: "deny", + reason: "missing-skill-key", + message: null, + }; + } + } + + return { + kind: "allow", + normalizedAgentId, + }; +}; diff --git a/src/features/agents/operations/chatInteractionWorkflow.ts b/src/features/agents/operations/chatInteractionWorkflow.ts new file mode 100644 index 00000000..a647c131 --- /dev/null +++ b/src/features/agents/operations/chatInteractionWorkflow.ts @@ -0,0 +1,112 @@ +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; + +export type StopRunIntent = + | { kind: "deny"; reason: "not-connected" | "missing-session-key"; message: string } + | { kind: "skip-busy" } + | { kind: "allow"; sessionKey: string }; + +export const planStopRunIntent = (input: { + status: GatewayStatus; + agentId: string; + sessionKey: string; + busyAgentId: string | null; +}): StopRunIntent => { + if (input.status !== "connected") { + return { + kind: "deny", + reason: "not-connected", + message: "Connect to gateway before stopping a run.", + }; + } + const sessionKey = input.sessionKey.trim(); + if (!sessionKey) { + return { + kind: "deny", + reason: "missing-session-key", + message: "Missing session key for agent.", + }; + } + if (input.busyAgentId === input.agentId) { + return { kind: "skip-busy" }; + } + return { + kind: "allow", + sessionKey, + }; +}; + +export type NewSessionIntent = + | { kind: "deny"; reason: "missing-agent" | "missing-session-key"; message: string } + | { kind: "allow"; sessionKey: string }; + +export const planNewSessionIntent = (input: { + hasAgent: boolean; + sessionKey: string; +}): NewSessionIntent => { + if (!input.hasAgent) { + return { + kind: "deny", + reason: "missing-agent", + message: "Failed to start new session: agent not found.", + }; + } + const sessionKey = input.sessionKey.trim(); + if (!sessionKey) { + return { + kind: "deny", + reason: "missing-session-key", + message: "Missing session key for agent.", + }; + } + return { + kind: "allow", + sessionKey, + }; +}; + +export type DraftFlushIntent = + | { kind: "skip"; reason: "missing-agent-id" | "missing-pending-value" } + | { kind: "flush"; agentId: string }; + +export const planDraftFlushIntent = (input: { + agentId: string | null; + hasPendingValue: boolean; +}): DraftFlushIntent => { + if (!input.agentId) { + return { + kind: "skip", + reason: "missing-agent-id", + }; + } + if (!input.hasPendingValue) { + return { + kind: "skip", + reason: "missing-pending-value", + }; + } + return { + kind: "flush", + agentId: input.agentId, + }; +}; + +export type DraftTimerIntent = + | { kind: "skip"; reason: "missing-agent-id" } + | { kind: "schedule"; agentId: string; delayMs: number }; + +export const planDraftTimerIntent = (input: { + agentId: string; + delayMs?: number; +}): DraftTimerIntent => { + if (!input.agentId) { + return { + kind: "skip", + reason: "missing-agent-id", + }; + } + return { + kind: "schedule", + agentId: input.agentId, + delayMs: input.delayMs ?? 250, + }; +}; diff --git a/src/features/agents/operations/chatSendOperation.ts b/src/features/agents/operations/chatSendOperation.ts new file mode 100644 index 00000000..09b937c3 --- /dev/null +++ b/src/features/agents/operations/chatSendOperation.ts @@ -0,0 +1,228 @@ +import { + isWebchatSessionMutationBlockedError, + syncGatewaySessionSettings, + type GatewayClient, +} from "@/lib/gateway/GatewayClient"; +import { + buildAgentInstruction, + isMetaMarkdown, + parseMetaMarkdown, +} from "@/lib/text/message-extract"; +import type { AgentState } from "@/features/agents/state/store"; +import { randomUUID } from "@/lib/uuid"; +import type { TranscriptAppendMeta } from "@/features/agents/state/transcript"; + +type SendDispatchAction = + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta }; + +type SendDispatch = (action: SendDispatchAction) => void; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => { + const entries = agent.transcriptEntries; + let latest: number | null = null; + if (Array.isArray(entries)) { + for (const entry of entries) { + const ts = entry?.timestampMs; + if (typeof ts !== "number" || !Number.isFinite(ts)) continue; + latest = latest === null ? ts : Math.max(latest, ts); + } + } + if (latest !== null) return latest; + const lines = agent.outputLines; + for (const line of lines) { + if (!isMetaMarkdown(line)) continue; + const parsed = parseMetaMarkdown(line); + const ts = parsed?.timestamp; + if (typeof ts !== "number" || !Number.isFinite(ts)) continue; + latest = latest === null ? ts : Math.max(latest, ts); + } + return latest; +}; + +const resolveChatSendCompletionMode = ( + payload: unknown, + optimisticRunId: string +): "streaming-expected" | "terminal-immediate" => { + if (!payload || typeof payload !== "object") { + return "terminal-immediate"; + } + const value = payload as { status?: unknown; runId?: unknown }; + const status = typeof value.status === "string" ? value.status.trim().toLowerCase() : ""; + const runId = typeof value.runId === "string" ? value.runId.trim() : ""; + if ((status === "started" || status === "in_flight") && runId === optimisticRunId) { + return "streaming-expected"; + } + return "terminal-immediate"; +}; + +export async function sendChatMessageViaStudio(params: { + client: GatewayClientLike; + dispatch: SendDispatch; + getAgent: (agentId: string) => AgentState | null; + agentId: string; + sessionKey: string; + message: string; + clearRunTracking?: (runId: string) => void; + echoUserMessage?: boolean; + now?: () => number; + generateRunId?: () => string; +}): Promise<void> { + const trimmed = params.message.trim(); + if (!trimmed) return; + const echoUserMessage = params.echoUserMessage !== false; + + const generateRunId = params.generateRunId ?? (() => randomUUID()); + const now = params.now ?? (() => Date.now()); + + const agentId = params.agentId; + const runId = generateRunId(); + + params.clearRunTracking?.(runId); + + const agent = params.getAgent(agentId); + if (!agent) { + params.dispatch({ + type: "appendOutput", + agentId, + line: "Error: Agent not found.", + }); + return; + } + + const isResetCommand = /^\/(reset|new)(\s|$)/i.test(trimmed); + if (isResetCommand) { + params.dispatch({ + type: "updateAgent", + agentId, + patch: { + outputLines: [], + streamText: null, + thinkingTrace: null, + lastResult: null, + sessionEpoch: (agent.sessionEpoch ?? 0) + 1, + transcriptEntries: [], + lastHistoryRequestRevision: null, + lastAppliedHistoryRequestId: null, + }, + }); + } + + const userTimestamp = now(); + const latestTranscriptTimestamp = resolveLatestTranscriptTimestampMs(agent); + const optimisticUserOrderTimestamp = + typeof latestTranscriptTimestamp === "number" + ? Math.max(userTimestamp, latestTranscriptTimestamp + 1) + : userTimestamp; + params.dispatch({ + type: "updateAgent", + agentId, + patch: { + status: "running", + runId, + runStartedAt: userTimestamp, + streamText: "", + thinkingTrace: null, + draft: "", + ...(echoUserMessage ? { lastUserMessage: trimmed } : {}), + lastActivityAt: userTimestamp, + }, + }); + if (echoUserMessage) { + params.dispatch({ + type: "appendOutput", + agentId, + line: `> ${trimmed}`, + transcript: { + source: "local-send", + runId, + sessionKey: params.sessionKey, + timestampMs: optimisticUserOrderTimestamp, + role: "user", + kind: "user", + }, + }); + } + + try { + if (!params.sessionKey) { + throw new Error("Missing session key for agent."); + } + + let createdSession = agent.sessionCreated; + if (!agent.sessionSettingsSynced) { + try { + await syncGatewaySessionSettings({ + client: params.client as unknown as GatewayClient, + sessionKey: params.sessionKey, + model: agent.model ?? null, + thinkingLevel: agent.thinkingLevel ?? null, + execHost: agent.sessionExecHost, + execSecurity: agent.sessionExecSecurity, + execAsk: agent.sessionExecAsk, + }); + createdSession = true; + params.dispatch({ + type: "updateAgent", + agentId, + patch: { sessionSettingsSynced: true, sessionCreated: true }, + }); + } catch (syncError) { + if (!isWebchatSessionMutationBlockedError(syncError)) { + throw syncError; + } + createdSession = true; + params.dispatch({ + type: "updateAgent", + agentId, + patch: { sessionSettingsSynced: true, sessionCreated: true }, + }); + } + } + + const sendResult = await params.client.call("chat.send", { + sessionKey: params.sessionKey, + message: buildAgentInstruction({ message: trimmed }), + deliver: false, + idempotencyKey: runId, + }); + + if (!createdSession) { + params.dispatch({ + type: "updateAgent", + agentId, + patch: { sessionCreated: true }, + }); + } + + if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") { + params.dispatch({ + type: "updateAgent", + agentId, + patch: { + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Gateway error"; + params.dispatch({ + type: "updateAgent", + agentId, + patch: { status: "error", runId: null, runStartedAt: null, streamText: null, thinkingTrace: null }, + }); + params.dispatch({ + type: "appendOutput", + agentId, + line: `Error: ${msg}`, + }); + } +} diff --git a/src/features/agents/operations/configMutationGatePolicy.ts b/src/features/agents/operations/configMutationGatePolicy.ts new file mode 100644 index 00000000..1f31e3c2 --- /dev/null +++ b/src/features/agents/operations/configMutationGatePolicy.ts @@ -0,0 +1,19 @@ +import type { GatewayStatus } from "./gatewayRestartPolicy"; + +export type ConfigMutationGateInput = { + status: GatewayStatus; + hasRunningAgents: boolean; + nextMutationRequiresIdleAgents: boolean; + hasActiveMutation: boolean; + hasRestartBlockInProgress: boolean; + queuedCount: number; +}; + +export function shouldStartNextConfigMutation(input: ConfigMutationGateInput): boolean { + if (input.status !== "connected") return false; + if (input.queuedCount <= 0) return false; + if (input.hasActiveMutation) return false; + if (input.hasRestartBlockInProgress) return false; + if (input.hasRunningAgents && input.nextMutationRequiresIdleAgents) return false; + return true; +} diff --git a/src/features/agents/operations/createAgentBootstrapOperation.ts b/src/features/agents/operations/createAgentBootstrapOperation.ts new file mode 100644 index 00000000..e3f443da --- /dev/null +++ b/src/features/agents/operations/createAgentBootstrapOperation.ts @@ -0,0 +1,129 @@ +import { + type AgentPermissionsDraft, + resolvePresetDefaultsForRole, + updateAgentPermissionsViaStudio, +} from "@/features/agents/operations/agentPermissionsOperation"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + planCreateAgentBootstrapCommands, + type CreateBootstrapCommand, +} from "@/features/agents/operations/createAgentBootstrapWorkflow"; + +type CreateCompletion = { + agentId: string; + agentName: string; +}; + +type CreatedAgent = { + agentId: string; + sessionKey: string; +}; + +export const CREATE_AGENT_DEFAULT_PERMISSIONS: Readonly<AgentPermissionsDraft> = + Object.freeze(resolvePresetDefaultsForRole("autonomous")); + +const resolveBootstrapErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message || "Failed to apply default permissions."; + } + return "Failed to apply default permissions."; +}; + +export async function applyCreateAgentBootstrapPermissions(params: { + client: GatewayClient; + agentId: string; + sessionKey: string; + draft: AgentPermissionsDraft; + loadAgents: () => Promise<void>; +}): Promise<void> { + await updateAgentPermissionsViaStudio({ + client: params.client, + agentId: params.agentId, + sessionKey: params.sessionKey, + draft: params.draft, + loadAgents: params.loadAgents, + }); +} + +export async function runCreateAgentBootstrapOperation(params: { + completion: CreateCompletion; + focusedAgentId: string | null; + loadAgents: () => Promise<void>; + findAgentById: (agentId: string) => CreatedAgent | null; + applyDefaultPermissions: (input: { agentId: string; sessionKey: string }) => Promise<void>; + refreshGatewayConfigSnapshot: () => Promise<unknown>; + planCommands?: typeof planCreateAgentBootstrapCommands; +}): Promise<CreateBootstrapCommand[]> { + const plan = params.planCommands ?? planCreateAgentBootstrapCommands; + + await params.loadAgents(); + let createdAgent = params.findAgentById(params.completion.agentId); + if (!createdAgent) { + await params.loadAgents(); + createdAgent = params.findAgentById(params.completion.agentId); + } + + let bootstrapErrorMessage: string | null = null; + if (createdAgent) { + try { + await params.applyDefaultPermissions({ + agentId: createdAgent.agentId, + sessionKey: createdAgent.sessionKey, + }); + await params.refreshGatewayConfigSnapshot(); + } catch (error) { + bootstrapErrorMessage = resolveBootstrapErrorMessage(error); + } + } + + return plan({ + completion: params.completion, + createdAgent, + bootstrapErrorMessage, + focusedAgentId: params.focusedAgentId, + }); +} + +export function executeCreateAgentBootstrapCommands(params: { + commands: CreateBootstrapCommand[]; + setCreateAgentModalError: (message: string | null) => void; + setGlobalError: (message: string) => void; + setCreateAgentBlock: (value: null) => void; + setCreateAgentModalOpen: (open: boolean) => void; + flushPendingDraft: (agentId: string | null) => void; + selectAgent: (agentId: string) => void; + setInspectSidebarCapabilities: (agentId: string) => void; + setMobilePaneChat: () => void; +}): void { + for (const command of params.commands) { + if (command.kind === "set-create-modal-error") { + params.setCreateAgentModalError(command.message); + continue; + } + if (command.kind === "set-global-error") { + params.setGlobalError(command.message); + continue; + } + if (command.kind === "set-create-block") { + params.setCreateAgentBlock(command.value); + continue; + } + if (command.kind === "set-create-modal-open") { + params.setCreateAgentModalOpen(command.open); + continue; + } + if (command.kind === "flush-pending-draft") { + params.flushPendingDraft(command.agentId); + continue; + } + if (command.kind === "select-agent") { + params.selectAgent(command.agentId); + continue; + } + if (command.kind === "set-inspect-sidebar") { + params.setInspectSidebarCapabilities(command.agentId); + continue; + } + params.setMobilePaneChat(); + } +} diff --git a/src/features/agents/operations/createAgentBootstrapWorkflow.ts b/src/features/agents/operations/createAgentBootstrapWorkflow.ts new file mode 100644 index 00000000..8b5796f6 --- /dev/null +++ b/src/features/agents/operations/createAgentBootstrapWorkflow.ts @@ -0,0 +1,64 @@ +export type CreateBootstrapFacts = { + completion: { agentId: string; agentName: string }; + createdAgent: { agentId: string; sessionKey: string } | null; + bootstrapErrorMessage: string | null; + focusedAgentId: string | null; +}; + +export type CreateBootstrapCommand = + | { kind: "set-create-modal-error"; message: string | null } + | { kind: "set-global-error"; message: string } + | { kind: "set-create-block"; value: null } + | { kind: "set-create-modal-open"; open: boolean } + | { kind: "flush-pending-draft"; agentId: string | null } + | { kind: "select-agent"; agentId: string } + | { kind: "set-inspect-sidebar"; agentId: string; tab: "capabilities" } + | { kind: "set-mobile-pane"; pane: "chat" }; + +const buildMissingCreatedAgentMessage = (agentName: string): string => + `Agent "${agentName}" was created, but Studio could not load it yet.`; + +const buildBootstrapGlobalErrorMessage = (errorMessage: string): string => + `Agent created, but default permissions could not be applied: ${errorMessage}`; + +const buildBootstrapModalErrorMessage = (errorMessage: string): string => + `Default permissions failed: ${errorMessage}`; + +export function planCreateAgentBootstrapCommands( + facts: CreateBootstrapFacts +): CreateBootstrapCommand[] { + if (!facts.createdAgent) { + const message = buildMissingCreatedAgentMessage(facts.completion.agentName); + return [ + { kind: "set-create-modal-error", message }, + { kind: "set-global-error", message }, + { kind: "set-create-block", value: null }, + { kind: "set-create-modal-open", open: false }, + ]; + } + + const commands: CreateBootstrapCommand[] = []; + if (facts.bootstrapErrorMessage) { + commands.push({ + kind: "set-global-error", + message: buildBootstrapGlobalErrorMessage(facts.bootstrapErrorMessage), + }); + } + commands.push({ kind: "flush-pending-draft", agentId: facts.focusedAgentId }); + commands.push({ kind: "select-agent", agentId: facts.completion.agentId }); + commands.push({ + kind: "set-inspect-sidebar", + agentId: facts.completion.agentId, + tab: "capabilities", + }); + commands.push({ kind: "set-mobile-pane", pane: "chat" }); + commands.push({ + kind: "set-create-modal-error", + message: facts.bootstrapErrorMessage + ? buildBootstrapModalErrorMessage(facts.bootstrapErrorMessage) + : null, + }); + commands.push({ kind: "set-create-block", value: null }); + commands.push({ kind: "set-create-modal-open", open: false }); + return commands; +} diff --git a/src/features/agents/operations/cronCreateOperation.ts b/src/features/agents/operations/cronCreateOperation.ts new file mode 100644 index 00000000..c3f6d0b2 --- /dev/null +++ b/src/features/agents/operations/cronCreateOperation.ts @@ -0,0 +1,91 @@ +import { + buildCronJobCreateInput, + type CronCreateDraft, +} from "@/lib/cron/createPayloadBuilder"; +import { + createCronJob as createCronJobDefault, + filterCronJobsForAgent, + listCronJobs as listCronJobsDefault, + sortCronJobsByUpdatedAt, + type CronJobCreateInput, + type CronJobSummary, +} from "@/lib/cron/types"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +export const CRON_ACTION_BUSY_MESSAGE = "Please wait for the current cron action to finish."; + +const resolveCreateAgentId = (agentId: string) => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Failed to create cron job: missing agent id."); + } + return trimmed; +}; + +const resolveCreateErrorMessage = (error: unknown) => + error instanceof Error ? error.message : "Failed to create cron job."; + +export type CronBusyState = { + createBusy: boolean; + runBusyJobId: string | null; + deleteBusyJobId: string | null; +}; + +type CronCreateDeps = { + buildInput?: (agentId: string, draft: CronCreateDraft) => CronJobCreateInput; + createCronJob?: (client: GatewayClient, input: CronJobCreateInput) => Promise<unknown>; + listCronJobs?: ( + client: GatewayClient, + params: { includeDisabled?: boolean } + ) => Promise<{ jobs: CronJobSummary[] }>; +}; + +const isCronActionBusy = (busy: CronBusyState) => + busy.createBusy || Boolean(busy.runBusyJobId) || Boolean(busy.deleteBusyJobId); + +export const performCronCreateFlow = async (params: { + client: GatewayClient; + agentId: string; + draft: CronCreateDraft; + busy: CronBusyState; + onBusyChange: (busy: boolean) => void; + onError: (message: string | null) => void; + onJobs: (jobs: CronJobSummary[]) => void; + deps?: CronCreateDeps; +}): Promise<"created"> => { + if (isCronActionBusy(params.busy)) { + params.onError(CRON_ACTION_BUSY_MESSAGE); + throw new Error(CRON_ACTION_BUSY_MESSAGE); + } + + let resolvedAgentId = ""; + try { + resolvedAgentId = resolveCreateAgentId(params.agentId); + } catch (error) { + const message = resolveCreateErrorMessage(error); + params.onError(message); + throw error; + } + + const buildInput = params.deps?.buildInput ?? buildCronJobCreateInput; + const createCronJob = params.deps?.createCronJob ?? createCronJobDefault; + const listCronJobs = params.deps?.listCronJobs ?? listCronJobsDefault; + + params.onBusyChange(true); + params.onError(null); + + try { + const input = buildInput(resolvedAgentId, params.draft); + await createCronJob(params.client, input); + const listResult = await listCronJobs(params.client, { includeDisabled: true }); + const jobs = sortCronJobsByUpdatedAt(filterCronJobsForAgent(listResult.jobs, resolvedAgentId)); + params.onJobs(jobs); + return "created"; + } catch (error) { + const message = resolveCreateErrorMessage(error); + params.onError(message); + throw error instanceof Error ? error : new Error(message); + } finally { + params.onBusyChange(false); + } +}; diff --git a/src/features/agents/operations/deleteAgentOperation.ts b/src/features/agents/operations/deleteAgentOperation.ts new file mode 100644 index 00000000..1f29d220 --- /dev/null +++ b/src/features/agents/operations/deleteAgentOperation.ts @@ -0,0 +1,118 @@ +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { fetchJson as defaultFetchJson } from "@/lib/http"; +import { + removeCronJobsForAgentWithBackup, + restoreCronJobs, + type CronJobRestoreInput, +} from "@/lib/cron/types"; +import { deleteGatewayAgent } from "@/lib/gateway/agentConfig"; + +type FetchJson = typeof defaultFetchJson; + +export type GatewayAgentStateMove = { from: string; to: string }; + +export type TrashAgentStateResult = { + trashDir: string; + moved: GatewayAgentStateMove[]; +}; + +export type RestoreAgentStateResult = { + restored: GatewayAgentStateMove[]; +}; + +type DeleteAgentTransactionDeps = { + trashAgentState: (agentId: string) => Promise<TrashAgentStateResult>; + restoreAgentState: (agentId: string, trashDir: string) => Promise<RestoreAgentStateResult>; + removeCronJobsForAgentWithBackup: (agentId: string) => Promise<CronJobRestoreInput[]>; + restoreCronJobs: (jobs: CronJobRestoreInput[]) => Promise<void>; + deleteGatewayAgent: (agentId: string) => Promise<void>; + logError?: (message: string, error: unknown) => void; +}; + +export type DeleteAgentTransactionResult = { + trashed: TrashAgentStateResult; + restored: RestoreAgentStateResult | null; +}; + +const runDeleteFlow = async ( + deps: DeleteAgentTransactionDeps, + agentId: string +): Promise<DeleteAgentTransactionResult> => { + const trimmedAgentId = agentId.trim(); + if (!trimmedAgentId) { + throw new Error("Agent id is required."); + } + + const trashed = await deps.trashAgentState(trimmedAgentId); + let removedCronJobs: CronJobRestoreInput[] = []; + + try { + removedCronJobs = await deps.removeCronJobsForAgentWithBackup(trimmedAgentId); + await deps.deleteGatewayAgent(trimmedAgentId); + return { trashed, restored: null }; + } catch (err) { + if (removedCronJobs.length > 0) { + try { + await deps.restoreCronJobs(removedCronJobs); + } catch (restoreCronErr) { + deps.logError?.("Failed to restore removed cron jobs.", restoreCronErr); + } + } + if (trashed.moved.length > 0) { + try { + await deps.restoreAgentState(trimmedAgentId, trashed.trashDir); + } catch (restoreErr) { + deps.logError?.("Failed to restore trashed agent state.", restoreErr); + } + } + throw err; + } +}; + +export const deleteAgentViaStudio = async (params: { + client: GatewayClient; + agentId: string; + fetchJson?: FetchJson; + logError?: (message: string, error: unknown) => void; +}): Promise<DeleteAgentTransactionResult> => { + const fetchJson = params.fetchJson ?? defaultFetchJson; + const logError = params.logError ?? ((message, error) => console.error(message, error)); + + return runDeleteFlow( + { + trashAgentState: async (agentId) => { + const { result } = await fetchJson<{ result: TrashAgentStateResult }>( + "/api/gateway/agent-state", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId }), + } + ); + return result; + }, + restoreAgentState: async (agentId, trashDir) => { + const { result } = await fetchJson<{ result: RestoreAgentStateResult }>( + "/api/gateway/agent-state", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId, trashDir }), + } + ); + return result; + }, + removeCronJobsForAgentWithBackup: async (agentId) => { + return await removeCronJobsForAgentWithBackup(params.client, agentId); + }, + restoreCronJobs: async (jobs) => { + await restoreCronJobs(params.client, jobs); + }, + deleteGatewayAgent: async (agentId) => { + await deleteGatewayAgent({ client: params.client, agentId }); + }, + logError, + }, + params.agentId + ); +}; diff --git a/src/features/agents/operations/fleetLifecycleWorkflow.ts b/src/features/agents/operations/fleetLifecycleWorkflow.ts new file mode 100644 index 00000000..5bbdeffc --- /dev/null +++ b/src/features/agents/operations/fleetLifecycleWorkflow.ts @@ -0,0 +1,96 @@ +import type { AgentState } from "@/features/agents/state/store"; + +export type SummarySnapshotSeed = Pick<AgentState, "sessionCreated" | "sessionKey">; + +export type SummarySnapshotIntent = + | { kind: "skip" } + | { + kind: "fetch"; + keys: string[]; + limit: number; + maxChars: number; + }; + +export type ReconcileEligibility = { + shouldCheck: boolean; + reason: "ok" | "not-running" | "missing-run-id" | "not-session-created"; +}; + +const SUMMARY_PREVIEW_LIMIT = 8; +const SUMMARY_PREVIEW_MAX_CHARS = 240; + +export const resolveSummarySnapshotKeys = (params: { + agents: Array<{ sessionCreated: boolean; sessionKey: string }>; + maxKeys: number; +}): string[] => { + return Array.from( + new Set( + params.agents + .filter((agent) => agent.sessionCreated) + .map((agent) => agent.sessionKey) + .filter((key): key is string => typeof key === "string" && key.trim().length > 0) + ) + ).slice(0, params.maxKeys); +}; + +export const resolveSummarySnapshotIntent = (params: { + agents: SummarySnapshotSeed[]; + maxKeys: number; +}): SummarySnapshotIntent => { + const keys = resolveSummarySnapshotKeys({ + agents: params.agents, + maxKeys: params.maxKeys, + }); + if (keys.length === 0) { + return { kind: "skip" }; + } + return { + kind: "fetch", + keys, + limit: SUMMARY_PREVIEW_LIMIT, + maxChars: SUMMARY_PREVIEW_MAX_CHARS, + }; +}; + +export const resolveReconcileEligibility = (params: { + status: "running" | "idle" | "error"; + sessionCreated: boolean; + runId: string | null; +}): ReconcileEligibility => { + if (params.status !== "running") { + return { shouldCheck: false, reason: "not-running" }; + } + if (!params.sessionCreated) { + return { shouldCheck: false, reason: "not-session-created" }; + } + const runId = params.runId?.trim() ?? ""; + if (!runId) { + return { shouldCheck: false, reason: "missing-run-id" }; + } + return { shouldCheck: true, reason: "ok" }; +}; + +export const buildReconcileTerminalPatch = (params: { + outcome: "ok" | "error"; +}): { + status: "idle" | "error"; + runId: null; + runStartedAt: null; + streamText: null; + thinkingTrace: null; +} => { + return { + status: params.outcome === "error" ? "error" : "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }; +}; + +export const resolveReconcileWaitOutcome = (status: unknown): "ok" | "error" | null => { + if (status === "ok" || status === "error") { + return status; + } + return null; +}; diff --git a/src/features/agents/operations/gatewayConfigSyncWorkflow.ts b/src/features/agents/operations/gatewayConfigSyncWorkflow.ts new file mode 100644 index 00000000..52b15fd6 --- /dev/null +++ b/src/features/agents/operations/gatewayConfigSyncWorkflow.ts @@ -0,0 +1,87 @@ +import { readConfigAgentList } from "@/lib/gateway/agentConfig"; +import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; + +export type GatewayConnectionStatus = "disconnected" | "connecting" | "connected"; + +type RecordLike = Record<string, unknown>; + +const asRecord = (value: unknown): RecordLike | null => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as RecordLike; +}; + +export const resolveGatewayConfigRecord = ( + snapshot: GatewayModelPolicySnapshot | null +): RecordLike | null => { + return asRecord(snapshot?.config ?? null); +}; + +export const resolveSandboxRepairAgentIds = ( + snapshot: GatewayModelPolicySnapshot | null +): string[] => { + const baseConfig = resolveGatewayConfigRecord(snapshot); + if (!baseConfig) return []; + + const list = readConfigAgentList(baseConfig); + return list + .filter((entry) => { + const sandbox = asRecord(entry.sandbox); + const mode = typeof sandbox?.mode === "string" ? sandbox.mode.trim().toLowerCase() : ""; + if (mode !== "all") return false; + + const tools = asRecord(entry.tools); + const sandboxBlock = asRecord(tools?.sandbox); + const sandboxTools = asRecord(sandboxBlock?.tools); + const allow = sandboxTools?.allow; + return Array.isArray(allow) && allow.length === 0; + }) + .map((entry) => entry.id); +}; + +export type SandboxRepairIntent = + | { kind: "skip"; reason: "not-connected" | "already-attempted" | "no-eligible-agents" } + | { kind: "repair"; agentIds: string[] }; + +export const resolveSandboxRepairIntent = (params: { + status: GatewayConnectionStatus; + attempted: boolean; + snapshot: GatewayModelPolicySnapshot | null; +}): SandboxRepairIntent => { + if (params.status !== "connected") { + return { kind: "skip", reason: "not-connected" }; + } + if (params.attempted) { + return { kind: "skip", reason: "already-attempted" }; + } + + const agentIds = resolveSandboxRepairAgentIds(params.snapshot); + if (agentIds.length === 0) { + return { kind: "skip", reason: "no-eligible-agents" }; + } + + return { kind: "repair", agentIds }; +}; + +export const shouldRefreshGatewayConfigForSettingsRoute = (params: { + status: GatewayConnectionStatus; + settingsRouteActive: boolean; + inspectSidebarAgentId: string | null; +}): boolean => { + if (!params.settingsRouteActive) return false; + if (!params.inspectSidebarAgentId) return false; + if (params.status !== "connected") return false; + return true; +}; + +export type GatewayModelsSyncIntent = { kind: "clear" } | { kind: "load" }; + +export const resolveGatewayModelsSyncIntent = (params: { + status: GatewayConnectionStatus; +}): GatewayModelsSyncIntent => { + if (params.status !== "connected") { + return { kind: "clear" }; + } + return { kind: "load" }; +}; diff --git a/src/features/agents/operations/gatewayRestartPolicy.ts b/src/features/agents/operations/gatewayRestartPolicy.ts new file mode 100644 index 00000000..6de22d5f --- /dev/null +++ b/src/features/agents/operations/gatewayRestartPolicy.ts @@ -0,0 +1,17 @@ +export type GatewayStatus = "disconnected" | "connecting" | "connected"; + +export type RestartObservation = { + sawDisconnect: boolean; +}; + +export function observeGatewayRestart( + prev: RestartObservation, + status: GatewayStatus +): { next: RestartObservation; restartComplete: boolean } { + const sawDisconnect = prev.sawDisconnect || status !== "connected"; + return { + next: { sawDisconnect }, + restartComplete: status === "connected" && sawDisconnect, + }; +} + diff --git a/src/features/agents/operations/historyLifecycleWorkflow.ts b/src/features/agents/operations/historyLifecycleWorkflow.ts new file mode 100644 index 00000000..28f0eaad --- /dev/null +++ b/src/features/agents/operations/historyLifecycleWorkflow.ts @@ -0,0 +1,119 @@ +import type { AgentState } from "@/features/agents/state/store"; + +export type HistoryRequestIntent = + | { + kind: "skip"; + reason: "missing-agent" | "session-not-created" | "missing-session-key" | "in-flight"; + } + | { + kind: "fetch"; + sessionKey: string; + limit: number; + requestRevision: number; + requestEpoch: number; + requestId: string; + loadedAt: number; + }; + +export type HistoryResponseDisposition = + | { + kind: "drop"; + reason: + | "session-key-changed" + | "session-epoch-changed" + | "transcript-revision-changed"; + } + | { + kind: "apply"; + }; + +const resolveHistoryFetchLimit = (params: { + requestedLimit?: number; + defaultLimit: number; + maxLimit: number; +}): number => { + const requested = params.requestedLimit; + if (typeof requested !== "number" || !Number.isFinite(requested) || requested <= 0) { + return params.defaultLimit; + } + return Math.min(params.maxLimit, Math.floor(requested)); +}; + +export const resolveHistoryRequestIntent = (params: { + agent: AgentState | null; + requestedLimit?: number; + maxLimit: number; + defaultLimit: number; + inFlightSessionKeys: Set<string>; + requestId: string; + loadedAt: number; +}): HistoryRequestIntent => { + if (!params.agent) { + return { kind: "skip", reason: "missing-agent" }; + } + if (!params.agent.sessionCreated) { + return { kind: "skip", reason: "session-not-created" }; + } + const sessionKey = params.agent.sessionKey.trim(); + if (!sessionKey) { + return { kind: "skip", reason: "missing-session-key" }; + } + if (params.inFlightSessionKeys.has(sessionKey)) { + return { kind: "skip", reason: "in-flight" }; + } + return { + kind: "fetch", + sessionKey, + limit: resolveHistoryFetchLimit({ + requestedLimit: params.requestedLimit, + defaultLimit: params.defaultLimit, + maxLimit: params.maxLimit, + }), + requestRevision: params.agent.transcriptRevision ?? params.agent.outputLines.length, + requestEpoch: params.agent.sessionEpoch ?? 0, + requestId: params.requestId, + loadedAt: params.loadedAt, + }; +}; + +export const resolveHistoryResponseDisposition = (params: { + latestAgent: AgentState | null; + expectedSessionKey: string; + requestEpoch: number; + requestRevision: number; +}): HistoryResponseDisposition => { + const latest = params.latestAgent; + if (!latest || latest.sessionKey.trim() !== params.expectedSessionKey) { + return { kind: "drop", reason: "session-key-changed" }; + } + if ((latest.sessionEpoch ?? 0) !== params.requestEpoch) { + return { kind: "drop", reason: "session-epoch-changed" }; + } + const latestRevision = latest.transcriptRevision ?? latest.outputLines.length; + if (latestRevision !== params.requestRevision) { + return { kind: "drop", reason: "transcript-revision-changed" }; + } + return { kind: "apply" }; +}; + +export const buildHistoryMetadataPatch = (params: { + loadedAt: number; + fetchedCount: number; + limit: number; + requestId: string; +}): Pick< + AgentState, + | "historyLoadedAt" + | "historyFetchLimit" + | "historyFetchedCount" + | "historyMaybeTruncated" + | "lastAppliedHistoryRequestId" +> => { + return { + historyLoadedAt: params.loadedAt, + historyFetchLimit: params.limit, + historyFetchedCount: params.fetchedCount, + historyMaybeTruncated: params.fetchedCount >= params.limit, + lastAppliedHistoryRequestId: params.requestId, + }; +}; diff --git a/src/features/agents/operations/historySyncOperation.ts b/src/features/agents/operations/historySyncOperation.ts new file mode 100644 index 00000000..5f1e8eab --- /dev/null +++ b/src/features/agents/operations/historySyncOperation.ts @@ -0,0 +1,325 @@ +import { + buildHistoryMetadataPatch, + resolveHistoryRequestIntent, + resolveHistoryResponseDisposition, +} from "@/features/agents/operations/historyLifecycleWorkflow"; +import { + buildHistoryLines, + buildHistorySyncPatch, + resolveHistoryRunStatePatch, +} from "@/features/agents/state/runtimeEventBridge"; +import type { AgentState } from "@/features/agents/state/store"; +import { + areTranscriptEntriesEqual, + buildOutputLinesFromTranscriptEntries, + buildTranscriptEntriesFromLines, + mergeTranscriptEntriesWithHistory, + sortTranscriptEntries, + type TranscriptEntry, +} from "@/features/agents/state/transcript"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; + +type ChatHistoryMessage = Record<string, unknown>; + +type ChatHistoryResult = { + sessionKey: string; + messages: ChatHistoryMessage[]; +}; + +type GatewayClientLike = { + call: <T = unknown>(method: string, params: unknown) => Promise<T>; +}; + +export type HistorySyncCommand = + | { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> } + | { kind: "logMetric"; metric: string; meta: Record<string, unknown> } + | { kind: "logError"; message: string; error: unknown } + | { kind: "noop"; reason: string }; + +type HistorySyncDispatchAction = { + type: "updateAgent"; + agentId: string; + patch: Partial<AgentState>; +}; + +type RunHistorySyncOperationParams = { + client: GatewayClientLike; + agentId: string; + requestedLimit?: number; + getAgent: (agentId: string) => AgentState | null; + inFlightSessionKeys: Set<string>; + requestId: string; + loadedAt: number; + defaultLimit: number; + maxLimit: number; + transcriptV2Enabled: boolean; +}; + +export const executeHistorySyncCommands = (params: { + commands: HistorySyncCommand[]; + dispatch: (action: HistorySyncDispatchAction) => void; + logMetric: (metric: string, meta?: unknown) => void; + isDisconnectLikeError: (error: unknown) => boolean; + logError: (message: string, error: unknown) => void; +}) => { + for (const command of params.commands) { + if (command.kind === "dispatchUpdateAgent") { + params.dispatch({ + type: "updateAgent", + agentId: command.agentId, + patch: command.patch, + }); + continue; + } + if (command.kind === "logMetric") { + params.logMetric(command.metric, command.meta); + continue; + } + if (command.kind === "logError") { + if (params.isDisconnectLikeError(command.error)) continue; + params.logError(command.message, command.error); + } + } +}; + +const areStringArraysEqual = (left: string[], right: string[]): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +}; + +const scoreResolvedRunAssistantEntry = (entry: TranscriptEntry): number => { + let score = 0; + if (entry.confirmed) score += 4; + if (entry.source === "runtime-chat") score += 2; + if (entry.source === "history") score += 1; + if (typeof entry.timestampMs === "number" && Number.isFinite(entry.timestampMs)) { + score += 1; + } + return score; +}; + +const collapseNonActiveRunAssistantDuplicates = ( + entries: TranscriptEntry[], + activeRunId: string +): TranscriptEntry[] => { + const normalizedActiveRunId = activeRunId.trim(); + const next: TranscriptEntry[] = []; + const byRunAssistantText = new Map<string, number>(); + for (const entry of entries) { + const normalizedRunId = entry.runId?.trim() ?? ""; + const isResolvedRunAssistant = + normalizedRunId.length > 0 && + normalizedRunId !== normalizedActiveRunId && + entry.kind === "assistant" && + entry.role === "assistant"; + if (!isResolvedRunAssistant) { + next.push(entry); + continue; + } + const dedupeKey = normalizeAssistantDisplayText(entry.text); + if (!dedupeKey) { + next.push(entry); + continue; + } + const runScopedKey = `${normalizedRunId}:${dedupeKey}`; + const existingIndex = byRunAssistantText.get(runScopedKey); + if (existingIndex === undefined) { + byRunAssistantText.set(runScopedKey, next.length); + next.push(entry); + continue; + } + const current = next[existingIndex]; + if (!current) { + byRunAssistantText.set(runScopedKey, next.length); + next.push(entry); + continue; + } + const currentScore = scoreResolvedRunAssistantEntry(current); + const nextScore = scoreResolvedRunAssistantEntry(entry); + const shouldReplace = + nextScore > currentScore || + (nextScore === currentScore && entry.sequenceKey > current.sequenceKey); + if (shouldReplace) { + next[existingIndex] = entry; + } + } + return sortTranscriptEntries(next); +}; + +export const runHistorySyncOperation = async ( + params: RunHistorySyncOperationParams +): Promise<HistorySyncCommand[]> => { + const requestAgent = params.getAgent(params.agentId); + const requestIntent = resolveHistoryRequestIntent({ + agent: requestAgent, + requestedLimit: params.requestedLimit, + maxLimit: params.maxLimit, + defaultLimit: params.defaultLimit, + inFlightSessionKeys: params.inFlightSessionKeys, + requestId: params.requestId, + loadedAt: params.loadedAt, + }); + if (requestIntent.kind === "skip") { + return [{ kind: "noop", reason: requestIntent.reason }]; + } + + params.inFlightSessionKeys.add(requestIntent.sessionKey); + const commands: HistorySyncCommand[] = [ + { + kind: "dispatchUpdateAgent", + agentId: params.agentId, + patch: { + lastHistoryRequestRevision: requestIntent.requestRevision, + }, + }, + ]; + + try { + const result = await params.client.call<ChatHistoryResult>("chat.history", { + sessionKey: requestIntent.sessionKey, + limit: requestIntent.limit, + }); + const latest = params.getAgent(params.agentId); + const responseDisposition = resolveHistoryResponseDisposition({ + latestAgent: latest, + expectedSessionKey: requestIntent.sessionKey, + requestEpoch: requestIntent.requestEpoch, + requestRevision: requestIntent.requestRevision, + }); + const historyMessages = result.messages ?? []; + const metadataPatch: Partial<AgentState> = buildHistoryMetadataPatch({ + loadedAt: requestIntent.loadedAt, + fetchedCount: historyMessages.length, + limit: requestIntent.limit, + requestId: requestIntent.requestId, + }); + + if (responseDisposition.kind === "drop") { + const reason = responseDisposition.reason.replace(/-/g, "_"); + commands.push({ + kind: "logMetric", + metric: "history_response_dropped_stale", + meta: { + reason, + agentId: params.agentId, + requestId: requestIntent.requestId, + }, + }); + return commands; + } + + if (!latest) { + return commands; + } + + if (params.transcriptV2Enabled) { + const existingEntries = Array.isArray(latest.transcriptEntries) + ? latest.transcriptEntries + : buildTranscriptEntriesFromLines({ + lines: latest.outputLines, + sessionKey: latest.sessionKey, + source: "legacy", + startSequence: 0, + confirmed: true, + }); + const history = buildHistoryLines(historyMessages); + const runStatePatch = resolveHistoryRunStatePatch({ + status: latest.status, + runId: latest.runId, + lastRole: history.lastRole, + lastUserAt: history.lastUserAt, + loadedAt: requestIntent.loadedAt, + }); + const normalizedLastAssistant = history.lastAssistant + ? normalizeAssistantDisplayText(history.lastAssistant) + : null; + const rawHistoryEntries = buildTranscriptEntriesFromLines({ + lines: history.lines, + sessionKey: requestIntent.sessionKey, + source: "history", + startSequence: latest.transcriptSequenceCounter ?? existingEntries.length, + confirmed: true, + }); + const historyEntryOccurrenceByKey = new Map<string, number>(); + const historyEntries = rawHistoryEntries.map((entry) => { + const identityKey = `${entry.kind}:${entry.role}:${entry.timestampMs ?? "none"}:${entry.fingerprint}`; + const occurrence = historyEntryOccurrenceByKey.get(identityKey) ?? 0; + historyEntryOccurrenceByKey.set(identityKey, occurrence + 1); + return { + ...entry, + entryId: `history:${requestIntent.sessionKey}:${identityKey}:occ:${occurrence}`, + }; + }); + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries, + historyEntries, + }); + const activeRunId = latest.status === "running" ? (latest.runId?.trim() ?? "") : ""; + const finalEntries = collapseNonActiveRunAssistantDuplicates(merged.entries, activeRunId); + if (merged.conflictCount > 0) { + commands.push({ + kind: "logMetric", + metric: "transcript_merge_conflicts", + meta: { + agentId: params.agentId, + requestId: requestIntent.requestId, + conflictCount: merged.conflictCount, + }, + }); + } + const mergedLines = buildOutputLinesFromTranscriptEntries(finalEntries); + const transcriptChanged = !areTranscriptEntriesEqual(existingEntries, finalEntries); + const linesChanged = !areStringArraysEqual(latest.outputLines, mergedLines); + commands.push({ + kind: "dispatchUpdateAgent", + agentId: params.agentId, + patch: { + ...metadataPatch, + ...(runStatePatch ?? {}), + ...(transcriptChanged || linesChanged + ? { + transcriptEntries: finalEntries, + outputLines: mergedLines, + } + : {}), + ...(normalizedLastAssistant ? { lastResult: normalizedLastAssistant } : {}), + ...(normalizedLastAssistant ? { latestPreview: normalizedLastAssistant } : {}), + ...(typeof history.lastAssistantAt === "number" + ? { lastAssistantMessageAt: history.lastAssistantAt } + : {}), + ...(history.lastUser ? { lastUserMessage: history.lastUser } : {}), + }, + }); + return commands; + } + + const patch = buildHistorySyncPatch({ + messages: historyMessages, + currentLines: latest.outputLines, + loadedAt: requestIntent.loadedAt, + status: latest.status, + runId: latest.runId, + }); + commands.push({ + kind: "dispatchUpdateAgent", + agentId: params.agentId, + patch: { + ...patch, + ...metadataPatch, + }, + }); + return commands; + } catch (err) { + commands.push({ + kind: "logError", + message: err instanceof Error ? err.message : "Failed to load chat history.", + error: err, + }); + return commands; + } finally { + params.inFlightSessionKeys.delete(requestIntent.sessionKey); + } +}; diff --git a/src/features/agents/operations/latestUpdateWorkflow.ts b/src/features/agents/operations/latestUpdateWorkflow.ts new file mode 100644 index 00000000..2ac8846b --- /dev/null +++ b/src/features/agents/operations/latestUpdateWorkflow.ts @@ -0,0 +1,71 @@ +import { parseAgentIdFromSessionKey } from "@/lib/gateway/GatewayClient"; + +export type LatestUpdateKind = "heartbeat" | "cron" | null; + +export type LatestUpdateIntent = + | { kind: "reset" } + | { + kind: "fetch-heartbeat"; + agentId: string; + sessionLimit: number; + historyLimit: number; + } + | { kind: "fetch-cron"; agentId: string } + | { kind: "noop" }; + +const SPECIAL_UPDATE_HEARTBEAT_RE = /\bheartbeat\b/i; +const SPECIAL_UPDATE_CRON_RE = /\bcron\b/i; +const HEARTBEAT_SESSION_LIMIT = 48; +const HEARTBEAT_HISTORY_LIMIT = 200; + +export const resolveLatestUpdateKind = (message: string): LatestUpdateKind => { + const lowered = message.toLowerCase(); + const heartbeatIndex = lowered.search(SPECIAL_UPDATE_HEARTBEAT_RE); + const cronIndex = lowered.search(SPECIAL_UPDATE_CRON_RE); + if (heartbeatIndex === -1 && cronIndex === -1) return null; + if (heartbeatIndex === -1) return "cron"; + if (cronIndex === -1) return "heartbeat"; + return cronIndex > heartbeatIndex ? "cron" : "heartbeat"; +}; + +export const resolveLatestUpdateIntent = (params: { + message: string; + agentId: string; + sessionKey: string; + hasExistingOverride: boolean; +}): LatestUpdateIntent => { + const kind = resolveLatestUpdateKind(params.message); + if (!kind) { + return params.hasExistingOverride ? { kind: "reset" } : { kind: "noop" }; + } + if (kind === "heartbeat") { + const resolvedAgentId = + params.agentId.trim() || parseAgentIdFromSessionKey(params.sessionKey) || ""; + if (!resolvedAgentId) { + return { kind: "reset" }; + } + return { + kind: "fetch-heartbeat", + agentId: resolvedAgentId, + sessionLimit: HEARTBEAT_SESSION_LIMIT, + historyLimit: HEARTBEAT_HISTORY_LIMIT, + }; + } + return { + kind: "fetch-cron", + agentId: params.agentId.trim(), + }; +}; + +export const buildLatestUpdatePatch = ( + content: string, + kind?: "heartbeat" | "cron" +): { + latestOverride: string | null; + latestOverrideKind: "heartbeat" | "cron" | null; +} => { + return { + latestOverride: content || null, + latestOverrideKind: content && kind ? kind : null, + }; +}; diff --git a/src/features/agents/operations/mutationLifecycleWorkflow.ts b/src/features/agents/operations/mutationLifecycleWorkflow.ts new file mode 100644 index 00000000..18532445 --- /dev/null +++ b/src/features/agents/operations/mutationLifecycleWorkflow.ts @@ -0,0 +1,453 @@ +import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy"; +import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; +import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; + +export type MutationKind = "create-agent" | "rename-agent" | "delete-agent"; + +export type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart"; + +export type MutationBlockState = { + kind: MutationKind; + agentId: string; + agentName: string; + phase: MutationBlockPhase; + startedAt: number; + sawDisconnect: boolean; +}; + +export type MutationStartGuardResult = + | { kind: "allow" } + | { + kind: "deny"; + reason: "not-connected" | "create-block-active" | "rename-block-active" | "delete-block-active"; + }; + +export const resolveMutationStartGuard = (params: { + status: "connected" | "connecting" | "disconnected"; + hasCreateBlock: boolean; + hasRenameBlock: boolean; + hasDeleteBlock: boolean; +}): MutationStartGuardResult => { + if (params.status !== "connected") { + return { kind: "deny", reason: "not-connected" }; + } + if (params.hasCreateBlock) { + return { kind: "deny", reason: "create-block-active" }; + } + if (params.hasRenameBlock) { + return { kind: "deny", reason: "rename-block-active" }; + } + if (params.hasDeleteBlock) { + return { kind: "deny", reason: "delete-block-active" }; + } + return { kind: "allow" }; +}; + +export const buildQueuedMutationBlock = (params: { + kind: MutationKind; + agentId: string; + agentName: string; + startedAt: number; +}): MutationBlockState => { + return { + kind: params.kind, + agentId: params.agentId, + agentName: params.agentName, + phase: "queued", + startedAt: params.startedAt, + sawDisconnect: false, + }; +}; + +export const buildMutatingMutationBlock = (block: MutationBlockState): MutationBlockState => { + return { + ...block, + phase: "mutating", + }; +}; + +export type MutationPostRunIntent = + | { kind: "clear" } + | { kind: "awaiting-restart"; patch: { phase: "awaiting-restart"; sawDisconnect: boolean } }; + +export const resolveMutationPostRunIntent = (params: { + disposition: "completed" | "awaiting-restart"; +}): MutationPostRunIntent => { + if (params.disposition === "awaiting-restart") { + return { + kind: "awaiting-restart", + patch: { + phase: "awaiting-restart", + sawDisconnect: false, + }, + }; + } + return { kind: "clear" }; +}; + +export type MutationSideEffectCommand = + | { kind: "reload-agents" } + | { kind: "clear-mutation-block" } + | { kind: "set-mobile-pane"; pane: "chat" } + | { kind: "patch-mutation-block"; patch: { phase: "awaiting-restart"; sawDisconnect: boolean } }; + +export const buildMutationSideEffectCommands = (params: { + disposition: "completed" | "awaiting-restart"; +}): MutationSideEffectCommand[] => { + const postRunIntent = resolveMutationPostRunIntent({ + disposition: params.disposition, + }); + if (postRunIntent.kind === "clear") { + return [ + { kind: "reload-agents" }, + { kind: "clear-mutation-block" }, + { kind: "set-mobile-pane", pane: "chat" }, + ]; + } + return [{ kind: "patch-mutation-block", patch: postRunIntent.patch }]; +}; + +export type MutationTimeoutIntent = + | { kind: "none" } + | { kind: "timeout"; reason: "create-timeout" | "rename-timeout" | "delete-timeout" }; + +const resolveTimeoutReason = ( + kind: MutationKind +): "create-timeout" | "rename-timeout" | "delete-timeout" => { + if (kind === "create-agent") { + return "create-timeout"; + } + if (kind === "rename-agent") { + return "rename-timeout"; + } + return "delete-timeout"; +}; + +export const resolveMutationTimeoutIntent = (params: { + block: MutationBlockState | null; + nowMs: number; + maxWaitMs: number; +}): MutationTimeoutIntent => { + if (!params.block) { + return { kind: "none" }; + } + const elapsed = params.nowMs - params.block.startedAt; + if (elapsed < params.maxWaitMs) { + return { kind: "none" }; + } + return { + kind: "timeout", + reason: resolveTimeoutReason(params.block.kind), + }; +}; + +export type MutationWorkflowKind = "rename-agent" | "delete-agent"; + +export type MutationWorkflowResult = { + disposition: "completed" | "awaiting-restart"; +}; + +export type AwaitingRestartPatch = { + phase: "awaiting-restart"; + sawDisconnect: boolean; +}; + +export type MutationWorkflowPostRunEffects = { + shouldReloadAgents: boolean; + shouldClearBlock: boolean; + awaitingRestartPatch: AwaitingRestartPatch | null; +}; + +export type MutationWorkflowDeps = { + executeMutation: () => Promise<void>; + shouldAwaitRemoteRestart: () => Promise<boolean>; +}; + +export type AgentConfigMutationLifecycleKind = MutationWorkflowKind; + +export type AgentConfigMutationLifecycleDeps = { + enqueueConfigMutation: (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => Promise<void>; + setQueuedBlock: () => void; + setMutatingBlock: () => void; + patchBlockAwaitingRestart: (patch: { phase: "awaiting-restart"; sawDisconnect: boolean }) => void; + clearBlock: () => void; + executeMutation: () => Promise<void>; + shouldAwaitRemoteRestart: () => Promise<boolean>; + reloadAgents: () => Promise<void>; + setMobilePaneChat: () => void; + onError: (message: string) => void; +}; + +export type CreateAgentBlockState = { + agentName: string; + phase: "queued" | "creating"; + startedAt: number; +}; + +export type CreateAgentLifecycleCompletion = { + agentId: string; + agentName: string; +}; + +export type CreateAgentMutationLifecycleDeps = { + enqueueConfigMutation: (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => Promise<void>; + createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>; + setQueuedBlock: (params: { agentName: string; startedAt: number }) => void; + setCreatingBlock: (agentName: string) => void; + onCompletion: (completion: CreateAgentLifecycleCompletion) => Promise<void> | void; + setCreateAgentModalError: (message: string | null) => void; + setCreateAgentBusy: (busy: boolean) => void; + clearCreateBlock: () => void; + onError: (message: string) => void; + now?: () => number; +}; + +export type MutationStatusBlock = { + phase: "queued" | "mutating" | "awaiting-restart"; + sawDisconnect: boolean; +}; + +type MutationFailureMessageByKind = Record<MutationWorkflowKind, string>; + +const FALLBACK_MUTATION_FAILURE_MESSAGE: MutationFailureMessageByKind = { + "rename-agent": "Failed to rename agent.", + "delete-agent": "Failed to delete agent.", +}; + +const assertMutationKind = (kind: string): MutationWorkflowKind => { + if (kind === "rename-agent" || kind === "delete-agent") { + return kind; + } + throw new Error(`Unknown config mutation kind: ${kind}`); +}; + +export const runConfigMutationWorkflow = async ( + params: { kind: MutationWorkflowKind; isLocalGateway: boolean }, + deps: MutationWorkflowDeps +): Promise<MutationWorkflowResult> => { + assertMutationKind(params.kind); + await deps.executeMutation(); + if (params.isLocalGateway) { + return { disposition: "completed" }; + } + const shouldAwaitRestart = await deps.shouldAwaitRemoteRestart(); + return { + disposition: shouldAwaitRestart ? "awaiting-restart" : "completed", + }; +}; + +export const runAgentConfigMutationLifecycle = async (params: { + kind: AgentConfigMutationLifecycleKind; + label: string; + isLocalGateway: boolean; + deps: AgentConfigMutationLifecycleDeps; +}): Promise<boolean> => { + params.deps.setQueuedBlock(); + try { + await params.deps.enqueueConfigMutation({ + kind: params.kind, + label: params.label, + run: async () => { + params.deps.setMutatingBlock(); + const result = await runConfigMutationWorkflow( + { kind: params.kind, isLocalGateway: params.isLocalGateway }, + { + executeMutation: params.deps.executeMutation, + shouldAwaitRemoteRestart: params.deps.shouldAwaitRemoteRestart, + } + ); + const commands = buildMutationSideEffectCommands({ + disposition: result.disposition, + }); + for (const command of commands) { + if (command.kind === "reload-agents") { + await params.deps.reloadAgents(); + continue; + } + if (command.kind === "clear-mutation-block") { + params.deps.clearBlock(); + continue; + } + if (command.kind === "set-mobile-pane") { + params.deps.setMobilePaneChat(); + continue; + } + params.deps.patchBlockAwaitingRestart(command.patch); + } + }, + }); + return true; + } catch (error) { + params.deps.clearBlock(); + params.deps.onError( + buildConfigMutationFailureMessage({ + kind: params.kind, + error, + }) + ); + return false; + } +}; + +export const runCreateAgentMutationLifecycle = async ( + params: { + payload: AgentCreateModalSubmitPayload; + status: "connected" | "connecting" | "disconnected"; + hasCreateBlock: boolean; + hasRenameBlock: boolean; + hasDeleteBlock: boolean; + createAgentBusy: boolean; + }, + deps: CreateAgentMutationLifecycleDeps +): Promise<boolean> => { + if (params.createAgentBusy) return false; + const guard = resolveMutationStartGuard({ + status: params.status, + hasCreateBlock: params.hasCreateBlock, + hasRenameBlock: params.hasRenameBlock, + hasDeleteBlock: params.hasDeleteBlock, + }); + if (guard.kind === "deny") { + if (guard.reason === "not-connected") { + deps.setCreateAgentModalError("Connect to gateway before creating an agent."); + } + return false; + } + + const name = params.payload.name.trim(); + if (!name) { + deps.setCreateAgentModalError("Agent name is required."); + return false; + } + + deps.setCreateAgentBusy(true); + deps.setCreateAgentModalError(null); + const startedAt = (deps.now ?? Date.now)(); + deps.setQueuedBlock({ agentName: name, startedAt }); + const avatarSeed = params.payload.avatarSeed?.trim() ?? null; + try { + const queuedMutation = deps.enqueueConfigMutation({ + kind: "create-agent", + label: `Create ${name}`, + run: async () => { + deps.setCreatingBlock(name); + const created = await deps.createAgent(name, avatarSeed); + await deps.onCompletion({ + agentId: created.id, + agentName: name, + }); + }, + }); + await queuedMutation; + return true; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create agent."; + deps.clearCreateBlock(); + deps.setCreateAgentModalError(message); + deps.onError(message); + return false; + } finally { + deps.setCreateAgentBusy(false); + } +}; + +export const isCreateBlockTimedOut = (params: { + block: CreateAgentBlockState | null; + nowMs: number; + maxWaitMs: number; +}): boolean => { + if (!params.block || params.block.phase === "queued") { + return false; + } + const timeoutIntent = resolveMutationTimeoutIntent({ + block: { + kind: "create-agent", + agentId: "", + agentName: params.block.agentName, + phase: "mutating", + startedAt: params.block.startedAt, + sawDisconnect: false, + }, + nowMs: params.nowMs, + maxWaitMs: params.maxWaitMs, + }); + return timeoutIntent.kind === "timeout" && timeoutIntent.reason === "create-timeout"; +}; + +export const buildConfigMutationFailureMessage = (params: { + kind: MutationWorkflowKind; + error: unknown; +}): string => { + const fallback = FALLBACK_MUTATION_FAILURE_MESSAGE[params.kind]; + if (params.error instanceof Error) { + return params.error.message || fallback; + } + return fallback; +}; + +export const resolveConfigMutationStatusLine = (params: { + block: MutationStatusBlock | null; + status: GatewayStatus; + mutatingLabel?: string; +}): string | null => { + const { block, status } = params; + if (!block) return null; + if (block.phase === "queued") { + return "Waiting for active runs to finish"; + } + if (block.phase === "mutating") { + return params.mutatingLabel ?? "Submitting config change"; + } + if (!block.sawDisconnect) { + return "Waiting for gateway to restart"; + } + return status === "connected" + ? "Gateway is back online, syncing agents" + : "Gateway restart in progress"; +}; + +export const buildAwaitingRestartPatch = (): AwaitingRestartPatch => { + return { + phase: "awaiting-restart", + sawDisconnect: false, + }; +}; + +export const resolveConfigMutationPostRunEffects = ( + result: MutationWorkflowResult +): MutationWorkflowPostRunEffects => { + const commands = buildMutationSideEffectCommands({ + disposition: result.disposition, + }); + let shouldReloadAgents = false; + let shouldClearBlock = false; + let awaitingRestartPatch: AwaitingRestartPatch | null = null; + for (const command of commands) { + if (command.kind === "reload-agents") { + shouldReloadAgents = true; + continue; + } + if (command.kind === "clear-mutation-block") { + shouldClearBlock = true; + continue; + } + if (command.kind === "patch-mutation-block") { + awaitingRestartPatch = command.patch; + } + } + return { + shouldReloadAgents, + shouldClearBlock, + awaitingRestartPatch, + }; +}; diff --git a/src/features/agents/operations/runtimeSyncControlWorkflow.ts b/src/features/agents/operations/runtimeSyncControlWorkflow.ts new file mode 100644 index 00000000..6dad7f6b --- /dev/null +++ b/src/features/agents/operations/runtimeSyncControlWorkflow.ts @@ -0,0 +1,111 @@ +import type { AgentState } from "@/features/agents/state/store"; + +export type RuntimeSyncStatus = "disconnected" | "connecting" | "connected"; + +export const RUNTIME_SYNC_RECONCILE_INTERVAL_MS = 3000; +export const RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS = 4500; +export const RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT = 200; +export const RUNTIME_SYNC_MAX_HISTORY_LIMIT = 5000; + +const RUNTIME_SYNC_MIN_LOAD_MORE_HISTORY_LIMIT = 400; + +type RuntimeSyncHistoryBootstrapAgent = Pick< + AgentState, + "agentId" | "sessionCreated" | "historyLoadedAt" +>; + +type RuntimeSyncFocusedPollingAgent = Pick<AgentState, "agentId" | "status">; + +export type RuntimeSyncReconcilePollingIntent = + | { kind: "start"; intervalMs: number; runImmediately: true } + | { kind: "stop"; reason: "not-connected" }; + +export type RuntimeSyncFocusedHistoryPollingIntent = + | { kind: "start"; agentId: string; intervalMs: number; runImmediately: true } + | { + kind: "stop"; + reason: "not-connected" | "missing-focused-agent" | "focused-not-running"; + }; + +export const resolveRuntimeSyncReconcilePollingIntent = (params: { + status: RuntimeSyncStatus; +}): RuntimeSyncReconcilePollingIntent => { + if (params.status !== "connected") { + return { kind: "stop", reason: "not-connected" }; + } + return { + kind: "start", + intervalMs: RUNTIME_SYNC_RECONCILE_INTERVAL_MS, + runImmediately: true, + }; +}; + +export const resolveRuntimeSyncBootstrapHistoryAgentIds = (params: { + status: RuntimeSyncStatus; + agents: RuntimeSyncHistoryBootstrapAgent[]; +}): string[] => { + if (params.status !== "connected") return []; + const ids: string[] = []; + for (const agent of params.agents) { + if (!agent.sessionCreated) continue; + if (agent.historyLoadedAt !== null) continue; + const agentId = agent.agentId.trim(); + if (!agentId) continue; + ids.push(agentId); + } + return ids; +}; + +export const resolveRuntimeSyncFocusedHistoryPollingIntent = (params: { + status: RuntimeSyncStatus; + focusedAgentId: string | null; + focusedAgentRunning: boolean; +}): RuntimeSyncFocusedHistoryPollingIntent => { + if (params.status !== "connected") { + return { kind: "stop", reason: "not-connected" }; + } + const focusedAgentId = params.focusedAgentId?.trim() ?? ""; + if (!focusedAgentId) { + return { kind: "stop", reason: "missing-focused-agent" }; + } + if (!params.focusedAgentRunning) { + return { kind: "stop", reason: "focused-not-running" }; + } + return { + kind: "start", + agentId: focusedAgentId, + intervalMs: RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS, + runImmediately: true, + }; +}; + +export const shouldRuntimeSyncContinueFocusedHistoryPolling = (params: { + agentId: string; + agents: RuntimeSyncFocusedPollingAgent[]; +}): boolean => { + const target = params.agentId.trim(); + if (!target) return false; + const agent = params.agents.find((entry) => entry.agentId === target) ?? null; + if (!agent) return false; + return agent.status === "running"; +}; + +export const resolveRuntimeSyncLoadMoreHistoryLimit = (params: { + currentLimit: number | null; + defaultLimit: number; + maxLimit: number; +}): number => { + const currentLimit = + typeof params.currentLimit === "number" && Number.isFinite(params.currentLimit) + ? params.currentLimit + : params.defaultLimit; + const nextLimit = Math.max(RUNTIME_SYNC_MIN_LOAD_MORE_HISTORY_LIMIT, currentLimit * 2); + return Math.min(params.maxLimit, nextLimit); +}; + +export const resolveRuntimeSyncGapRecoveryIntent = () => { + return { + refreshSummarySnapshot: true, + reconcileRunningAgents: true, + } as const; +}; diff --git a/src/features/agents/operations/settingsRouteWorkflow.ts b/src/features/agents/operations/settingsRouteWorkflow.ts new file mode 100644 index 00000000..45fa25bf --- /dev/null +++ b/src/features/agents/operations/settingsRouteWorkflow.ts @@ -0,0 +1,279 @@ +export type SettingsRouteTab = + | "personality" + | "capabilities" + | "skills" + | "system" + | "automations" + | "advanced"; + +export type InspectSidebarState = + | { agentId: string; tab: SettingsRouteTab } + | null; + +export type SettingsRouteNavCommand = + | { kind: "select-agent"; agentId: string | null } + | { kind: "set-inspect-sidebar"; value: InspectSidebarState } + | { kind: "set-mobile-pane-chat" } + | { kind: "set-personality-dirty"; value: boolean } + | { kind: "flush-pending-draft"; agentId: string | null } + | { kind: "push"; href: string } + | { kind: "replace"; href: string }; + +export const SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM = "settingsAgentId"; + +export const parseSettingsRouteAgentIdFromPathname = (pathname: string): string | null => { + const match = pathname.match(/^\/agents\/([^/]+)\/settings\/?$/); + if (!match) return null; + try { + const decoded = decodeURIComponent(match[1] ?? ""); + const trimmed = decoded.trim(); + return trimmed ? trimmed : null; + } catch { + const raw = (match[1] ?? "").trim(); + return raw ? raw : null; + } +}; + +export const parseSettingsRouteAgentIdFromQueryParam = (value: string | null | undefined): string | null => { + const trimmed = (value ?? "").trim(); + if (!trimmed) return null; + try { + const decoded = decodeURIComponent(trimmed).trim(); + return decoded ? decoded : null; + } catch { + return trimmed; + } +}; + +export const buildSettingsRouteHref = (agentId: string): string => { + const resolved = agentId.trim(); + if (!resolved) { + throw new Error("Cannot build settings route href: agent id is empty."); + } + return `/?${SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM}=${encodeURIComponent(resolved)}`; +}; + +export const shouldConfirmDiscardPersonalityChanges = (params: { + settingsRouteActive: boolean; + activeTab: SettingsRouteTab; + personalityHasUnsavedChanges: boolean; +}): boolean => { + if (!params.settingsRouteActive) return false; + if (params.activeTab !== "personality") return false; + return params.personalityHasUnsavedChanges; +}; + +export const planBackToChatCommands = (input: { + settingsRouteActive: boolean; + activeTab: SettingsRouteTab; + personalityHasUnsavedChanges: boolean; + discardConfirmed: boolean; +}): SettingsRouteNavCommand[] => { + if ( + shouldConfirmDiscardPersonalityChanges({ + settingsRouteActive: input.settingsRouteActive, + activeTab: input.activeTab, + personalityHasUnsavedChanges: input.personalityHasUnsavedChanges, + }) && + !input.discardConfirmed + ) { + return []; + } + + return [ + { kind: "set-personality-dirty", value: false }, + { kind: "push", href: "/" }, + ]; +}; + +export const planSettingsTabChangeCommands = (input: { + nextTab: SettingsRouteTab; + currentInspectSidebar: InspectSidebarState; + settingsRouteAgentId: string | null; + settingsRouteActive: boolean; + personalityHasUnsavedChanges: boolean; + discardConfirmed: boolean; +}): SettingsRouteNavCommand[] => { + const resolvedAgentId = + (input.currentInspectSidebar?.agentId ?? input.settingsRouteAgentId ?? "").trim(); + if (!resolvedAgentId) return []; + + const currentTab = input.currentInspectSidebar?.tab ?? "personality"; + if (currentTab === input.nextTab) return []; + + const requiresDiscardConfirmation = + currentTab === "personality" && + input.nextTab !== "personality" && + shouldConfirmDiscardPersonalityChanges({ + settingsRouteActive: input.settingsRouteActive, + activeTab: currentTab, + personalityHasUnsavedChanges: input.personalityHasUnsavedChanges, + }); + + if (requiresDiscardConfirmation && !input.discardConfirmed) { + return []; + } + + const commands: SettingsRouteNavCommand[] = []; + if (requiresDiscardConfirmation) { + commands.push({ kind: "set-personality-dirty", value: false }); + } + commands.push({ + kind: "set-inspect-sidebar", + value: { agentId: resolvedAgentId, tab: input.nextTab }, + }); + return commands; +}; + +export const planOpenSettingsRouteCommands = (input: { + agentId: string; + currentInspectSidebar: InspectSidebarState; + focusedAgentId: string | null; +}): SettingsRouteNavCommand[] => { + const resolvedAgentId = input.agentId.trim(); + if (!resolvedAgentId) return []; + + const commands: SettingsRouteNavCommand[] = [ + { + kind: "flush-pending-draft", + agentId: input.focusedAgentId, + }, + { + kind: "select-agent", + agentId: resolvedAgentId, + }, + ]; + + if (input.currentInspectSidebar?.agentId !== resolvedAgentId) { + commands.push({ + kind: "set-inspect-sidebar", + value: { + agentId: resolvedAgentId, + tab: input.currentInspectSidebar?.tab ?? "personality", + }, + }); + } + + commands.push( + { kind: "set-mobile-pane-chat" }, + { kind: "push", href: buildSettingsRouteHref(resolvedAgentId) } + ); + + return commands; +}; + +export const planFleetSelectCommands = (input: { + agentId: string; + currentInspectSidebar: InspectSidebarState; + focusedAgentId: string | null; +}): SettingsRouteNavCommand[] => { + const resolvedAgentId = input.agentId.trim(); + if (!resolvedAgentId) return []; + + const commands: SettingsRouteNavCommand[] = [ + { + kind: "flush-pending-draft", + agentId: input.focusedAgentId, + }, + { + kind: "select-agent", + agentId: resolvedAgentId, + }, + ]; + + if (input.currentInspectSidebar) { + commands.push({ + kind: "set-inspect-sidebar", + value: { + ...input.currentInspectSidebar, + agentId: resolvedAgentId, + }, + }); + } + + commands.push({ kind: "set-mobile-pane-chat" }); + return commands; +}; + +export const planSettingsRouteSyncCommands = (input: { + settingsRouteActive: boolean; + settingsRouteAgentId: string | null; + status: "disconnected" | "connecting" | "connected"; + agentsLoadedOnce: boolean; + selectedAgentId: string | null; + hasRouteAgent: boolean; + currentInspectSidebar: InspectSidebarState; +}): SettingsRouteNavCommand[] => { + const commands: SettingsRouteNavCommand[] = []; + const routeAgentId = (input.settingsRouteAgentId ?? "").trim(); + + if (routeAgentId && input.hasRouteAgent) { + if (input.currentInspectSidebar?.agentId !== routeAgentId) { + commands.push({ + kind: "set-inspect-sidebar", + value: { + agentId: routeAgentId, + tab: input.currentInspectSidebar?.tab ?? "personality", + }, + }); + } + if (input.selectedAgentId !== routeAgentId) { + commands.push({ kind: "select-agent", agentId: routeAgentId }); + } + } + + if ( + input.settingsRouteActive && + routeAgentId && + input.status === "connected" && + input.agentsLoadedOnce && + !input.hasRouteAgent + ) { + commands.push({ kind: "replace", href: "/" }); + } + + return commands; +}; + +export const planNonRouteSelectionSyncCommands = (input: { + settingsRouteActive: boolean; + selectedAgentId: string | null; + focusedAgentId: string | null; + hasSelectedAgentInAgents: boolean; + currentInspectSidebar: InspectSidebarState; + hasInspectSidebarAgent: boolean; +}): SettingsRouteNavCommand[] => { + if (input.settingsRouteActive) return []; + + const commands: SettingsRouteNavCommand[] = []; + const selectedAgentId = input.selectedAgentId?.trim() ?? ""; + + if (input.currentInspectSidebar) { + if (!selectedAgentId) { + commands.push({ kind: "set-inspect-sidebar", value: null }); + } else if (input.currentInspectSidebar.agentId !== selectedAgentId) { + commands.push({ + kind: "set-inspect-sidebar", + value: { + ...input.currentInspectSidebar, + agentId: selectedAgentId, + }, + }); + } + } + + if (input.currentInspectSidebar?.agentId && !input.hasInspectSidebarAgent) { + commands.push({ kind: "set-inspect-sidebar", value: null }); + } + + if (selectedAgentId && !input.hasSelectedAgentInAgents) { + commands.push({ kind: "select-agent", agentId: null }); + } + + const nextSelectedAgentId = input.focusedAgentId ?? null; + if (input.selectedAgentId !== nextSelectedAgentId) { + commands.push({ kind: "select-agent", agentId: nextSelectedAgentId }); + } + + return commands; +}; diff --git a/src/features/agents/operations/specialLatestUpdateOperation.ts b/src/features/agents/operations/specialLatestUpdateOperation.ts new file mode 100644 index 00000000..b1206bc5 --- /dev/null +++ b/src/features/agents/operations/specialLatestUpdateOperation.ts @@ -0,0 +1,147 @@ +import type { CronJobSummary } from "@/lib/cron/types"; +import { + buildLatestUpdatePatch, + resolveLatestUpdateIntent, +} from "@/features/agents/operations/latestUpdateWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import { extractText, isHeartbeatPrompt, stripUiMetadata } from "@/lib/text/message-extract"; + +type ChatHistoryMessage = Record<string, unknown>; + +type ChatHistoryResult = { + messages?: ChatHistoryMessage[]; +}; + +type SessionsListEntry = { + key?: string; + updatedAt?: number | null; + origin?: { label?: string | null } | null; +}; + +type SessionsListResult = { + sessions?: SessionsListEntry[]; +}; + +const findLatestHeartbeatResponse = (messages: ChatHistoryMessage[]) => { + let awaitingHeartbeatReply = false; + let latestResponse: string | null = null; + for (const message of messages) { + const role = typeof message.role === "string" ? message.role : ""; + if (role === "user") { + const text = stripUiMetadata(extractText(message) ?? "").trim(); + awaitingHeartbeatReply = isHeartbeatPrompt(text); + continue; + } + if (role === "assistant" && awaitingHeartbeatReply) { + const text = stripUiMetadata(extractText(message) ?? "").trim(); + if (text) { + latestResponse = text; + } + } + } + return latestResponse; +}; + +export type SpecialLatestUpdateDeps = { + callGateway: (method: string, params: unknown) => Promise<unknown>; + listCronJobs: () => Promise<{ jobs: CronJobSummary[] }>; + resolveCronJobForAgent: (jobs: CronJobSummary[], agentId: string) => CronJobSummary | null; + formatCronJobDisplay: (job: CronJobSummary) => string; + dispatchUpdateAgent: ( + agentId: string, + patch: { latestOverride: string | null; latestOverrideKind: "heartbeat" | "cron" | null } + ) => void; + isDisconnectLikeError: (err: unknown) => boolean; + logError: (message: string) => void; +}; + +export type SpecialLatestUpdateOperation = { + update: (agentId: string, agent: AgentState, message: string) => Promise<void>; + refreshHeartbeat: (agents: AgentState[]) => void; + clearInFlight: (agentId: string) => void; +}; + +export function createSpecialLatestUpdateOperation( + deps: SpecialLatestUpdateDeps +): SpecialLatestUpdateOperation { + const inFlight = new Set<string>(); + + const update: SpecialLatestUpdateOperation["update"] = async (agentId, agent, message) => { + const intent = resolveLatestUpdateIntent({ + message, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + hasExistingOverride: Boolean(agent.latestOverride || agent.latestOverrideKind), + }); + if (intent.kind === "noop") return; + if (intent.kind === "reset") { + deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch("")); + return; + } + + const key = agentId; + if (inFlight.has(key)) return; + inFlight.add(key); + + try { + if (intent.kind === "fetch-heartbeat") { + const result = (await deps.callGateway("sessions.list", { + agentId: intent.agentId, + includeGlobal: false, + includeUnknown: false, + limit: intent.sessionLimit, + })) as SessionsListResult; + + const entries = Array.isArray(result.sessions) ? result.sessions : []; + const heartbeatSessions = entries.filter((entry) => { + const label = entry.origin?.label; + return typeof label === "string" && label.toLowerCase() === "heartbeat"; + }); + const candidates = heartbeatSessions.length > 0 ? heartbeatSessions : entries; + const sorted = [...candidates].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + const sessionKey = sorted[0]?.key; + if (!sessionKey) { + deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch("")); + return; + } + + const history = (await deps.callGateway("chat.history", { + sessionKey, + limit: intent.historyLimit, + })) as ChatHistoryResult; + const messages = Array.isArray(history.messages) ? history.messages : []; + const content = findLatestHeartbeatResponse(messages) ?? ""; + deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "heartbeat")); + return; + } + + if (intent.kind === "fetch-cron") { + const cronResult = await deps.listCronJobs(); + const job = deps.resolveCronJobForAgent(cronResult.jobs, intent.agentId); + const content = job ? deps.formatCronJobDisplay(job) : ""; + deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "cron")); + } + } catch (err) { + if (!deps.isDisconnectLikeError(err)) { + const message = + err instanceof Error ? err.message : "Failed to load latest cron/heartbeat update."; + deps.logError(message); + } + } finally { + inFlight.delete(key); + } + }; + + const refreshHeartbeat: SpecialLatestUpdateOperation["refreshHeartbeat"] = (agents) => { + for (const agent of agents) { + void update(agent.agentId, agent, "heartbeat"); + } + }; + + const clearInFlight: SpecialLatestUpdateOperation["clearInFlight"] = (agentId) => { + inFlight.delete(agentId); + }; + + return { update, refreshHeartbeat, clearInFlight }; +} + diff --git a/src/features/agents/operations/studioBootstrapOperation.ts b/src/features/agents/operations/studioBootstrapOperation.ts new file mode 100644 index 00000000..c75fbf94 --- /dev/null +++ b/src/features/agents/operations/studioBootstrapOperation.ts @@ -0,0 +1,260 @@ +import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration"; +import { + planBootstrapSelection, + planFocusedFilterPatch, + planFocusedPreferenceRestore, + planFocusedSelectionPatch, +} from "@/features/agents/operations/studioBootstrapWorkflow"; +import type { AgentState, AgentStoreSeed, FocusFilter } from "@/features/agents/state/store"; +import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import type { StudioSettings, StudioSettingsPatch } from "@/lib/studio/settings"; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +export type StudioBootstrapLoadCommand = + | { kind: "set-gateway-config-snapshot"; snapshot: GatewayModelPolicySnapshot } + | { kind: "hydrate-agents"; seeds: AgentStoreSeed[]; initialSelectedAgentId: string | undefined } + | { kind: "mark-session-created"; agentId: string; sessionSettingsSynced: boolean } + | { kind: "apply-summary-patch"; agentId: string; patch: Partial<AgentState> } + | { kind: "set-error"; message: string }; + +export async function runStudioBootstrapLoadOperation(params: { + client: GatewayClientLike; + gatewayUrl: string; + cachedConfigSnapshot: GatewayModelPolicySnapshot | null; + loadStudioSettings: () => Promise<StudioSettings | null>; + isDisconnectLikeError: (err: unknown) => boolean; + preferredSelectedAgentId: string | null; + hasCurrentSelection: boolean; + logError?: (message: string, error: unknown) => void; +}): Promise<StudioBootstrapLoadCommand[]> { + try { + const result = await hydrateAgentFleetFromGateway({ + client: params.client, + gatewayUrl: params.gatewayUrl, + cachedConfigSnapshot: params.cachedConfigSnapshot, + loadStudioSettings: params.loadStudioSettings, + isDisconnectLikeError: params.isDisconnectLikeError, + logError: params.logError, + }); + + const selectionIntent = planBootstrapSelection({ + hasCurrentSelection: params.hasCurrentSelection, + preferredSelectedAgentId: params.preferredSelectedAgentId, + availableAgentIds: result.seeds.map((seed) => seed.agentId), + suggestedSelectedAgentId: result.suggestedSelectedAgentId, + }); + + const commands: StudioBootstrapLoadCommand[] = []; + if (!params.cachedConfigSnapshot && result.configSnapshot) { + commands.push({ + kind: "set-gateway-config-snapshot", + snapshot: result.configSnapshot, + }); + } + + commands.push({ + kind: "hydrate-agents", + seeds: result.seeds, + initialSelectedAgentId: selectionIntent.initialSelectedAgentId, + }); + + const sessionSettingsSyncedAgentIds = new Set(result.sessionSettingsSyncedAgentIds); + for (const agentId of result.sessionCreatedAgentIds) { + commands.push({ + kind: "mark-session-created", + agentId, + sessionSettingsSynced: sessionSettingsSyncedAgentIds.has(agentId), + }); + } + + for (const entry of result.summaryPatches) { + commands.push({ + kind: "apply-summary-patch", + agentId: entry.agentId, + patch: entry.patch, + }); + } + + return commands; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load agents."; + return [{ kind: "set-error", message }]; + } +} + +export function executeStudioBootstrapLoadCommands(params: { + commands: StudioBootstrapLoadCommand[]; + setGatewayConfigSnapshot: (snapshot: GatewayModelPolicySnapshot) => void; + hydrateAgents: (agents: AgentStoreSeed[], selectedAgentId?: string) => void; + dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void; + setError: (message: string) => void; +}): void { + for (const command of params.commands) { + if (command.kind === "set-gateway-config-snapshot") { + params.setGatewayConfigSnapshot(command.snapshot); + continue; + } + if (command.kind === "hydrate-agents") { + params.hydrateAgents(command.seeds, command.initialSelectedAgentId); + continue; + } + if (command.kind === "mark-session-created") { + params.dispatchUpdateAgent(command.agentId, { + sessionCreated: true, + sessionSettingsSynced: command.sessionSettingsSynced, + }); + continue; + } + if (command.kind === "apply-summary-patch") { + params.dispatchUpdateAgent(command.agentId, command.patch); + continue; + } + params.setError(command.message); + } +} + +export type StudioFocusedPreferenceLoadCommand = + | { kind: "set-focused-preferences-loaded"; value: boolean } + | { kind: "set-preferred-selected-agent-id"; agentId: string | null } + | { kind: "set-focus-filter"; filter: FocusFilter } + | { kind: "log-error"; message: string; error: unknown }; + +export async function runStudioFocusedPreferenceLoadOperation(params: { + gatewayUrl: string; + loadStudioSettings: () => Promise<StudioSettings | null>; + isFocusFilterTouched: () => boolean; +}): Promise<StudioFocusedPreferenceLoadCommand[]> { + const key = params.gatewayUrl.trim(); + if (!key) { + return [ + { kind: "set-preferred-selected-agent-id", agentId: null }, + { kind: "set-focused-preferences-loaded", value: true }, + ]; + } + + try { + const settings = await params.loadStudioSettings(); + if (!settings || params.isFocusFilterTouched()) { + return [{ kind: "set-focused-preferences-loaded", value: true }]; + } + + const restoreIntent = planFocusedPreferenceRestore({ + settings, + gatewayKey: key, + focusFilterTouched: false, + }); + + return [ + { + kind: "set-preferred-selected-agent-id", + agentId: restoreIntent.preferredSelectedAgentId, + }, + { + kind: "set-focus-filter", + filter: restoreIntent.focusFilter, + }, + { kind: "set-focused-preferences-loaded", value: true }, + ]; + } catch (error) { + return [ + { + kind: "log-error", + message: "Failed to load focused preference.", + error, + }, + { kind: "set-focused-preferences-loaded", value: true }, + ]; + } +} + +export function executeStudioFocusedPreferenceLoadCommands(params: { + commands: StudioFocusedPreferenceLoadCommand[]; + setFocusedPreferencesLoaded: (value: boolean) => void; + setPreferredSelectedAgentId: (agentId: string | null) => void; + setFocusFilter: (filter: FocusFilter) => void; + logError: (message: string, error: unknown) => void; +}): void { + for (const command of params.commands) { + if (command.kind === "set-focused-preferences-loaded") { + params.setFocusedPreferencesLoaded(command.value); + continue; + } + if (command.kind === "set-preferred-selected-agent-id") { + params.setPreferredSelectedAgentId(command.agentId); + continue; + } + if (command.kind === "set-focus-filter") { + params.setFocusFilter(command.filter); + continue; + } + params.logError(command.message, command.error); + } +} + +export type StudioFocusedPatchCommand = { + kind: "schedule-settings-patch"; + patch: StudioSettingsPatch; + debounceMs: number; +}; + +export function runStudioFocusFilterPersistenceOperation(params: { + gatewayUrl: string; + focusFilterTouched: boolean; + focusFilter: FocusFilter; +}): StudioFocusedPatchCommand[] { + const patchIntent = planFocusedFilterPatch({ + gatewayKey: params.gatewayUrl, + focusFilterTouched: params.focusFilterTouched, + focusFilter: params.focusFilter, + }); + if (patchIntent.kind !== "patch") { + return []; + } + return [ + { + kind: "schedule-settings-patch", + patch: patchIntent.patch, + debounceMs: patchIntent.debounceMs, + }, + ]; +} + +export function runStudioFocusedSelectionPersistenceOperation(params: { + gatewayUrl: string; + status: "connected" | "connecting" | "disconnected"; + focusedPreferencesLoaded: boolean; + agentsLoadedOnce: boolean; + selectedAgentId: string | null; +}): StudioFocusedPatchCommand[] { + const patchIntent = planFocusedSelectionPatch({ + gatewayKey: params.gatewayUrl, + status: params.status, + focusedPreferencesLoaded: params.focusedPreferencesLoaded, + agentsLoadedOnce: params.agentsLoadedOnce, + selectedAgentId: params.selectedAgentId, + }); + + if (patchIntent.kind !== "patch") { + return []; + } + + return [ + { + kind: "schedule-settings-patch", + patch: patchIntent.patch, + debounceMs: patchIntent.debounceMs, + }, + ]; +} + +export function executeStudioFocusedPatchCommands(params: { + commands: StudioFocusedPatchCommand[]; + schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; +}): void { + for (const command of params.commands) { + params.schedulePatch(command.patch, command.debounceMs); + } +} diff --git a/src/features/agents/operations/studioBootstrapWorkflow.ts b/src/features/agents/operations/studioBootstrapWorkflow.ts new file mode 100644 index 00000000..7d83df9a --- /dev/null +++ b/src/features/agents/operations/studioBootstrapWorkflow.ts @@ -0,0 +1,158 @@ +import type { FocusFilter } from "@/features/agents/state/store"; +import { + resolveFocusedPreference, + type StudioSettings, + type StudioSettingsPatch, +} from "@/lib/studio/settings"; + +const FOCUSED_PATCH_DEBOUNCE_MS = 300; + +export type BootstrapSelectionIntent = { + initialSelectedAgentId: string | undefined; +}; + +export function planBootstrapSelection(params: { + hasCurrentSelection: boolean; + preferredSelectedAgentId: string | null; + availableAgentIds: string[]; + suggestedSelectedAgentId: string | null; +}): BootstrapSelectionIntent { + if (params.hasCurrentSelection) { + return { initialSelectedAgentId: undefined }; + } + + const preferredSelectedAgentId = params.preferredSelectedAgentId?.trim() ?? ""; + if ( + preferredSelectedAgentId.length > 0 && + params.availableAgentIds.some((agentId) => agentId === preferredSelectedAgentId) + ) { + return { initialSelectedAgentId: preferredSelectedAgentId }; + } + + const suggestedSelectedAgentId = params.suggestedSelectedAgentId?.trim() ?? ""; + return { + initialSelectedAgentId: + suggestedSelectedAgentId.length > 0 ? suggestedSelectedAgentId : undefined, + }; +} + +export type FocusFilterPatchIntent = + | { + kind: "skip"; + reason: "missing-gateway-key" | "focus-filter-not-touched"; + } + | { + kind: "patch"; + patch: StudioSettingsPatch; + debounceMs: number; + }; + +export function planFocusedFilterPatch(params: { + gatewayKey: string; + focusFilterTouched: boolean; + focusFilter: FocusFilter; +}): FocusFilterPatchIntent { + const gatewayKey = params.gatewayKey.trim(); + if (!gatewayKey) { + return { kind: "skip", reason: "missing-gateway-key" }; + } + if (!params.focusFilterTouched) { + return { kind: "skip", reason: "focus-filter-not-touched" }; + } + + return { + kind: "patch", + patch: { + focused: { + [gatewayKey]: { + mode: "focused", + filter: params.focusFilter, + }, + }, + }, + debounceMs: FOCUSED_PATCH_DEBOUNCE_MS, + }; +} + +export type FocusedSelectionPatchIntent = + | { + kind: "skip"; + reason: + | "missing-gateway-key" + | "not-connected" + | "focused-preferences-not-loaded" + | "agents-not-loaded"; + } + | { + kind: "patch"; + patch: StudioSettingsPatch; + debounceMs: number; + }; + +export function planFocusedSelectionPatch(params: { + gatewayKey: string; + status: "connected" | "connecting" | "disconnected"; + focusedPreferencesLoaded: boolean; + agentsLoadedOnce: boolean; + selectedAgentId: string | null; +}): FocusedSelectionPatchIntent { + const gatewayKey = params.gatewayKey.trim(); + if (!gatewayKey) { + return { kind: "skip", reason: "missing-gateway-key" }; + } + if (params.status !== "connected") { + return { kind: "skip", reason: "not-connected" }; + } + if (!params.focusedPreferencesLoaded) { + return { kind: "skip", reason: "focused-preferences-not-loaded" }; + } + if (!params.agentsLoadedOnce) { + return { kind: "skip", reason: "agents-not-loaded" }; + } + + return { + kind: "patch", + patch: { + focused: { + [gatewayKey]: { + mode: "focused", + selectedAgentId: params.selectedAgentId, + }, + }, + }, + debounceMs: FOCUSED_PATCH_DEBOUNCE_MS, + }; +} + +export type FocusedPreferenceRestoreIntent = { + preferredSelectedAgentId: string | null; + focusFilter: FocusFilter; +}; + +export function planFocusedPreferenceRestore(params: { + settings: StudioSettings | null; + gatewayKey: string; + focusFilterTouched: boolean; +}): FocusedPreferenceRestoreIntent { + const gatewayKey = params.gatewayKey.trim(); + if (!gatewayKey || params.focusFilterTouched || !params.settings) { + return { + preferredSelectedAgentId: null, + focusFilter: "all", + }; + } + + const preference = resolveFocusedPreference(params.settings, gatewayKey); + if (!preference) { + return { + preferredSelectedAgentId: null, + focusFilter: "all", + }; + } + + const restoredFilter = preference.filter === "running" ? "all" : preference.filter; + return { + preferredSelectedAgentId: preference.selectedAgentId, + focusFilter: restoredFilter, + }; +} diff --git a/src/features/agents/operations/useAgentSettingsMutationController.ts b/src/features/agents/operations/useAgentSettingsMutationController.ts new file mode 100644 index 00000000..1c5cd380 --- /dev/null +++ b/src/features/agents/operations/useAgentSettingsMutationController.ts @@ -0,0 +1,1064 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation"; +import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation"; +import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; +import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; +import { + planAgentSettingsMutation, + type AgentSettingsMutationContext, +} from "@/features/agents/operations/agentSettingsMutationWorkflow"; +import { + buildQueuedMutationBlock, + runAgentConfigMutationLifecycle, + type MutationBlockState, + type MutationWorkflowKind, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; +import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow"; +import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; +import { useGatewayRestartBlock } from "@/features/agents/operations/useGatewayRestartBlock"; +import type { AgentState } from "@/features/agents/state/store"; +import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder"; +import { + filterCronJobsForAgent, + listCronJobs, + removeCronJob, + runCronJobNow, + sortCronJobsByUpdatedAt, + type CronJobSummary, +} from "@/lib/cron/types"; +import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient"; +import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient"; +import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import { + readGatewayAgentSkillsAllowlist, + renameGatewayAgent, + updateGatewayAgentSkillsAllowlist, +} from "@/lib/gateway/agentConfig"; +import { fetchJson } from "@/lib/http"; +import { canRemoveSkillSource, filterOsCompatibleSkills } from "@/lib/skills/presentation"; +import { removeSkillFromGateway } from "@/lib/skills/remove"; +import { + installSkill, + loadAgentSkillStatus, + updateSkill, + type SkillStatusEntry, + type SkillStatusReport, +} from "@/lib/skills/types"; + +export type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind }; +export type SkillSetupMessage = { kind: "success" | "error"; message: string }; +export type SkillSetupMessageMap = Record<string, SkillSetupMessage>; + +type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKey">; + +export type UseAgentSettingsMutationControllerParams = { + client: GatewayClient; + status: GatewayStatus; + isLocalGateway: boolean; + agents: AgentForSettingsMutation[]; + hasCreateBlock: boolean; + enqueueConfigMutation: (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => Promise<void>; + gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; + settingsRouteActive: boolean; + inspectSidebarAgentId: string | null; + inspectSidebarTab: SettingsRouteTab | null; + loadAgents: () => Promise<void>; + refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>; + clearInspectSidebar: () => void; + setInspectSidebarCapabilities: (agentId: string) => void; + dispatchUpdateAgent: (agentId: string, patch: Partial<AgentState>) => void; + setMobilePaneChat: () => void; + setError: (message: string) => void; +}; + +export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) { + const skillsLoadRequestIdRef = useRef(0); + const [settingsSkillsReport, setSettingsSkillsReport] = useState<SkillStatusReport | null>(null); + const [settingsSkillsLoading, setSettingsSkillsLoading] = useState(false); + const [settingsSkillsError, setSettingsSkillsError] = useState<string | null>(null); + const [settingsSkillsBusy, setSettingsSkillsBusy] = useState(false); + const [settingsSkillsBusyKey, setSettingsSkillsBusyKey] = useState<string | null>(null); + const [settingsSkillMessages, setSettingsSkillMessages] = useState<SkillSetupMessageMap>({}); + const [settingsSkillApiKeyDrafts, setSettingsSkillApiKeyDrafts] = useState<Record<string, string>>( + {} + ); + const [settingsCronJobs, setSettingsCronJobs] = useState<CronJobSummary[]>([]); + const [settingsCronLoading, setSettingsCronLoading] = useState(false); + const [settingsCronError, setSettingsCronError] = useState<string | null>(null); + const [cronCreateBusy, setCronCreateBusy] = useState(false); + const [cronRunBusyJobId, setCronRunBusyJobId] = useState<string | null>(null); + const [cronDeleteBusyJobId, setCronDeleteBusyJobId] = useState<string | null>(null); + const [restartingMutationBlock, setRestartingMutationBlock] = + useState<RestartingMutationBlockState | null>(null); + const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000; + const SKILL_INSTALL_TIMEOUT_MS = 120_000; + + const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent"; + const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent"; + const hasRestartBlockInProgress = Boolean( + restartingMutationBlock && restartingMutationBlock.phase !== "queued" + ); + + const mutationContext: AgentSettingsMutationContext = useMemo( + () => ({ + status: params.status, + hasCreateBlock: params.hasCreateBlock, + hasRenameBlock: hasRenameMutationBlock, + hasDeleteBlock: hasDeleteMutationBlock, + cronCreateBusy, + cronRunBusyJobId, + cronDeleteBusyJobId, + }), + [ + cronCreateBusy, + cronDeleteBusyJobId, + cronRunBusyJobId, + hasDeleteMutationBlock, + hasRenameMutationBlock, + params.hasCreateBlock, + params.status, + ] + ); + + const setSkillMessage = useCallback((skillKey: string, message?: SkillSetupMessage) => { + const normalizedSkillKey = skillKey.trim(); + if (!normalizedSkillKey) { + return; + } + setSettingsSkillMessages((current) => { + const next = { ...current }; + if (!message) { + delete next[normalizedSkillKey]; + } else { + next[normalizedSkillKey] = message; + } + return next; + }); + }, []); + + const loadSkillsForSettingsAgent = useCallback( + async (agentId: string) => { + const requestId = skillsLoadRequestIdRef.current + 1; + skillsLoadRequestIdRef.current = requestId; + const resolvedAgentId = agentId.trim(); + if (!resolvedAgentId) { + if (requestId === skillsLoadRequestIdRef.current) { + setSettingsSkillsReport(null); + setSettingsSkillsError("Failed to load skills: missing agent id."); + } + return; + } + setSettingsSkillsLoading(true); + setSettingsSkillsError(null); + try { + const report = await loadAgentSkillStatus(params.client, resolvedAgentId); + if (requestId !== skillsLoadRequestIdRef.current) { + return; + } + setSettingsSkillsReport(report); + } catch (err) { + if (requestId !== skillsLoadRequestIdRef.current) { + return; + } + const message = err instanceof Error ? err.message : "Failed to load skills."; + setSettingsSkillsReport(null); + setSettingsSkillsError(message); + if (!isGatewayDisconnectLikeError(err)) { + console.error(message); + } + } finally { + if (requestId === skillsLoadRequestIdRef.current) { + setSettingsSkillsLoading(false); + } + } + }, + [params.client] + ); + + useEffect(() => { + const skillsTabActive = + params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system"; + if ( + !params.settingsRouteActive || + !params.inspectSidebarAgentId || + params.status !== "connected" || + !skillsTabActive + ) { + skillsLoadRequestIdRef.current += 1; + setSettingsSkillsReport(null); + setSettingsSkillsLoading(false); + setSettingsSkillsError(null); + setSettingsSkillsBusy(false); + setSettingsSkillsBusyKey(null); + setSettingsSkillMessages({}); + setSettingsSkillApiKeyDrafts({}); + return; + } + void loadSkillsForSettingsAgent(params.inspectSidebarAgentId); + }, [ + loadSkillsForSettingsAgent, + params.inspectSidebarAgentId, + params.inspectSidebarTab, + params.settingsRouteActive, + params.status, + ]); + + useEffect(() => { + setSettingsSkillsBusyKey(null); + setSettingsSkillMessages({}); + setSettingsSkillApiKeyDrafts({}); + }, [params.inspectSidebarAgentId]); + + const loadCronJobsForSettingsAgent = useCallback( + async (agentId: string) => { + const resolvedAgentId = agentId.trim(); + if (!resolvedAgentId) { + setSettingsCronJobs([]); + setSettingsCronError("Failed to load schedules: missing agent id."); + return; + } + setSettingsCronLoading(true); + setSettingsCronError(null); + try { + const result = await listCronJobs(params.client, { includeDisabled: true }); + const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId); + setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered)); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load schedules."; + setSettingsCronJobs([]); + setSettingsCronError(message); + if (!isGatewayDisconnectLikeError(err)) { + console.error(message); + } + } finally { + setSettingsCronLoading(false); + } + }, + [params.client] + ); + + useEffect(() => { + if ( + !params.settingsRouteActive || + !params.inspectSidebarAgentId || + params.status !== "connected" || + params.inspectSidebarTab !== "automations" + ) { + setSettingsCronJobs([]); + setSettingsCronLoading(false); + setSettingsCronError(null); + setCronRunBusyJobId(null); + setCronDeleteBusyJobId(null); + return; + } + void loadCronJobsForSettingsAgent(params.inspectSidebarAgentId); + }, [ + loadCronJobsForSettingsAgent, + params.inspectSidebarAgentId, + params.inspectSidebarTab, + params.settingsRouteActive, + params.status, + ]); + + const runRestartingMutationLifecycle = useCallback( + async (input: { + kind: MutationWorkflowKind; + agentId: string; + agentName: string; + label: string; + executeMutation: () => Promise<void>; + }) => { + return await runAgentConfigMutationLifecycle({ + kind: input.kind, + label: input.label, + isLocalGateway: params.isLocalGateway, + deps: { + enqueueConfigMutation: params.enqueueConfigMutation, + setQueuedBlock: () => { + const queuedBlock = buildQueuedMutationBlock({ + kind: input.kind, + agentId: input.agentId, + agentName: input.agentName, + startedAt: Date.now(), + }); + setRestartingMutationBlock({ + kind: input.kind, + agentId: queuedBlock.agentId, + agentName: queuedBlock.agentName, + phase: queuedBlock.phase, + startedAt: queuedBlock.startedAt, + sawDisconnect: queuedBlock.sawDisconnect, + }); + }, + setMutatingBlock: () => { + setRestartingMutationBlock((current) => { + if (!current) return current; + if (current.kind !== input.kind || current.agentId !== input.agentId) return current; + return { + ...current, + phase: "mutating", + }; + }); + }, + patchBlockAwaitingRestart: (patch) => { + setRestartingMutationBlock((current) => { + if (!current) return current; + if (current.kind !== input.kind || current.agentId !== input.agentId) return current; + return { + ...current, + ...patch, + }; + }); + }, + clearBlock: () => { + setRestartingMutationBlock((current) => { + if (!current) return current; + if (current.kind !== input.kind || current.agentId !== input.agentId) return current; + return null; + }); + }, + executeMutation: async () => { + const timeoutLabel = + input.kind === "delete-agent" + ? "Delete agent request timed out." + : "Rename agent request timed out."; + await Promise.race([ + input.executeMutation(), + new Promise<never>((_, reject) => { + window.setTimeout( + () => + reject( + new Error( + `${timeoutLabel} The gateway did not respond in time.` + ) + ), + REMOTE_MUTATION_EXEC_TIMEOUT_MS + ); + }), + ]); + }, + shouldAwaitRemoteRestart: async () => false, + reloadAgents: params.loadAgents, + setMobilePaneChat: params.setMobilePaneChat, + onError: params.setError, + }, + }); + }, + [ + params.enqueueConfigMutation, + params.isLocalGateway, + params.loadAgents, + params.setError, + params.setMobilePaneChat, + ] + ); + + useGatewayRestartBlock({ + status: params.status, + block: restartingMutationBlock, + setBlock: setRestartingMutationBlock, + maxWaitMs: 90_000, + onTimeout: () => { + const timeoutMessage = + restartingMutationBlock?.kind === "delete-agent" + ? "Gateway restart timed out after deleting the agent." + : "Gateway restart timed out after renaming the agent."; + setRestartingMutationBlock(null); + params.setError(timeoutMessage); + }, + onRestartComplete: async (_, ctx) => { + await params.loadAgents(); + if (ctx.isCancelled()) return; + setRestartingMutationBlock(null); + params.setMobilePaneChat(); + }, + }); + + useEffect(() => { + if (!restartingMutationBlock) return; + if (restartingMutationBlock.kind !== "delete-agent") return; + if (restartingMutationBlock.phase !== "awaiting-restart") return; + if (params.status !== "connected") return; + + const deletedAgentStillPresent = params.agents.some( + (entry) => entry.agentId === restartingMutationBlock.agentId + ); + if (!deletedAgentStillPresent) { + setRestartingMutationBlock(null); + params.setMobilePaneChat(); + return; + } + + let cancelled = false; + const refreshAgents = async () => { + try { + await params.loadAgents(); + } catch (error) { + if (!isGatewayDisconnectLikeError(error)) { + console.error("Failed to refresh agents while awaiting delete restart.", error); + } + } + }; + + const intervalId = window.setInterval(() => { + if (cancelled) return; + void refreshAgents(); + }, 2500); + void refreshAgents(); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [ + params.agents, + params.loadAgents, + params.setMobilePaneChat, + params.status, + restartingMutationBlock, + ]); + + const handleDeleteAgent = useCallback( + async (agentId: string) => { + const decision = planAgentSettingsMutation( + { kind: "delete-agent", agentId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + params.setError(decision.message); + } + return; + } + + const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId); + if (!agent) return; + const confirmed = window.confirm( + `Delete ${agent.name}? This removes the agent from gateway config + scheduled automations and moves its workspace/state into ~/.openclaw/trash on the gateway host.` + ); + if (!confirmed) return; + + await runRestartingMutationLifecycle({ + kind: "delete-agent", + agentId: decision.normalizedAgentId, + agentName: agent.name, + label: `Delete ${agent.name}`, + executeMutation: async () => { + await deleteAgentViaStudio({ + client: params.client, + agentId: decision.normalizedAgentId, + fetchJson, + logError: (message, error) => console.error(message, error), + }); + params.clearInspectSidebar(); + }, + }); + }, + [mutationContext, params, runRestartingMutationLifecycle] + ); + + const handleCreateCronJob = useCallback( + async (agentId: string, draft: CronCreateDraft) => { + const decision = planAgentSettingsMutation( + { kind: "create-cron-job", agentId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + setSettingsCronError(decision.message); + } + return; + } + + try { + await performCronCreateFlow({ + client: params.client, + agentId: decision.normalizedAgentId, + draft, + busy: { + createBusy: cronCreateBusy, + runBusyJobId: cronRunBusyJobId, + deleteBusyJobId: cronDeleteBusyJobId, + }, + onBusyChange: setCronCreateBusy, + onError: setSettingsCronError, + onJobs: setSettingsCronJobs, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create automation."; + if (!isGatewayDisconnectLikeError(err)) { + console.error(message); + } + throw err; + } + }, + [cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client] + ); + + const handleRunCronJob = useCallback( + async (agentId: string, jobId: string) => { + const decision = planAgentSettingsMutation( + { kind: "run-cron-job", agentId, jobId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + setSettingsCronError(decision.message); + } + return; + } + + const resolvedJobId = decision.normalizedJobId as string; + const resolvedAgentId = decision.normalizedAgentId; + setCronRunBusyJobId(resolvedJobId); + setSettingsCronError(null); + try { + await runCronJobNow(params.client, resolvedJobId); + await loadCronJobsForSettingsAgent(resolvedAgentId); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to run schedule."; + setSettingsCronError(message); + console.error(message); + } finally { + setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current)); + } + }, + [loadCronJobsForSettingsAgent, mutationContext, params.client] + ); + + const handleDeleteCronJob = useCallback( + async (agentId: string, jobId: string) => { + const decision = planAgentSettingsMutation( + { kind: "delete-cron-job", agentId, jobId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + setSettingsCronError(decision.message); + } + return; + } + + const resolvedJobId = decision.normalizedJobId as string; + const resolvedAgentId = decision.normalizedAgentId; + setCronDeleteBusyJobId(resolvedJobId); + setSettingsCronError(null); + try { + const result = await removeCronJob(params.client, resolvedJobId); + if (result.ok && result.removed) { + setSettingsCronJobs((jobs) => jobs.filter((job) => job.id !== resolvedJobId)); + } + await loadCronJobsForSettingsAgent(resolvedAgentId); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete schedule."; + setSettingsCronError(message); + console.error(message); + } finally { + setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current)); + } + }, + [loadCronJobsForSettingsAgent, mutationContext, params.client] + ); + + const handleRenameAgent = useCallback( + async (agentId: string, name: string) => { + const decision = planAgentSettingsMutation( + { kind: "rename-agent", agentId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + params.setError(decision.message); + } + return false; + } + const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId); + if (!agent) return false; + + return await runRestartingMutationLifecycle({ + kind: "rename-agent", + agentId: decision.normalizedAgentId, + agentName: name, + label: `Rename ${agent.name}`, + executeMutation: async () => { + await renameGatewayAgent({ + client: params.client, + agentId: decision.normalizedAgentId, + name, + }); + params.dispatchUpdateAgent(decision.normalizedAgentId, { name }); + }, + }); + }, + [mutationContext, params, runRestartingMutationLifecycle] + ); + + const handleUpdateAgentPermissions = useCallback( + async (agentId: string, draft: AgentPermissionsDraft) => { + const decision = planAgentSettingsMutation( + { kind: "update-agent-permissions", agentId }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + params.setError(decision.message); + } + return; + } + + const agent = params.agents.find((entry) => entry.agentId === decision.normalizedAgentId); + if (!agent) return; + + await params.enqueueConfigMutation({ + kind: "update-agent-permissions", + label: `Update permissions for ${agent.name}`, + run: async () => { + await updateAgentPermissionsViaStudio({ + client: params.client, + agentId: decision.normalizedAgentId, + sessionKey: agent.sessionKey, + draft, + loadAgents: async () => {}, + }); + await params.loadAgents(); + await params.refreshGatewayConfigSnapshot(); + params.setInspectSidebarCapabilities(decision.normalizedAgentId); + params.setMobilePaneChat(); + }, + }); + }, + [mutationContext, params] + ); + + const reloadSkillsIfVisible = useCallback( + async (agentId: string) => { + const skillsTabActive = + params.inspectSidebarTab === "skills" || params.inspectSidebarTab === "system"; + if ( + params.settingsRouteActive && + skillsTabActive && + params.inspectSidebarAgentId === agentId && + params.status === "connected" + ) { + await loadSkillsForSettingsAgent(agentId); + } + }, + [ + loadSkillsForSettingsAgent, + params.inspectSidebarAgentId, + params.inspectSidebarTab, + params.settingsRouteActive, + params.status, + ] + ); + + const runSkillsMutation = useCallback( + async (input: { + agentId: string; + decisionKind: + | "use-all-skills" + | "disable-all-skills" + | "set-skills-allowlist" + | "set-skill-enabled"; + skillName?: string; + run: (normalizedAgentId: string) => Promise<void>; + }) => { + const decision = planAgentSettingsMutation( + { + kind: input.decisionKind, + agentId: input.agentId, + ...(input.skillName ? { skillName: input.skillName } : {}), + }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + setSettingsSkillsError(decision.message); + } + return; + } + + const agent = + params.agents.find((entry) => entry.agentId === decision.normalizedAgentId) ?? null; + setSettingsSkillsBusy(true); + setSettingsSkillsError(null); + try { + await params.enqueueConfigMutation({ + kind: "update-agent-skills", + label: `Update skills for ${agent?.name ?? decision.normalizedAgentId}`, + run: async () => { + await input.run(decision.normalizedAgentId); + await params.loadAgents(); + await params.refreshGatewayConfigSnapshot(); + await reloadSkillsIfVisible(decision.normalizedAgentId); + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update skills."; + setSettingsSkillsError(message); + if (!isGatewayDisconnectLikeError(err)) { + console.error(message); + } + } finally { + setSettingsSkillsBusy(false); + } + }, + [mutationContext, params, reloadSkillsIfVisible] + ); + + const handleUseAllSkills = useCallback( + async (agentId: string) => { + await runSkillsMutation({ + agentId, + decisionKind: "use-all-skills", + run: async (normalizedAgentId) => { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "all", + }); + }, + }); + }, + [params.client, runSkillsMutation] + ); + + const handleDisableAllSkills = useCallback( + async (agentId: string) => { + await runSkillsMutation({ + agentId, + decisionKind: "disable-all-skills", + run: async (normalizedAgentId) => { + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "none", + }); + }, + }); + }, + [params.client, runSkillsMutation] + ); + + const handleSetSkillEnabled = useCallback( + async (agentId: string, skillName: string, enabled: boolean) => { + await runSkillsMutation({ + agentId, + decisionKind: "set-skill-enabled", + skillName, + run: async (normalizedAgentId) => { + const resolvedSkillName = skillName.trim(); + const visibleSkillNames = Array.from( + new Set( + filterOsCompatibleSkills(settingsSkillsReport?.skills ?? []) + .map((entry) => entry.name.trim()) + .filter((name) => name.length > 0) + ) + ); + if (visibleSkillNames.length === 0) { + throw new Error("Cannot update skill access: no skills available for this agent."); + } + const existingAllowlist = await readGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + }); + const baseline = existingAllowlist ?? visibleSkillNames; + const next = new Set( + baseline.map((value) => value.trim()).filter((value) => value.length > 0) + ); + if (enabled) { + next.add(resolvedSkillName); + } else { + next.delete(resolvedSkillName); + } + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: [...next], + }); + }, + }); + }, + [params.client, runSkillsMutation, settingsSkillsReport] + ); + + const handleSetSkillsAllowlist = useCallback( + async (agentId: string, skillNames: string[]) => { + await runSkillsMutation({ + agentId, + decisionKind: "set-skills-allowlist", + run: async (normalizedAgentId) => { + const normalizedSkillNames = Array.from( + new Set( + skillNames + .map((value) => value.trim()) + .filter((value) => value.length > 0) + ) + ); + if (normalizedSkillNames.length === 0) { + throw new Error("Cannot set selected skills mode: choose at least one skill."); + } + await updateGatewayAgentSkillsAllowlist({ + client: params.client, + agentId: normalizedAgentId, + mode: "allowlist", + skillNames: normalizedSkillNames, + }); + }, + }); + }, + [params.client, runSkillsMutation] + ); + + const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => { + const normalizedSkillKey = skillKey.trim(); + if (!normalizedSkillKey) { + return; + } + setSettingsSkillApiKeyDrafts((current) => ({ + ...current, + [normalizedSkillKey]: value, + })); + }, []); + + const runSkillSetupMutation = useCallback( + async (input: { + agentId: string; + decisionKind: + | "install-skill" + | "remove-skill" + | "save-skill-api-key" + | "set-skill-global-enabled"; + skillKey: string; + label: string; + run: () => Promise<{ successMessage: string }>; + refreshConfigSnapshot?: boolean; + }) => { + const normalizedSkillKey = input.skillKey.trim(); + const decision = planAgentSettingsMutation( + { + kind: input.decisionKind, + agentId: input.agentId, + skillKey: normalizedSkillKey, + }, + mutationContext + ); + if (decision.kind === "deny") { + if (decision.message) { + setSettingsSkillsError(decision.message); + } + return; + } + + setSettingsSkillsError(null); + setSettingsSkillsBusyKey(normalizedSkillKey); + setSkillMessage(normalizedSkillKey); + try { + await params.enqueueConfigMutation({ + kind: "update-skill-setup", + label: input.label, + run: async () => { + const result = await input.run(); + if (input.refreshConfigSnapshot) { + await params.refreshGatewayConfigSnapshot(); + } + await reloadSkillsIfVisible(decision.normalizedAgentId); + setSkillMessage(normalizedSkillKey, { + kind: "success", + message: result.successMessage, + }); + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update skill setup."; + setSettingsSkillsError(message); + setSkillMessage(normalizedSkillKey, { + kind: "error", + message, + }); + if (!isGatewayDisconnectLikeError(err)) { + console.error(message); + } + } finally { + setSettingsSkillsBusyKey((current) => (current === normalizedSkillKey ? null : current)); + } + }, + [mutationContext, params, reloadSkillsIfVisible, setSkillMessage] + ); + + const handleInstallSkill = useCallback( + async (agentId: string, skillKey: string, name: string, installId: string) => { + await runSkillSetupMutation({ + agentId, + decisionKind: "install-skill", + skillKey, + label: `Install dependencies for ${name.trim() || skillKey.trim()}`, + run: async () => { + const result = await installSkill(params.client, { + name, + installId, + timeoutMs: SKILL_INSTALL_TIMEOUT_MS, + }); + return { + successMessage: result.message || "Installed", + }; + }, + }); + }, + [SKILL_INSTALL_TIMEOUT_MS, params.client, runSkillSetupMutation] + ); + + const handleRemoveSkill = useCallback( + async ( + agentId: string, + skill: Pick<SkillStatusEntry, "skillKey" | "source" | "baseDir"> + ) => { + const report = settingsSkillsReport; + const normalizedSkillKey = skill.skillKey.trim(); + if (!normalizedSkillKey) { + const message = "Skill key is required to remove the skill."; + setSettingsSkillsError(message); + return; + } + if (!report) { + const message = "Cannot remove skill: skills are not loaded."; + setSettingsSkillsError(message); + setSkillMessage(normalizedSkillKey, { + kind: "error", + message, + }); + return; + } + const normalizedSource = skill.source.trim(); + if (!canRemoveSkillSource(normalizedSource)) { + const message = `Skill source cannot be removed from Studio: ${normalizedSource || "unknown"}.`; + setSettingsSkillsError(message); + setSkillMessage(normalizedSkillKey, { + kind: "error", + message, + }); + return; + } + + await runSkillSetupMutation({ + agentId, + decisionKind: "remove-skill", + skillKey: normalizedSkillKey, + label: `Remove ${normalizedSkillKey}`, + run: async () => { + const result = await removeSkillFromGateway({ + skillKey: normalizedSkillKey, + source: normalizedSource, + baseDir: skill.baseDir, + workspaceDir: report.workspaceDir, + managedSkillsDir: report.managedSkillsDir, + }); + return { + successMessage: result.removed + ? "Skill removed from gateway files" + : "Skill files were already removed", + }; + }, + }); + }, + [runSkillSetupMutation, setSkillMessage, settingsSkillsReport] + ); + + const handleSaveSkillApiKey = useCallback( + async (agentId: string, skillKey: string) => { + const normalizedSkillKey = skillKey.trim(); + const apiKey = (settingsSkillApiKeyDrafts[normalizedSkillKey] ?? "").trim(); + if (!apiKey) { + const message = "API key cannot be empty."; + setSettingsSkillsError(message); + setSkillMessage(normalizedSkillKey, { + kind: "error", + message, + }); + return; + } + await runSkillSetupMutation({ + agentId, + decisionKind: "save-skill-api-key", + skillKey: normalizedSkillKey, + label: `Save API key for ${normalizedSkillKey}`, + refreshConfigSnapshot: true, + run: async () => { + await updateSkill(params.client, { + skillKey: normalizedSkillKey, + apiKey, + }); + return { + successMessage: "API key saved", + }; + }, + }); + }, + [params.client, runSkillSetupMutation, setSkillMessage, settingsSkillApiKeyDrafts] + ); + + const handleSetSkillGlobalEnabled = useCallback( + async (agentId: string, skillKey: string, enabled: boolean) => { + const normalizedSkillKey = skillKey.trim(); + await runSkillSetupMutation({ + agentId, + decisionKind: "set-skill-global-enabled", + skillKey: normalizedSkillKey, + label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`, + refreshConfigSnapshot: true, + run: async () => { + await updateSkill(params.client, { + skillKey: normalizedSkillKey, + enabled, + }); + return { + successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally", + }; + }, + }); + }, + [params.client, runSkillSetupMutation] + ); + + return { + settingsSkillsReport, + settingsSkillsLoading, + settingsSkillsError, + settingsSkillsBusy, + settingsSkillsBusyKey, + settingsSkillMessages, + settingsSkillApiKeyDrafts, + settingsCronJobs, + settingsCronLoading, + settingsCronError, + cronCreateBusy, + cronRunBusyJobId, + cronDeleteBusyJobId, + restartingMutationBlock, + hasRenameMutationBlock, + hasDeleteMutationBlock, + hasRestartBlockInProgress, + handleDeleteAgent, + handleCreateCronJob, + handleRunCronJob, + handleDeleteCronJob, + handleRenameAgent, + handleUpdateAgentPermissions, + handleUseAllSkills, + handleDisableAllSkills, + handleSetSkillsAllowlist, + handleSetSkillEnabled, + handleInstallSkill, + handleRemoveSkill, + handleSkillApiKeyDraftChange, + handleSaveSkillApiKey, + handleSetSkillGlobalEnabled, + }; +} diff --git a/src/features/agents/operations/useChatInteractionController.ts b/src/features/agents/operations/useChatInteractionController.ts new file mode 100644 index 00000000..c692e0e1 --- /dev/null +++ b/src/features/agents/operations/useChatInteractionController.ts @@ -0,0 +1,388 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { createRafBatcher } from "@/lib/dom"; +import { + planDraftFlushIntent, + planDraftTimerIntent, + planNewSessionIntent, + planStopRunIntent, +} from "@/features/agents/operations/chatInteractionWorkflow"; +import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; +import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue"; +import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store"; +import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; + +type ChatInteractionDispatchAction = + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string } + | { type: "enqueueQueuedMessage"; agentId: string; message: string } + | { type: "removeQueuedMessage"; agentId: string; index: number } + | { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }; + +type GatewayClientLike = { + call: (method: string, params: unknown) => Promise<unknown>; +}; + +export type UseChatInteractionControllerParams = { + client: GatewayClientLike; + status: GatewayStatus; + agents: AgentState[]; + dispatch: (action: ChatInteractionDispatchAction) => void; + setError: (message: string) => void; + getAgents: () => AgentState[]; + clearRunTracking: (runId?: string | null) => void; + clearHistoryInFlight: (sessionKey: string) => void; + clearSpecialUpdateMarker: (agentId: string) => void; + clearSpecialLatestUpdateInFlight: (agentId: string) => void; + setInspectSidebarNull: () => void; + setMobilePaneChat: () => void; + draftDebounceMs?: number; +}; + +export type ChatInteractionController = { + stopBusyAgentId: string | null; + flushPendingDraft: (agentId: string | null) => void; + handleDraftChange: (agentId: string, value: string) => void; + handleSend: (agentId: string, sessionKey: string, message: string) => Promise<void>; + removeQueuedMessage: (agentId: string, index: number) => void; + handleNewSession: (agentId: string) => Promise<void>; + handleStopRun: (agentId: string, sessionKey: string) => Promise<void>; + queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void; + clearPendingLivePatch: (agentId: string) => void; +}; + +export function useChatInteractionController( + params: UseChatInteractionControllerParams +): ChatInteractionController { + const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null); + const stopBusyAgentIdRef = useRef<string | null>(stopBusyAgentId); + const pendingDraftValuesRef = useRef<Map<string, string>>(new Map()); + const pendingDraftTimersRef = useRef<Map<string, number>>(new Map()); + const pendingLivePatchesRef = useRef<Map<string, Partial<AgentState>>>(new Map()); + const activeQueueSendAgentIdsRef = useRef<Set<string>>(new Set()); + const flushLivePatchesRef = useRef<() => void>(() => {}); + const livePatchBatcherRef = useRef(createRafBatcher(() => flushLivePatchesRef.current())); + + useEffect(() => { + stopBusyAgentIdRef.current = stopBusyAgentId; + }, [stopBusyAgentId]); + + const flushPendingDraft = useCallback( + (agentId: string | null) => { + const hasPendingValue = Boolean(agentId && pendingDraftValuesRef.current.has(agentId)); + const flushIntent = planDraftFlushIntent({ + agentId, + hasPendingValue, + }); + if (flushIntent.kind !== "flush") return; + + const timer = pendingDraftTimersRef.current.get(flushIntent.agentId) ?? null; + if (timer !== null) { + window.clearTimeout(timer); + pendingDraftTimersRef.current.delete(flushIntent.agentId); + } + + const value = pendingDraftValuesRef.current.get(flushIntent.agentId); + if (value === undefined) return; + pendingDraftValuesRef.current.delete(flushIntent.agentId); + params.dispatch({ + type: "updateAgent", + agentId: flushIntent.agentId, + patch: { draft: value }, + }); + }, + [params] + ); + + useEffect(() => { + const timers = pendingDraftTimersRef.current; + const values = pendingDraftValuesRef.current; + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer); + } + timers.clear(); + values.clear(); + }; + }, []); + + const flushPendingLivePatches = useCallback(() => { + const pending = pendingLivePatchesRef.current; + if (pending.size === 0) return; + const entries = [...pending.entries()]; + pending.clear(); + for (const [agentId, patch] of entries) { + params.dispatch({ type: "updateAgent", agentId, patch }); + } + }, [params]); + + useEffect(() => { + flushLivePatchesRef.current = flushPendingLivePatches; + }, [flushPendingLivePatches]); + + useEffect(() => { + const batcher = livePatchBatcherRef.current; + const pending = pendingLivePatchesRef.current; + return () => { + batcher.cancel(); + pending.clear(); + }; + }, []); + + const queueLivePatch = useCallback((agentId: string, patch: Partial<AgentState>) => { + const key = agentId.trim(); + if (!key) return; + const existing = pendingLivePatchesRef.current.get(key); + pendingLivePatchesRef.current.set(key, mergePendingLivePatch(existing, patch)); + livePatchBatcherRef.current.schedule(); + }, []); + + const clearPendingLivePatch = useCallback((agentId: string) => { + const key = agentId.trim(); + if (!key) return; + const pending = pendingLivePatchesRef.current; + if (!pending.has(key)) return; + pending.delete(key); + if (pending.size === 0) { + livePatchBatcherRef.current.cancel(); + } + }, []); + + const handleDraftChange = useCallback( + (agentId: string, value: string) => { + pendingDraftValuesRef.current.set(agentId, value); + const existingTimer = pendingDraftTimersRef.current.get(agentId) ?? null; + if (existingTimer !== null) { + window.clearTimeout(existingTimer); + } + + const timerIntent = planDraftTimerIntent({ + agentId, + delayMs: params.draftDebounceMs, + }); + if (timerIntent.kind !== "schedule") { + pendingDraftTimersRef.current.delete(agentId); + return; + } + + const timer = window.setTimeout(() => { + pendingDraftTimersRef.current.delete(agentId); + const pendingValue = pendingDraftValuesRef.current.get(agentId); + const flushIntent = planDraftFlushIntent({ + agentId, + hasPendingValue: pendingValue !== undefined, + }); + if (flushIntent.kind !== "flush" || pendingValue === undefined) return; + pendingDraftValuesRef.current.delete(agentId); + params.dispatch({ + type: "updateAgent", + agentId, + patch: { draft: pendingValue }, + }); + }, timerIntent.delayMs); + pendingDraftTimersRef.current.set(agentId, timer); + }, + [params] + ); + + const handleSend = useCallback( + async (agentId: string, sessionKey: string, message: string) => { + const trimmed = message.trim(); + if (!trimmed) return; + const pendingDraftTimer = pendingDraftTimersRef.current.get(agentId) ?? null; + if (pendingDraftTimer !== null) { + window.clearTimeout(pendingDraftTimer); + pendingDraftTimersRef.current.delete(agentId); + } + pendingDraftValuesRef.current.delete(agentId); + const agent = + params.agents.find((entry) => entry.agentId === agentId) ?? + params.getAgents().find((entry) => entry.agentId === agentId) ?? + null; + if (!agent) { + params.dispatch({ + type: "appendOutput", + agentId, + line: "Error: Agent not found.", + }); + return; + } + if (agent.status === "running") { + params.dispatch({ + type: "enqueueQueuedMessage", + agentId, + message: trimmed, + }); + return; + } + clearPendingLivePatch(agent.agentId); + await sendChatMessageViaStudio({ + client: params.client, + dispatch: params.dispatch, + getAgent: (currentAgentId) => + params.getAgents().find((entry) => entry.agentId === currentAgentId) ?? null, + agentId, + sessionKey, + message: trimmed, + clearRunTracking: (runId) => params.clearRunTracking(runId), + }); + }, + [clearPendingLivePatch, params] + ); + + const removeQueuedMessage = useCallback( + (agentId: string, index: number) => { + if (!Number.isInteger(index) || index < 0) return; + params.dispatch({ + type: "removeQueuedMessage", + agentId, + index, + }); + }, + [params] + ); + + const sendNextQueuedMessage = useCallback( + async (agent: Pick<AgentState, "agentId" | "sessionKey"> & { nextMessage: string }) => { + if (params.status !== "connected") return; + const nextMessage = agent.nextMessage.trim(); + if (!nextMessage) return; + params.dispatch({ + type: "shiftQueuedMessage", + agentId: agent.agentId, + expectedMessage: nextMessage, + }); + clearPendingLivePatch(agent.agentId); + await sendChatMessageViaStudio({ + client: params.client, + dispatch: params.dispatch, + getAgent: (currentAgentId) => + params.getAgents().find((entry) => entry.agentId === currentAgentId) ?? null, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: nextMessage, + clearRunTracking: (runId) => params.clearRunTracking(runId), + }); + }, + [clearPendingLivePatch, params] + ); + + useEffect(() => { + if (params.status !== "connected") return; + for (const agent of params.agents) { + if (agent.status !== "idle") continue; + const nextMessage = agent.queuedMessages?.[0]; + if (!nextMessage) continue; + if (activeQueueSendAgentIdsRef.current.has(agent.agentId)) continue; + activeQueueSendAgentIdsRef.current.add(agent.agentId); + void (async () => { + try { + await sendNextQueuedMessage({ + agentId: agent.agentId, + sessionKey: agent.sessionKey, + nextMessage, + }); + } finally { + activeQueueSendAgentIdsRef.current.delete(agent.agentId); + } + })(); + } + }, [params.agents, params.status, sendNextQueuedMessage]); + + const handleStopRun = useCallback( + async (agentId: string, sessionKey: string) => { + const stopIntent = planStopRunIntent({ + status: params.status, + agentId, + sessionKey, + busyAgentId: stopBusyAgentIdRef.current, + }); + if (stopIntent.kind === "deny") { + params.setError(stopIntent.message); + return; + } + if (stopIntent.kind === "skip-busy") { + return; + } + + setStopBusyAgentId(agentId); + stopBusyAgentIdRef.current = agentId; + try { + await params.client.call("chat.abort", { + sessionKey: stopIntent.sessionKey, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to stop run."; + params.setError(message); + console.error(message); + params.dispatch({ + type: "appendOutput", + agentId, + line: `Stop failed: ${message}`, + }); + } finally { + setStopBusyAgentId((current) => { + const next = current === agentId ? null : current; + stopBusyAgentIdRef.current = next; + return next; + }); + } + }, + [params] + ); + + const handleNewSession = useCallback( + async (agentId: string) => { + const agent = params.getAgents().find((entry) => entry.agentId === agentId); + const newSessionIntent = planNewSessionIntent({ + hasAgent: Boolean(agent), + sessionKey: agent?.sessionKey ?? "", + }); + if (newSessionIntent.kind === "deny" && newSessionIntent.reason === "missing-agent") { + params.setError(newSessionIntent.message); + return; + } + if (!agent) return; + + try { + if (newSessionIntent.kind === "deny") { + throw new Error(newSessionIntent.message); + } + await params.client.call("sessions.reset", { key: newSessionIntent.sessionKey }); + const patch = buildNewSessionAgentPatch(agent); + params.clearRunTracking(agent.runId); + params.clearHistoryInFlight(newSessionIntent.sessionKey); + params.clearSpecialUpdateMarker(agentId); + params.clearSpecialLatestUpdateInFlight(agentId); + params.dispatch({ + type: "updateAgent", + agentId, + patch, + }); + params.setInspectSidebarNull(); + params.setMobilePaneChat(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to start new session."; + params.setError(message); + params.dispatch({ + type: "appendOutput", + agentId, + line: `New session failed: ${message}`, + }); + } + }, + [params] + ); + + return { + stopBusyAgentId, + flushPendingDraft, + handleDraftChange, + handleSend, + removeQueuedMessage, + handleNewSession, + handleStopRun, + queueLivePatch, + clearPendingLivePatch, + }; +} diff --git a/src/features/agents/operations/useConfigMutationQueue.ts b/src/features/agents/operations/useConfigMutationQueue.ts new file mode 100644 index 00000000..fe77986b --- /dev/null +++ b/src/features/agents/operations/useConfigMutationQueue.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useState } from "react"; + +import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy"; +import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy"; +import { randomUUID } from "@/lib/uuid"; + +export type ConfigMutationKind = + | "create-agent" + | "rename-agent" + | "delete-agent" + | "update-agent-execution-role" + | "update-agent-permissions" + | "update-agent-skills" + | "update-skill-setup" + | "repair-sandbox-tool-allowlist"; + +type QueuedConfigMutation = { + id: string; + kind: ConfigMutationKind; + label: string; + requiresIdleAgents: boolean; + run: () => Promise<void>; + resolve: () => void; + reject: (error: unknown) => void; +}; + +export type ActiveConfigMutation = { + kind: ConfigMutationKind; + label: string; +}; + +const mutationRequiresIdleAgents = (kind: ConfigMutationKind): boolean => + kind === "create-agent" || kind === "rename-agent" || kind === "delete-agent"; + +export function useConfigMutationQueue(params: { + status: GatewayStatus; + hasRunningAgents: boolean; + hasRestartBlockInProgress: boolean; +}) { + const [queuedConfigMutations, setQueuedConfigMutations] = useState<QueuedConfigMutation[]>([]); + const [activeConfigMutation, setActiveConfigMutation] = useState<QueuedConfigMutation | null>( + null + ); + + const enqueueConfigMutation = useCallback( + (params: { + kind: ConfigMutationKind; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => + new Promise<void>((resolve, reject) => { + const queued: QueuedConfigMutation = { + id: randomUUID(), + kind: params.kind, + label: params.label, + requiresIdleAgents: params.requiresIdleAgents ?? mutationRequiresIdleAgents(params.kind), + run: params.run, + resolve, + reject, + }; + setQueuedConfigMutations((current) => [...current, queued]); + }), + [] + ); + + useEffect(() => { + if ( + !shouldStartNextConfigMutation({ + status: params.status, + hasRunningAgents: params.hasRunningAgents, + nextMutationRequiresIdleAgents: Boolean(queuedConfigMutations[0]?.requiresIdleAgents), + hasActiveMutation: Boolean(activeConfigMutation), + hasRestartBlockInProgress: params.hasRestartBlockInProgress, + queuedCount: queuedConfigMutations.length, + }) + ) { + return; + } + + const next = queuedConfigMutations[0]; + if (!next) return; + setQueuedConfigMutations((current) => current.slice(1)); + setActiveConfigMutation(next); + }, [ + activeConfigMutation, + params.hasRestartBlockInProgress, + params.hasRunningAgents, + params.status, + queuedConfigMutations, + ]); + + useEffect(() => { + if (!activeConfigMutation) return; + let mounted = true; + const run = async () => { + try { + await activeConfigMutation.run(); + activeConfigMutation.resolve(); + } catch (error) { + activeConfigMutation.reject(error); + } finally { + if (mounted) { + setActiveConfigMutation(null); + } + } + }; + void run(); + return () => { + mounted = false; + }; + }, [activeConfigMutation]); + + return { + enqueueConfigMutation, + queuedCount: queuedConfigMutations.length, + queuedBlockedByRunningAgents: + Boolean(queuedConfigMutations[0]?.requiresIdleAgents) && params.hasRunningAgents, + activeConfigMutation: activeConfigMutation + ? ({ kind: activeConfigMutation.kind, label: activeConfigMutation.label } satisfies ActiveConfigMutation) + : null, + }; +} diff --git a/src/features/agents/operations/useGatewayConfigSyncController.ts b/src/features/agents/operations/useGatewayConfigSyncController.ts new file mode 100644 index 00000000..4c6c5d19 --- /dev/null +++ b/src/features/agents/operations/useGatewayConfigSyncController.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { + resolveGatewayModelsSyncIntent, + resolveSandboxRepairIntent, + shouldRefreshGatewayConfigForSettingsRoute, + type GatewayConnectionStatus, +} from "@/features/agents/operations/gatewayConfigSyncWorkflow"; +import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; +import { + buildGatewayModelChoices, + type GatewayModelChoice, + type GatewayModelPolicySnapshot, +} from "@/lib/gateway/models"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +const defaultLogError = (message: string, err: unknown) => { + console.error(message, err); +}; + +export type UseGatewayConfigSyncControllerParams = { + client: GatewayClient; + status: GatewayConnectionStatus; + settingsRouteActive: boolean; + inspectSidebarAgentId: string | null; + gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; + setGatewayConfigSnapshot: (snapshot: GatewayModelPolicySnapshot | null) => void; + setGatewayModels: (models: GatewayModelChoice[]) => void; + setGatewayModelsError: (message: string | null) => void; + enqueueConfigMutation: (params: { + kind: "repair-sandbox-tool-allowlist"; + label: string; + run: () => Promise<void>; + requiresIdleAgents?: boolean; + }) => Promise<void>; + loadAgents: () => Promise<void>; + isDisconnectLikeError: (err: unknown) => boolean; + logError?: (message: string, err: unknown) => void; +}; + +export type GatewayConfigSyncController = { + refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>; +}; + +export function useGatewayConfigSyncController( + params: UseGatewayConfigSyncControllerParams +): GatewayConfigSyncController { + const sandboxRepairAttemptedRef = useRef(false); + + const logError = params.logError ?? defaultLogError; + + const refreshGatewayConfigSnapshot = useCallback(async () => { + if (params.status !== "connected") return null; + try { + const snapshot = await params.client.call<GatewayModelPolicySnapshot>("config.get", {}); + params.setGatewayConfigSnapshot(snapshot); + return snapshot; + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to refresh gateway config.", err); + } + return null; + } + }, [ + params.client, + params.isDisconnectLikeError, + params.setGatewayConfigSnapshot, + params.status, + logError, + ]); + + useEffect(() => { + const repairIntent = resolveSandboxRepairIntent({ + status: params.status, + attempted: sandboxRepairAttemptedRef.current, + snapshot: params.gatewayConfigSnapshot, + }); + if (repairIntent.kind !== "repair") return; + + sandboxRepairAttemptedRef.current = true; + void params.enqueueConfigMutation({ + kind: "repair-sandbox-tool-allowlist", + label: "Repair sandbox tool access", + run: async () => { + for (const agentId of repairIntent.agentIds) { + await updateGatewayAgentOverrides({ + client: params.client, + agentId, + overrides: { + tools: { + sandbox: { + tools: { + allow: ["*"], + }, + }, + }, + }, + }); + } + await params.loadAgents(); + }, + }); + }, [ + params.client, + params.enqueueConfigMutation, + params.gatewayConfigSnapshot, + params.loadAgents, + params.status, + ]); + + useEffect(() => { + if ( + !shouldRefreshGatewayConfigForSettingsRoute({ + status: params.status, + settingsRouteActive: params.settingsRouteActive, + inspectSidebarAgentId: params.inspectSidebarAgentId, + }) + ) { + return; + } + void refreshGatewayConfigSnapshot(); + }, [ + params.inspectSidebarAgentId, + params.settingsRouteActive, + params.status, + refreshGatewayConfigSnapshot, + ]); + + useEffect(() => { + const syncIntent = resolveGatewayModelsSyncIntent({ status: params.status }); + if (syncIntent.kind === "clear") { + params.setGatewayModels([]); + params.setGatewayModelsError(null); + params.setGatewayConfigSnapshot(null); + return; + } + + let cancelled = false; + const loadModels = async () => { + let configSnapshot: GatewayModelPolicySnapshot | null = null; + try { + configSnapshot = await params.client.call<GatewayModelPolicySnapshot>("config.get", {}); + if (!cancelled) { + params.setGatewayConfigSnapshot(configSnapshot); + } + } catch (err) { + if (!params.isDisconnectLikeError(err)) { + logError("Failed to load gateway config.", err); + } + } + + try { + const result = await params.client.call<{ models: GatewayModelChoice[] }>( + "models.list", + {} + ); + if (cancelled) return; + const catalog = Array.isArray(result.models) ? result.models : []; + params.setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot)); + params.setGatewayModelsError(null); + } catch (err) { + if (cancelled) return; + const message = err instanceof Error ? err.message : "Failed to load models."; + params.setGatewayModelsError(message); + params.setGatewayModels([]); + if (!params.isDisconnectLikeError(err)) { + logError("Failed to load gateway models.", err); + } + } + }; + + void loadModels(); + return () => { + cancelled = true; + }; + }, [ + params.client, + params.isDisconnectLikeError, + params.setGatewayConfigSnapshot, + params.setGatewayModels, + params.setGatewayModelsError, + params.status, + logError, + ]); + + return { + refreshGatewayConfigSnapshot, + }; +} diff --git a/src/features/agents/operations/useGatewayRestartBlock.ts b/src/features/agents/operations/useGatewayRestartBlock.ts new file mode 100644 index 00000000..c1f075f7 --- /dev/null +++ b/src/features/agents/operations/useGatewayRestartBlock.ts @@ -0,0 +1,69 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useRef } from "react"; + +import { observeGatewayRestart, type GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy"; + +type RestartBlockState = { + phase: string; + startedAt: number; + sawDisconnect: boolean; +}; + +export function useGatewayRestartBlock<T extends RestartBlockState>(params: { + status: GatewayStatus; + block: T | null; + setBlock: Dispatch<SetStateAction<T | null>>; + maxWaitMs: number; + onRestartComplete: (block: T, ctx: { isCancelled: () => boolean }) => void | Promise<void>; + onTimeout: () => void; +}) { + const { block, maxWaitMs, onRestartComplete, onTimeout, setBlock, status } = params; + const onRestartCompleteRef = useRef(onRestartComplete); + const onTimeoutRef = useRef(onTimeout); + + useEffect(() => { + onRestartCompleteRef.current = onRestartComplete; + onTimeoutRef.current = onTimeout; + }, [onRestartComplete, onTimeout]); + + useEffect(() => { + if (!block || block.phase !== "awaiting-restart") return; + + const observed = observeGatewayRestart({ sawDisconnect: block.sawDisconnect }, status); + + if (!block.sawDisconnect && observed.next.sawDisconnect) { + setBlock((current) => { + if (!current || current.phase !== "awaiting-restart" || current.sawDisconnect) { + return current; + } + return { ...current, sawDisconnect: true }; + }); + return; + } + + if (!observed.restartComplete) return; + + const currentBlock = block; + let cancelled = false; + const finalize = async () => { + await onRestartCompleteRef.current(currentBlock, { isCancelled: () => cancelled }); + }; + void finalize(); + return () => { + cancelled = true; + }; + }, [block, setBlock, status]); + + useEffect(() => { + if (!block) return; + if (block.phase === "queued") return; + const elapsed = Date.now() - block.startedAt; + const remaining = Math.max(0, maxWaitMs - elapsed); + const timeoutId = window.setTimeout(() => { + onTimeoutRef.current(); + }, remaining); + return () => { + window.clearTimeout(timeoutId); + }; + }, [block, maxWaitMs]); +} diff --git a/src/features/agents/operations/useRuntimeSyncController.ts b/src/features/agents/operations/useRuntimeSyncController.ts new file mode 100644 index 00000000..9aa59a42 --- /dev/null +++ b/src/features/agents/operations/useRuntimeSyncController.ts @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { + executeAgentReconcileCommands, + runAgentReconcileOperation, +} from "@/features/agents/operations/agentReconcileOperation"; +import { resolveSummarySnapshotIntent } from "@/features/agents/operations/fleetLifecycleWorkflow"; +import { + executeHistorySyncCommands, + runHistorySyncOperation, +} from "@/features/agents/operations/historySyncOperation"; +import { + RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT, + RUNTIME_SYNC_MAX_HISTORY_LIMIT, + resolveRuntimeSyncBootstrapHistoryAgentIds, + resolveRuntimeSyncFocusedHistoryPollingIntent, + resolveRuntimeSyncGapRecoveryIntent, + resolveRuntimeSyncLoadMoreHistoryLimit, + resolveRuntimeSyncReconcilePollingIntent, + shouldRuntimeSyncContinueFocusedHistoryPolling, +} from "@/features/agents/operations/runtimeSyncControlWorkflow"; +import { + buildSummarySnapshotPatches, + type SummaryPreviewSnapshot, + type SummaryStatusSnapshot, +} from "@/features/agents/state/runtimeEventBridge"; +import type { AgentState } from "@/features/agents/state/store"; +import { TRANSCRIPT_V2_ENABLED, logTranscriptDebugMetric } from "@/features/agents/state/transcript"; +import { randomUUID } from "@/lib/uuid"; + +type RuntimeSyncDispatchAction = { + type: "updateAgent"; + agentId: string; + patch: Partial<AgentState>; +}; + +type GatewayClientLike = { + call: <T = unknown>(method: string, params: unknown) => Promise<T>; + onGap: (handler: (info: { expected: number; received: number }) => void) => () => void; +}; + +export type UseRuntimeSyncControllerParams = { + client: GatewayClientLike; + status: "disconnected" | "connecting" | "connected"; + agents: AgentState[]; + focusedAgentId: string | null; + focusedAgentRunning: boolean; + dispatch: (action: RuntimeSyncDispatchAction) => void; + clearRunTracking: (runId: string) => void; + isDisconnectLikeError: (error: unknown) => boolean; + defaultHistoryLimit?: number; + maxHistoryLimit?: number; +}; + +export type RuntimeSyncController = { + loadSummarySnapshot: () => Promise<void>; + loadAgentHistory: (agentId: string, options?: { limit?: number }) => Promise<void>; + loadMoreAgentHistory: (agentId: string) => void; + reconcileRunningAgents: () => Promise<void>; + clearHistoryInFlight: (sessionKey: string) => void; +}; + +export function useRuntimeSyncController( + params: UseRuntimeSyncControllerParams +): RuntimeSyncController { + const agentsRef = useRef(params.agents); + const historyInFlightRef = useRef<Set<string>>(new Set()); + const reconcileRunInFlightRef = useRef<Set<string>>(new Set()); + + const defaultHistoryLimit = params.defaultHistoryLimit ?? RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT; + const maxHistoryLimit = params.maxHistoryLimit ?? RUNTIME_SYNC_MAX_HISTORY_LIMIT; + + useEffect(() => { + agentsRef.current = params.agents; + }, [params.agents]); + + const clearHistoryInFlight = useCallback((sessionKey: string) => { + const key = sessionKey.trim(); + if (!key) return; + historyInFlightRef.current.delete(key); + }, []); + + const loadSummarySnapshot = useCallback(async () => { + const snapshotAgents = agentsRef.current; + const summaryIntent = resolveSummarySnapshotIntent({ + agents: snapshotAgents, + maxKeys: 64, + }); + if (summaryIntent.kind === "skip") return; + const activeAgents = snapshotAgents.filter((agent) => agent.sessionCreated); + try { + const [statusSummary, previewResult] = await Promise.all([ + params.client.call<SummaryStatusSnapshot>("status", {}), + params.client.call<SummaryPreviewSnapshot>("sessions.preview", { + keys: summaryIntent.keys, + limit: summaryIntent.limit, + maxChars: summaryIntent.maxChars, + }), + ]); + for (const entry of buildSummarySnapshotPatches({ + agents: activeAgents, + statusSummary, + previewResult, + })) { + params.dispatch({ + type: "updateAgent", + agentId: entry.agentId, + patch: entry.patch, + }); + } + } catch (error) { + if (!params.isDisconnectLikeError(error)) { + console.error("Failed to load summary snapshot.", error); + } + } + }, [params.client, params.dispatch, params.isDisconnectLikeError]); + + const loadAgentHistory = useCallback( + async (agentId: string, options?: { limit?: number }) => { + const commands = await runHistorySyncOperation({ + client: params.client, + agentId, + requestedLimit: options?.limit, + getAgent: (targetAgentId) => + agentsRef.current.find((entry) => entry.agentId === targetAgentId) ?? null, + inFlightSessionKeys: historyInFlightRef.current, + requestId: randomUUID(), + loadedAt: Date.now(), + defaultLimit: defaultHistoryLimit, + maxLimit: maxHistoryLimit, + transcriptV2Enabled: TRANSCRIPT_V2_ENABLED, + }); + executeHistorySyncCommands({ + commands, + dispatch: params.dispatch, + logMetric: (metric, meta) => logTranscriptDebugMetric(metric, meta), + isDisconnectLikeError: params.isDisconnectLikeError, + logError: (message, error) => console.error(message, error), + }); + }, + [ + defaultHistoryLimit, + maxHistoryLimit, + params.client, + params.dispatch, + params.isDisconnectLikeError, + ] + ); + + const loadMoreAgentHistory = useCallback( + (agentId: string) => { + const agent = agentsRef.current.find((entry) => entry.agentId === agentId) ?? null; + const nextLimit = resolveRuntimeSyncLoadMoreHistoryLimit({ + currentLimit: agent?.historyFetchLimit ?? null, + defaultLimit: defaultHistoryLimit, + maxLimit: maxHistoryLimit, + }); + void loadAgentHistory(agentId, { limit: nextLimit }); + }, + [defaultHistoryLimit, loadAgentHistory, maxHistoryLimit] + ); + + const reconcileRunningAgents = useCallback(async () => { + if (params.status !== "connected") return; + const commands = await runAgentReconcileOperation({ + client: params.client, + agents: agentsRef.current, + getLatestAgent: (agentId) => + agentsRef.current.find((entry) => entry.agentId === agentId) ?? null, + claimRunId: (runId) => { + const normalized = runId.trim(); + if (!normalized) return false; + if (reconcileRunInFlightRef.current.has(normalized)) return false; + reconcileRunInFlightRef.current.add(normalized); + return true; + }, + releaseRunId: (runId) => { + const normalized = runId.trim(); + if (!normalized) return; + reconcileRunInFlightRef.current.delete(normalized); + }, + isDisconnectLikeError: params.isDisconnectLikeError, + }); + executeAgentReconcileCommands({ + commands, + dispatch: params.dispatch, + clearRunTracking: params.clearRunTracking, + requestHistoryRefresh: (agentId) => { + void loadAgentHistory(agentId); + }, + logInfo: (message) => console.info(message), + logWarn: (message, error) => console.warn(message, error), + }); + }, [ + loadAgentHistory, + params.clearRunTracking, + params.client, + params.dispatch, + params.isDisconnectLikeError, + params.status, + ]); + + useEffect(() => { + if (params.status !== "connected") return; + void loadSummarySnapshot(); + }, [loadSummarySnapshot, params.status]); + + useEffect(() => { + const reconcileIntent = resolveRuntimeSyncReconcilePollingIntent({ + status: params.status, + }); + if (reconcileIntent.kind === "stop") return; + void reconcileRunningAgents(); + const timer = window.setInterval(() => { + void reconcileRunningAgents(); + }, reconcileIntent.intervalMs); + return () => { + window.clearInterval(timer); + }; + }, [params.status, reconcileRunningAgents]); + + useEffect(() => { + const bootstrapAgentIds = resolveRuntimeSyncBootstrapHistoryAgentIds({ + status: params.status, + agents: params.agents, + }); + for (const agentId of bootstrapAgentIds) { + void loadAgentHistory(agentId); + } + }, [loadAgentHistory, params.agents, params.status]); + + useEffect(() => { + const pollingIntent = resolveRuntimeSyncFocusedHistoryPollingIntent({ + status: params.status, + focusedAgentId: params.focusedAgentId, + focusedAgentRunning: params.focusedAgentRunning, + }); + if (pollingIntent.kind === "stop") return; + void loadAgentHistory(pollingIntent.agentId); + const timer = window.setInterval(() => { + const shouldContinue = shouldRuntimeSyncContinueFocusedHistoryPolling({ + agentId: pollingIntent.agentId, + agents: agentsRef.current, + }); + if (!shouldContinue) return; + void loadAgentHistory(pollingIntent.agentId); + }, pollingIntent.intervalMs); + return () => { + window.clearInterval(timer); + }; + }, [loadAgentHistory, params.focusedAgentId, params.focusedAgentRunning, params.status]); + + useEffect(() => { + return params.client.onGap((info) => { + const recoveryIntent = resolveRuntimeSyncGapRecoveryIntent(); + console.warn(`Gateway event gap expected ${info.expected}, received ${info.received}.`); + if (recoveryIntent.refreshSummarySnapshot) { + void loadSummarySnapshot(); + } + if (recoveryIntent.reconcileRunningAgents) { + void reconcileRunningAgents(); + } + }); + }, [loadSummarySnapshot, params.client, reconcileRunningAgents]); + + return { + loadSummarySnapshot, + loadAgentHistory, + loadMoreAgentHistory, + reconcileRunningAgents, + clearHistoryInFlight, + }; +} diff --git a/src/features/agents/operations/useSettingsRouteController.ts b/src/features/agents/operations/useSettingsRouteController.ts new file mode 100644 index 00000000..331e02ed --- /dev/null +++ b/src/features/agents/operations/useSettingsRouteController.ts @@ -0,0 +1,256 @@ +import { useCallback, useEffect } from "react"; + +import { + planBackToChatCommands, + planFleetSelectCommands, + planNonRouteSelectionSyncCommands, + planOpenSettingsRouteCommands, + planSettingsRouteSyncCommands, + planSettingsTabChangeCommands, + shouldConfirmDiscardPersonalityChanges, + type InspectSidebarState, + type SettingsRouteNavCommand, + type SettingsRouteTab, +} from "@/features/agents/operations/settingsRouteWorkflow"; + +export type UseSettingsRouteControllerParams = { + settingsRouteActive: boolean; + settingsRouteAgentId: string | null; + status: "disconnected" | "connecting" | "connected"; + agentsLoadedOnce: boolean; + selectedAgentId: string | null; + focusedAgentId: string | null; + personalityHasUnsavedChanges: boolean; + activeTab: SettingsRouteTab; + inspectSidebar: InspectSidebarState; + agents: Array<{ agentId: string }>; + flushPendingDraft: (agentId: string | null) => void; + dispatchSelectAgent: (agentId: string | null) => void; + setInspectSidebar: ( + next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState) + ) => void; + setMobilePaneChat: () => void; + setPersonalityHasUnsavedChanges: (next: boolean) => void; + push: (href: string) => void; + replace: (href: string) => void; + confirmDiscard: () => boolean; +}; + +export type SettingsRouteController = { + handleBackToChat: () => void; + handleSettingsRouteTabChange: (nextTab: SettingsRouteTab) => void; + handleOpenAgentSettingsRoute: (agentId: string) => void; + handleFleetSelectAgent: (agentId: string) => void; +}; + +const executeSettingsRouteCommands = ( + commands: SettingsRouteNavCommand[], + params: Pick< + UseSettingsRouteControllerParams, + | "dispatchSelectAgent" + | "setInspectSidebar" + | "setMobilePaneChat" + | "setPersonalityHasUnsavedChanges" + | "flushPendingDraft" + | "push" + | "replace" + > +) => { + for (const command of commands) { + switch (command.kind) { + case "select-agent": + params.dispatchSelectAgent(command.agentId); + break; + case "set-inspect-sidebar": + params.setInspectSidebar(command.value); + break; + case "set-mobile-pane-chat": + params.setMobilePaneChat(); + break; + case "set-personality-dirty": + params.setPersonalityHasUnsavedChanges(command.value); + break; + case "flush-pending-draft": + params.flushPendingDraft(command.agentId); + break; + case "push": + params.push(command.href); + break; + case "replace": + params.replace(command.href); + break; + default: { + const _exhaustive: never = command; + throw new Error(`Unsupported settings route command: ${_exhaustive}`); + } + } + } +}; + +export function useSettingsRouteController( + params: UseSettingsRouteControllerParams +): SettingsRouteController { + const applyCommands = useCallback( + (commands: SettingsRouteNavCommand[]) => { + executeSettingsRouteCommands(commands, { + dispatchSelectAgent: params.dispatchSelectAgent, + setInspectSidebar: params.setInspectSidebar, + setMobilePaneChat: params.setMobilePaneChat, + setPersonalityHasUnsavedChanges: params.setPersonalityHasUnsavedChanges, + flushPendingDraft: params.flushPendingDraft, + push: params.push, + replace: params.replace, + }); + }, + [ + params.dispatchSelectAgent, + params.flushPendingDraft, + params.push, + params.replace, + params.setInspectSidebar, + params.setMobilePaneChat, + params.setPersonalityHasUnsavedChanges, + ] + ); + + const handleBackToChat = useCallback(() => { + const needsDiscardConfirmation = shouldConfirmDiscardPersonalityChanges({ + settingsRouteActive: params.settingsRouteActive, + activeTab: params.activeTab, + personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + }); + const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true; + const commands = planBackToChatCommands({ + settingsRouteActive: params.settingsRouteActive, + activeTab: params.activeTab, + personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + discardConfirmed, + }); + applyCommands(commands); + }, [ + applyCommands, + params.activeTab, + params.confirmDiscard, + params.personalityHasUnsavedChanges, + params.settingsRouteActive, + ]); + + const handleSettingsRouteTabChange = useCallback( + (nextTab: SettingsRouteTab) => { + const currentTab = params.inspectSidebar?.tab ?? "personality"; + const needsDiscardConfirmation = + currentTab === "personality" && + nextTab !== "personality" && + shouldConfirmDiscardPersonalityChanges({ + settingsRouteActive: params.settingsRouteActive, + activeTab: currentTab, + personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + }); + const discardConfirmed = needsDiscardConfirmation ? params.confirmDiscard() : true; + + const commands = planSettingsTabChangeCommands({ + nextTab, + currentInspectSidebar: params.inspectSidebar, + settingsRouteAgentId: params.settingsRouteAgentId, + settingsRouteActive: params.settingsRouteActive, + personalityHasUnsavedChanges: params.personalityHasUnsavedChanges, + discardConfirmed, + }); + applyCommands(commands); + }, + [ + applyCommands, + params.confirmDiscard, + params.inspectSidebar, + params.personalityHasUnsavedChanges, + params.settingsRouteActive, + params.settingsRouteAgentId, + ] + ); + + const handleOpenAgentSettingsRoute = useCallback( + (agentId: string) => { + const commands = planOpenSettingsRouteCommands({ + agentId, + currentInspectSidebar: params.inspectSidebar, + focusedAgentId: params.focusedAgentId, + }); + applyCommands(commands); + }, + [applyCommands, params.focusedAgentId, params.inspectSidebar] + ); + + const handleFleetSelectAgent = useCallback( + (agentId: string) => { + const commands = planFleetSelectCommands({ + agentId, + currentInspectSidebar: params.inspectSidebar, + focusedAgentId: params.focusedAgentId, + }); + applyCommands(commands); + }, + [applyCommands, params.focusedAgentId, params.inspectSidebar] + ); + + useEffect(() => { + const routeAgentId = (params.settingsRouteAgentId ?? "").trim(); + const hasRouteAgent = routeAgentId + ? params.agents.some((agent) => agent.agentId === routeAgentId) + : false; + + const commands = planSettingsRouteSyncCommands({ + settingsRouteActive: params.settingsRouteActive, + settingsRouteAgentId: params.settingsRouteAgentId, + status: params.status, + agentsLoadedOnce: params.agentsLoadedOnce, + selectedAgentId: params.selectedAgentId, + hasRouteAgent, + currentInspectSidebar: params.inspectSidebar, + }); + + applyCommands(commands); + }, [ + applyCommands, + params.agents, + params.agentsLoadedOnce, + params.inspectSidebar, + params.selectedAgentId, + params.settingsRouteActive, + params.settingsRouteAgentId, + params.status, + ]); + + useEffect(() => { + const hasSelectedAgentInAgents = params.selectedAgentId + ? params.agents.some((agent) => agent.agentId === params.selectedAgentId) + : false; + const hasInspectSidebarAgent = params.inspectSidebar?.agentId + ? params.agents.some((agent) => agent.agentId === params.inspectSidebar?.agentId) + : false; + + const commands = planNonRouteSelectionSyncCommands({ + settingsRouteActive: params.settingsRouteActive, + selectedAgentId: params.selectedAgentId, + focusedAgentId: params.focusedAgentId, + hasSelectedAgentInAgents, + currentInspectSidebar: params.inspectSidebar, + hasInspectSidebarAgent, + }); + + applyCommands(commands); + }, [ + applyCommands, + params.agents, + params.focusedAgentId, + params.inspectSidebar, + params.selectedAgentId, + params.settingsRouteActive, + ]); + + return { + handleBackToChat, + handleSettingsRouteTabChange, + handleOpenAgentSettingsRoute, + handleFleetSelectAgent, + }; +} diff --git a/src/features/agents/state/gatewayEventIngressWorkflow.ts b/src/features/agents/state/gatewayEventIngressWorkflow.ts new file mode 100644 index 00000000..9b000b65 --- /dev/null +++ b/src/features/agents/state/gatewayEventIngressWorkflow.ts @@ -0,0 +1,102 @@ +import { resolveExecApprovalEventEffects, type ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import { parseAgentIdFromSessionKey, type EventFrame } from "@/lib/gateway/GatewayClient"; + +export type CronTranscriptIntent = { + agentId: string; + sessionKey: string; + dedupeKey: string; + line: string; + timestampMs: number; + activityAtMs: number | null; +}; + +export type GatewayEventIngressDecision = { + approvalEffects: ExecApprovalEventEffects | null; + cronDedupeKeyToRecord: string | null; + cronTranscriptIntent: CronTranscriptIntent | null; +}; + +const NO_CRON_DECISION = { + cronDedupeKeyToRecord: null, + cronTranscriptIntent: null, +} as const; + +const resolveCronDecision = (params: { + event: EventFrame; + agents: AgentState[]; + seenCronDedupeKeys: ReadonlySet<string>; + nowMs: number; +}): Pick<GatewayEventIngressDecision, "cronDedupeKeyToRecord" | "cronTranscriptIntent"> => { + if (params.event.event !== "cron") { + return NO_CRON_DECISION; + } + const payload = params.event.payload; + if (!payload || typeof payload !== "object") { + return NO_CRON_DECISION; + } + const record = payload as Record<string, unknown>; + if (record.action !== "finished") { + return NO_CRON_DECISION; + } + const sessionKey = typeof record.sessionKey === "string" ? record.sessionKey.trim() : ""; + if (!sessionKey) { + return NO_CRON_DECISION; + } + const agentId = parseAgentIdFromSessionKey(sessionKey); + if (!agentId) { + return NO_CRON_DECISION; + } + const jobId = typeof record.jobId === "string" ? record.jobId.trim() : ""; + if (!jobId) { + return NO_CRON_DECISION; + } + const sessionId = typeof record.sessionId === "string" ? record.sessionId.trim() : ""; + const runAtMs = typeof record.runAtMs === "number" ? record.runAtMs : null; + const status = typeof record.status === "string" ? record.status.trim() : ""; + const error = typeof record.error === "string" ? record.error.trim() : ""; + const summary = typeof record.summary === "string" ? record.summary.trim() : ""; + + const dedupeKey = `cron:${jobId}:${sessionId || (runAtMs ?? "none")}`; + if (params.seenCronDedupeKeys.has(dedupeKey)) { + return NO_CRON_DECISION; + } + + const agent = params.agents.find((entry) => entry.agentId === agentId) ?? null; + if (!agent) { + return { + cronDedupeKeyToRecord: dedupeKey, + cronTranscriptIntent: null, + }; + } + + const header = `Cron finished (${status || "unknown"}): ${jobId}`; + const body = summary || error || "(no output)"; + return { + cronDedupeKeyToRecord: dedupeKey, + cronTranscriptIntent: { + agentId, + sessionKey: agent.sessionKey, + dedupeKey, + line: `${header}\n\n${body}`, + timestampMs: runAtMs ?? params.nowMs, + activityAtMs: runAtMs, + }, + }; +}; + +export const resolveGatewayEventIngressDecision = (params: { + event: EventFrame; + agents: AgentState[]; + seenCronDedupeKeys: ReadonlySet<string>; + nowMs: number; +}): GatewayEventIngressDecision => { + const approvalEffects = resolveExecApprovalEventEffects({ + event: params.event, + agents: params.agents, + }); + return { + approvalEffects, + ...resolveCronDecision(params), + }; +}; diff --git a/src/features/agents/state/gatewayRuntimeEventHandler.ts b/src/features/agents/state/gatewayRuntimeEventHandler.ts new file mode 100644 index 00000000..f107ada6 --- /dev/null +++ b/src/features/agents/state/gatewayRuntimeEventHandler.ts @@ -0,0 +1,464 @@ +import type { AgentState } from "@/features/agents/state/store"; +import { logTranscriptDebugMetric } from "@/features/agents/state/transcript"; +import { + classifyGatewayEventKind, + getChatSummaryPatch, + resolveAssistantCompletionTimestamp, + type AgentEventPayload, + type ChatEventPayload, +} from "@/features/agents/state/runtimeEventBridge"; +import { decideSummaryRefreshEvent } from "@/features/agents/state/runtimeEventPolicy"; +import { isClosedRun } from "@/features/agents/state/runtimeTerminalWorkflow"; +import { + createRuntimeEventCoordinatorState, + markChatRunSeen, + pruneRuntimeEventCoordinatorState, + reduceClearRunTracking, + reduceLifecycleFallbackFired, + reduceMarkActivityThrottled, + reduceRuntimeAgentWorkflowCommands, + reduceRuntimeChatWorkflowCommands, + reduceRuntimePolicyIntents, + type RuntimeCoordinatorDispatchAction, + type RuntimeCoordinatorEffectCommand, +} from "@/features/agents/state/runtimeEventCoordinatorWorkflow"; +import { type EventFrame, isSameSessionKey } from "@/lib/gateway/GatewayClient"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; +import { + extractText, + extractThinking, + extractToolLines, + isTraceMarkdown, + stripUiMetadata, +} from "@/lib/text/message-extract"; +import { planRuntimeChatEvent } from "@/features/agents/state/runtimeChatEventWorkflow"; +import { planRuntimeAgentEvent } from "@/features/agents/state/runtimeAgentEventWorkflow"; + +export type GatewayRuntimeEventHandlerDeps = { + getStatus: () => "disconnected" | "connecting" | "connected"; + getAgents: () => AgentState[]; + dispatch: (action: RuntimeCoordinatorDispatchAction) => void; + queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void; + clearPendingLivePatch: (agentId: string) => void; + now?: () => number; + + loadSummarySnapshot: () => Promise<void>; + requestHistoryRefresh: (command: { + agentId: string; + reason: "chat-final-no-trace"; + }) => Promise<void> | void; + refreshHeartbeatLatestUpdate: () => void; + bumpHeartbeatTick: () => void; + + setTimeout: (fn: () => void, delayMs: number) => number; + clearTimeout: (id: number) => void; + + isDisconnectLikeError: (err: unknown) => boolean; + logWarn?: (message: string, meta?: unknown) => void; + shouldSuppressRunAbortedLine?: (params: { + agentId: string; + runId: string | null; + sessionKey: string; + stopReason: string | null; + }) => boolean; + + updateSpecialLatestUpdate: (agentId: string, agent: AgentState, message: string) => void; +}; + +export type GatewayRuntimeEventHandler = { + handleEvent: (event: EventFrame) => void; + clearRunTracking: (runId?: string | null) => void; + dispose: () => void; +}; + +const findAgentBySessionKey = (agents: AgentState[], sessionKey: string): string | null => { + const exact = agents.find((agent) => isSameSessionKey(agent.sessionKey, sessionKey)); + return exact ? exact.agentId : null; +}; + +const findAgentByRunId = (agents: AgentState[], runId: string): string | null => { + const match = agents.find((agent) => agent.runId === runId); + return match ? match.agentId : null; +}; + +const resolveRole = (message: unknown) => + message && typeof message === "object" + ? (message as Record<string, unknown>).role + : null; + +export function createGatewayRuntimeEventHandler( + deps: GatewayRuntimeEventHandlerDeps +): GatewayRuntimeEventHandler { + const now = deps.now ?? (() => Date.now()); + const CLOSED_RUN_TTL_MS = 30_000; + const LIFECYCLE_FALLBACK_DELAY_MS = 0; + + let coordinatorState = createRuntimeEventCoordinatorState(); + + const lifecycleFallbackTimerIdByRun = new Map<string, number>(); + let summaryRefreshTimer: number | null = null; + + const toRunId = (runId?: string | null): string => runId?.trim() ?? ""; + + const logWarn = + deps.logWarn ?? + ((message: string, meta?: unknown) => { + console.warn(message, meta); + }); + + const cancelLifecycleFallback = (runId?: string | null) => { + const key = toRunId(runId); + if (!key) return; + const timerId = lifecycleFallbackTimerIdByRun.get(key); + if (typeof timerId !== "number") return; + deps.clearTimeout(timerId); + lifecycleFallbackTimerIdByRun.delete(key); + }; + + const executeCoordinatorEffects = (effects: RuntimeCoordinatorEffectCommand[]) => { + for (const effect of effects) { + if (effect.kind === "dispatch") { + deps.dispatch(effect.action); + continue; + } + if (effect.kind === "queueLivePatch") { + deps.queueLivePatch(effect.agentId, effect.patch); + continue; + } + if (effect.kind === "clearPendingLivePatch") { + deps.clearPendingLivePatch(effect.agentId); + continue; + } + if (effect.kind === "requestHistoryRefresh") { + deps.setTimeout(() => { + void deps.requestHistoryRefresh({ + agentId: effect.agentId, + reason: effect.reason, + }); + }, effect.deferMs); + continue; + } + if (effect.kind === "scheduleSummaryRefresh") { + if (effect.includeHeartbeatRefresh) { + deps.bumpHeartbeatTick(); + deps.refreshHeartbeatLatestUpdate(); + } + if (summaryRefreshTimer !== null) { + deps.clearTimeout(summaryRefreshTimer); + } + summaryRefreshTimer = deps.setTimeout(() => { + summaryRefreshTimer = null; + void deps.loadSummarySnapshot(); + }, effect.delayMs); + continue; + } + if (effect.kind === "cancelLifecycleFallback") { + cancelLifecycleFallback(effect.runId); + continue; + } + if (effect.kind === "scheduleLifecycleFallback") { + const fallbackTimerId = deps.setTimeout(() => { + lifecycleFallbackTimerIdByRun.delete(effect.runId); + const fallbackReduced = reduceLifecycleFallbackFired({ + state: coordinatorState, + runId: effect.runId, + agentId: effect.agentId, + sessionKey: effect.sessionKey, + finalText: effect.finalText, + transitionPatch: effect.transitionPatch, + nowMs: now(), + options: { closedRunTtlMs: CLOSED_RUN_TTL_MS }, + }); + coordinatorState = fallbackReduced.state; + executeCoordinatorEffects(fallbackReduced.effects); + }, effect.delayMs); + lifecycleFallbackTimerIdByRun.set(effect.runId, fallbackTimerId); + continue; + } + if (effect.kind === "appendAbortedIfNotSuppressed") { + const suppressAbortedLine = + deps.shouldSuppressRunAbortedLine?.({ + agentId: effect.agentId, + runId: effect.runId, + sessionKey: effect.sessionKey, + stopReason: effect.stopReason, + }) ?? false; + if (!suppressAbortedLine) { + deps.dispatch({ + type: "appendOutput", + agentId: effect.agentId, + line: "Run aborted.", + transcript: { + source: "runtime-chat", + runId: effect.runId, + sessionKey: effect.sessionKey, + timestampMs: effect.timestampMs, + role: "assistant", + kind: "assistant", + }, + }); + } + continue; + } + if (effect.kind === "logMetric") { + logTranscriptDebugMetric(effect.metric, effect.meta); + continue; + } + if (effect.kind === "logWarn") { + logWarn(effect.message, effect.meta); + continue; + } + if (effect.kind === "updateSpecialLatest") { + const agent = + effect.agentSnapshot?.agentId === effect.agentId + ? effect.agentSnapshot + : deps.getAgents().find((entry) => entry.agentId === effect.agentId); + if (agent) { + void deps.updateSpecialLatestUpdate(effect.agentId, agent, effect.message); + } + } + } + }; + + const clearRunTracking = (runId?: string | null) => { + const cleared = reduceClearRunTracking({ + state: coordinatorState, + runId, + }); + coordinatorState = cleared.state; + executeCoordinatorEffects(cleared.effects); + }; + + const pruneCoordinatorState = (at: number = now()) => { + const pruned = pruneRuntimeEventCoordinatorState({ + state: coordinatorState, + at, + }); + coordinatorState = pruned.state; + executeCoordinatorEffects(pruned.effects); + }; + + const dispose = () => { + if (summaryRefreshTimer !== null) { + deps.clearTimeout(summaryRefreshTimer); + summaryRefreshTimer = null; + } + for (const timerId of lifecycleFallbackTimerIdByRun.values()) { + deps.clearTimeout(timerId); + } + lifecycleFallbackTimerIdByRun.clear(); + coordinatorState = createRuntimeEventCoordinatorState(); + }; + + const handleRuntimeChatEvent = (payload: ChatEventPayload) => { + if (!payload.sessionKey) return; + pruneCoordinatorState(); + + if ( + payload.runId && + payload.state === "delta" && + isClosedRun(coordinatorState.runtimeTerminalState, payload.runId) + ) { + logTranscriptDebugMetric("late_event_ignored_closed_run", { + stream: "chat", + state: payload.state, + runId: payload.runId, + }); + return; + } + + coordinatorState = markChatRunSeen(coordinatorState, payload.runId); + + const agentsSnapshot = deps.getAgents(); + const agentId = findAgentBySessionKey(agentsSnapshot, payload.sessionKey); + if (!agentId) return; + const agent = agentsSnapshot.find((entry) => entry.agentId === agentId); + const activeRunId = agent?.runId?.trim() ?? ""; + const role = resolveRole(payload.message); + const nowMs = now(); + + if (payload.runId && activeRunId && activeRunId !== payload.runId) { + clearRunTracking(payload.runId); + return; + } + if ( + !activeRunId && + agent?.status !== "running" && + payload.state === "delta" && + role !== "user" && + role !== "system" + ) { + clearRunTracking(payload.runId ?? null); + return; + } + + const summaryPatch = getChatSummaryPatch(payload, nowMs); + if (summaryPatch) { + deps.dispatch({ + type: "updateAgent", + agentId, + patch: { + ...summaryPatch, + sessionCreated: true, + }, + }); + } + + if (role === "user" || role === "system") { + return; + } + + const activityReduced = reduceMarkActivityThrottled({ + state: coordinatorState, + agentId, + at: nowMs, + }); + coordinatorState = activityReduced.state; + executeCoordinatorEffects(activityReduced.effects); + + const nextTextRaw = extractText(payload.message); + const nextText = nextTextRaw ? stripUiMetadata(nextTextRaw) : null; + const nextThinking = extractThinking(payload.message ?? payload); + const toolLines = extractToolLines(payload.message ?? payload); + const isToolRole = role === "tool" || role === "toolResult"; + const assistantCompletionAt = resolveAssistantCompletionTimestamp({ + role, + state: payload.state, + message: payload.message, + now: now(), + }); + const normalizedAssistantFinalText = + payload.state === "final" && + role === "assistant" && + !isToolRole && + typeof nextText === "string" + ? normalizeAssistantDisplayText(nextText) + : null; + const finalAssistantText = + normalizedAssistantFinalText && normalizedAssistantFinalText.length > 0 + ? normalizedAssistantFinalText + : null; + + const chatWorkflow = planRuntimeChatEvent({ + payload, + agentId, + agent, + activeRunId: activeRunId || null, + runtimeTerminalState: coordinatorState.runtimeTerminalState, + role, + nowMs, + nextTextRaw, + nextText, + nextThinking, + toolLines, + isToolRole, + assistantCompletionAt, + finalAssistantText, + hasThinkingStarted: payload.runId + ? coordinatorState.thinkingStartedAtByRun.has(payload.runId) + : false, + hasTraceInOutput: + agent?.outputLines.some((line) => isTraceMarkdown(line.trim())) ?? false, + isThinkingDebugSessionSeen: coordinatorState.thinkingDebugBySession.has( + payload.sessionKey + ), + thinkingStartedAtMs: payload.runId + ? (coordinatorState.thinkingStartedAtByRun.get(payload.runId) ?? null) + : null, + }); + + const reduced = reduceRuntimeChatWorkflowCommands({ + state: coordinatorState, + payload, + agentId, + agent, + commands: chatWorkflow.commands, + nowMs, + options: { closedRunTtlMs: CLOSED_RUN_TTL_MS }, + }); + coordinatorState = reduced.state; + executeCoordinatorEffects(reduced.effects); + }; + + const handleRuntimeAgentEvent = (payload: AgentEventPayload) => { + if (!payload.runId) return; + pruneCoordinatorState(); + + const agentsSnapshot = deps.getAgents(); + const directMatch = payload.sessionKey + ? findAgentBySessionKey(agentsSnapshot, payload.sessionKey) + : null; + const agentId = directMatch ?? findAgentByRunId(agentsSnapshot, payload.runId); + if (!agentId) return; + const agent = agentsSnapshot.find((entry) => entry.agentId === agentId); + if (!agent) return; + + const nowMs = now(); + const agentWorkflow = planRuntimeAgentEvent({ + payload, + agent, + activeRunId: agent.runId?.trim() || null, + nowMs, + runtimeTerminalState: coordinatorState.runtimeTerminalState, + hasChatEvents: coordinatorState.chatRunSeen.has(payload.runId), + hasPendingFallbackTimer: lifecycleFallbackTimerIdByRun.has( + toRunId(payload.runId) + ), + previousThinkingRaw: coordinatorState.thinkingStreamByRun.get(payload.runId) ?? null, + previousAssistantRaw: + coordinatorState.assistantStreamByRun.get(payload.runId) ?? null, + thinkingStartedAtMs: + coordinatorState.thinkingStartedAtByRun.get(payload.runId) ?? null, + historyRefreshRequested: + coordinatorState.historyRefreshRequestedByRun.has(payload.runId), + lifecycleFallbackDelayMs: LIFECYCLE_FALLBACK_DELAY_MS, + }); + + const reduced = reduceRuntimeAgentWorkflowCommands({ + state: coordinatorState, + payload, + agentId, + agent, + commands: agentWorkflow.commands, + nowMs, + options: { closedRunTtlMs: CLOSED_RUN_TTL_MS }, + }); + coordinatorState = reduced.state; + executeCoordinatorEffects(reduced.effects); + }; + + const handleEvent = (event: EventFrame) => { + const eventKind = classifyGatewayEventKind(event.event); + + if (eventKind === "summary-refresh") { + const summaryIntents = decideSummaryRefreshEvent({ + event: event.event, + status: deps.getStatus(), + }); + const reduced = reduceRuntimePolicyIntents({ + state: coordinatorState, + intents: summaryIntents, + nowMs: now(), + options: { closedRunTtlMs: CLOSED_RUN_TTL_MS }, + }); + coordinatorState = reduced.state; + executeCoordinatorEffects(reduced.effects); + return; + } + + if (eventKind === "runtime-chat") { + const payload = event.payload as ChatEventPayload | undefined; + if (!payload) return; + handleRuntimeChatEvent(payload); + return; + } + + if (eventKind === "runtime-agent") { + const payload = event.payload as AgentEventPayload | undefined; + if (!payload) return; + handleRuntimeAgentEvent(payload); + } + }; + + return { handleEvent, clearRunTracking, dispose }; +} diff --git a/src/features/agents/state/livePatchQueue.ts b/src/features/agents/state/livePatchQueue.ts new file mode 100644 index 00000000..38e643c3 --- /dev/null +++ b/src/features/agents/state/livePatchQueue.ts @@ -0,0 +1,26 @@ +import type { AgentState } from "@/features/agents/state/store"; + +const normalizedRunId = (value: unknown): string => { + return typeof value === "string" ? value.trim() : ""; +}; + +export const mergePendingLivePatch = ( + existing: Partial<AgentState> | undefined, + incoming: Partial<AgentState> +): Partial<AgentState> => { + if (!existing) return incoming; + + const existingRunId = normalizedRunId(existing.runId); + const incomingRunId = normalizedRunId(incoming.runId); + + if (incomingRunId && existingRunId && incomingRunId !== existingRunId) { + return incoming; + } + + if (incomingRunId && !existingRunId) { + const { streamText: _dropStreamText, thinkingTrace: _dropThinkingTrace, ...rest } = existing; + return { ...rest, ...incoming }; + } + + return { ...existing, ...incoming }; +}; diff --git a/src/features/agents/state/runtimeAgentEventWorkflow.ts b/src/features/agents/state/runtimeAgentEventWorkflow.ts new file mode 100644 index 00000000..77f04c2a --- /dev/null +++ b/src/features/agents/state/runtimeAgentEventWorkflow.ts @@ -0,0 +1,436 @@ +import type { AgentState } from "@/features/agents/state/store"; +import { + getAgentSummaryPatch, + isReasoningRuntimeAgentStream, + mergeRuntimeStream, + resolveLifecyclePatch, + shouldPublishAssistantStream, + type AgentEventPayload, +} from "@/features/agents/state/runtimeEventBridge"; +import { + decideRuntimeAgentEvent, + type RuntimePolicyIntent, +} from "@/features/agents/state/runtimeEventPolicy"; +import { + deriveLifecycleTerminalDecision, + isClosedRun, + type LifecycleTerminalDecision, + type RuntimeTerminalState, +} from "@/features/agents/state/runtimeTerminalWorkflow"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; +import { + extractText, + extractThinking, + extractThinkingFromTaggedStream, + extractToolLines, + formatToolCallMarkdown, + isUiMetadataPrefix, + stripUiMetadata, +} from "@/lib/text/message-extract"; + +export type RuntimeAgentWorkflowCommand = + | { kind: "applyPolicyIntents"; intents: RuntimePolicyIntent[] } + | { kind: "logMetric"; metric: string; meta: Record<string, unknown> } + | { kind: "markActivity"; at: number } + | { kind: "setThinkingStreamRaw"; runId: string; raw: string } + | { kind: "setAssistantStreamRaw"; runId: string; raw: string } + | { kind: "markThinkingStarted"; runId: string; at: number } + | { kind: "queueAgentPatch"; patch: Partial<AgentState> } + | { kind: "appendToolLines"; lines: string[]; timestampMs: number } + | { kind: "markHistoryRefreshRequested"; runId: string } + | { + kind: "scheduleHistoryRefresh"; + delayMs: number; + reason: "chat-final-no-trace"; + } + | { + kind: "applyLifecycleDecision"; + decision: LifecycleTerminalDecision; + transitionPatch: Partial<AgentState>; + shouldClearPendingLivePatch: boolean; + }; + +export type RuntimeAgentWorkflowInput = { + payload: AgentEventPayload; + agent: AgentState; + activeRunId: string | null; + nowMs: number; + runtimeTerminalState: RuntimeTerminalState; + hasChatEvents: boolean; + hasPendingFallbackTimer: boolean; + previousThinkingRaw: string | null; + previousAssistantRaw: string | null; + thinkingStartedAtMs: number | null; + historyRefreshRequested: boolean; + lifecycleFallbackDelayMs: number; +}; + +export type RuntimeAgentWorkflowResult = { + commands: RuntimeAgentWorkflowCommand[]; +}; + +const extractReasoningBody = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^reasoning:\s*([\s\S]*)$/i); + if (!match) return null; + const body = (match[1] ?? "").trim(); + return body || null; +}; + +const normalizeReasoningComparable = (value: string): string => + normalizeAssistantDisplayText(value).trim().toLowerCase(); + +const hasUnclosedThinkingTag = (value: string): boolean => { + const openMatches = [ + ...value.matchAll(/<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi), + ]; + if (openMatches.length === 0) return false; + const closeMatches = [ + ...value.matchAll(/<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi), + ]; + const lastOpen = openMatches[openMatches.length - 1]; + const lastClose = closeMatches[closeMatches.length - 1]; + if (!lastOpen) return false; + if (!lastClose) return true; + return (lastClose.index ?? -1) < (lastOpen.index ?? -1); +}; + +const hasReasoningSignal = ({ + rawText, + rawDelta, + mergedRaw, +}: { + rawText: string; + rawDelta: string; + mergedRaw: string; +}): boolean => { + if (hasUnclosedThinkingTag(mergedRaw)) return true; + return Boolean(extractReasoningBody(rawText) ?? extractReasoningBody(rawDelta)); +}; + +const isReasoningOnlyAssistantChunk = ({ + rawText, + rawDelta, + mergedRaw, + cleaned, + liveThinking, +}: { + rawText: string; + rawDelta: string; + mergedRaw: string; + cleaned: string; + liveThinking: string | null; +}): boolean => { + if (!liveThinking) return false; + const normalizedCleaned = normalizeReasoningComparable(cleaned); + const normalizedThinking = normalizeReasoningComparable(liveThinking); + const normalizedCleanedReasoningBody = normalizeReasoningComparable( + extractReasoningBody(cleaned) ?? "" + ); + const cleanedMatchesReasoning = + !normalizedCleaned || + normalizedCleaned === normalizedThinking || + (normalizedCleanedReasoningBody.length > 0 && + normalizedCleanedReasoningBody === normalizedThinking); + if (!cleanedMatchesReasoning) return false; + return hasReasoningSignal({ rawText, rawDelta, mergedRaw }); +}; + +const resolveThinkingFromAgentStream = ( + data: Record<string, unknown> | null, + rawStream: string, + opts?: { treatPlainTextAsThinking?: boolean } +): string | null => { + if (data) { + const extracted = extractThinking(data); + if (extracted) return extracted; + const text = typeof data.text === "string" ? data.text : ""; + const delta = typeof data.delta === "string" ? data.delta : ""; + const prefixed = extractReasoningBody(text) ?? extractReasoningBody(delta); + if (prefixed) return prefixed; + if (opts?.treatPlainTextAsThinking) { + const cleanedDelta = delta.trim(); + if (cleanedDelta) return cleanedDelta; + const cleanedText = text.trim(); + if (cleanedText) return cleanedText; + } + } + const tagged = extractThinkingFromTaggedStream(rawStream); + return tagged || null; +}; + +export const planRuntimeAgentEvent = ( + input: RuntimeAgentWorkflowInput +): RuntimeAgentWorkflowResult => { + const commands: RuntimeAgentWorkflowCommand[] = []; + const { + payload, + agent, + activeRunId, + nowMs, + runtimeTerminalState, + hasChatEvents, + hasPendingFallbackTimer, + previousThinkingRaw, + previousAssistantRaw, + thinkingStartedAtMs, + historyRefreshRequested, + lifecycleFallbackDelayMs, + } = input; + const runId = payload.runId?.trim() ?? ""; + if (!runId) return { commands }; + const stream = typeof payload.stream === "string" ? payload.stream : ""; + const data = + payload.data && typeof payload.data === "object" + ? (payload.data as Record<string, unknown>) + : null; + const phase = typeof data?.phase === "string" ? data.phase : ""; + + const preflightIntents = decideRuntimeAgentEvent({ + runId, + stream, + phase, + activeRunId, + agentStatus: agent.status, + isClosedRun: isClosedRun(runtimeTerminalState, runId), + }); + const hasOnlyPreflightCleanup = + preflightIntents.length > 0 && + preflightIntents.every((intent) => intent.kind === "clearRunTracking"); + if (hasOnlyPreflightCleanup) { + commands.push({ kind: "applyPolicyIntents", intents: preflightIntents }); + return { commands }; + } + if (preflightIntents.some((intent) => intent.kind === "ignore")) { + if ( + preflightIntents.some( + (intent) => + intent.kind === "ignore" && intent.reason === "closed-run-event" + ) + ) { + commands.push({ + kind: "logMetric", + metric: "late_event_ignored_closed_run", + meta: { + stream: payload.stream, + runId, + }, + }); + } + return { commands }; + } + + commands.push({ kind: "markActivity", at: nowMs }); + + if (isReasoningRuntimeAgentStream(stream)) { + const rawText = typeof data?.text === "string" ? data.text : ""; + const rawDelta = typeof data?.delta === "string" ? data.delta : ""; + const previousRaw = previousThinkingRaw ?? ""; + let mergedRaw = previousRaw; + if (rawText) { + mergedRaw = rawText; + } else if (rawDelta) { + mergedRaw = mergeRuntimeStream(previousRaw, rawDelta); + } + if (mergedRaw) { + commands.push({ kind: "setThinkingStreamRaw", runId, raw: mergedRaw }); + } + const liveThinking = + resolveThinkingFromAgentStream(data, mergedRaw, { + treatPlainTextAsThinking: true, + }) ?? (mergedRaw.trim() ? mergedRaw.trim() : null); + if (liveThinking) { + if (typeof thinkingStartedAtMs !== "number") { + commands.push({ kind: "markThinkingStarted", runId, at: nowMs }); + } + commands.push({ + kind: "queueAgentPatch", + patch: { + status: "running", + runId, + ...(agent.runStartedAt === null ? { runStartedAt: nowMs } : {}), + sessionCreated: true, + lastActivityAt: nowMs, + thinkingTrace: liveThinking, + }, + }); + } + return { commands }; + } + + if (stream === "assistant") { + const rawText = typeof data?.text === "string" ? data.text : ""; + const rawDelta = typeof data?.delta === "string" ? data.delta : ""; + const previousRaw = previousAssistantRaw ?? ""; + let mergedRaw = previousRaw; + if (rawText) { + mergedRaw = rawText; + } else if (rawDelta) { + mergedRaw = mergeRuntimeStream(previousRaw, rawDelta); + } + if (mergedRaw) { + commands.push({ kind: "setAssistantStreamRaw", runId, raw: mergedRaw }); + } + + const liveThinking = resolveThinkingFromAgentStream(data, mergedRaw); + const patch: Partial<AgentState> = { + status: "running", + runId, + lastActivityAt: nowMs, + sessionCreated: true, + }; + if (liveThinking) { + if (typeof thinkingStartedAtMs !== "number") { + commands.push({ kind: "markThinkingStarted", runId, at: nowMs }); + } + patch.thinkingTrace = liveThinking; + } + if (agent.runStartedAt === null) { + patch.runStartedAt = nowMs; + } + if (mergedRaw && (!rawText || !isUiMetadataPrefix(rawText.trim()))) { + const visibleText = + extractText({ role: "assistant", content: mergedRaw }) ?? mergedRaw; + const cleaned = stripUiMetadata(visibleText); + const reasoningOnlyChunk = isReasoningOnlyAssistantChunk({ + rawText, + rawDelta, + mergedRaw, + cleaned, + liveThinking, + }); + if ( + cleaned && + !reasoningOnlyChunk && + shouldPublishAssistantStream({ + nextText: cleaned, + rawText, + hasChatEvents, + currentStreamText: agent.streamText ?? null, + }) + ) { + patch.streamText = cleaned; + } + } + commands.push({ kind: "queueAgentPatch", patch }); + return { commands }; + } + + if (stream === "tool") { + const name = typeof data?.name === "string" ? data.name : "tool"; + const toolCallId = + typeof data?.toolCallId === "string" ? data.toolCallId : ""; + if (phase && phase !== "result") { + const args = + (data?.arguments as unknown) ?? + (data?.args as unknown) ?? + (data?.input as unknown) ?? + (data?.parameters as unknown) ?? + null; + const line = formatToolCallMarkdown({ + id: toolCallId || undefined, + name, + arguments: args, + }); + if (line) { + commands.push({ + kind: "appendToolLines", + lines: [line], + timestampMs: nowMs, + }); + } + return { commands }; + } + + if (phase !== "result") { + return { commands }; + } + + const result = data?.result; + const isError = + typeof data?.isError === "boolean" ? data.isError : undefined; + const resultRecord = + result && typeof result === "object" + ? (result as Record<string, unknown>) + : null; + const details = + resultRecord && "details" in resultRecord ? resultRecord.details : undefined; + let content: unknown = result; + if (resultRecord) { + if (Array.isArray(resultRecord.content)) { + content = resultRecord.content; + } else if (typeof resultRecord.text === "string") { + content = resultRecord.text; + } + } + + const lines = extractToolLines({ + role: "tool", + toolName: name, + toolCallId, + isError, + details, + content, + }); + if (lines.length > 0) { + commands.push({ kind: "appendToolLines", lines, timestampMs: nowMs }); + } + + if (agent.showThinkingTraces && !historyRefreshRequested) { + commands.push({ kind: "markHistoryRefreshRequested", runId }); + commands.push({ + kind: "scheduleHistoryRefresh", + delayMs: 750, + reason: "chat-final-no-trace", + }); + } + return { commands }; + } + + if (stream !== "lifecycle") { + return { commands }; + } + + const summaryPatch = getAgentSummaryPatch(payload, nowMs); + if (!summaryPatch) { + return { commands }; + } + if (phase !== "start" && phase !== "end" && phase !== "error") { + return { commands }; + } + + const transition = resolveLifecyclePatch({ + phase, + incomingRunId: runId, + currentRunId: agent.runId, + lastActivityAt: summaryPatch.lastActivityAt ?? nowMs, + }); + if (transition.kind === "ignore") { + return { commands }; + } + + const normalizedStreamText = agent.streamText + ? normalizeAssistantDisplayText(agent.streamText) + : ""; + const lifecycleDecision = deriveLifecycleTerminalDecision({ + mode: "event", + state: runtimeTerminalState, + runId, + phase, + hasPendingFallbackTimer, + fallbackDelayMs: lifecycleFallbackDelayMs, + fallbackFinalText: + normalizedStreamText.length > 0 ? normalizedStreamText : null, + transitionClearsRunTracking: transition.clearRunTracking, + }); + + commands.push({ + kind: "applyLifecycleDecision", + decision: lifecycleDecision, + transitionPatch: transition.patch, + shouldClearPendingLivePatch: transition.kind === "terminal", + }); + + return { commands }; +}; diff --git a/src/features/agents/state/runtimeChatEventWorkflow.ts b/src/features/agents/state/runtimeChatEventWorkflow.ts new file mode 100644 index 00000000..a9670164 --- /dev/null +++ b/src/features/agents/state/runtimeChatEventWorkflow.ts @@ -0,0 +1,374 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { TranscriptAppendMeta } from "@/features/agents/state/transcript"; +import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge"; +import { decideRuntimeChatEvent, type RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy"; +import { + deriveChatTerminalDecision, + type ChatTerminalDecision, + type RuntimeTerminalState, +} from "@/features/agents/state/runtimeTerminalWorkflow"; +import { + formatMetaMarkdown, + formatThinkingMarkdown, + isUiMetadataPrefix, +} from "@/lib/text/message-extract"; + +export type RuntimeChatWorkflowCommand = + | { kind: "applyChatTerminalDecision"; decision: ChatTerminalDecision } + | { kind: "applyPolicyIntents"; intents: RuntimePolicyIntent[] } + | { kind: "appendOutput"; line: string; transcript: TranscriptAppendMeta } + | { kind: "appendToolLines"; lines: string[]; timestampMs: number } + | { kind: "applyTerminalCommit"; runId: string; seq: number | null } + | { kind: "appendAbortedIfNotSuppressed"; timestampMs: number } + | { kind: "logMetric"; metric: string; meta: Record<string, unknown> } + | { kind: "markThinkingDebugSession"; sessionKey: string } + | { kind: "logWarn"; message: string; meta?: unknown }; + +export type RuntimeChatWorkflowInput = { + payload: ChatEventPayload; + agentId: string; + agent: AgentState | undefined; + activeRunId: string | null; + runtimeTerminalState: RuntimeTerminalState; + role: unknown; + nowMs: number; + nextTextRaw: string | null; + nextText: string | null; + nextThinking: string | null; + toolLines: string[]; + isToolRole: boolean; + assistantCompletionAt: number | null; + finalAssistantText: string | null; + hasThinkingStarted: boolean; + hasTraceInOutput: boolean; + isThinkingDebugSessionSeen: boolean; + thinkingStartedAtMs: number | null; +}; + +export type RuntimeChatWorkflowResult = { + commands: RuntimeChatWorkflowCommand[]; +}; + +const terminalAssistantMetaEntryId = (runId?: string | null) => { + const key = runId?.trim() ?? ""; + return key ? `run:${key}:assistant:meta` : undefined; +}; + +const terminalAssistantFinalEntryId = (runId?: string | null) => { + const key = runId?.trim() ?? ""; + return key ? `run:${key}:assistant:final` : undefined; +}; + +const resolveTerminalSeq = (payload: ChatEventPayload): number | null => { + const seq = payload.seq; + if (typeof seq !== "number" || !Number.isFinite(seq)) return null; + return seq; +}; + +const summarizeThinkingMessage = (message: unknown) => { + if (!message || typeof message !== "object") { + return { type: typeof message }; + } + const record = message as Record<string, unknown>; + const summary: Record<string, unknown> = { keys: Object.keys(record) }; + const content = record.content; + if (Array.isArray(content)) { + summary.contentTypes = content.map((item) => { + if (item && typeof item === "object") { + const entry = item as Record<string, unknown>; + return typeof entry.type === "string" ? entry.type : "object"; + } + return typeof item; + }); + } else if (typeof content === "string") { + summary.contentLength = content.length; + } + if (typeof record.text === "string") { + summary.textLength = record.text.length; + } + for (const key of ["analysis", "reasoning", "thinking"]) { + const value = record[key]; + if (typeof value === "string") { + summary[`${key}Length`] = value.length; + } else if (value && typeof value === "object") { + summary[`${key}Keys`] = Object.keys(value as Record<string, unknown>); + } + } + return summary; +}; + +export const planRuntimeChatEvent = ( + input: RuntimeChatWorkflowInput +): RuntimeChatWorkflowResult => { + const commands: RuntimeChatWorkflowCommand[] = []; + const { + payload, + agentId, + agent, + activeRunId, + runtimeTerminalState, + role, + nowMs, + nextTextRaw, + nextText, + nextThinking, + toolLines, + isToolRole, + assistantCompletionAt, + finalAssistantText, + hasThinkingStarted, + hasTraceInOutput, + isThinkingDebugSessionSeen, + thinkingStartedAtMs, + } = input; + + if (payload.state === "delta") { + if (typeof nextTextRaw === "string" && isUiMetadataPrefix(nextTextRaw.trim())) { + return { commands }; + } + const deltaIntents = decideRuntimeChatEvent({ + agentId, + state: payload.state, + runId: payload.runId ?? null, + role, + activeRunId, + agentStatus: agent?.status ?? "idle", + now: nowMs, + agentRunStartedAt: agent?.runStartedAt ?? null, + nextThinking, + nextText, + hasThinkingStarted, + isClosedRun: false, + isStaleTerminal: false, + shouldRequestHistoryRefresh: false, + shouldUpdateLastResult: false, + shouldSetRunIdle: false, + shouldSetRunError: false, + lastResultText: null, + assistantCompletionAt: null, + shouldQueueLatestUpdate: false, + latestUpdateMessage: null, + }); + const hasOnlyDeltaCleanup = + deltaIntents.length > 0 && + deltaIntents.every((intent) => intent.kind === "clearRunTracking"); + if (hasOnlyDeltaCleanup) { + commands.push({ kind: "applyPolicyIntents", intents: deltaIntents }); + return { commands }; + } + if (deltaIntents.some((intent) => intent.kind === "ignore")) { + return { commands }; + } + commands.push({ kind: "applyPolicyIntents", intents: deltaIntents }); + if (toolLines.length > 0) { + commands.push({ + kind: "appendToolLines", + lines: toolLines, + timestampMs: nowMs, + }); + } + return { commands }; + } + + const shouldRequestHistoryRefresh = + payload.state === "final" && + !nextThinking && + role === "assistant" && + Boolean(agent) && + !hasTraceInOutput; + const shouldUpdateLastResult = + payload.state === "final" && !isToolRole && typeof finalAssistantText === "string"; + const shouldQueueLatestUpdate = + payload.state === "final" && Boolean(agent?.lastUserMessage && !agent.latestOverride); + const terminalSeq = payload.state === "final" ? resolveTerminalSeq(payload) : null; + const chatTerminalDecision = + payload.state === "final" + ? deriveChatTerminalDecision({ + state: runtimeTerminalState, + runId: payload.runId, + isFinal: true, + seq: terminalSeq, + }) + : null; + + if (chatTerminalDecision) { + commands.push({ + kind: "applyChatTerminalDecision", + decision: chatTerminalDecision, + }); + } + + if (payload.state === "final" && payload.runId && chatTerminalDecision?.isStaleTerminal) { + commands.push({ + kind: "logMetric", + metric: "stale_terminal_chat_event_ignored", + meta: { + runId: payload.runId, + seq: terminalSeq, + lastTerminalSeq: chatTerminalDecision.lastTerminalSeqBeforeFinal, + commitSource: chatTerminalDecision.commitSourceBeforeFinal, + }, + }); + } + + const chatIntents = decideRuntimeChatEvent({ + agentId, + state: payload.state, + runId: payload.runId ?? null, + role, + activeRunId, + agentStatus: agent?.status ?? "idle", + now: nowMs, + agentRunStartedAt: agent?.runStartedAt ?? null, + nextThinking, + nextText, + hasThinkingStarted, + isClosedRun: false, + isStaleTerminal: chatTerminalDecision?.isStaleTerminal ?? false, + shouldRequestHistoryRefresh, + shouldUpdateLastResult, + shouldSetRunIdle: Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"), + shouldSetRunError: Boolean(payload.runId && agent?.runId === payload.runId && payload.state === "error"), + lastResultText: shouldUpdateLastResult ? finalAssistantText : null, + assistantCompletionAt: payload.state === "final" ? assistantCompletionAt : null, + shouldQueueLatestUpdate, + latestUpdateMessage: shouldQueueLatestUpdate ? (agent?.lastUserMessage ?? null) : null, + }); + const hasOnlyRunCleanup = + chatIntents.length > 0 && + chatIntents.every((intent) => intent.kind === "clearRunTracking"); + if (hasOnlyRunCleanup) { + commands.push({ kind: "applyPolicyIntents", intents: chatIntents }); + return { commands }; + } + if (chatIntents.some((intent) => intent.kind === "ignore")) { + return { commands }; + } + + if (payload.state === "final") { + if (payload.runId && chatTerminalDecision?.fallbackCommittedBeforeFinal && role === "assistant" && !isToolRole) { + commands.push({ + kind: "logMetric", + metric: "lifecycle_fallback_replaced_by_chat_final", + meta: { + runId: payload.runId, + seq: terminalSeq, + lastTerminalSeq: chatTerminalDecision.lastTerminalSeqBeforeFinal ?? null, + }, + }); + } + if (!nextThinking && role === "assistant" && !isThinkingDebugSessionSeen) { + commands.push({ + kind: "markThinkingDebugSession", + sessionKey: payload.sessionKey, + }); + commands.push({ + kind: "logWarn", + message: "No thinking trace extracted from chat event.", + meta: { + sessionKey: payload.sessionKey, + message: summarizeThinkingMessage(payload.message ?? payload), + }, + }); + } + const thinkingText = nextThinking ?? agent?.thinkingTrace ?? null; + const thinkingLine = thinkingText ? formatThinkingMarkdown(thinkingText) : ""; + if (role === "assistant" && typeof assistantCompletionAt === "number") { + const thinkingDurationMs = + typeof thinkingStartedAtMs === "number" + ? Math.max(0, assistantCompletionAt - thinkingStartedAtMs) + : null; + commands.push({ + kind: "appendOutput", + line: formatMetaMarkdown({ + role: "assistant", + timestamp: assistantCompletionAt, + thinkingDurationMs, + }), + transcript: { + source: "runtime-chat", + runId: payload.runId ?? null, + sessionKey: payload.sessionKey, + timestampMs: assistantCompletionAt, + role: "assistant", + kind: "meta", + entryId: terminalAssistantMetaEntryId(payload.runId ?? null), + confirmed: true, + }, + }); + } + if (thinkingLine) { + commands.push({ + kind: "appendOutput", + line: thinkingLine, + transcript: { + source: "runtime-chat", + runId: payload.runId ?? null, + sessionKey: payload.sessionKey, + timestampMs: assistantCompletionAt ?? nowMs, + role: "assistant", + kind: "thinking", + }, + }); + } + if (toolLines.length > 0) { + commands.push({ + kind: "appendToolLines", + lines: toolLines, + timestampMs: assistantCompletionAt ?? nowMs, + }); + } + if (!isToolRole && typeof finalAssistantText === "string") { + commands.push({ + kind: "appendOutput", + line: finalAssistantText, + transcript: { + source: "runtime-chat", + runId: payload.runId ?? null, + sessionKey: payload.sessionKey, + timestampMs: assistantCompletionAt ?? nowMs, + role: "assistant", + kind: "assistant", + entryId: terminalAssistantFinalEntryId(payload.runId ?? null), + confirmed: true, + }, + }); + } + if (payload.runId) { + commands.push({ + kind: "applyTerminalCommit", + runId: payload.runId, + seq: terminalSeq, + }); + } + commands.push({ kind: "applyPolicyIntents", intents: chatIntents }); + return { commands }; + } + + if (payload.state === "aborted") { + commands.push({ + kind: "appendAbortedIfNotSuppressed", + timestampMs: nowMs, + }); + commands.push({ kind: "applyPolicyIntents", intents: chatIntents }); + return { commands }; + } + + if (payload.state === "error") { + commands.push({ + kind: "appendOutput", + line: payload.errorMessage ? `Error: ${payload.errorMessage}` : "Run error.", + transcript: { + source: "runtime-chat", + runId: payload.runId ?? null, + sessionKey: payload.sessionKey, + timestampMs: nowMs, + role: "assistant", + kind: "assistant", + }, + }); + commands.push({ kind: "applyPolicyIntents", intents: chatIntents }); + } + + return { commands }; +}; diff --git a/src/features/agents/state/runtimeEventBridge.ts b/src/features/agents/state/runtimeEventBridge.ts new file mode 100644 index 00000000..a5f3d738 --- /dev/null +++ b/src/features/agents/state/runtimeEventBridge.ts @@ -0,0 +1,662 @@ +import type { AgentState } from "./store"; +import { + extractText, + extractThinking, + extractToolLines, + formatMetaMarkdown, + formatThinkingMarkdown, + isHeartbeatPrompt, + isMetaMarkdown, + isToolMarkdown, + isTraceMarkdown, + isUiMetadataPrefix, + stripUiMetadata, +} from "@/lib/text/message-extract"; +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; + +type LifecyclePhase = "start" | "end" | "error"; + +type LifecyclePatchInput = { + phase: LifecyclePhase; + incomingRunId: string; + currentRunId: string | null; + lastActivityAt: number; +}; + +type LifecycleTransitionStart = { + kind: "start"; + patch: Partial<AgentState>; + clearRunTracking: false; +}; + +type LifecycleTransitionTerminal = { + kind: "terminal"; + patch: Partial<AgentState>; + clearRunTracking: true; +}; + +type LifecycleTransitionIgnore = { + kind: "ignore"; +}; + +export type LifecycleTransition = + | LifecycleTransitionStart + | LifecycleTransitionTerminal + | LifecycleTransitionIgnore; + +type ShouldPublishAssistantStreamInput = { + nextText: string; + rawText: string; + hasChatEvents: boolean; + currentStreamText: string | null; +}; + +type AssistantCompletionTimestampInput = { + role: unknown; + state: ChatEventPayload["state"]; + message: unknown; + now?: number; +}; + +type DedupeRunLinesResult = { + appended: string[]; + nextSeen: Set<string>; +}; + +export type ChatEventPayload = { + runId: string; + sessionKey: string; + state: "delta" | "final" | "aborted" | "error"; + seq?: number; + stopReason?: string; + message?: unknown; + errorMessage?: string; +}; + +export type AgentEventPayload = { + runId: string; + seq?: number; + stream?: string; + data?: Record<string, unknown>; + sessionKey?: string; +}; + +export type SummarySnapshotAgent = { + agentId: string; + sessionKey: string; + status?: AgentState["status"]; +}; + +export type SummarySessionStatusEntry = { + key: string; + updatedAt: number | null; +}; + +export type SummaryStatusSnapshot = { + sessions?: { + recent?: SummarySessionStatusEntry[]; + byAgent?: Array<{ agentId: string; recent: SummarySessionStatusEntry[] }>; + }; +}; + +export type SummaryPreviewItem = { + role: "user" | "assistant" | "tool" | "system" | "other"; + text: string; + timestamp?: number | string; +}; + +export type SummaryPreviewEntry = { + key: string; + status: "ok" | "empty" | "missing" | "error"; + items: SummaryPreviewItem[]; +}; + +export type SummaryPreviewSnapshot = { + ts: number; + previews: SummaryPreviewEntry[]; +}; + +export type SummarySnapshotPatch = { + agentId: string; + patch: Partial<AgentState>; +}; + +export type ChatHistoryMessage = Record<string, unknown>; + +export type HistoryLinesResult = { + lines: string[]; + lastAssistant: string | null; + lastAssistantAt: number | null; + lastRole: string | null; + lastUser: string | null; + lastUserAt: number | null; +}; + +export type HistorySyncPatchInput = { + messages: ChatHistoryMessage[]; + currentLines: string[]; + loadedAt: number; + status: AgentState["status"]; + runId: string | null; +}; + +export type GatewayEventKind = + | "summary-refresh" + | "runtime-chat" + | "runtime-agent" + | "ignore"; + +const REASONING_STREAM_NAME_HINTS = ["reason", "think", "analysis", "trace"]; + +export const classifyGatewayEventKind = (event: string): GatewayEventKind => { + if (event === "presence" || event === "heartbeat") return "summary-refresh"; + if (event === "chat") return "runtime-chat"; + if (event === "agent") return "runtime-agent"; + return "ignore"; +}; + +export const isReasoningRuntimeAgentStream = (stream: string): boolean => { + const normalized = stream.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === "assistant" || normalized === "tool" || normalized === "lifecycle") { + return false; + } + return REASONING_STREAM_NAME_HINTS.some((hint) => normalized.includes(hint)); +}; + +export const mergeRuntimeStream = (current: string, incoming: string): string => { + if (!incoming) return current; + if (!current) return incoming; + if (incoming.startsWith(current)) return incoming; + if (current.startsWith(incoming)) return current; + if (current.endsWith(incoming)) return current; + if (incoming.endsWith(current)) return incoming; + return `${current}${incoming}`; +}; + +export const dedupeRunLines = (seen: Set<string>, lines: string[]): DedupeRunLinesResult => { + const nextSeen = new Set(seen); + const appended: string[] = []; + for (const line of lines) { + if (!line || nextSeen.has(line)) continue; + nextSeen.add(line); + appended.push(line); + } + return { appended, nextSeen }; +}; + +const toTimestampMs = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +}; + +const extractMessageTimestamp = (message: unknown): number | null => { + if (!message || typeof message !== "object") return null; + const record = message as Record<string, unknown>; + return ( + toTimestampMs(record.timestamp) ?? toTimestampMs(record.createdAt) ?? toTimestampMs(record.at) + ); +}; + +export const resolveAssistantCompletionTimestamp = ({ + role, + state, + message, + now = Date.now(), +}: AssistantCompletionTimestampInput): number | null => { + if (role !== "assistant" || state !== "final") return null; + return extractMessageTimestamp(message) ?? now; +}; + +export const buildHistoryLines = (messages: ChatHistoryMessage[]): HistoryLinesResult => { + const lines: string[] = []; + let lastAssistant: string | null = null; + let lastAssistantAt: number | null = null; + let lastRole: string | null = null; + let lastUser: string | null = null; + let lastUserAt: number | null = null; + const resolveAssistantTerminalLine = (message: ChatHistoryMessage): string | null => { + const stopReason = + typeof message.stopReason === "string" ? message.stopReason.trim().toLowerCase() : ""; + if (stopReason === "aborted") return "Run aborted."; + if (stopReason === "error") { + const errorMessage = + typeof message.errorMessage === "string" ? message.errorMessage.trim() : ""; + return errorMessage ? `Error: ${errorMessage}` : "Run error."; + } + const fallbackError = + typeof message.errorMessage === "string" ? message.errorMessage.trim() : ""; + return fallbackError ? `Error: ${fallbackError}` : null; + }; + const isRestartSentinelMessage = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return false; + return /^(?:System:\s*\[[^\]]+\]\s*)?GatewayRestart:\s*\{/.test(trimmed); + }; + for (const message of messages) { + const role = typeof message.role === "string" ? message.role : "other"; + const extracted = extractText(message); + const baseText = stripUiMetadata(extracted?.trim() ?? ""); + const text = role === "assistant" ? normalizeAssistantDisplayText(baseText) : baseText; + const thinking = + role === "assistant" ? formatThinkingMarkdown(extractThinking(message) ?? "") : ""; + const toolLines = extractToolLines(message); + if (role === "system") { + if (toolLines.length > 0) { + lines.push(...toolLines); + } + continue; + } + if (role === "user") { + if (text && isHeartbeatPrompt(text)) continue; + if (text && isRestartSentinelMessage(text)) continue; + if (text) { + const at = extractMessageTimestamp(message); + if (typeof at === "number") { + lines.push(formatMetaMarkdown({ role: "user", timestamp: at })); + } + lines.push(`> ${text}`); + lastUser = text; + if (typeof at === "number") { + lastUserAt = at; + } + } + lastRole = "user"; + } else if (role === "assistant") { + const terminalLine = + !text && !thinking && toolLines.length === 0 ? resolveAssistantTerminalLine(message) : null; + if (!text && !thinking && toolLines.length === 0 && !terminalLine) { + continue; + } + const at = extractMessageTimestamp(message); + if (typeof at === "number") { + lastAssistantAt = at; + } + if (typeof at === "number") { + lines.push(formatMetaMarkdown({ role: "assistant", timestamp: at })); + } + if (thinking) { + lines.push(thinking); + } + if (toolLines.length > 0) { + lines.push(...toolLines); + } + if (text) { + lines.push(text); + lastAssistant = text; + } + if (terminalLine) { + lines.push(terminalLine); + lastAssistant = terminalLine; + } + lastRole = "assistant"; + } else if (toolLines.length > 0) { + lines.push(...toolLines); + } else if (text) { + lines.push(text); + } + } + return { lines, lastAssistant, lastAssistantAt, lastRole, lastUser, lastUserAt }; +}; + +const HISTORY_RUNNING_RECOVERY_WINDOW_MS = 2 * 60 * 60 * 1000; + +export const resolveHistoryRunStatePatch = (params: { + status: AgentState["status"]; + runId: string | null; + lastRole: string | null; + lastUserAt: number | null; + loadedAt: number; +}): Partial<AgentState> | null => { + const activeRunId = params.runId?.trim() ?? ""; + if (activeRunId) return null; + + if (params.status === "running" && params.lastRole === "assistant") { + return { + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }; + } + + if (params.status === "running" || params.lastRole !== "user") { + return null; + } + + const lastUserAt = params.lastUserAt; + if (typeof lastUserAt !== "number" || !Number.isFinite(lastUserAt)) { + return null; + } + if (params.loadedAt < lastUserAt) { + return null; + } + const ageMs = params.loadedAt - lastUserAt; + if (ageMs > HISTORY_RUNNING_RECOVERY_WINDOW_MS) { + return null; + } + + return { + status: "running", + runId: null, + runStartedAt: lastUserAt, + streamText: null, + thinkingTrace: null, + }; +}; + +export const mergeHistoryWithPending = ( + historyLines: string[], + currentLines: string[] +): string[] => { + const normalizeUserLine = (line: string): string | null => { + const trimmed = line.trim(); + if (!trimmed.startsWith(">")) return null; + const text = trimmed.replace(/^>\s?/, ""); + const normalized = text.replace(/\s+/g, " ").trim(); + return normalized || null; + }; + + const isPlainAssistantLine = (line: string): boolean => { + const trimmed = line.trim(); + if (!trimmed) return false; + if (trimmed.startsWith(">")) return false; + if (isMetaMarkdown(trimmed)) return false; + if (isTraceMarkdown(trimmed)) return false; + if (isToolMarkdown(trimmed)) return false; + return true; + }; + + const countLines = (lines: string[]) => { + const counts = new Map<string, number>(); + for (const line of lines) { + counts.set(line, (counts.get(line) ?? 0) + 1); + } + return counts; + }; + + if (currentLines.length === 0) return historyLines; + if (historyLines.length === 0) return historyLines; + const historyCounts = countLines(historyLines); + const currentCounts = countLines(currentLines); + const merged = [...historyLines]; + let cursor = 0; + for (const line of currentLines) { + let foundIndex = -1; + for (let i = cursor; i < merged.length; i += 1) { + if (merged[i] === line) { + foundIndex = i; + break; + } + } + if (foundIndex !== -1) { + cursor = foundIndex + 1; + continue; + } + const normalizedUserLine = normalizeUserLine(line); + if (normalizedUserLine) { + for (let i = cursor; i < merged.length; i += 1) { + const normalizedMergedLine = normalizeUserLine(merged[i] ?? ""); + if (!normalizedMergedLine) continue; + if (normalizedMergedLine !== normalizedUserLine) continue; + foundIndex = i; + break; + } + if (foundIndex !== -1) { + cursor = foundIndex + 1; + continue; + } + } + merged.splice(cursor, 0, line); + cursor += 1; + } + const assistantLineCount = new Map<string, number>(); + const bounded: string[] = []; + for (const line of merged) { + if (!isPlainAssistantLine(line)) { + bounded.push(line); + continue; + } + const nextCount = (assistantLineCount.get(line) ?? 0) + 1; + const historyCount = historyCounts.get(line) ?? 0; + const currentCount = currentCounts.get(line) ?? 0; + const hasOverlap = historyCount > 0 && currentCount > 0; + if (hasOverlap && nextCount > historyCount) { + continue; + } + assistantLineCount.set(line, nextCount); + bounded.push(line); + } + return bounded; +}; + +export const buildHistorySyncPatch = ({ + messages, + currentLines, + loadedAt, + status, + runId, +}: HistorySyncPatchInput): Partial<AgentState> => { + const { lines, lastAssistant, lastAssistantAt, lastRole, lastUser, lastUserAt } = + buildHistoryLines(messages); + const runStatePatch = resolveHistoryRunStatePatch({ + status, + runId, + lastRole, + lastUserAt, + loadedAt, + }); + if (lines.length === 0) return { historyLoadedAt: loadedAt }; + const mergedLines = mergeHistoryWithPending(lines, currentLines); + const isSame = + mergedLines.length === currentLines.length && + mergedLines.every((line, index) => line === currentLines[index]); + if (isSame) { + const patch: Partial<AgentState> = { + historyLoadedAt: loadedAt, + ...(runStatePatch ?? {}), + }; + if (typeof lastAssistantAt === "number") { + patch.lastAssistantMessageAt = lastAssistantAt; + } + return patch; + } + const patch: Partial<AgentState> = { + outputLines: mergedLines, + lastResult: lastAssistant ?? null, + ...(lastAssistant ? { latestPreview: lastAssistant } : {}), + ...(typeof lastAssistantAt === "number" ? { lastAssistantMessageAt: lastAssistantAt } : {}), + ...(lastUser ? { lastUserMessage: lastUser } : {}), + historyLoadedAt: loadedAt, + ...(runStatePatch ?? {}), + }; + return patch; +}; + +export const buildSummarySnapshotPatches = ({ + agents, + statusSummary, + previewResult, +}: { + agents: SummarySnapshotAgent[]; + statusSummary: SummaryStatusSnapshot; + previewResult: SummaryPreviewSnapshot; +}): SummarySnapshotPatch[] => { + const previewMap = new Map<string, SummaryPreviewEntry>(); + for (const entry of previewResult.previews ?? []) { + previewMap.set(entry.key, entry); + } + const activityByKey = new Map<string, number>(); + const addActivity = (entries?: SummarySessionStatusEntry[]) => { + if (!entries) return; + for (const entry of entries) { + if (!entry?.key || typeof entry.updatedAt !== "number") continue; + activityByKey.set(entry.key, entry.updatedAt); + } + }; + addActivity(statusSummary.sessions?.recent); + for (const group of statusSummary.sessions?.byAgent ?? []) { + addActivity(group.recent); + } + const patches: SummarySnapshotPatch[] = []; + for (const agent of agents) { + const patch: Partial<AgentState> = {}; + const activity = activityByKey.get(agent.sessionKey); + if (typeof activity === "number") { + patch.lastActivityAt = activity; + } + const preview = previewMap.get(agent.sessionKey); + if (preview?.items?.length) { + const latestItem = preview.items[preview.items.length - 1]; + if (latestItem?.role === "assistant" && agent.status !== "running") { + const previewTs = toTimestampMs(latestItem.timestamp); + if (typeof previewTs === "number") { + patch.lastAssistantMessageAt = previewTs; + } else if (typeof activity === "number") { + patch.lastAssistantMessageAt = activity; + } + } + const lastAssistant = [...preview.items] + .reverse() + .find((item) => item.role === "assistant"); + const lastUser = [...preview.items].reverse().find((item) => item.role === "user"); + if (lastAssistant?.text) { + patch.latestPreview = stripUiMetadata(lastAssistant.text); + } + if (lastUser?.text) { + patch.lastUserMessage = stripUiMetadata(lastUser.text); + } + } + if (Object.keys(patch).length > 0) { + patches.push({ agentId: agent.agentId, patch }); + } + } + return patches; +}; + +export const resolveLifecyclePatch = (input: LifecyclePatchInput): LifecycleTransition => { + const { phase, incomingRunId, currentRunId, lastActivityAt } = input; + if (phase === "start") { + return { + kind: "start", + clearRunTracking: false, + patch: { + status: "running", + runId: incomingRunId, + runStartedAt: lastActivityAt, + sessionCreated: true, + lastActivityAt, + }, + }; + } + if (currentRunId && currentRunId !== incomingRunId) { + return { kind: "ignore" }; + } + if (phase === "error") { + return { + kind: "terminal", + clearRunTracking: true, + patch: { + status: "error", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + lastActivityAt, + }, + }; + } + return { + kind: "terminal", + clearRunTracking: true, + patch: { + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + lastActivityAt, + }, + }; +}; + +export const shouldPublishAssistantStream = ({ + nextText, + rawText, + hasChatEvents, + currentStreamText, +}: ShouldPublishAssistantStreamInput): boolean => { + const next = nextText.trim(); + if (!next) return false; + if (!hasChatEvents) return true; + if (rawText.trim()) return true; + const current = currentStreamText?.trim() ?? ""; + if (!current) return true; + if (next.length <= current.length) return false; + return next.startsWith(current); +}; + +export const getChatSummaryPatch = ( + payload: ChatEventPayload, + now: number = Date.now() +): Partial<AgentState> | null => { + const message = payload.message; + const role = + message && typeof message === "object" + ? (message as Record<string, unknown>).role + : null; + const rawText = extractText(message); + if (typeof rawText === "string" && isUiMetadataPrefix(rawText.trim())) { + return { lastActivityAt: now }; + } + const cleaned = typeof rawText === "string" ? stripUiMetadata(rawText) : null; + const patch: Partial<AgentState> = { lastActivityAt: now }; + if (role === "user") { + if (cleaned) { + patch.lastUserMessage = cleaned; + } + return patch; + } + if (role === "assistant") { + if (cleaned) { + patch.latestPreview = cleaned; + } + return patch; + } + if (payload.state === "error" && payload.errorMessage) { + patch.latestPreview = payload.errorMessage; + } + return patch; +}; + +export const getAgentSummaryPatch = ( + payload: AgentEventPayload, + now: number = Date.now() +): Partial<AgentState> | null => { + if (payload.stream !== "lifecycle") return null; + const phase = typeof payload.data?.phase === "string" ? payload.data.phase : ""; + if (!phase) return null; + const patch: Partial<AgentState> = { lastActivityAt: now }; + if (phase === "start") { + patch.status = "running"; + return patch; + } + if (phase === "end") { + patch.status = "idle"; + return patch; + } + if (phase === "error") { + patch.status = "error"; + return patch; + } + return patch; +}; diff --git a/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts b/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts new file mode 100644 index 00000000..32bffed3 --- /dev/null +++ b/src/features/agents/state/runtimeEventCoordinatorWorkflow.ts @@ -0,0 +1,846 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { TranscriptAppendMeta } from "@/features/agents/state/transcript"; +import { + dedupeRunLines, + type AgentEventPayload, + type ChatEventPayload, +} from "@/features/agents/state/runtimeEventBridge"; +import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy"; +import type { RuntimeChatWorkflowCommand } from "@/features/agents/state/runtimeChatEventWorkflow"; +import type { RuntimeAgentWorkflowCommand } from "@/features/agents/state/runtimeAgentEventWorkflow"; +import { + applyTerminalCommit, + clearRunTerminalState, + createRuntimeTerminalState, + deriveLifecycleTerminalDecision, + markClosedRun, + pruneClosedRuns, + type RuntimeTerminalCommand, + type RuntimeTerminalState, +} from "@/features/agents/state/runtimeTerminalWorkflow"; +import { formatMetaMarkdown } from "@/lib/text/message-extract"; + +export type RuntimeEventCoordinatorState = { + runtimeTerminalState: RuntimeTerminalState; + chatRunSeen: Set<string>; + assistantStreamByRun: Map<string, string>; + thinkingStreamByRun: Map<string, string>; + thinkingStartedAtByRun: Map<string, number>; + toolLinesSeenByRun: Map<string, Set<string>>; + historyRefreshRequestedByRun: Set<string>; + thinkingDebugBySession: Set<string>; + lastActivityMarkByAgent: Map<string, number>; +}; + +export type RuntimeCoordinatorDispatchAction = + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { + type: "appendOutput"; + agentId: string; + line: string; + transcript?: TranscriptAppendMeta; + } + | { type: "markActivity"; agentId: string; at?: number }; + +export type RuntimeCoordinatorEffectCommand = + | { kind: "dispatch"; action: RuntimeCoordinatorDispatchAction } + | { kind: "queueLivePatch"; agentId: string; patch: Partial<AgentState> } + | { kind: "clearPendingLivePatch"; agentId: string } + | { + kind: "requestHistoryRefresh"; + agentId: string; + reason: "chat-final-no-trace"; + deferMs: number; + } + | { + kind: "scheduleSummaryRefresh"; + delayMs: number; + includeHeartbeatRefresh: boolean; + } + | { kind: "cancelLifecycleFallback"; runId: string } + | { + kind: "scheduleLifecycleFallback"; + runId: string; + delayMs: number; + agentId: string; + sessionKey: string; + finalText: string; + transitionPatch: Partial<AgentState>; + } + | { + kind: "appendAbortedIfNotSuppressed"; + agentId: string; + runId: string | null; + sessionKey: string; + stopReason: string | null; + timestampMs: number; + } + | { kind: "logMetric"; metric: string; meta: Record<string, unknown> } + | { kind: "logWarn"; message: string; meta?: unknown } + | { + kind: "updateSpecialLatest"; + agentId: string; + message: string; + agentSnapshot?: AgentState; + }; + +type ReduceResult = { + state: RuntimeEventCoordinatorState; + effects: RuntimeCoordinatorEffectCommand[]; +}; + +type ReduceOptions = { + closedRunTtlMs?: number; +}; + +const CLOSED_RUN_TTL_MS = 30_000; +const MARK_ACTIVITY_THROTTLE_MS = 300; + +const toRunId = (runId?: string | null): string => runId?.trim() ?? ""; + +const terminalAssistantMetaEntryId = (runId?: string | null) => { + const key = runId?.trim() ?? ""; + return key ? `run:${key}:assistant:meta` : undefined; +}; + +const terminalAssistantFinalEntryId = (runId?: string | null) => { + const key = runId?.trim() ?? ""; + return key ? `run:${key}:assistant:final` : undefined; +}; + +const cloneState = (state: RuntimeEventCoordinatorState): RuntimeEventCoordinatorState => ({ + runtimeTerminalState: state.runtimeTerminalState, + chatRunSeen: new Set(state.chatRunSeen), + assistantStreamByRun: new Map(state.assistantStreamByRun), + thinkingStreamByRun: new Map(state.thinkingStreamByRun), + thinkingStartedAtByRun: new Map(state.thinkingStartedAtByRun), + toolLinesSeenByRun: new Map(state.toolLinesSeenByRun), + historyRefreshRequestedByRun: new Set(state.historyRefreshRequestedByRun), + thinkingDebugBySession: new Set(state.thinkingDebugBySession), + lastActivityMarkByAgent: new Map(state.lastActivityMarkByAgent), +}); + +const clearRunTrackingState = ( + state: RuntimeEventCoordinatorState, + runId?: string | null +): ReduceResult => { + const key = toRunId(runId); + if (!key) return { state, effects: [] }; + const nextState = cloneState(state); + nextState.chatRunSeen.delete(key); + nextState.assistantStreamByRun.delete(key); + nextState.thinkingStreamByRun.delete(key); + nextState.thinkingStartedAtByRun.delete(key); + nextState.toolLinesSeenByRun.delete(key); + nextState.historyRefreshRequestedByRun.delete(key); + return { + state: nextState, + effects: [{ kind: "cancelLifecycleFallback", runId: key }], + }; +}; + +const applyRuntimeTerminalCommands = (params: { + state: RuntimeEventCoordinatorState; + commands: RuntimeTerminalCommand[]; + nowMs: number; + closedRunTtlMs: number; + onScheduleLifecycleFallback?: ( + command: Extract<RuntimeTerminalCommand, { kind: "scheduleLifecycleFallback" }> + ) => RuntimeCoordinatorEffectCommand | null; +}): ReduceResult => { + let nextState = params.state; + const effects: RuntimeCoordinatorEffectCommand[] = []; + + for (const command of params.commands) { + if (command.kind === "cancelLifecycleFallback") { + effects.push({ kind: "cancelLifecycleFallback", runId: command.runId }); + continue; + } + if (command.kind === "clearRunTerminalState") { + effects.push({ kind: "cancelLifecycleFallback", runId: command.runId }); + nextState = { + ...nextState, + runtimeTerminalState: clearRunTerminalState(nextState.runtimeTerminalState, { + runId: command.runId, + }), + }; + continue; + } + if (command.kind === "markRunClosed") { + nextState = { + ...nextState, + runtimeTerminalState: markClosedRun(nextState.runtimeTerminalState, { + runId: command.runId, + now: params.nowMs, + ttlMs: params.closedRunTtlMs, + }), + }; + continue; + } + if (command.kind === "clearRunTracking") { + const cleared = clearRunTrackingState(nextState, command.runId); + nextState = cleared.state; + effects.push(...cleared.effects); + continue; + } + if (command.kind === "scheduleLifecycleFallback") { + const scheduled = params.onScheduleLifecycleFallback?.(command); + if (scheduled) { + effects.push(scheduled); + } + } + } + + return { state: nextState, effects }; +}; + +const appendToolLinesEffects = (params: { + state: RuntimeEventCoordinatorState; + agentId: string; + runId: string | null; + sessionKey: string | undefined; + source: "runtime-chat" | "runtime-agent"; + timestampMs: number; + lines: string[]; +}): ReduceResult => { + const { agentId, runId, sessionKey, source, timestampMs, lines } = params; + if (lines.length === 0) { + return { state: params.state, effects: [] }; + } + + if (!runId) { + const effects: RuntimeCoordinatorEffectCommand[] = lines.map((line) => ({ + kind: "dispatch", + action: { + type: "appendOutput", + agentId, + line, + transcript: { + source, + runId: null, + sessionKey, + timestampMs, + kind: "tool", + role: "tool", + }, + }, + })); + return { state: params.state, effects }; + } + + const current = params.state.toolLinesSeenByRun.get(runId) ?? new Set<string>(); + const { appended, nextSeen } = dedupeRunLines(current, lines); + if (appended.length === 0) { + return { state: params.state, effects: [] }; + } + + const nextToolLinesSeenByRun = new Map(params.state.toolLinesSeenByRun); + nextToolLinesSeenByRun.set(runId, nextSeen); + const nextState = { + ...params.state, + toolLinesSeenByRun: nextToolLinesSeenByRun, + }; + const effects: RuntimeCoordinatorEffectCommand[] = appended.map((line) => ({ + kind: "dispatch", + action: { + type: "appendOutput", + agentId, + line, + transcript: { + source, + runId, + sessionKey, + timestampMs, + kind: "tool", + role: "tool", + }, + }, + })); + return { state: nextState, effects }; +}; + +const reduceMarkActivity = (params: { + state: RuntimeEventCoordinatorState; + agentId: string; + at: number; +}): ReduceResult => { + const lastAt = params.state.lastActivityMarkByAgent.get(params.agentId) ?? 0; + if (params.at - lastAt < MARK_ACTIVITY_THROTTLE_MS) { + return { state: params.state, effects: [] }; + } + const nextLastActivity = new Map(params.state.lastActivityMarkByAgent); + nextLastActivity.set(params.agentId, params.at); + return { + state: { + ...params.state, + lastActivityMarkByAgent: nextLastActivity, + }, + effects: [ + { + kind: "dispatch", + action: { + type: "markActivity", + agentId: params.agentId, + at: params.at, + }, + }, + ], + }; +}; + +export function reduceMarkActivityThrottled(params: { + state: RuntimeEventCoordinatorState; + agentId: string; + at: number; +}): ReduceResult { + return reduceMarkActivity(params); +} + +export function createRuntimeEventCoordinatorState(): RuntimeEventCoordinatorState { + return { + runtimeTerminalState: createRuntimeTerminalState(), + chatRunSeen: new Set<string>(), + assistantStreamByRun: new Map<string, string>(), + thinkingStreamByRun: new Map<string, string>(), + thinkingStartedAtByRun: new Map<string, number>(), + toolLinesSeenByRun: new Map<string, Set<string>>(), + historyRefreshRequestedByRun: new Set<string>(), + thinkingDebugBySession: new Set<string>(), + lastActivityMarkByAgent: new Map<string, number>(), + }; +} + +export function markChatRunSeen( + state: RuntimeEventCoordinatorState, + runId?: string | null +): RuntimeEventCoordinatorState { + const key = toRunId(runId); + if (!key) return state; + if (state.chatRunSeen.has(key)) return state; + const nextChatRunSeen = new Set(state.chatRunSeen); + nextChatRunSeen.add(key); + return { + ...state, + chatRunSeen: nextChatRunSeen, + }; +} + +export function reduceClearRunTracking(params: { + state: RuntimeEventCoordinatorState; + runId?: string | null; +}): ReduceResult { + return clearRunTrackingState(params.state, params.runId); +} + +export function pruneRuntimeEventCoordinatorState(params: { + state: RuntimeEventCoordinatorState; + at: number; +}): ReduceResult { + const pruned = pruneClosedRuns(params.state.runtimeTerminalState, { at: params.at }); + if (pruned.expiredRunIds.length === 0) { + return { state: params.state, effects: [] }; + } + const effects: RuntimeCoordinatorEffectCommand[] = pruned.expiredRunIds.map((runId) => ({ + kind: "cancelLifecycleFallback", + runId, + })); + return { + state: { + ...params.state, + runtimeTerminalState: pruned.state, + }, + effects, + }; +} + +export function reduceRuntimePolicyIntents(params: { + state: RuntimeEventCoordinatorState; + intents: RuntimePolicyIntent[]; + nowMs: number; + agentForLatestUpdate?: AgentState; + options?: ReduceOptions; +}): ReduceResult { + let nextState = params.state; + const effects: RuntimeCoordinatorEffectCommand[] = []; + const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS; + + for (const intent of params.intents) { + if (intent.kind === "ignore") { + continue; + } + if (intent.kind === "clearRunTracking") { + const cleared = clearRunTrackingState(nextState, intent.runId); + nextState = cleared.state; + effects.push(...cleared.effects); + continue; + } + if (intent.kind === "markRunClosed") { + nextState = { + ...nextState, + runtimeTerminalState: markClosedRun(nextState.runtimeTerminalState, { + runId: intent.runId, + now: params.nowMs, + ttlMs: closedRunTtlMs, + }), + }; + continue; + } + if (intent.kind === "markThinkingStarted") { + if (!nextState.thinkingStartedAtByRun.has(intent.runId)) { + const nextThinkingStartedAtByRun = new Map(nextState.thinkingStartedAtByRun); + nextThinkingStartedAtByRun.set(intent.runId, intent.at); + nextState = { + ...nextState, + thinkingStartedAtByRun: nextThinkingStartedAtByRun, + }; + } + continue; + } + if (intent.kind === "clearPendingLivePatch") { + effects.push({ kind: "clearPendingLivePatch", agentId: intent.agentId }); + continue; + } + if (intent.kind === "queueLivePatch") { + effects.push({ + kind: "queueLivePatch", + agentId: intent.agentId, + patch: intent.patch, + }); + continue; + } + if (intent.kind === "dispatchUpdateAgent") { + effects.push({ + kind: "dispatch", + action: { + type: "updateAgent", + agentId: intent.agentId, + patch: intent.patch, + }, + }); + continue; + } + if (intent.kind === "requestHistoryRefresh") { + effects.push({ + kind: "requestHistoryRefresh", + agentId: intent.agentId, + reason: intent.reason, + deferMs: 0, + }); + continue; + } + if (intent.kind === "queueLatestUpdate") { + const agentSnapshot = + params.agentForLatestUpdate?.agentId === intent.agentId + ? params.agentForLatestUpdate + : undefined; + effects.push({ + kind: "updateSpecialLatest", + agentId: intent.agentId, + message: intent.message, + agentSnapshot, + }); + continue; + } + if (intent.kind === "scheduleSummaryRefresh") { + effects.push({ + kind: "scheduleSummaryRefresh", + delayMs: intent.delayMs, + includeHeartbeatRefresh: intent.includeHeartbeatRefresh, + }); + } + } + + return { state: nextState, effects }; +} + +export function reduceRuntimeChatWorkflowCommands(params: { + state: RuntimeEventCoordinatorState; + payload: ChatEventPayload; + agentId: string; + agent: AgentState | undefined; + commands: RuntimeChatWorkflowCommand[]; + nowMs: number; + options?: ReduceOptions; +}): ReduceResult { + let nextState = params.state; + const effects: RuntimeCoordinatorEffectCommand[] = []; + const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS; + + for (const command of params.commands) { + if (command.kind === "applyChatTerminalDecision") { + nextState = { + ...nextState, + runtimeTerminalState: command.decision.state, + }; + const terminalReduced = applyRuntimeTerminalCommands({ + state: nextState, + commands: command.decision.commands, + nowMs: params.nowMs, + closedRunTtlMs, + }); + nextState = terminalReduced.state; + effects.push(...terminalReduced.effects); + continue; + } + if (command.kind === "logMetric") { + effects.push({ kind: "logMetric", metric: command.metric, meta: command.meta }); + continue; + } + if (command.kind === "markThinkingDebugSession") { + if (!nextState.thinkingDebugBySession.has(command.sessionKey)) { + const nextThinkingDebugBySession = new Set(nextState.thinkingDebugBySession); + nextThinkingDebugBySession.add(command.sessionKey); + nextState = { + ...nextState, + thinkingDebugBySession: nextThinkingDebugBySession, + }; + } + continue; + } + if (command.kind === "logWarn") { + effects.push({ kind: "logWarn", message: command.message, meta: command.meta }); + continue; + } + if (command.kind === "appendOutput") { + effects.push({ + kind: "dispatch", + action: { + type: "appendOutput", + agentId: params.agentId, + line: command.line, + transcript: command.transcript, + }, + }); + continue; + } + if (command.kind === "appendToolLines") { + const toolLinesReduced = appendToolLinesEffects({ + state: nextState, + agentId: params.agentId, + runId: params.payload.runId ?? null, + sessionKey: params.payload.sessionKey, + source: "runtime-chat", + timestampMs: command.timestampMs, + lines: command.lines, + }); + nextState = toolLinesReduced.state; + effects.push(...toolLinesReduced.effects); + continue; + } + if (command.kind === "applyTerminalCommit") { + nextState = { + ...nextState, + runtimeTerminalState: applyTerminalCommit(nextState.runtimeTerminalState, { + runId: command.runId, + source: "chat-final", + seq: command.seq, + }), + }; + continue; + } + if (command.kind === "appendAbortedIfNotSuppressed") { + effects.push({ + kind: "appendAbortedIfNotSuppressed", + agentId: params.agentId, + runId: params.payload.runId ?? null, + sessionKey: params.payload.sessionKey, + stopReason: params.payload.stopReason?.trim() ?? null, + timestampMs: command.timestampMs, + }); + continue; + } + if (command.kind === "applyPolicyIntents") { + const policyReduced = reduceRuntimePolicyIntents({ + state: nextState, + intents: command.intents, + nowMs: params.nowMs, + agentForLatestUpdate: params.agent, + options: { closedRunTtlMs }, + }); + nextState = policyReduced.state; + effects.push(...policyReduced.effects); + continue; + } + } + + return { state: nextState, effects }; +} + +export function reduceRuntimeAgentWorkflowCommands(params: { + state: RuntimeEventCoordinatorState; + payload: AgentEventPayload; + agentId: string; + agent: AgentState; + commands: RuntimeAgentWorkflowCommand[]; + nowMs: number; + options?: ReduceOptions; +}): ReduceResult { + let nextState = params.state; + const effects: RuntimeCoordinatorEffectCommand[] = []; + const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS; + + for (const command of params.commands) { + if (command.kind === "applyPolicyIntents") { + const policyReduced = reduceRuntimePolicyIntents({ + state: nextState, + intents: command.intents, + nowMs: params.nowMs, + options: { closedRunTtlMs }, + }); + nextState = policyReduced.state; + effects.push(...policyReduced.effects); + continue; + } + if (command.kind === "logMetric") { + effects.push({ kind: "logMetric", metric: command.metric, meta: command.meta }); + continue; + } + if (command.kind === "markActivity") { + const activityReduced = reduceMarkActivity({ + state: nextState, + agentId: params.agentId, + at: command.at, + }); + nextState = activityReduced.state; + effects.push(...activityReduced.effects); + continue; + } + if (command.kind === "setThinkingStreamRaw") { + const nextThinkingStreamByRun = new Map(nextState.thinkingStreamByRun); + nextThinkingStreamByRun.set(command.runId, command.raw); + nextState = { + ...nextState, + thinkingStreamByRun: nextThinkingStreamByRun, + }; + continue; + } + if (command.kind === "setAssistantStreamRaw") { + const nextAssistantStreamByRun = new Map(nextState.assistantStreamByRun); + nextAssistantStreamByRun.set(command.runId, command.raw); + nextState = { + ...nextState, + assistantStreamByRun: nextAssistantStreamByRun, + }; + continue; + } + if (command.kind === "markThinkingStarted") { + if (!nextState.thinkingStartedAtByRun.has(command.runId)) { + const nextThinkingStartedAtByRun = new Map(nextState.thinkingStartedAtByRun); + nextThinkingStartedAtByRun.set(command.runId, command.at); + nextState = { + ...nextState, + thinkingStartedAtByRun: nextThinkingStartedAtByRun, + }; + } + continue; + } + if (command.kind === "queueAgentPatch") { + effects.push({ + kind: "queueLivePatch", + agentId: params.agentId, + patch: command.patch, + }); + continue; + } + if (command.kind === "appendToolLines") { + const toolLinesReduced = appendToolLinesEffects({ + state: nextState, + agentId: params.agentId, + runId: params.payload.runId ?? null, + sessionKey: params.payload.sessionKey ?? params.agent.sessionKey, + source: "runtime-agent", + timestampMs: command.timestampMs, + lines: command.lines, + }); + nextState = toolLinesReduced.state; + effects.push(...toolLinesReduced.effects); + continue; + } + if (command.kind === "markHistoryRefreshRequested") { + const nextHistoryRefreshRequestedByRun = new Set(nextState.historyRefreshRequestedByRun); + nextHistoryRefreshRequestedByRun.add(command.runId); + nextState = { + ...nextState, + historyRefreshRequestedByRun: nextHistoryRefreshRequestedByRun, + }; + continue; + } + if (command.kind === "scheduleHistoryRefresh") { + effects.push({ + kind: "requestHistoryRefresh", + agentId: params.agentId, + reason: command.reason, + deferMs: command.delayMs, + }); + continue; + } + if (command.kind === "applyLifecycleDecision") { + if (command.shouldClearPendingLivePatch) { + effects.push({ + kind: "clearPendingLivePatch", + agentId: params.agentId, + }); + } + + nextState = { + ...nextState, + runtimeTerminalState: command.decision.state, + }; + + const terminalReduced = applyRuntimeTerminalCommands({ + state: nextState, + commands: command.decision.commands, + nowMs: params.nowMs, + closedRunTtlMs, + onScheduleLifecycleFallback: (scheduledCommand) => ({ + kind: "scheduleLifecycleFallback", + runId: scheduledCommand.runId, + delayMs: scheduledCommand.delayMs, + agentId: params.agentId, + sessionKey: params.payload.sessionKey ?? params.agent.sessionKey, + finalText: scheduledCommand.finalText, + transitionPatch: command.transitionPatch, + }), + }); + + nextState = terminalReduced.state; + effects.push(...terminalReduced.effects); + + if (!command.decision.deferTransitionPatch) { + effects.push({ + kind: "dispatch", + action: { + type: "updateAgent", + agentId: params.agentId, + patch: command.transitionPatch, + }, + }); + } + } + } + + return { state: nextState, effects }; +} + +export function reduceLifecycleFallbackFired(params: { + state: RuntimeEventCoordinatorState; + runId: string; + agentId: string; + sessionKey: string; + finalText: string; + transitionPatch: Partial<AgentState>; + nowMs: number; + options?: ReduceOptions; +}): ReduceResult { + const closedRunTtlMs = params.options?.closedRunTtlMs ?? CLOSED_RUN_TTL_MS; + let nextState = params.state; + const effects: RuntimeCoordinatorEffectCommand[] = []; + const runId = toRunId(params.runId); + if (!runId) return { state: nextState, effects }; + + const fallbackDecision = deriveLifecycleTerminalDecision({ + mode: "fallback-fired", + state: nextState.runtimeTerminalState, + runId, + }); + nextState = { + ...nextState, + runtimeTerminalState: fallbackDecision.state, + }; + + if (!fallbackDecision.shouldCommitFallback) { + return { state: nextState, effects }; + } + + const assistantCompletionAt = params.nowMs; + const startedAt = nextState.thinkingStartedAtByRun.get(runId); + const thinkingDurationMs = + typeof startedAt === "number" + ? Math.max(0, assistantCompletionAt - startedAt) + : null; + + effects.push({ + kind: "dispatch", + action: { + type: "appendOutput", + agentId: params.agentId, + line: formatMetaMarkdown({ + role: "assistant", + timestamp: assistantCompletionAt, + thinkingDurationMs, + }), + transcript: { + source: "runtime-agent", + runId, + sessionKey: params.sessionKey, + timestampMs: assistantCompletionAt, + role: "assistant", + kind: "meta", + entryId: terminalAssistantMetaEntryId(runId), + confirmed: false, + }, + }, + }); + + if (params.finalText) { + effects.push({ + kind: "dispatch", + action: { + type: "appendOutput", + agentId: params.agentId, + line: params.finalText, + transcript: { + source: "runtime-agent", + runId, + sessionKey: params.sessionKey, + timestampMs: assistantCompletionAt, + role: "assistant", + kind: "assistant", + entryId: terminalAssistantFinalEntryId(runId), + confirmed: false, + }, + }, + }); + } + + effects.push({ + kind: "dispatch", + action: { + type: "updateAgent", + agentId: params.agentId, + patch: { + lastResult: params.finalText, + lastAssistantMessageAt: assistantCompletionAt, + }, + }, + }); + + nextState = { + ...nextState, + runtimeTerminalState: applyTerminalCommit(nextState.runtimeTerminalState, { + runId, + source: "lifecycle-fallback", + seq: null, + }), + }; + + const terminalReduced = applyRuntimeTerminalCommands({ + state: nextState, + commands: fallbackDecision.commands, + nowMs: params.nowMs, + closedRunTtlMs, + }); + nextState = terminalReduced.state; + effects.push(...terminalReduced.effects); + + effects.push({ + kind: "dispatch", + action: { + type: "updateAgent", + agentId: params.agentId, + patch: params.transitionPatch, + }, + }); + + return { state: nextState, effects }; +} diff --git a/src/features/agents/state/runtimeEventPolicy.ts b/src/features/agents/state/runtimeEventPolicy.ts new file mode 100644 index 00000000..efad3531 --- /dev/null +++ b/src/features/agents/state/runtimeEventPolicy.ts @@ -0,0 +1,212 @@ +import type { AgentState } from "@/features/agents/state/store"; +import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge"; + +type ConnectionStatus = "disconnected" | "connecting" | "connected"; + +export type RuntimePolicyIntent = + | { kind: "ignore"; reason: string } + | { kind: "clearRunTracking"; runId: string } + | { kind: "markRunClosed"; runId: string } + | { kind: "markThinkingStarted"; runId: string; at: number } + | { kind: "clearPendingLivePatch"; agentId: string } + | { kind: "queueLivePatch"; agentId: string; patch: Partial<AgentState> } + | { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> } + | { kind: "requestHistoryRefresh"; agentId: string; reason: "chat-final-no-trace" } + | { kind: "queueLatestUpdate"; agentId: string; message: string } + | { kind: "scheduleSummaryRefresh"; delayMs: number; includeHeartbeatRefresh: boolean }; + +export type RuntimeChatPolicyInput = { + agentId: string; + state: ChatEventPayload["state"]; + runId: string | null; + role: unknown; + activeRunId: string | null; + agentStatus: AgentState["status"]; + now: number; + agentRunStartedAt: number | null; + nextThinking: string | null; + nextText: string | null; + hasThinkingStarted: boolean; + isClosedRun: boolean; + isStaleTerminal: boolean; + shouldRequestHistoryRefresh: boolean; + shouldUpdateLastResult: boolean; + shouldSetRunIdle: boolean; + shouldSetRunError: boolean; + lastResultText: string | null; + assistantCompletionAt: number | null; + shouldQueueLatestUpdate: boolean; + latestUpdateMessage: string | null; +}; + +export type RuntimeAgentPolicyInput = { + runId: string; + stream: string; + phase: string; + activeRunId: string | null; + agentStatus: AgentState["status"]; + isClosedRun: boolean; +}; + +export type RuntimeSummaryPolicyInput = { + event: string; + status: ConnectionStatus; +}; + +const isLifecycleStart = (stream: string, phase: string): boolean => + stream === "lifecycle" && phase === "start"; + +const toRunId = (runId: string | null | undefined): string => runId?.trim() ?? ""; + +export const decideRuntimeChatEvent = ( + input: RuntimeChatPolicyInput +): RuntimePolicyIntent[] => { + const runId = toRunId(input.runId); + const activeRunId = toRunId(input.activeRunId); + const role = input.role; + + if (input.state === "delta") { + if (runId && input.isClosedRun) { + return [{ kind: "ignore", reason: "closed-run-delta" }]; + } + if (runId && activeRunId && activeRunId !== runId) { + return [{ kind: "clearRunTracking", runId }]; + } + if ( + !activeRunId && + input.agentStatus !== "running" && + role !== "user" && + role !== "system" + ) { + return runId + ? [{ kind: "clearRunTracking", runId }] + : [{ kind: "ignore", reason: "inactive-agent-delta" }]; + } + if (role === "user" || role === "system") { + return []; + } + const patch: Partial<AgentState> = {}; + const intents: RuntimePolicyIntent[] = []; + if (input.nextThinking) { + if (runId && !input.hasThinkingStarted) { + intents.push({ kind: "markThinkingStarted", runId, at: input.now }); + } + patch.thinkingTrace = input.nextThinking; + patch.status = "running"; + } + if (typeof input.nextText === "string") { + patch.streamText = input.nextText; + patch.status = "running"; + } + if (runId) { + patch.runId = runId; + } + if (input.agentRunStartedAt === null) { + patch.runStartedAt = input.now; + } + if (Object.keys(patch).length > 0) { + intents.push({ + kind: "queueLivePatch", + agentId: input.agentId, + patch, + }); + } + return intents; + } + + if (runId && activeRunId && activeRunId !== runId) { + return [{ kind: "clearRunTracking", runId }]; + } + if (runId && input.isStaleTerminal) { + return [{ kind: "ignore", reason: "stale-terminal-event" }]; + } + + const intents: RuntimePolicyIntent[] = [ + { kind: "clearPendingLivePatch", agentId: input.agentId }, + ]; + if (runId) { + intents.push({ kind: "clearRunTracking", runId }); + intents.push({ kind: "markRunClosed", runId }); + } + + if (input.state === "final") { + if (input.shouldRequestHistoryRefresh) { + intents.push({ + kind: "requestHistoryRefresh", + agentId: input.agentId, + reason: "chat-final-no-trace", + }); + } + if (input.shouldUpdateLastResult && input.lastResultText) { + intents.push({ + kind: "dispatchUpdateAgent", + agentId: input.agentId, + patch: { lastResult: input.lastResultText }, + }); + } + if (input.shouldQueueLatestUpdate && input.latestUpdateMessage) { + intents.push({ + kind: "queueLatestUpdate", + agentId: input.agentId, + message: input.latestUpdateMessage, + }); + } + } + + const patch: Partial<AgentState> = { + streamText: null, + thinkingTrace: null, + runStartedAt: null, + }; + if (typeof input.assistantCompletionAt === "number") { + patch.lastAssistantMessageAt = input.assistantCompletionAt; + } + if (input.shouldSetRunIdle) { + patch.status = "idle"; + patch.runId = null; + } else if (input.shouldSetRunError) { + patch.status = "error"; + patch.runId = null; + } + + intents.push({ + kind: "dispatchUpdateAgent", + agentId: input.agentId, + patch, + }); + + return intents; +}; + +export const decideRuntimeAgentEvent = ( + input: RuntimeAgentPolicyInput +): RuntimePolicyIntent[] => { + if (!isLifecycleStart(input.stream, input.phase) && input.isClosedRun) { + return [{ kind: "ignore", reason: "closed-run-event" }]; + } + if (input.activeRunId && input.activeRunId !== input.runId) { + if (!isLifecycleStart(input.stream, input.phase)) { + return [{ kind: "clearRunTracking", runId: input.runId }]; + } + } + if (!input.activeRunId && input.agentStatus !== "running") { + if (!isLifecycleStart(input.stream, input.phase)) { + return [{ kind: "clearRunTracking", runId: input.runId }]; + } + } + return []; +}; + +export const decideSummaryRefreshEvent = ( + input: RuntimeSummaryPolicyInput +): RuntimePolicyIntent[] => { + if (input.status !== "connected") return []; + if (input.event !== "presence" && input.event !== "heartbeat") return []; + return [ + { + kind: "scheduleSummaryRefresh", + delayMs: 750, + includeHeartbeatRefresh: input.event === "heartbeat", + }, + ]; +}; diff --git a/src/features/agents/state/runtimeTerminalWorkflow.ts b/src/features/agents/state/runtimeTerminalWorkflow.ts new file mode 100644 index 00000000..3f9bc5bb --- /dev/null +++ b/src/features/agents/state/runtimeTerminalWorkflow.ts @@ -0,0 +1,303 @@ +export type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback"; + +export type RuntimeTerminalRunState = { + chatFinalSeen: boolean; + terminalCommitted: boolean; + lastTerminalSeq: number | null; + commitSource: RuntimeTerminalCommitSource | null; +}; + +export type RuntimeTerminalState = { + runStateByRun: ReadonlyMap<string, RuntimeTerminalRunState>; + closedRunExpiresByRun: ReadonlyMap<string, number>; +}; + +export type RuntimeTerminalCommand = + | { kind: "scheduleLifecycleFallback"; runId: string; delayMs: number; finalText: string } + | { kind: "cancelLifecycleFallback"; runId: string } + | { kind: "clearRunTerminalState"; runId: string } + | { kind: "markRunClosed"; runId: string } + | { kind: "clearRunTracking"; runId: string }; + +export type ChatTerminalDecision = { + state: RuntimeTerminalState; + commands: RuntimeTerminalCommand[]; + isStaleTerminal: boolean; + fallbackCommittedBeforeFinal: boolean; + lastTerminalSeqBeforeFinal: number | null; + commitSourceBeforeFinal: RuntimeTerminalCommitSource | null; +}; + +type LifecycleTerminalEventDecisionInput = { + mode: "event"; + state: RuntimeTerminalState; + runId?: string | null; + phase: string; + hasPendingFallbackTimer: boolean; + fallbackDelayMs: number; + fallbackFinalText: string | null; + transitionClearsRunTracking: boolean; +}; + +type LifecycleTerminalFallbackFireDecisionInput = { + mode: "fallback-fired"; + state: RuntimeTerminalState; + runId?: string | null; +}; + +export type LifecycleTerminalDecisionInput = + | LifecycleTerminalEventDecisionInput + | LifecycleTerminalFallbackFireDecisionInput; + +export type LifecycleTerminalDecision = { + state: RuntimeTerminalState; + commands: RuntimeTerminalCommand[]; + shouldCommitFallback: boolean; + deferTransitionPatch: boolean; +}; + +const emptyRunState = (): RuntimeTerminalRunState => ({ + chatFinalSeen: false, + terminalCommitted: false, + lastTerminalSeq: null, + commitSource: null, +}); + +const normalizeRunId = (runId?: string | null): string => runId?.trim() ?? ""; + +const ensureRunState = ( + state: RuntimeTerminalState, + runId: string +): { state: RuntimeTerminalState; runState: RuntimeTerminalRunState } => { + const existing = state.runStateByRun.get(runId); + if (existing) return { state, runState: existing }; + const runStateByRun = new Map(state.runStateByRun); + const created = emptyRunState(); + runStateByRun.set(runId, created); + return { + state: { + runStateByRun, + closedRunExpiresByRun: state.closedRunExpiresByRun, + }, + runState: created, + }; +}; + +export const clearRunTerminalState = ( + state: RuntimeTerminalState, + input: { runId?: string | null } +): RuntimeTerminalState => { + const runId = normalizeRunId(input.runId); + if (!runId) return state; + if (!state.runStateByRun.has(runId)) return state; + const runStateByRun = new Map(state.runStateByRun); + runStateByRun.delete(runId); + return { + runStateByRun, + closedRunExpiresByRun: state.closedRunExpiresByRun, + }; +}; + +export const createRuntimeTerminalState = (): RuntimeTerminalState => ({ + runStateByRun: new Map<string, RuntimeTerminalRunState>(), + closedRunExpiresByRun: new Map<string, number>(), +}); + +export const applyTerminalCommit = ( + state: RuntimeTerminalState, + input: { + runId: string; + source: RuntimeTerminalCommitSource; + seq: number | null; + } +): RuntimeTerminalState => { + const runId = normalizeRunId(input.runId); + if (!runId) return state; + const current = state.runStateByRun.get(runId) ?? emptyRunState(); + const next: RuntimeTerminalRunState = { + ...current, + terminalCommitted: true, + commitSource: input.source, + chatFinalSeen: input.source === "chat-final" ? true : current.chatFinalSeen, + lastTerminalSeq: + typeof input.seq === "number" ? input.seq : current.lastTerminalSeq, + }; + const runStateByRun = new Map(state.runStateByRun); + runStateByRun.set(runId, next); + return { + runStateByRun, + closedRunExpiresByRun: state.closedRunExpiresByRun, + }; +}; + +export const deriveChatTerminalDecision = (input: { + state: RuntimeTerminalState; + runId?: string | null; + isFinal: boolean; + seq: number | null; +}): ChatTerminalDecision => { + const runId = normalizeRunId(input.runId); + if (!input.isFinal || !runId) { + return { + state: input.state, + commands: [], + isStaleTerminal: false, + fallbackCommittedBeforeFinal: false, + lastTerminalSeqBeforeFinal: null, + commitSourceBeforeFinal: null, + }; + } + + const ensured = ensureRunState(input.state, runId); + const runState = ensured.runState; + const fallbackCommittedBeforeFinal = + runState.terminalCommitted && runState.commitSource === "lifecycle-fallback"; + const isStaleTerminal = (() => { + if (!runState.terminalCommitted) return false; + if (typeof input.seq !== "number") { + return runState.commitSource === "chat-final"; + } + if (typeof runState.lastTerminalSeq !== "number") return false; + return input.seq <= runState.lastTerminalSeq; + })(); + const runStateByRun = new Map(ensured.state.runStateByRun); + runStateByRun.set(runId, { + ...runState, + chatFinalSeen: true, + }); + return { + state: { + runStateByRun, + closedRunExpiresByRun: ensured.state.closedRunExpiresByRun, + }, + commands: [{ kind: "cancelLifecycleFallback", runId }], + isStaleTerminal, + fallbackCommittedBeforeFinal, + lastTerminalSeqBeforeFinal: runState.lastTerminalSeq, + commitSourceBeforeFinal: runState.commitSource, + }; +}; + +export const deriveLifecycleTerminalDecision = ( + input: LifecycleTerminalDecisionInput +): LifecycleTerminalDecision => { + const runId = normalizeRunId(input.runId); + if (!runId) { + return { + state: input.state, + commands: [], + shouldCommitFallback: false, + deferTransitionPatch: false, + }; + } + + if (input.mode === "fallback-fired") { + const runState = input.state.runStateByRun.get(runId); + if (!runState || runState.chatFinalSeen) { + return { + state: input.state, + commands: [], + shouldCommitFallback: false, + deferTransitionPatch: false, + }; + } + return { + state: input.state, + commands: [ + { kind: "markRunClosed", runId }, + { kind: "clearRunTracking", runId }, + ], + shouldCommitFallback: true, + deferTransitionPatch: false, + }; + } + + const ensured = ensureRunState(input.state, runId); + const runState = ensured.runState; + const commands: RuntimeTerminalCommand[] = []; + let state = ensured.state; + let deferTransitionPatch = false; + + const shouldScheduleFallback = input.phase === "end" && !runState.chatFinalSeen; + if (shouldScheduleFallback) { + if (input.fallbackFinalText) { + commands.push({ kind: "cancelLifecycleFallback", runId }); + commands.push({ + kind: "scheduleLifecycleFallback", + runId, + delayMs: input.fallbackDelayMs, + finalText: input.fallbackFinalText, + }); + deferTransitionPatch = true; + } else { + commands.push({ kind: "clearRunTerminalState", runId }); + state = clearRunTerminalState(state, { runId }); + } + } else if (input.hasPendingFallbackTimer) { + commands.push({ kind: "cancelLifecycleFallback", runId }); + if (!runState.terminalCommitted && !runState.chatFinalSeen) { + commands.push({ kind: "clearRunTerminalState", runId }); + state = clearRunTerminalState(state, { runId }); + } + } + + if (input.transitionClearsRunTracking && !deferTransitionPatch) { + commands.push({ kind: "markRunClosed", runId }); + commands.push({ kind: "clearRunTracking", runId }); + } + + return { + state, + commands, + shouldCommitFallback: false, + deferTransitionPatch, + }; +}; + +export const markClosedRun = ( + state: RuntimeTerminalState, + input: { runId?: string | null; now: number; ttlMs: number } +): RuntimeTerminalState => { + const runId = normalizeRunId(input.runId); + if (!runId) return state; + const closedRunExpiresByRun = new Map(state.closedRunExpiresByRun); + closedRunExpiresByRun.set(runId, input.now + input.ttlMs); + return { + runStateByRun: state.runStateByRun, + closedRunExpiresByRun, + }; +}; + +export const pruneClosedRuns = ( + state: RuntimeTerminalState, + input: { at: number } +): { state: RuntimeTerminalState; expiredRunIds: string[] } => { + const expiredRunIds: string[] = []; + const closedRunExpiresByRun = new Map(state.closedRunExpiresByRun); + for (const [runId, expiresAt] of closedRunExpiresByRun.entries()) { + if (expiresAt <= input.at) { + closedRunExpiresByRun.delete(runId); + expiredRunIds.push(runId); + } + } + if (expiredRunIds.length === 0) { + return { state, expiredRunIds }; + } + const runStateByRun = new Map(state.runStateByRun); + for (const runId of expiredRunIds) { + runStateByRun.delete(runId); + } + return { + state: { + runStateByRun, + closedRunExpiresByRun, + }, + expiredRunIds, + }; +}; + +export const isClosedRun = (state: RuntimeTerminalState, runId?: string | null): boolean => { + const key = normalizeRunId(runId); + if (!key) return false; + return state.closedRunExpiresByRun.has(key); +}; diff --git a/src/features/agents/state/sessionSettingsMutations.ts b/src/features/agents/state/sessionSettingsMutations.ts new file mode 100644 index 00000000..ed356283 --- /dev/null +++ b/src/features/agents/state/sessionSettingsMutations.ts @@ -0,0 +1,141 @@ +import { + isWebchatSessionMutationBlockedError, + syncGatewaySessionSettings, + type GatewayClient, + type GatewaySessionsPatchResult, +} from "@/lib/gateway/GatewayClient"; + +type SessionSettingField = "model" | "thinkingLevel"; + +type AgentSessionState = { + agentId: string; + sessionCreated: boolean; + model?: string | null; + thinkingLevel?: string | null; +}; + +type SessionSettingsDispatchAction = + | { + type: "updateAgent"; + agentId: string; + patch: { + model?: string | null; + thinkingLevel?: string | null; + sessionSettingsSynced?: boolean; + sessionCreated?: boolean; + }; + } + | { + type: "appendOutput"; + agentId: string; + line: string; + }; + +type SessionSettingsDispatch = (action: SessionSettingsDispatchAction) => void; + +export type ApplySessionSettingMutationParams = { + agents: AgentSessionState[]; + dispatch: SessionSettingsDispatch; + client: GatewayClient; + agentId: string; + sessionKey: string; + field: SessionSettingField; + value: string | null; +}; + +const buildFallbackError = (field: SessionSettingField) => + field === "model" ? "Failed to set model." : "Failed to set thinking level."; + +const buildErrorPrefix = (field: SessionSettingField) => + field === "model" ? "Model update failed" : "Thinking update failed"; + +const buildWebchatBlockedMessage = (field: SessionSettingField) => + field === "model" + ? "Model update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works." + : "Thinking level update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works."; + +export const applySessionSettingMutation = async ({ + agents, + dispatch, + client, + agentId, + sessionKey, + field, + value, +}: ApplySessionSettingMutationParams) => { + const targetAgent = agents.find((candidate) => candidate.agentId === agentId) ?? null; + const previousModel = targetAgent?.model ?? null; + const previousThinkingLevel = targetAgent?.thinkingLevel ?? null; + dispatch({ + type: "updateAgent", + agentId, + patch: { + [field]: value, + sessionSettingsSynced: false, + }, + }); + try { + const result = await syncGatewaySessionSettings({ + client, + sessionKey, + ...(field === "model" ? { model: value ?? null } : { thinkingLevel: value ?? null }), + }); + const patch: { + model?: string | null; + thinkingLevel?: string | null; + sessionSettingsSynced: boolean; + sessionCreated: boolean; + } = { sessionSettingsSynced: true, sessionCreated: true }; + if (field === "model") { + const resolvedModel = resolveModelFromPatchResult(result); + if (resolvedModel !== undefined) { + patch.model = resolvedModel; + } + } else { + const nextThinkingLevel = + typeof result.entry?.thinkingLevel === "string" ? result.entry.thinkingLevel : undefined; + if (nextThinkingLevel !== undefined) { + patch.thinkingLevel = nextThinkingLevel; + } + } + dispatch({ + type: "updateAgent", + agentId, + patch, + }); + } catch (err) { + if (isWebchatSessionMutationBlockedError(err)) { + dispatch({ + type: "updateAgent", + agentId, + patch: { + ...(field === "model" + ? { model: previousModel } + : { thinkingLevel: previousThinkingLevel }), + sessionSettingsSynced: true, + sessionCreated: true, + }, + }); + dispatch({ + type: "appendOutput", + agentId, + line: buildWebchatBlockedMessage(field), + }); + return; + } + const msg = err instanceof Error ? err.message : buildFallbackError(field); + dispatch({ + type: "appendOutput", + agentId, + line: `${buildErrorPrefix(field)}: ${msg}`, + }); + } +}; + +const resolveModelFromPatchResult = (result: GatewaySessionsPatchResult): string | null | undefined => { + const provider = + typeof result.resolved?.modelProvider === "string" ? result.resolved.modelProvider.trim() : ""; + const model = typeof result.resolved?.model === "string" ? result.resolved.model.trim() : ""; + if (!provider || !model) return undefined; + return `${provider}/${model}`; +}; diff --git a/src/features/agents/state/store.tsx b/src/features/agents/state/store.tsx new file mode 100644 index 00000000..6c802a41 --- /dev/null +++ b/src/features/agents/state/store.tsx @@ -0,0 +1,585 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + type ReactNode, +} from "react"; +import { + areTranscriptEntriesEqual, + buildOutputLinesFromTranscriptEntries, + buildTranscriptEntriesFromLines, + createTranscriptEntryFromLine, + sortTranscriptEntries, + TRANSCRIPT_V2_ENABLED, + type TranscriptAppendMeta, + type TranscriptEntry, +} from "@/features/agents/state/transcript"; + +export type AgentStatus = "idle" | "running" | "error"; +export type FocusFilter = "all" | "running" | "approvals"; + +export type AgentStoreSeed = { + agentId: string; + name: string; + sessionKey: string; + avatarSeed?: string | null; + avatarUrl?: string | null; + model?: string | null; + thinkingLevel?: string | null; + sessionExecHost?: "sandbox" | "gateway" | "node"; + sessionExecSecurity?: "deny" | "allowlist" | "full"; + sessionExecAsk?: "off" | "on-miss" | "always"; + toolCallingEnabled?: boolean; + showThinkingTraces?: boolean; +}; + +export type AgentState = AgentStoreSeed & { + status: AgentStatus; + sessionCreated: boolean; + awaitingUserInput: boolean; + hasUnseenActivity: boolean; + outputLines: string[]; + lastResult: string | null; + lastDiff: string | null; + runId: string | null; + runStartedAt: number | null; + streamText: string | null; + thinkingTrace: string | null; + latestOverride: string | null; + latestOverrideKind: "heartbeat" | "cron" | null; + lastAssistantMessageAt: number | null; + lastActivityAt: number | null; + latestPreview: string | null; + lastUserMessage: string | null; + draft: string; + queuedMessages?: string[]; + sessionSettingsSynced: boolean; + historyLoadedAt: number | null; + historyFetchLimit: number | null; + historyFetchedCount: number | null; + historyMaybeTruncated: boolean; + toolCallingEnabled: boolean; + showThinkingTraces: boolean; + transcriptEntries?: TranscriptEntry[]; + transcriptRevision?: number; + transcriptSequenceCounter?: number; + sessionEpoch?: number; + lastHistoryRequestRevision?: number | null; + lastAppliedHistoryRequestId?: string | null; +}; + +export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState> => { + return { + sessionKey: agent.sessionKey, + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + outputLines: [], + lastResult: null, + lastDiff: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + queuedMessages: [], + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + awaitingUserInput: false, + hasUnseenActivity: false, + sessionCreated: true, + sessionSettingsSynced: true, + transcriptEntries: [], + transcriptRevision: (agent.transcriptRevision ?? 0) + 1, + transcriptSequenceCounter: 0, + sessionEpoch: (agent.sessionEpoch ?? 0) + 1, + lastHistoryRequestRevision: null, + lastAppliedHistoryRequestId: null, + }; +}; + +export type AgentStoreState = { + agents: AgentState[]; + selectedAgentId: string | null; + loading: boolean; + error: string | null; +}; + +type Action = + | { type: "hydrateAgents"; agents: AgentStoreSeed[]; selectedAgentId?: string } + | { type: "setError"; error: string | null } + | { type: "setLoading"; loading: boolean } + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta } + | { type: "enqueueQueuedMessage"; agentId: string; message: string } + | { type: "removeQueuedMessage"; agentId: string; index: number } + | { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string } + | { type: "markActivity"; agentId: string; at?: number } + | { type: "selectAgent"; agentId: string | null }; + +const initialState: AgentStoreState = { + agents: [], + selectedAgentId: null, + loading: false, + error: null, +}; + +const areStringArraysEqual = (left: string[], right: string[]): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +}; + +const ensureTranscriptEntries = (agent: AgentState): TranscriptEntry[] => { + if (Array.isArray(agent.transcriptEntries)) { + return agent.transcriptEntries; + } + return buildTranscriptEntriesFromLines({ + lines: agent.outputLines, + sessionKey: agent.sessionKey, + source: "legacy", + startSequence: 0, + confirmed: true, + }); +}; + +const nextTranscriptSequenceCounter = ( + currentCounter: number | undefined, + entries: TranscriptEntry[] +): number => { + const derived = entries.reduce((max, entry) => Math.max(max, entry.sequenceKey + 1), 0); + return Math.max(currentCounter ?? 0, derived); +}; + +const createRuntimeAgentState = ( + seed: AgentStoreSeed, + existing?: AgentState | null +): AgentState => { + const sameSessionKey = existing?.sessionKey === seed.sessionKey; + const outputLines = sameSessionKey ? (existing?.outputLines ?? []) : []; + const queuedMessages = sameSessionKey ? [...(existing?.queuedMessages ?? [])] : []; + const transcriptEntries = sameSessionKey + ? Array.isArray(existing?.transcriptEntries) + ? existing.transcriptEntries + : buildTranscriptEntriesFromLines({ + lines: outputLines, + sessionKey: seed.sessionKey, + source: "legacy", + startSequence: 0, + confirmed: true, + }) + : []; + return { + ...seed, + avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId, + avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null, + model: seed.model ?? existing?.model ?? null, + thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high", + sessionExecHost: seed.sessionExecHost ?? existing?.sessionExecHost, + sessionExecSecurity: seed.sessionExecSecurity ?? existing?.sessionExecSecurity, + sessionExecAsk: seed.sessionExecAsk ?? existing?.sessionExecAsk, + status: sameSessionKey ? (existing?.status ?? "idle") : "idle", + sessionCreated: sameSessionKey ? (existing?.sessionCreated ?? false) : false, + awaitingUserInput: sameSessionKey ? (existing?.awaitingUserInput ?? false) : false, + hasUnseenActivity: sameSessionKey ? (existing?.hasUnseenActivity ?? false) : false, + outputLines, + lastResult: sameSessionKey ? (existing?.lastResult ?? null) : null, + lastDiff: sameSessionKey ? (existing?.lastDiff ?? null) : null, + runId: sameSessionKey ? (existing?.runId ?? null) : null, + runStartedAt: sameSessionKey ? (existing?.runStartedAt ?? null) : null, + streamText: sameSessionKey ? (existing?.streamText ?? null) : null, + thinkingTrace: sameSessionKey ? (existing?.thinkingTrace ?? null) : null, + latestOverride: sameSessionKey ? (existing?.latestOverride ?? null) : null, + latestOverrideKind: sameSessionKey ? (existing?.latestOverrideKind ?? null) : null, + lastAssistantMessageAt: sameSessionKey ? (existing?.lastAssistantMessageAt ?? null) : null, + lastActivityAt: sameSessionKey ? (existing?.lastActivityAt ?? null) : null, + latestPreview: sameSessionKey ? (existing?.latestPreview ?? null) : null, + lastUserMessage: sameSessionKey ? (existing?.lastUserMessage ?? null) : null, + draft: sameSessionKey ? (existing?.draft ?? "") : "", + queuedMessages, + sessionSettingsSynced: sameSessionKey ? (existing?.sessionSettingsSynced ?? false) : false, + historyLoadedAt: sameSessionKey ? (existing?.historyLoadedAt ?? null) : null, + historyFetchLimit: sameSessionKey ? (existing?.historyFetchLimit ?? null) : null, + historyFetchedCount: sameSessionKey ? (existing?.historyFetchedCount ?? null) : null, + historyMaybeTruncated: sameSessionKey ? (existing?.historyMaybeTruncated ?? false) : false, + toolCallingEnabled: seed.toolCallingEnabled ?? existing?.toolCallingEnabled ?? false, + showThinkingTraces: seed.showThinkingTraces ?? existing?.showThinkingTraces ?? true, + transcriptEntries, + transcriptRevision: sameSessionKey + ? (existing?.transcriptRevision ?? outputLines.length) + : 0, + transcriptSequenceCounter: sameSessionKey + ? (existing?.transcriptSequenceCounter ?? + nextTranscriptSequenceCounter(existing?.transcriptSequenceCounter, transcriptEntries)) + : 0, + sessionEpoch: sameSessionKey + ? (existing?.sessionEpoch ?? 0) + : (existing?.sessionEpoch ?? 0) + 1, + lastHistoryRequestRevision: sameSessionKey + ? (existing?.lastHistoryRequestRevision ?? null) + : null, + lastAppliedHistoryRequestId: sameSessionKey + ? (existing?.lastAppliedHistoryRequestId ?? null) + : null, + }; +}; + +const reducer = (state: AgentStoreState, action: Action): AgentStoreState => { + switch (action.type) { + case "hydrateAgents": { + const byId = new Map(state.agents.map((agent) => [agent.agentId, agent])); + const agents = action.agents.map((seed) => + createRuntimeAgentState(seed, byId.get(seed.agentId)) + ); + const requestedSelectedAgentId = action.selectedAgentId?.trim() ?? ""; + const selectedAgentId = + requestedSelectedAgentId && + agents.some((agent) => agent.agentId === requestedSelectedAgentId) + ? requestedSelectedAgentId + : state.selectedAgentId && + agents.some((agent) => agent.agentId === state.selectedAgentId) + ? state.selectedAgentId + : agents[0]?.agentId ?? null; + return { + ...state, + agents, + selectedAgentId, + loading: false, + error: null, + }; + } + case "setError": + return { ...state, error: action.error, loading: false }; + case "setLoading": + return { ...state, loading: action.loading }; + case "updateAgent": + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const patch = action.patch; + const nextSessionKey = (patch.sessionKey ?? agent.sessionKey).trim(); + const sessionKeyChanged = nextSessionKey !== agent.sessionKey.trim(); + const patchHasTranscriptEntries = Array.isArray(patch.transcriptEntries); + const patchHasOutputLines = Array.isArray(patch.outputLines); + const patchMutatesTranscript = patchHasTranscriptEntries || patchHasOutputLines; + + const existingEntries = ensureTranscriptEntries(agent); + const base: AgentState = { ...agent, ...patch }; + let nextEntries: TranscriptEntry[] = existingEntries; + if (Array.isArray(base.transcriptEntries)) { + nextEntries = base.transcriptEntries as TranscriptEntry[]; + } + let nextOutputLines: string[] = agent.outputLines; + if (Array.isArray(base.outputLines)) { + nextOutputLines = base.outputLines as string[]; + } + let transcriptMutated = false; + + if (patchHasTranscriptEntries) { + const patchedTranscriptEntries = patch.transcriptEntries as TranscriptEntry[]; + const normalized = TRANSCRIPT_V2_ENABLED + ? sortTranscriptEntries(patchedTranscriptEntries) + : [...patchedTranscriptEntries]; + transcriptMutated = !areTranscriptEntriesEqual(existingEntries, normalized); + nextEntries = normalized; + nextOutputLines = buildOutputLinesFromTranscriptEntries(normalized); + } else if (patchHasOutputLines) { + const patchedOutputLines = patch.outputLines as string[]; + const rebuilt = buildTranscriptEntriesFromLines({ + lines: patchedOutputLines, + sessionKey: nextSessionKey || agent.sessionKey, + source: "legacy", + startSequence: 0, + confirmed: true, + }); + const normalized = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(rebuilt) : rebuilt; + transcriptMutated = !areStringArraysEqual(agent.outputLines, patchedOutputLines); + nextEntries = normalized; + nextOutputLines = TRANSCRIPT_V2_ENABLED + ? buildOutputLinesFromTranscriptEntries(normalized) + : [...patchedOutputLines]; + } + + const revision = transcriptMutated + ? (agent.transcriptRevision ?? 0) + 1 + : (patch.transcriptRevision ?? agent.transcriptRevision ?? 0); + const nextCounter = patchMutatesTranscript + ? nextTranscriptSequenceCounter(base.transcriptSequenceCounter, nextEntries) + : (base.transcriptSequenceCounter ?? agent.transcriptSequenceCounter ?? 0); + + return { + ...base, + outputLines: nextOutputLines, + transcriptEntries: nextEntries, + transcriptRevision: revision, + transcriptSequenceCounter: nextCounter, + sessionEpoch: + patch.sessionEpoch !== undefined + ? patch.sessionEpoch + : sessionKeyChanged + ? (agent.sessionEpoch ?? 0) + 1 + : (agent.sessionEpoch ?? 0), + }; + }), + }; + case "appendOutput": + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const existingEntries = ensureTranscriptEntries(agent); + const nextSequence = nextTranscriptSequenceCounter( + agent.transcriptSequenceCounter, + existingEntries + ); + const nextEntry = createTranscriptEntryFromLine({ + line: action.line, + sessionKey: action.transcript?.sessionKey ?? agent.sessionKey, + source: action.transcript?.source ?? "legacy", + runId: action.transcript?.runId ?? agent.runId, + timestampMs: action.transcript?.timestampMs, + fallbackTimestampMs: action.transcript?.timestampMs ?? Date.now(), + role: action.transcript?.role, + kind: action.transcript?.kind, + entryId: action.transcript?.entryId, + confirmed: action.transcript?.confirmed, + sequenceKey: nextSequence, + }); + if (!nextEntry) { + return { ...agent, outputLines: [...agent.outputLines, action.line] }; + } + const nextEntryId = nextEntry.entryId.trim(); + const existingIndex = + nextEntryId.length > 0 + ? existingEntries.findIndex((entry) => entry.entryId === nextEntryId) + : -1; + const hasReplacement = existingIndex >= 0; + + let nextEntries: TranscriptEntry[]; + if (hasReplacement) { + let replacedOne = false; + const replaced = existingEntries.reduce<TranscriptEntry[]>((acc, entry) => { + if (entry.entryId !== nextEntryId) { + acc.push(entry); + return acc; + } + if (replacedOne) { + return acc; + } + replacedOne = true; + acc.push({ + ...nextEntry, + sequenceKey: entry.sequenceKey, + }); + return acc; + }, []); + nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(replaced) : replaced; + } else { + const appended = [...existingEntries, nextEntry]; + nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(appended) : appended; + } + + return { + ...agent, + outputLines: + TRANSCRIPT_V2_ENABLED || hasReplacement + ? buildOutputLinesFromTranscriptEntries(nextEntries) + : [...agent.outputLines, action.line], + transcriptEntries: nextEntries, + transcriptRevision: (agent.transcriptRevision ?? 0) + 1, + transcriptSequenceCounter: Math.max( + agent.transcriptSequenceCounter ?? 0, + nextEntry.sequenceKey + 1 + ), + }; + }), + }; + case "enqueueQueuedMessage": + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const message = action.message.trim(); + if (!message) return agent; + const queuedMessages = [...(agent.queuedMessages ?? []), message]; + return { ...agent, queuedMessages }; + }), + }; + case "removeQueuedMessage": + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + if (!Number.isInteger(action.index) || action.index < 0) return agent; + const queuedMessages = agent.queuedMessages ?? []; + if (action.index >= queuedMessages.length) return agent; + return { + ...agent, + queuedMessages: queuedMessages.filter((_, index) => index !== action.index), + }; + }), + }; + case "shiftQueuedMessage": + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const queuedMessages = agent.queuedMessages ?? []; + if (queuedMessages.length === 0) return agent; + if ( + action.expectedMessage !== undefined && + action.expectedMessage.trim() !== queuedMessages[0] + ) { + return agent; + } + return { ...agent, queuedMessages: queuedMessages.slice(1) }; + }), + }; + case "markActivity": { + const at = action.at ?? Date.now(); + return { + ...state, + agents: state.agents.map((agent) => { + if (agent.agentId !== action.agentId) return agent; + const isSelected = state.selectedAgentId === action.agentId; + return { + ...agent, + lastActivityAt: at, + hasUnseenActivity: isSelected ? false : true, + }; + }), + }; + } + case "selectAgent": { + if (action.agentId === state.selectedAgentId) { + if (action.agentId === null) { + return state; + } + const selected = state.agents.find((agent) => agent.agentId === action.agentId) ?? null; + if (!selected || !selected.hasUnseenActivity) { + return state; + } + } + return { + ...state, + selectedAgentId: action.agentId, + agents: + action.agentId === null + ? state.agents + : state.agents.map((agent) => + agent.agentId === action.agentId + ? { ...agent, hasUnseenActivity: false } + : agent + ), + }; + } + default: + return state; + } +}; + +export const agentStoreReducer = reducer; +export const initialAgentStoreState = initialState; + +type AgentStoreContextValue = { + state: AgentStoreState; + dispatch: React.Dispatch<Action>; + hydrateAgents: (agents: AgentStoreSeed[], selectedAgentId?: string) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; +}; + +const AgentStoreContext = createContext<AgentStoreContextValue | null>(null); + +export const AgentStoreProvider = ({ children }: { children: ReactNode }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + const hydrateAgents = useCallback( + (agents: AgentStoreSeed[], selectedAgentId?: string) => { + dispatch({ type: "hydrateAgents", agents, selectedAgentId }); + }, + [dispatch] + ); + + const setLoading = useCallback( + (loading: boolean) => dispatch({ type: "setLoading", loading }), + [dispatch] + ); + + const setError = useCallback( + (error: string | null) => dispatch({ type: "setError", error }), + [dispatch] + ); + + const value = useMemo( + () => ({ state, dispatch, hydrateAgents, setLoading, setError }), + [dispatch, hydrateAgents, setError, setLoading, state] + ); + + return ( + <AgentStoreContext.Provider value={value}>{children}</AgentStoreContext.Provider> + ); +}; + +export const useAgentStore = () => { + const ctx = useContext(AgentStoreContext); + if (!ctx) { + throw new Error("AgentStoreProvider is missing."); + } + return ctx; +}; + +export const getSelectedAgent = (state: AgentStoreState): AgentState | null => { + if (!state.selectedAgentId) return null; + return state.agents.find((agent) => agent.agentId === state.selectedAgentId) ?? null; +}; + +export const getFilteredAgents = (state: AgentStoreState, filter: FocusFilter): AgentState[] => { + const statusPriority: Record<AgentStatus, number> = { + running: 0, + idle: 1, + error: 2, + }; + const getActivityTimestamp = (agent: AgentState) => + Math.max(agent.lastActivityAt ?? 0, agent.runStartedAt ?? 0, agent.lastAssistantMessageAt ?? 0); + const sortAgents = (agents: AgentState[], prioritizeStatus: boolean) => + agents + .map((agent, index) => ({ agent, index })) + .sort((left, right) => { + if (prioritizeStatus) { + const statusDelta = + statusPriority[left.agent.status] - statusPriority[right.agent.status]; + if (statusDelta !== 0) return statusDelta; + } + const timeDelta = getActivityTimestamp(right.agent) - getActivityTimestamp(left.agent); + if (timeDelta !== 0) return timeDelta; + return left.index - right.index; + }) + .map(({ agent }) => agent); + switch (filter) { + case "all": + return sortAgents(state.agents, true); + case "running": + return sortAgents(state.agents.filter((agent) => agent.status === "running"), false); + case "approvals": + return sortAgents(state.agents.filter((agent) => agent.awaitingUserInput), false); + default: { + const _exhaustive: never = filter; + void _exhaustive; + return sortAgents(state.agents, true); + } + } +}; diff --git a/src/features/agents/state/transcript.ts b/src/features/agents/state/transcript.ts new file mode 100644 index 00000000..7ebaa616 --- /dev/null +++ b/src/features/agents/state/transcript.ts @@ -0,0 +1,433 @@ +import { + isMetaMarkdown, + isToolMarkdown, + isTraceMarkdown, + parseMetaMarkdown, +} from "@/lib/text/message-extract"; + +const ENABLED_RE = /^(1|true|yes|on)$/i; + +const readBooleanFlag = (value: string | undefined): boolean => { + return ENABLED_RE.test((value ?? "").trim()); +}; + +export const TRANSCRIPT_V2_ENABLED = readBooleanFlag( + process.env.NEXT_PUBLIC_STUDIO_TRANSCRIPT_V2 +); + +export const TRANSCRIPT_DEBUG_ENABLED = readBooleanFlag( + process.env.NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG +); + +export const logTranscriptDebugMetric = (metric: string, meta?: unknown) => { + if (!TRANSCRIPT_DEBUG_ENABLED) return; + if (meta === undefined) { + console.debug(`[transcript] ${metric}`); + return; + } + console.debug(`[transcript] ${metric}`, meta); +}; + +export type TranscriptEntryKind = "meta" | "user" | "assistant" | "thinking" | "tool"; + +export type TranscriptEntryRole = "user" | "assistant" | "tool" | "system" | "other"; + +export type TranscriptEntrySource = + | "local-send" + | "runtime-chat" + | "runtime-agent" + | "history" + | "legacy"; + +export type TranscriptEntry = { + entryId: string; + role: TranscriptEntryRole; + kind: TranscriptEntryKind; + text: string; + sessionKey: string; + runId: string | null; + source: TranscriptEntrySource; + timestampMs: number | null; + sequenceKey: number; + confirmed: boolean; + fingerprint: string; +}; + +export type TranscriptAppendMeta = { + source?: TranscriptEntrySource; + runId?: string | null; + sessionKey?: string; + timestampMs?: number | null; + role?: TranscriptEntryRole; + kind?: TranscriptEntryKind; + entryId?: string; + confirmed?: boolean; +}; + +export type BuildTranscriptEntriesFromLinesParams = { + lines: string[]; + sessionKey: string; + source: TranscriptEntrySource; + runId?: string | null; + startSequence?: number; + defaultTimestampMs?: number | null; + confirmed?: boolean; + entryIdPrefix?: string; +}; + +export type MergeTranscriptEntriesResult = { + entries: TranscriptEntry[]; + mergedCount: number; + confirmedCount: number; + conflictCount: number; +}; + +const BUCKET_MS = 2_000; + +const normalizeComparableText = (value: string): string => { + return value.replace(/\s+/g, " ").trim(); +}; + +const fnv1a = (value: string): string => { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +}; + +const toBucket = (timestampMs: number | null): string => { + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "none"; + return String(Math.floor(timestampMs / BUCKET_MS)); +}; + +const resolveKindRoleFromLine = ( + line: string, + overrides?: { kind?: TranscriptEntryKind; role?: TranscriptEntryRole } +): { kind: TranscriptEntryKind; role: TranscriptEntryRole } => { + if (overrides?.kind && overrides?.role) { + return { kind: overrides.kind, role: overrides.role }; + } + if (overrides?.kind) { + const roleByKind: Record<TranscriptEntryKind, TranscriptEntryRole> = { + meta: "other", + user: "user", + assistant: "assistant", + thinking: "assistant", + tool: "tool", + }; + return { kind: overrides.kind, role: overrides.role ?? roleByKind[overrides.kind] }; + } + if (isMetaMarkdown(line)) { + const parsed = parseMetaMarkdown(line); + const role = parsed?.role ?? overrides?.role ?? "other"; + return { kind: "meta", role }; + } + if (line.trim().startsWith(">")) { + return { kind: "user", role: "user" }; + } + if (isTraceMarkdown(line)) { + return { kind: "thinking", role: "assistant" }; + } + if (isToolMarkdown(line)) { + return { kind: "tool", role: "tool" }; + } + return { kind: overrides?.kind ?? "assistant", role: overrides?.role ?? "assistant" }; +}; + +const resolveTimestampForLine = ( + line: string, + fallback: number | null, + explicit?: number | null +): number | null => { + if (typeof explicit === "number" && Number.isFinite(explicit)) { + return explicit; + } + if (isMetaMarkdown(line)) { + const parsed = parseMetaMarkdown(line); + if (parsed && typeof parsed.timestamp === "number") { + return parsed.timestamp; + } + } + return fallback; +}; + +const buildFingerprint = (entry: { + role: TranscriptEntryRole; + kind: TranscriptEntryKind; + text: string; + sessionKey: string; + runId: string | null; + timestampMs: number | null; +}) => { + const normalized = normalizeComparableText(entry.text); + const seed = [ + entry.role, + entry.kind, + normalized, + entry.sessionKey.trim(), + entry.runId?.trim() ?? "", + toBucket(entry.timestampMs), + ].join("|"); + return fnv1a(seed); +}; + +const hasNumericTimestamp = (value: number | null): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; + +const compareEntries = (a: TranscriptEntry, b: TranscriptEntry): number => { + const aTimestamp = a.timestampMs; + const bTimestamp = b.timestampMs; + const aHasTs = hasNumericTimestamp(aTimestamp); + const bHasTs = hasNumericTimestamp(bTimestamp); + if (aHasTs && bHasTs) { + const aTs = aTimestamp as number; + const bTs = bTimestamp as number; + if (aTs !== bTs) { + return aTs - bTs; + } + } + return a.sequenceKey - b.sequenceKey; +}; + +const withUniqueEntryIds = (entries: TranscriptEntry[]): TranscriptEntry[] => { + const next: TranscriptEntry[] = []; + const seen = new Set<string>(); + for (const entry of entries) { + if (seen.has(entry.entryId)) continue; + seen.add(entry.entryId); + next.push(entry); + } + return next; +}; + +export const sortTranscriptEntries = (entries: TranscriptEntry[]): TranscriptEntry[] => { + const deduped = withUniqueEntryIds(entries); + return [...deduped].sort(compareEntries); +}; + +export const buildOutputLinesFromTranscriptEntries = ( + entries: TranscriptEntry[] +): string[] => { + return entries.map((entry) => entry.text); +}; + +export const areTranscriptEntriesEqual = ( + left: TranscriptEntry[], + right: TranscriptEntry[] +): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + const a = left[i]; + const b = right[i]; + if (!a || !b) return false; + if (a.entryId !== b.entryId) return false; + if (a.text !== b.text) return false; + if (a.timestampMs !== b.timestampMs) return false; + if (a.confirmed !== b.confirmed) return false; + } + return true; +}; + +export const createTranscriptEntryFromLine = (params: { + line: string; + sessionKey: string; + source: TranscriptEntrySource; + sequenceKey: number; + runId?: string | null; + timestampMs?: number | null; + fallbackTimestampMs?: number | null; + role?: TranscriptEntryRole; + kind?: TranscriptEntryKind; + entryId?: string; + confirmed?: boolean; +}): TranscriptEntry | null => { + const text = params.line; + if (!text) return null; + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) return null; + const resolved = resolveKindRoleFromLine(text, { + kind: params.kind, + role: params.role, + }); + const timestampMs = resolveTimestampForLine( + text, + params.fallbackTimestampMs ?? null, + params.timestampMs + ); + const runId = params.runId?.trim() || null; + const fingerprint = buildFingerprint({ + role: resolved.role, + kind: resolved.kind, + text, + sessionKey, + runId, + timestampMs, + }); + const entryId = + params.entryId?.trim() || + `${params.source}:${sessionKey}:${params.sequenceKey}:${resolved.kind}:${fingerprint}`; + return { + entryId, + role: resolved.role, + kind: resolved.kind, + text, + sessionKey, + runId, + source: params.source, + timestampMs, + sequenceKey: params.sequenceKey, + confirmed: params.confirmed ?? params.source === "history", + fingerprint, + }; +}; + +export const buildTranscriptEntriesFromLines = ({ + lines, + sessionKey, + source, + runId, + startSequence = 0, + defaultTimestampMs = null, + confirmed, + entryIdPrefix, +}: BuildTranscriptEntriesFromLinesParams): TranscriptEntry[] => { + const entries: TranscriptEntry[] = []; + let cursor = startSequence; + let activeTimestamp = defaultTimestampMs; + for (const line of lines) { + const parsedMeta = isMetaMarkdown(line) ? parseMetaMarkdown(line) : null; + if (parsedMeta && typeof parsedMeta.timestamp === "number") { + activeTimestamp = parsedMeta.timestamp; + } + const entry = createTranscriptEntryFromLine({ + line, + sessionKey, + source, + runId, + sequenceKey: cursor, + timestampMs: parsedMeta?.timestamp ?? undefined, + fallbackTimestampMs: activeTimestamp, + role: parsedMeta?.role, + kind: parsedMeta ? "meta" : undefined, + confirmed, + entryId: entryIdPrefix + ? `${entryIdPrefix}:${cursor}:${fnv1a(line)}` + : undefined, + }); + cursor += 1; + if (!entry) continue; + entries.push(entry); + } + return entries; +}; + +const resolveCandidateTimestampDelta = ( + candidate: TranscriptEntry, + target: TranscriptEntry +): number => { + if (!hasNumericTimestamp(candidate.timestampMs) || !hasNumericTimestamp(target.timestampMs)) { + return Number.MAX_SAFE_INTEGER; + } + return Math.abs(candidate.timestampMs - target.timestampMs); +}; + +const findHistoryMatchCandidateIndex = ( + existing: TranscriptEntry[], + historyEntry: TranscriptEntry, + matchedCandidateIndexes: Set<number> +): { index: number; conflict: boolean } | null => { + const normalizedTarget = normalizeComparableText(historyEntry.text); + const candidates: number[] = []; + for (let i = 0; i < existing.length; i += 1) { + const candidate = existing[i]; + if (!candidate) continue; + if (matchedCandidateIndexes.has(i)) continue; + if (candidate.sessionKey !== historyEntry.sessionKey) continue; + if (candidate.kind !== historyEntry.kind || candidate.role !== historyEntry.role) continue; + if (normalizeComparableText(candidate.text) !== normalizedTarget) continue; + candidates.push(i); + } + if (candidates.length === 0) return null; + if (candidates.length === 1) { + return { index: candidates[0]!, conflict: false }; + } + let bestIndex = candidates[0]!; + let bestDelta = resolveCandidateTimestampDelta(existing[bestIndex]!, historyEntry); + for (let i = 1; i < candidates.length; i += 1) { + const index = candidates[i]!; + const candidate = existing[index]!; + const delta = resolveCandidateTimestampDelta(candidate, historyEntry); + if (delta < bestDelta) { + bestIndex = index; + bestDelta = delta; + continue; + } + if (delta === bestDelta && candidate.sequenceKey < existing[bestIndex]!.sequenceKey) { + bestIndex = index; + } + } + return { index: bestIndex, conflict: true }; +}; + +export const mergeTranscriptEntriesWithHistory = (params: { + existingEntries: TranscriptEntry[]; + historyEntries: TranscriptEntry[]; +}): MergeTranscriptEntriesResult => { + const next = [...params.existingEntries]; + const matchedCandidateIndexes = new Set<number>(); + const byEntryId = new Map<string, number>(); + for (let i = 0; i < next.length; i += 1) { + byEntryId.set(next[i]!.entryId, i); + } + let mergedCount = 0; + let confirmedCount = 0; + let conflictCount = 0; + + for (const historyEntry of params.historyEntries) { + const existingById = byEntryId.get(historyEntry.entryId); + if (typeof existingById === "number") { + const current = next[existingById]!; + next[existingById] = { + ...current, + confirmed: true, + timestampMs: historyEntry.timestampMs ?? current.timestampMs, + }; + matchedCandidateIndexes.add(existingById); + continue; + } + + const matched = findHistoryMatchCandidateIndex(next, historyEntry, matchedCandidateIndexes); + if (matched) { + if (matched.conflict) { + conflictCount += 1; + } + const current = next[matched.index]!; + next[matched.index] = { + ...current, + confirmed: true, + timestampMs: historyEntry.timestampMs ?? current.timestampMs, + runId: current.runId ?? historyEntry.runId, + }; + confirmedCount += 1; + matchedCandidateIndexes.add(matched.index); + byEntryId.set(historyEntry.entryId, matched.index); + continue; + } + + const appendedIndex = next.length; + next.push(historyEntry); + byEntryId.set(historyEntry.entryId, appendedIndex); + matchedCandidateIndexes.add(appendedIndex); + mergedCount += 1; + } + + return { + entries: sortTranscriptEntries(next), + mergedCount, + confirmedCount, + conflictCount, + }; +}; diff --git a/src/features/canvas/components/AgentAvatar.tsx b/src/features/canvas/components/AgentAvatar.tsx deleted file mode 100644 index bb9abc47..00000000 --- a/src/features/canvas/components/AgentAvatar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useMemo } from "react"; - -import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar"; - -type AgentAvatarProps = { - seed: string; - name: string; - size?: number; - isSelected?: boolean; -}; - -export const AgentAvatar = ({ - seed, - name, - size = 112, - isSelected = false, -}: AgentAvatarProps) => { - const src = useMemo(() => buildAvatarDataUrl(seed), [seed]); - - return ( - <div - className={`flex items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white/70 shadow-sm ${isSelected ? "agent-avatar-selected" : ""}`} - style={{ width: size, height: size }} - > - <img className="h-full w-full" src={src} alt={`Avatar for ${name}`} /> - </div> - ); -}; diff --git a/src/features/canvas/components/AgentTile.tsx b/src/features/canvas/components/AgentTile.tsx deleted file mode 100644 index 705dd7b0..00000000 --- a/src/features/canvas/components/AgentTile.tsx +++ /dev/null @@ -1,646 +0,0 @@ -import type React from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import type { AgentTile as AgentTileType, TileSize } from "@/features/canvas/state/store"; -import { isTraceMarkdown, stripTraceMarkdown } from "@/lib/text/extractThinking"; -import { normalizeAgentName } from "@/lib/names/agentNames"; -import { Settings, Shuffle } from "lucide-react"; -import { - fetchProjectTileWorkspaceFiles, - updateProjectTileWorkspaceFiles, -} from "@/lib/projects/client"; -import { - WORKSPACE_FILE_META, - WORKSPACE_FILE_NAMES, - WORKSPACE_FILE_PLACEHOLDERS, - type WorkspaceFileName, -} from "@/lib/projects/workspaceFiles"; -import { AgentAvatar } from "./AgentAvatar"; - -export const MIN_TILE_SIZE = { width: 420, height: 520 }; - -const buildWorkspaceState = () => - Object.fromEntries( - WORKSPACE_FILE_NAMES.map((name) => [name, { content: "", exists: false }]) - ) as Record<WorkspaceFileName, { content: string; exists: boolean }>; - -const buildWorkspaceExpanded = () => - Object.fromEntries(WORKSPACE_FILE_NAMES.map((name) => [name, false])) as Record< - WorkspaceFileName, - boolean - >; - -const isWorkspaceFileName = (value: string): value is WorkspaceFileName => - WORKSPACE_FILE_NAMES.includes(value as WorkspaceFileName); - -type AgentTileProps = { - tile: AgentTileType; - projectId: string | null; - isSelected: boolean; - canSend: boolean; - onDelete: () => void; - onNameChange: (name: string) => Promise<boolean>; - onDraftChange: (value: string) => void; - onSend: (message: string) => void; - onModelChange: (value: string | null) => void; - onThinkingChange: (value: string | null) => void; - onAvatarShuffle: () => void; - onNameShuffle: () => void; - onResize?: (size: TileSize) => void; -}; - -export const AgentTile = ({ - tile, - projectId, - isSelected, - canSend, - onDelete, - onNameChange, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - onResize, -}: AgentTileProps) => { - const [nameDraft, setNameDraft] = useState(tile.name); - const [settingsOpen, setSettingsOpen] = useState(false); - const [workspaceFiles, setWorkspaceFiles] = useState(buildWorkspaceState); - const [workspaceExpanded, setWorkspaceExpanded] = useState(buildWorkspaceExpanded); - const [workspaceLoading, setWorkspaceLoading] = useState(false); - const [workspaceSaving, setWorkspaceSaving] = useState(false); - const [workspaceDirty, setWorkspaceDirty] = useState(false); - const [workspaceError, setWorkspaceError] = useState<string | null>(null); - const workspaceItemRefs = useRef<Record<WorkspaceFileName, HTMLDivElement | null>>( - buildWorkspaceExpanded() - ); - const outputRef = useRef<HTMLDivElement | null>(null); - const draftRef = useRef<HTMLTextAreaElement | null>(null); - const scrollOutputToBottom = useCallback(() => { - const el = outputRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - }, []); - - const handleOutputWheel = useCallback( - (event: React.WheelEvent<HTMLDivElement>) => { - if (!isSelected) return; - const el = outputRef.current; - if (!el) return; - event.preventDefault(); - event.stopPropagation(); - const maxTop = Math.max(0, el.scrollHeight - el.clientHeight); - const maxLeft = Math.max(0, el.scrollWidth - el.clientWidth); - const nextTop = Math.max(0, Math.min(maxTop, el.scrollTop + event.deltaY)); - const nextLeft = Math.max(0, Math.min(maxLeft, el.scrollLeft + event.deltaX)); - el.scrollTop = nextTop; - el.scrollLeft = nextLeft; - }, - [isSelected] - ); - - useEffect(() => { - const raf = requestAnimationFrame(scrollOutputToBottom); - return () => cancelAnimationFrame(raf); - }, [scrollOutputToBottom, tile.outputLines, tile.streamText]); - - const resizeDraft = useCallback(() => { - const el = draftRef.current; - if (!el) return; - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - }, []); - - useEffect(() => { - setNameDraft(tile.name); - }, [tile.name]); - - useEffect(() => { - resizeDraft(); - }, [resizeDraft, tile.draft]); - - useEffect(() => { - const output = outputRef.current; - if (!output) return; - const extra = Math.ceil(output.scrollHeight - output.clientHeight); - if (extra <= 0) return; - onResize?.({ width: tile.size.width, height: tile.size.height + extra }); - }, [ - onResize, - tile.outputLines, - tile.streamText, - tile.thinkingTrace, - tile.size.height, - tile.size.width, - ]); - - const commitName = async () => { - const next = normalizeAgentName(nameDraft); - if (!next) { - setNameDraft(tile.name); - return; - } - if (next === tile.name) { - return; - } - const ok = await onNameChange(next); - if (!ok) { - setNameDraft(tile.name); - return; - } - setNameDraft(next); - }; - - const statusColor = - tile.status === "running" - ? "bg-amber-200 text-amber-900" - : tile.status === "error" - ? "bg-rose-200 text-rose-900" - : "bg-emerald-200 text-emerald-900"; - const showThinking = tile.status === "running" && Boolean(tile.thinkingTrace); - const showTranscript = - tile.outputLines.length > 0 || Boolean(tile.streamText) || showThinking; - const avatarSeed = tile.avatarSeed ?? tile.agentId; - const panelBorder = "border-slate-200"; - - const loadWorkspaceFiles = useCallback(async () => { - if (!projectId) return; - setWorkspaceLoading(true); - setWorkspaceError(null); - try { - const result = await fetchProjectTileWorkspaceFiles(projectId, tile.id); - const nextState = buildWorkspaceState(); - for (const file of result.files) { - if (!isWorkspaceFileName(file.name)) continue; - nextState[file.name] = { - content: file.content ?? "", - exists: Boolean(file.exists), - }; - } - setWorkspaceFiles(nextState); - setWorkspaceDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to load workspace files."; - setWorkspaceError(message); - } finally { - setWorkspaceLoading(false); - } - }, [projectId, tile.id]); - - const saveWorkspaceFiles = useCallback(async () => { - if (!projectId) return; - setWorkspaceSaving(true); - setWorkspaceError(null); - try { - const payload = { - files: WORKSPACE_FILE_NAMES.map((name) => ({ - name, - content: workspaceFiles[name].content, - })), - }; - const result = await updateProjectTileWorkspaceFiles(projectId, tile.id, payload); - const nextState = buildWorkspaceState(); - for (const file of result.files) { - if (!isWorkspaceFileName(file.name)) continue; - nextState[file.name] = { - content: file.content ?? "", - exists: Boolean(file.exists), - }; - } - setWorkspaceFiles(nextState); - setWorkspaceDirty(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to save workspace files."; - setWorkspaceError(message); - } finally { - setWorkspaceSaving(false); - } - }, [projectId, tile.id, workspaceFiles]); - - useEffect(() => { - if (!settingsOpen) return; - void loadWorkspaceFiles(); - }, [loadWorkspaceFiles, settingsOpen]); - - const settingsModal = - settingsOpen && typeof document !== "undefined" - ? createPortal( - <div - className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 px-6 py-8 backdrop-blur-sm" - role="dialog" - aria-modal="true" - aria-label="Agent settings" - onClick={() => setSettingsOpen(false)} - > - <div - className="w-[min(92vw,920px)] max-h-[90vh] overflow-hidden rounded-[32px] border border-slate-200 bg-white/95 p-6 shadow-2xl" - onClick={(event) => event.stopPropagation()} - > - <div className="flex max-h-[calc(90vh-3rem)] flex-col gap-4 overflow-hidden"> - <div className="flex items-start justify-between gap-4"> - <div> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Agent settings - </div> - <div className="mt-1 text-2xl font-semibold text-slate-900"> - {tile.name} - </div> - </div> - <button - className="rounded-full border border-slate-200 px-4 py-2 text-xs font-semibold uppercase text-slate-600" - type="button" - onClick={() => setSettingsOpen(false)} - > - Close - </button> - </div> - - <div className="flex flex-col gap-4 overflow-hidden"> - <div className="rounded-3xl border border-slate-200 bg-white/80 p-4"> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Core - </div> - <label className="mt-4 flex flex-col gap-2 text-xs font-semibold uppercase text-slate-500"> - <span>Model</span> - <select - className="h-10 rounded-2xl border border-slate-200 bg-white/80 px-3 text-xs font-semibold text-slate-700" - value={tile.model ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onModelChange(value ? value : null); - }} - > - <option value="openai-codex/gpt-5.2-codex">GPT-5.2 Codex</option> - <option value="xai/grok-4-1-fast-reasoning"> - grok-4-1-fast-reasoning - </option> - <option value="xai/grok-4-1-fast-non-reasoning"> - grok-4-1-fast-non-reasoning - </option> - <option value="zai/glm-4.7">glm-4.7</option> - </select> - </label> - {tile.model === "xai/grok-4-1-fast-non-reasoning" ? null : ( - <label className="mt-4 flex flex-col gap-2 text-xs font-semibold uppercase text-slate-500"> - <span>Thinking</span> - <select - className="h-10 rounded-2xl border border-slate-200 bg-white/80 px-3 text-xs font-semibold text-slate-700" - value={tile.thinkingLevel ?? ""} - onChange={(event) => { - const value = event.target.value.trim(); - onThinkingChange(value ? value : null); - }} - > - <option value="">Default</option> - <option value="off">Off</option> - <option value="minimal">Minimal</option> - <option value="low">Low</option> - <option value="medium">Medium</option> - <option value="high">High</option> - <option value="xhigh">XHigh</option> - </select> - </label> - )} - <button - className="mt-6 w-full rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase text-rose-600" - type="button" - onClick={onDelete} - > - Delete agent - </button> - </div> - - <div className="flex min-h-[420px] flex-1 flex-col rounded-3xl border border-slate-200 bg-white/80 p-4"> - <div className="flex flex-wrap items-center justify-between gap-2"> - <div className="text-xs font-semibold uppercase tracking-wide text-slate-500"> - Workspace files - </div> - <div className="text-[11px] font-semibold uppercase text-slate-400"> - {workspaceLoading - ? "Loading..." - : workspaceDirty - ? "Unsaved changes" - : "All changes saved"} - </div> - </div> - {workspaceError ? ( - <div className="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-600"> - {workspaceError} - </div> - ) : null} - <div className="mt-4 flex-1 overflow-auto pr-1"> - <div className="flex flex-col gap-3"> - {WORKSPACE_FILE_NAMES.map((name) => { - const meta = WORKSPACE_FILE_META[name]; - const isOpen = workspaceExpanded[name]; - const file = workspaceFiles[name]; - return ( - <div - key={name} - className="rounded-2xl border border-slate-200 bg-white/90 p-4" - ref={(node) => { - workspaceItemRefs.current[name] = node; - }} - > - <div className="flex flex-wrap items-start justify-between gap-3"> - <div> - <div className="text-sm font-semibold text-slate-800"> - {meta.title} - </div> - <div className="text-xs text-slate-500"> - {meta.hint} - </div> - </div> - <div className="flex items-center gap-2"> - {!file.exists ? ( - <span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-[10px] font-semibold uppercase text-amber-700"> - new - </span> - ) : null} - <button - className="rounded-full border border-slate-200 px-3 py-1 text-[11px] font-semibold uppercase text-slate-600" - type="button" - onClick={() => { - const shouldOpen = !workspaceExpanded[name]; - setWorkspaceExpanded((prev) => ({ - ...prev, - [name]: !prev[name], - })); - if (!shouldOpen) return; - const scrollTarget = workspaceItemRefs.current[name]; - if (!scrollTarget) return; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - scrollTarget.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }); - }); - }} - > - {isOpen ? "Hide" : "Edit"} - </button> - </div> - </div> - {isOpen ? ( - <textarea - className="mt-3 min-h-[140px] w-full resize-y rounded-2xl border border-slate-200 bg-white/80 px-3 py-2 text-xs text-slate-800 outline-none" - value={file.content} - placeholder={ - file.content.trim().length === 0 - ? WORKSPACE_FILE_PLACEHOLDERS[name] - : undefined - } - disabled={workspaceLoading || workspaceSaving} - onChange={(event) => { - const value = event.target.value; - setWorkspaceFiles((prev) => ({ - ...prev, - [name]: { ...prev[name], content: value }, - })); - setWorkspaceDirty(true); - }} - /> - ) : null} - </div> - ); - })} - </div> - </div> - <div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 pt-4"> - <div className="text-xs text-slate-400"> - {workspaceDirty ? "Remember to save your changes." : "Up to date."} - </div> - <div className="flex items-center gap-2"> - <button - className="rounded-full border border-slate-200 px-4 py-2 text-xs font-semibold uppercase text-slate-600" - type="button" - onClick={() => setSettingsOpen(false)} - > - Close - </button> - <button - className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase text-white disabled:cursor-not-allowed disabled:bg-slate-400" - type="button" - disabled={ - !projectId || - workspaceLoading || - workspaceSaving || - !workspaceDirty - } - onClick={() => void saveWorkspaceFiles()} - > - {workspaceSaving ? "Saving..." : "Save changes"} - </button> - </div> - </div> - </div> - </div> - </div> - </div> - </div>, - document.body - ) - : null; - - return ( - <div data-tile className="relative flex h-full w-full flex-col gap-3"> - {settingsModal} - <div className="flex flex-col gap-3 px-4 pt-4 pb-4"> - <div className="flex items-start justify-between gap-2"> - <div className="flex flex-1 flex-col items-center gap-2"> - <div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white/90 px-3 py-1 shadow-sm"> - <input - className="w-full bg-transparent text-center text-xs font-semibold uppercase tracking-wide text-slate-700 outline-none" - value={nameDraft} - onChange={(event) => setNameDraft(event.target.value)} - onBlur={() => { - void commitName(); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.currentTarget.blur(); - } - if (event.key === "Escape") { - setNameDraft(tile.name); - event.currentTarget.blur(); - } - }} - /> - <button - className="nodrag flex h-6 w-6 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 hover:bg-white" - type="button" - aria-label="Shuffle name" - data-testid="agent-name-shuffle" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - onNameShuffle(); - }} - > - <Shuffle className="h-3 w-3" /> - </button> - </div> - <div className="relative"> - <div data-drag-handle> - <AgentAvatar - seed={avatarSeed} - name={tile.name} - size={120} - isSelected={isSelected} - /> - </div> - <div className="pointer-events-none absolute -bottom-3 left-1/2 -translate-x-1/2"> - <span - className={`rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide ${statusColor}`} - > - {tile.status} - </span> - </div> - <button - className="nodrag absolute -bottom-2 -right-2 flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 shadow-md hover:bg-white" - type="button" - aria-label="Shuffle avatar" - data-testid="agent-avatar-shuffle" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - onAvatarShuffle(); - }} - > - <Shuffle className="h-4 w-4" /> - </button> - </div> - </div> - </div> - <div className="mt-2 flex items-end gap-2"> - <div className="relative"> - <button - className="nodrag flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-600 hover:bg-white" - type="button" - data-testid="agent-options-toggle" - aria-label="Agent options" - onPointerDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - setSettingsOpen(true); - }} - > - <Settings className="h-4 w-4" /> - </button> - </div> - <textarea - ref={draftRef} - rows={1} - className="max-h-32 flex-1 resize-none rounded-2xl border border-slate-200 bg-white/90 px-3 py-2 text-xs text-slate-900 outline-none" - value={tile.draft} - onChange={(event) => { - onDraftChange(event.target.value); - resizeDraft(); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" || event.shiftKey) return; - if (!isSelected) return; - if (!canSend || tile.status === "running") return; - const message = tile.draft.trim(); - if (!message) return; - event.preventDefault(); - onSend(message); - }} - placeholder="type a message" - /> - <button - className="rounded-full bg-slate-900 px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:bg-slate-400" - type="button" - onClick={() => onSend(tile.draft)} - disabled={!canSend || tile.status === "running" || !tile.draft.trim()} - > - Send - </button> - </div> - </div> - - {showTranscript ? ( - <div - className={`flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl border ${panelBorder} bg-white/80 px-4 pb-4 pt-4 shadow-xl backdrop-blur`} - > - <div - ref={outputRef} - className="flex-1 overflow-auto rounded-2xl border border-slate-200 bg-white/60 p-3 text-xs text-slate-700" - onWheel={handleOutputWheel} - data-testid="agent-transcript" - > - <div className="flex flex-col gap-2"> - {showThinking ? ( - <div className="rounded-xl border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] font-medium text-amber-800"> - <div className="agent-markdown"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {tile.thinkingTrace} - </ReactMarkdown> - </div> - </div> - ) : null} - {(() => { - const nodes: React.ReactNode[] = []; - for (let index = 0; index < tile.outputLines.length; index += 1) { - const line = tile.outputLines[index]; - if (isTraceMarkdown(line)) { - const traces = [stripTraceMarkdown(line)]; - let cursor = index + 1; - while ( - cursor < tile.outputLines.length && - isTraceMarkdown(tile.outputLines[cursor]) - ) { - traces.push(stripTraceMarkdown(tile.outputLines[cursor])); - cursor += 1; - } - nodes.push( - <details - key={`${tile.id}-trace-${index}`} - className="rounded-xl border border-slate-200 bg-white/80 px-2 py-1 text-[11px] text-slate-600" - > - <summary className="cursor-pointer select-none font-semibold"> - Thinking - </summary> - <div className="agent-markdown mt-1 text-slate-700"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {traces.join("\n")} - </ReactMarkdown> - </div> - </details> - ); - index = cursor - 1; - continue; - } - nodes.push( - <div key={`${tile.id}-line-${index}`} className="agent-markdown"> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{line}</ReactMarkdown> - </div> - ); - } - return nodes; - })()} - {tile.streamText ? ( - <div className="agent-markdown text-slate-500"> - <ReactMarkdown remarkPlugins={[remarkGfm]}> - {tile.streamText} - </ReactMarkdown> - </div> - ) : null} - </div> - </div> - </div> - ) : null} - </div> - ); -}; diff --git a/src/features/canvas/components/AgentTileNode.tsx b/src/features/canvas/components/AgentTileNode.tsx deleted file mode 100644 index f0f2abce..00000000 --- a/src/features/canvas/components/AgentTileNode.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { NodeResizeControl, type Node, type NodeProps } from "@xyflow/react"; -import type { AgentTile as AgentTileType, TileSize } from "@/features/canvas/state/store"; -import { AgentTile, MIN_TILE_SIZE } from "./AgentTile"; - -export type AgentTileNodeData = { - tile: AgentTileType; - projectId: string | null; - canSend: boolean; - onResize: (size: TileSize) => void; - onDelete: () => void; - onNameChange: (name: string) => Promise<boolean>; - onDraftChange: (value: string) => void; - onSend: (message: string) => void; - onModelChange: (value: string | null) => void; - onThinkingChange: (value: string | null) => void; - onAvatarShuffle: () => void; - onNameShuffle: () => void; -}; - -type AgentTileNodeType = Node<AgentTileNodeData>; - -export const AgentTileNode = ({ data, selected }: NodeProps<AgentTileNodeType>) => { - const { - tile, - projectId, - canSend, - onResize, - onDelete, - onNameChange, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - } = data; - - return ( - <div className="h-full w-full"> - <NodeResizeControl - position="bottom" - className="tile-resize-handle" - minWidth={MIN_TILE_SIZE.width} - maxWidth={MIN_TILE_SIZE.width} - minHeight={MIN_TILE_SIZE.height} - resizeDirection="vertical" - onResizeEnd={(_, params) => { - onResize({ width: MIN_TILE_SIZE.width, height: params.height }); - }} - /> - <AgentTile - tile={tile} - projectId={projectId} - isSelected={selected} - canSend={canSend} - onDelete={onDelete} - onNameChange={onNameChange} - onDraftChange={onDraftChange} - onSend={onSend} - onModelChange={onModelChange} - onThinkingChange={onThinkingChange} - onAvatarShuffle={onAvatarShuffle} - onNameShuffle={onNameShuffle} - onResize={onResize} - /> - </div> - ); -}; diff --git a/src/features/canvas/components/CanvasFlow.tsx b/src/features/canvas/components/CanvasFlow.tsx deleted file mode 100644 index 6ba13f20..00000000 --- a/src/features/canvas/components/CanvasFlow.tsx +++ /dev/null @@ -1,226 +0,0 @@ -"use client"; - -import type React from "react"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { - ReactFlow, - Background, - Controls, - MiniMap, - ReactFlowProvider, - useNodesState, - type Node, - type OnMove, -} from "@xyflow/react"; -import type { - AgentTile, - CanvasTransform, - TilePosition, - TileSize, -} from "@/features/canvas/state/store"; -import { AgentTileNode, type AgentTileNodeData } from "./AgentTileNode"; -import { MIN_TILE_SIZE } from "./AgentTile"; - -type CanvasFlowProps = { - tiles: AgentTile[]; - projectId: string | null; - transform: CanvasTransform; - viewportRef?: React.MutableRefObject<HTMLDivElement | null>; - selectedTileId: string | null; - canSend: boolean; - onSelectTile: (id: string | null) => void; - onMoveTile: (id: string, position: TilePosition) => void; - onResizeTile: (id: string, size: TileSize) => void; - onDeleteTile: (id: string) => void; - onRenameTile: (id: string, name: string) => Promise<boolean>; - onDraftChange: (id: string, value: string) => void; - onSend: (id: string, sessionKey: string, message: string) => void; - onModelChange: (id: string, sessionKey: string, value: string | null) => void; - onThinkingChange: (id: string, sessionKey: string, value: string | null) => void; - onAvatarShuffle: (id: string) => void; - onNameShuffle: (id: string) => void; - onUpdateTransform: (patch: Partial<CanvasTransform>) => void; -}; - -type TileNode = Node<AgentTileNodeData>; - -const CanvasFlowInner = ({ - tiles, - projectId, - transform, - viewportRef, - selectedTileId, - canSend, - onSelectTile, - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - onUpdateTransform, -}: CanvasFlowProps) => { - const nodeTypes = useMemo(() => ({ agentTile: AgentTileNode }), []); - const handlersRef = useRef({ - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - }); - - useEffect(() => { - handlersRef.current = { - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - }; - }, [ - onMoveTile, - onResizeTile, - onDeleteTile, - onRenameTile, - onDraftChange, - onSend, - onModelChange, - onThinkingChange, - onAvatarShuffle, - onNameShuffle, - ]); - - const nodesFromTiles = useMemo<TileNode[]>( - () => - tiles.map((tile) => ({ - id: tile.id, - type: "agentTile", - position: tile.position, - width: MIN_TILE_SIZE.width, - height: tile.size.height, - dragHandle: "[data-drag-handle]", - data: { - tile, - projectId, - canSend, - onResize: (size) => handlersRef.current.onResizeTile(tile.id, size), - onDelete: () => handlersRef.current.onDeleteTile(tile.id), - onNameChange: (name) => handlersRef.current.onRenameTile(tile.id, name), - onDraftChange: (value) => handlersRef.current.onDraftChange(tile.id, value), - onSend: (message) => - handlersRef.current.onSend(tile.id, tile.sessionKey, message), - onModelChange: (value) => - handlersRef.current.onModelChange(tile.id, tile.sessionKey, value), - onThinkingChange: (value) => - handlersRef.current.onThinkingChange(tile.id, tile.sessionKey, value), - onAvatarShuffle: () => handlersRef.current.onAvatarShuffle(tile.id), - onNameShuffle: () => handlersRef.current.onNameShuffle(tile.id), - }, - })), - [canSend, projectId, tiles] - ); - - const [nodes, setNodes, onNodesChange] = useNodesState(nodesFromTiles); - - useEffect(() => { - setNodes(nodesFromTiles); - }, [nodesFromTiles, setNodes]); - - const handleMove: OnMove = useCallback( - (_event, viewport) => { - onUpdateTransform({ - zoom: viewport.zoom, - offsetX: viewport.x, - offsetY: viewport.y, - }); - }, - [onUpdateTransform] - ); - - const handleNodeDragStop = useCallback( - (_: React.MouseEvent, node: TileNode) => { - onMoveTile(node.id, node.position); - }, - [onMoveTile] - ); - - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: TileNode) => { - if (node.id === selectedTileId) return; - onSelectTile(node.id); - }, - [onSelectTile, selectedTileId] - ); - - const handleSelectionChange = useCallback( - ({ nodes: selectedNodes }: { nodes: TileNode[] }) => { - const nextSelection = selectedNodes[0]?.id ?? null; - if (nextSelection === selectedTileId) return; - onSelectTile(nextSelection); - }, - [onSelectTile, selectedTileId] - ); - - const setViewportRef = useCallback( - (node: HTMLDivElement | null) => { - if (viewportRef) { - viewportRef.current = node; - } - }, - [viewportRef] - ); - - return ( - <ReactFlow - ref={setViewportRef} - className="canvas-surface h-full w-full" - data-canvas-viewport - nodes={nodes} - edges={[]} - nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onNodeDragStop={handleNodeDragStop} - onNodeClick={handleNodeClick} - onSelectionChange={handleSelectionChange} - onPaneClick={() => { - if (selectedTileId !== null) { - onSelectTile(null); - } - }} - onMove={handleMove} - fitView - fitViewOptions={{ padding: 0.2 }} - minZoom={0.1} - maxZoom={2} - defaultViewport={{ - x: transform.offsetX, - y: transform.offsetY, - zoom: transform.zoom, - }} - > - <Background /> - <MiniMap /> - <Controls /> - </ReactFlow> - ); -}; - -export const CanvasFlow = (props: CanvasFlowProps) => ( - <ReactFlowProvider> - <CanvasFlowInner {...props} /> - </ReactFlowProvider> -); diff --git a/src/features/canvas/components/ConnectionPanel.tsx b/src/features/canvas/components/ConnectionPanel.tsx deleted file mode 100644 index bb699d89..00000000 --- a/src/features/canvas/components/ConnectionPanel.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; - -type ConnectionPanelProps = { - gatewayUrl: string; - token: string; - status: GatewayStatus; - error: string | null; - onGatewayUrlChange: (value: string) => void; - onTokenChange: (value: string) => void; - onConnect: () => void; - onDisconnect: () => void; -}; - -const statusStyles: Record<GatewayStatus, { label: string; className: string }> = - { - disconnected: { - label: "Disconnected", - className: "bg-slate-200 text-slate-700", - }, - connecting: { - label: "Connecting", - className: "bg-amber-200 text-amber-900", - }, - connected: { - label: "Connected", - className: "bg-emerald-200 text-emerald-900", - }, - }; - -export const ConnectionPanel = ({ - gatewayUrl, - token, - status, - error, - onGatewayUrlChange, - onTokenChange, - onConnect, - onDisconnect, -}: ConnectionPanelProps) => { - const statusConfig = statusStyles[status]; - const isConnected = status === "connected"; - const isConnecting = status === "connecting"; - - return ( - <div className="flex flex-col gap-3"> - <div className="flex flex-wrap items-center gap-3"> - <span - className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${statusConfig.className}`} - > - {statusConfig.label} - </span> - <button - className="rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-60" - type="button" - onClick={isConnected ? onDisconnect : onConnect} - disabled={isConnecting || !gatewayUrl.trim()} - > - {isConnected ? "Disconnect" : "Connect"} - </button> - </div> - <div className="grid gap-3 lg:grid-cols-[1.4fr_1fr]"> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600"> - Gateway URL - <input - className="h-10 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none transition focus:border-slate-500" - type="text" - value={gatewayUrl} - onChange={(event) => onGatewayUrlChange(event.target.value)} - placeholder="ws://127.0.0.1:18789" - spellCheck={false} - /> - </label> - <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-600"> - Token - <input - className="h-10 rounded-full border border-slate-300 bg-white/80 px-4 text-sm text-slate-900 outline-none transition focus:border-slate-500" - type="password" - value={token} - onChange={(event) => onTokenChange(event.target.value)} - placeholder="gateway token" - spellCheck={false} - /> - </label> - </div> - {error ? ( - <p className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700"> - {error} - </p> - ) : null} - </div> - ); -}; diff --git a/src/features/canvas/components/HeaderBar.tsx b/src/features/canvas/components/HeaderBar.tsx deleted file mode 100644 index 8c0fc478..00000000 --- a/src/features/canvas/components/HeaderBar.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { GatewayStatus } from "@/lib/gateway/GatewayClient"; - -type HeaderBarProps = { - projects: Array<{ id: string; name: string }>; - activeProjectId: string | null; - status: GatewayStatus; - onProjectChange: (projectId: string) => void; - onCreateProject: () => void; - onOpenProject: () => void; - onDeleteProject: () => void; - onNewAgent: () => void; - onCreateDiscordChannel: () => void; - canCreateDiscordChannel: boolean; -}; - -const statusStyles: Record<GatewayStatus, string> = { - disconnected: "bg-slate-200 text-slate-700", - connecting: "bg-amber-200 text-amber-900", - connected: "bg-emerald-200 text-emerald-900", -}; - -export const HeaderBar = ({ - projects, - activeProjectId, - status, - onProjectChange, - onCreateProject, - onOpenProject, - onDeleteProject, - onNewAgent, - onCreateDiscordChannel, - canCreateDiscordChannel, -}: HeaderBarProps) => { - const hasProjects = projects.length > 0; - - return ( - <div className="glass-panel flex flex-col gap-3 px-6 py-4"> - <div className="flex flex-wrap items-center gap-3"> - <div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto"> - {hasProjects ? ( - projects.map((project) => { - const isActive = project.id === activeProjectId; - return ( - <button - key={project.id} - className={`rounded-full px-4 py-2 text-sm font-semibold transition ${ - isActive - ? "bg-slate-900 text-white" - : "border border-slate-300 bg-white/80 text-slate-700 hover:border-slate-400" - }`} - type="button" - onClick={() => onProjectChange(project.id)} - > - {project.name} - </button> - ); - }) - ) : ( - <span className="text-sm font-semibold text-slate-500">No workspaces</span> - )} - </div> - <span - className={`rounded-full px-3 py-2 text-xs font-semibold uppercase ${statusStyles[status]}`} - > - {status} - </span> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onCreateProject} - > - New Workspace - </button> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onOpenProject} - > - Open Workspace - </button> - <button - className="rounded-full border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onDeleteProject} - disabled={!activeProjectId} - > - Delete Workspace - </button> - </div> - - <div className="flex flex-wrap items-center gap-3"> - {activeProjectId ? ( - <button - className="rounded-full bg-[var(--accent-strong)] px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110" - type="button" - onClick={onNewAgent} - > - New Agent - </button> - ) : null} - {canCreateDiscordChannel ? ( - <button - className="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold text-slate-700" - type="button" - onClick={onCreateDiscordChannel} - > - Create Discord Channel - </button> - ) : null} - </div> - </div> - ); -}; diff --git a/src/features/canvas/lib/transform.ts b/src/features/canvas/lib/transform.ts deleted file mode 100644 index 7a90c15c..00000000 --- a/src/features/canvas/lib/transform.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { AgentTile, CanvasTransform } from "@/features/canvas/state/store"; - -type Point = { x: number; y: number }; -type Size = { width: number; height: number }; - -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 3; - -export const clampZoom = (zoom: number): number => { - if (zoom < MIN_ZOOM) return MIN_ZOOM; - if (zoom > MAX_ZOOM) return MAX_ZOOM; - return zoom; -}; - -export const screenToWorld = (transform: CanvasTransform, screen: Point): Point => { - return { - x: (screen.x - transform.offsetX) / transform.zoom, - y: (screen.y - transform.offsetY) / transform.zoom, - }; -}; - -export const worldToScreen = (transform: CanvasTransform, world: Point): Point => { - return { - x: transform.offsetX + world.x * transform.zoom, - y: transform.offsetY + world.y * transform.zoom, - }; -}; - -export const zoomAtScreenPoint = ( - transform: CanvasTransform, - nextZoomRaw: number, - screenPoint: Point -): CanvasTransform => { - const worldPoint = screenToWorld(transform, screenPoint); - const nextZoom = clampZoom(nextZoomRaw); - - return { - zoom: nextZoom, - offsetX: screenPoint.x - worldPoint.x * nextZoom, - offsetY: screenPoint.y - worldPoint.y * nextZoom, - }; -}; - -export const zoomToFit = ( - tiles: AgentTile[], - viewportSize: Size, - paddingPx: number, - currentTransform: CanvasTransform -): CanvasTransform => { - if (tiles.length === 0) return currentTransform; - - const minX = Math.min(...tiles.map((tile) => tile.position.x)); - const minY = Math.min(...tiles.map((tile) => tile.position.y)); - const maxX = Math.max( - ...tiles.map((tile) => tile.position.x + tile.size.width) - ); - const maxY = Math.max( - ...tiles.map((tile) => tile.position.y + tile.size.height) - ); - - const boundsWidth = Math.max(1, maxX - minX); - const boundsHeight = Math.max(1, maxY - minY); - const availableWidth = Math.max(1, viewportSize.width - paddingPx * 2); - const availableHeight = Math.max(1, viewportSize.height - paddingPx * 2); - - const nextZoom = clampZoom( - Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight) - ); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - - return { - zoom: nextZoom, - offsetX: viewportSize.width / 2 - centerX * nextZoom, - offsetY: viewportSize.height / 2 - centerY * nextZoom, - }; -}; diff --git a/src/features/canvas/state/store.tsx b/src/features/canvas/state/store.tsx deleted file mode 100644 index f2c1c03f..00000000 --- a/src/features/canvas/state/store.tsx +++ /dev/null @@ -1,477 +0,0 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useReducer, - useRef, - type ReactNode, -} from "react"; - -import type { Project, ProjectTile, ProjectsStore } from "@/lib/projects/types"; -import { CANVAS_BASE_ZOOM } from "@/lib/canvasDefaults"; -import { - createProjectTile as apiCreateProjectTile, - createProject as apiCreateProject, - deleteProjectTile as apiDeleteProjectTile, - deleteProject as apiDeleteProject, - fetchProjectsStore, - openProject as apiOpenProject, - renameProjectTile as apiRenameProjectTile, - updateProjectTile as apiUpdateProjectTile, - saveProjectsStore, -} from "@/lib/projects/client"; - -export type AgentStatus = "idle" | "running" | "error"; - -export type TilePosition = { x: number; y: number }; -export type TileSize = { width: number; height: number }; - -export type AgentTile = ProjectTile & { - status: AgentStatus; - outputLines: string[]; - lastResult: string | null; - lastDiff: string | null; - runId: string | null; - streamText: string | null; - thinkingTrace: string | null; - draft: string; - sessionSettingsSynced: boolean; -}; - -export type ProjectRuntime = Omit<Project, "tiles"> & { - tiles: AgentTile[]; -}; - -export type CanvasTransform = { - zoom: number; - offsetX: number; - offsetY: number; -}; - -export type CanvasState = { - projects: ProjectRuntime[]; - activeProjectId: string | null; - selectedTileId: string | null; - canvas: CanvasTransform; - loading: boolean; - error: string | null; -}; - -type Action = - | { type: "loadStore"; store: ProjectsStore } - | { type: "setError"; error: string | null } - | { type: "setActiveProject"; projectId: string | null } - | { type: "addProject"; project: ProjectRuntime } - | { type: "removeProject"; projectId: string } - | { type: "updateProject"; projectId: string; patch: Partial<ProjectRuntime> } - | { type: "addTile"; projectId: string; tile: AgentTile } - | { type: "removeTile"; projectId: string; tileId: string } - | { type: "updateTile"; projectId: string; tileId: string; patch: Partial<AgentTile> } - | { type: "appendOutput"; projectId: string; tileId: string; line: string } - | { type: "setStream"; projectId: string; tileId: string; value: string | null } - | { type: "selectTile"; tileId: string | null } - | { type: "setCanvas"; patch: Partial<CanvasTransform> }; - -const initialState: CanvasState = { - projects: [], - activeProjectId: null, - selectedTileId: null, - canvas: { zoom: CANVAS_BASE_ZOOM, offsetX: 0, offsetY: 0 }, - loading: true, - error: null, -}; - -const buildSessionKey = (agentId: string) => `agent:${agentId}:main`; - -const createRuntimeTile = (tile: ProjectTile): AgentTile => ({ - ...tile, - sessionKey: tile.sessionKey || buildSessionKey(tile.agentId), - model: tile.model ?? "openai-codex/gpt-5.2-codex", - thinkingLevel: tile.thinkingLevel ?? "low", - avatarSeed: tile.avatarSeed ?? tile.agentId, - status: "idle", - outputLines: [], - lastResult: null, - lastDiff: null, - runId: null, - streamText: null, - thinkingTrace: null, - draft: "", - sessionSettingsSynced: false, -}); - -const hydrateProject = (project: Project): ProjectRuntime => ({ - ...project, - tiles: Array.isArray(project.tiles) ? project.tiles.map(createRuntimeTile) : [], -}); - -const dehydrateStore = (state: CanvasState): ProjectsStore => ({ - version: 2, - activeProjectId: state.activeProjectId, - projects: state.projects.map((project) => ({ - id: project.id, - name: project.name, - repoPath: project.repoPath, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - tiles: project.tiles.map((tile) => ({ - id: tile.id, - name: tile.name, - agentId: tile.agentId, - role: tile.role, - sessionKey: tile.sessionKey, - model: tile.model ?? null, - thinkingLevel: tile.thinkingLevel ?? null, - avatarSeed: tile.avatarSeed ?? null, - position: tile.position, - size: tile.size, - })), - })), -}); - -const updateProjectList = ( - state: CanvasState, - updater: (projects: ProjectRuntime[]) => ProjectRuntime[] -): CanvasState => { - return { ...state, projects: updater(state.projects) }; -}; - -const reducer = (state: CanvasState, action: Action): CanvasState => { - switch (action.type) { - case "loadStore": { - const projects = action.store.projects.map(hydrateProject); - const activeProjectId = - action.store.activeProjectId && - projects.some((project) => project.id === action.store.activeProjectId) - ? action.store.activeProjectId - : projects[0]?.id ?? null; - return { - ...state, - projects, - activeProjectId, - loading: false, - error: null, - }; - } - case "setError": - return { ...state, error: action.error, loading: false }; - case "setActiveProject": - return { ...state, activeProjectId: action.projectId, selectedTileId: null }; - case "addProject": - return updateProjectList(state, (projects) => [...projects, action.project]); - case "removeProject": - return updateProjectList(state, (projects) => - projects.filter((project) => project.id !== action.projectId) - ); - case "updateProject": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { ...project, ...action.patch, updatedAt: Date.now() } - : project - ) - ); - case "addTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: [...project.tiles, action.tile], - updatedAt: Date.now(), - } - : project - ) - ); - case "removeTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.filter((tile) => tile.id !== action.tileId), - updatedAt: Date.now(), - } - : project - ) - ); - case "updateTile": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId ? { ...tile, ...action.patch } : tile - ), - updatedAt: Date.now(), - } - : project - ) - ); - case "appendOutput": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId - ? { ...tile, outputLines: [...tile.outputLines, action.line] } - : tile - ), - } - : project - ) - ); - case "setStream": - return updateProjectList(state, (projects) => - projects.map((project) => - project.id === action.projectId - ? { - ...project, - tiles: project.tiles.map((tile) => - tile.id === action.tileId ? { ...tile, streamText: action.value } : tile - ), - } - : project - ) - ); - case "selectTile": - return { ...state, selectedTileId: action.tileId }; - case "setCanvas": - return { ...state, canvas: { ...state.canvas, ...action.patch } }; - default: - return state; - } -}; - -type StoreContextValue = { - state: CanvasState; - dispatch: React.Dispatch<Action>; - createTile: ( - projectId: string, - name: string, - role: ProjectTile["role"] - ) => Promise<{ tile: ProjectTile; warnings: string[] } | null>; - refreshStore: () => Promise<void>; - createProject: (name: string) => Promise<{ warnings: string[] } | null>; - openProject: (path: string) => Promise<{ warnings: string[] } | null>; - deleteProject: (projectId: string) => Promise<{ warnings: string[] } | null>; - deleteTile: ( - projectId: string, - tileId: string - ) => Promise<{ warnings: string[] } | null>; - renameTile: ( - projectId: string, - tileId: string, - name: string - ) => Promise<{ warnings: string[] } | { error: string } | null>; - updateTile: ( - projectId: string, - tileId: string, - payload: { avatarSeed?: string | null } - ) => Promise<{ warnings: string[] } | { error: string } | null>; -}; - -const StoreContext = createContext<StoreContextValue | null>(null); - -export const AgentCanvasProvider = ({ children }: { children: ReactNode }) => { - const [state, dispatch] = useReducer(reducer, initialState); - const lastSavedRef = useRef<string | null>(null); - const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); - - const refreshStore = useCallback(async () => { - try { - const store = await fetchProjectsStore(); - dispatch({ type: "loadStore", store }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to load workspaces."; - dispatch({ type: "setError", error: message }); - } - }, []); - - useEffect(() => { - void refreshStore(); - }, [refreshStore]); - - useEffect(() => { - if (state.loading) return; - const payload = dehydrateStore(state); - const serialized = JSON.stringify(payload); - if (serialized === lastSavedRef.current) return; - - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - } - saveTimerRef.current = setTimeout(() => { - void saveProjectsStore(payload).then(() => { - lastSavedRef.current = serialized; - }); - }, 250); - }, [state]); - - const createTile = useCallback( - async (projectId: string, name: string, role: ProjectTile["role"]) => { - try { - const result = await apiCreateProjectTile(projectId, { name, role }); - dispatch({ type: "loadStore", store: result.store }); - return { tile: result.tile, warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create tile."; - dispatch({ type: "setError", error: message }); - return null; - } - }, - [dispatch] - ); - - const createProject = useCallback(async (name: string) => { - try { - const result = await apiCreateProject({ name }); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to create workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const openProject = useCallback(async (path: string) => { - try { - const result = await apiOpenProject({ path }); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to open workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const deleteProject = useCallback(async (projectId: string) => { - try { - const result = await apiDeleteProject(projectId); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete workspace."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const deleteTile = useCallback(async (projectId: string, tileId: string) => { - try { - const result = await apiDeleteProjectTile(projectId, tileId); - dispatch({ type: "loadStore", store: result.store }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to delete tile."; - dispatch({ type: "setError", error: message }); - return null; - } - }, []); - - const renameTile = useCallback( - async (projectId: string, tileId: string, name: string) => { - const project = state.projects.find((item) => item.id === projectId); - const tile = project?.tiles.find((item) => item.id === tileId); - if (tile) { - dispatch({ type: "updateTile", projectId, tileId, patch: { name } }); - } - try { - const result = await apiRenameProjectTile(projectId, tileId, { name }); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to rename tile."; - if (tile) { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { name: tile.name }, - }); - } - dispatch({ type: "setError", error: message }); - return { error: message }; - } - }, - [dispatch, state.projects] - ); - - const updateTile = useCallback( - async ( - projectId: string, - tileId: string, - payload: { avatarSeed?: string | null } - ) => { - const project = state.projects.find((item) => item.id === projectId); - const tile = project?.tiles.find((item) => item.id === tileId); - if (tile) { - dispatch({ type: "updateTile", projectId, tileId, patch: payload }); - } - try { - const result = await apiUpdateProjectTile(projectId, tileId, payload); - return { warnings: result.warnings }; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to update tile."; - if (tile) { - dispatch({ - type: "updateTile", - projectId, - tileId, - patch: { avatarSeed: tile.avatarSeed ?? null }, - }); - } - dispatch({ type: "setError", error: message }); - return { error: message }; - } - }, - [dispatch, state.projects] - ); - - const value = useMemo<StoreContextValue>(() => { - return { - state, - dispatch, - createTile, - refreshStore, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - }; - }, [ - state, - createTile, - refreshStore, - createProject, - openProject, - deleteProject, - deleteTile, - renameTile, - updateTile, - ]); - - return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; -}; - -export const useAgentCanvasStore = () => { - const ctx = useContext(StoreContext); - if (!ctx) { - throw new Error("AgentCanvasProvider is missing."); - } - return ctx; -}; - -export const getActiveProject = (state: CanvasState): ProjectRuntime | null => { - return state.projects.find((project) => project.id === state.activeProjectId) ?? null; -}; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 080e237e..da1de72c 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,5 @@ -import { registerTracing } from "@/lib/tracing"; +import { registerOTel } from "@vercel/otel"; export const register = () => { - registerTracing(); + registerOTel({ serviceName: "openclaw-studio" }); }; diff --git a/src/lib/agent-state/local.ts b/src/lib/agent-state/local.ts new file mode 100644 index 00000000..ac3db495 --- /dev/null +++ b/src/lib/agent-state/local.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; + +export type GatewayAgentStateMove = { from: string; to: string }; + +export type TrashAgentStateResult = { + trashDir: string; + moved: GatewayAgentStateMove[]; +}; + +export type RestoreAgentStateResult = { + restored: GatewayAgentStateMove[]; +}; + +const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value); + +const utcStamp = (now: Date = new Date()) => { + const iso = now.toISOString(); // 2026-02-11T00:24:00.123Z + return iso.replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); // 20260211T002400Z +}; + +const moveIfExists = (src: string, dest: string, moves: GatewayAgentStateMove[]) => { + if (!fs.existsSync(src)) return; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.renameSync(src, dest); + moves.push({ from: src, to: dest }); +}; + +export const trashAgentStateLocally = (params: { agentId: string }): TrashAgentStateResult => { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("agentId is required."); + } + if (!isSafeAgentId(agentId)) { + throw new Error(`Invalid agentId: ${agentId}`); + } + + const base = resolveStateDir(); + const trashRoot = path.join(base, "trash", "studio-delete-agent"); + const stamp = utcStamp(); + const trashDir = path.join(trashRoot, `${stamp}-${agentId}-${randomUUID()}`); + fs.mkdirSync(path.join(trashDir, "agents"), { recursive: true }); + fs.mkdirSync(path.join(trashDir, "workspaces"), { recursive: true }); + + const moves: GatewayAgentStateMove[] = []; + moveIfExists( + path.join(base, `workspace-${agentId}`), + path.join(trashDir, "workspaces", `workspace-${agentId}`), + moves + ); + moveIfExists(path.join(base, "agents", agentId), path.join(trashDir, "agents", agentId), moves); + + return { trashDir, moved: moves }; +}; + +const ensureUnderBase = (base: string, candidate: string) => { + const resolvedBase = fs.existsSync(base) ? fs.realpathSync(base) : path.resolve(base); + const resolvedCandidate = fs.realpathSync(candidate); + const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : `${resolvedBase}${path.sep}`; + if (resolvedCandidate !== resolvedBase && !resolvedCandidate.startsWith(prefix)) { + throw new Error(`trashDir is not under ${base}: ${candidate}`); + } + return { resolvedBase, resolvedCandidate }; +}; + +export const restoreAgentStateLocally = (params: { + agentId: string; + trashDir: string; +}): RestoreAgentStateResult => { + const agentId = params.agentId.trim(); + const trashDirRaw = params.trashDir.trim(); + if (!agentId) { + throw new Error("agentId is required."); + } + if (!isSafeAgentId(agentId)) { + throw new Error(`Invalid agentId: ${agentId}`); + } + if (!trashDirRaw) { + throw new Error("trashDir is required."); + } + + const base = resolveStateDir(); + if (!fs.existsSync(trashDirRaw)) { + throw new Error(`trashDir does not exist: ${trashDirRaw}`); + } + const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(base, trashDirRaw); + + const moves: GatewayAgentStateMove[] = []; + const restoreIfExists = (src: string, dest: string) => { + if (!fs.existsSync(src)) return; + if (fs.existsSync(dest)) { + throw new Error(`Refusing to restore over existing path: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.renameSync(src, dest); + moves.push({ from: src, to: dest }); + }; + + restoreIfExists( + path.join(resolvedTrashDir, "workspaces", `workspace-${agentId}`), + path.join(base, `workspace-${agentId}`) + ); + restoreIfExists( + path.join(resolvedTrashDir, "agents", agentId), + path.join(base, "agents", agentId) + ); + + return { restored: moves }; +}; diff --git a/src/lib/projects/workspaceFiles.ts b/src/lib/agents/agentFiles.ts similarity index 52% rename from src/lib/projects/workspaceFiles.ts rename to src/lib/agents/agentFiles.ts index 9ae7d563..ad2e7e24 100644 --- a/src/lib/projects/workspaceFiles.ts +++ b/src/lib/agents/agentFiles.ts @@ -1,4 +1,4 @@ -export const WORKSPACE_FILE_NAMES = [ +export const AGENT_FILE_NAMES = [ "AGENTS.md", "SOUL.md", "IDENTITY.md", @@ -8,9 +8,28 @@ export const WORKSPACE_FILE_NAMES = [ "MEMORY.md", ] as const; -export type WorkspaceFileName = (typeof WORKSPACE_FILE_NAMES)[number]; +export type AgentFileName = (typeof AGENT_FILE_NAMES)[number]; -export const WORKSPACE_FILE_META: Record<WorkspaceFileName, { title: string; hint: string }> = { +export const PERSONALITY_FILE_NAMES = [ + "SOUL.md", + "AGENTS.md", + "USER.md", + "IDENTITY.md", +] as const satisfies readonly AgentFileName[]; + +export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number]; + +export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = { + "SOUL.md": "Persona", + "AGENTS.md": "Directives", + "USER.md": "Context", + "IDENTITY.md": "Identity", +}; + +export const isAgentFileName = (value: string): value is AgentFileName => + AGENT_FILE_NAMES.includes(value as AgentFileName); + +export const AGENT_FILE_META: Record<AgentFileName, { title: string; hint: string }> = { "AGENTS.md": { title: "AGENTS.md", hint: "Operating instructions, priorities, and rules.", @@ -41,12 +60,17 @@ export const WORKSPACE_FILE_META: Record<WorkspaceFileName, { title: string; hin }, }; -export const WORKSPACE_FILE_PLACEHOLDERS: Record<WorkspaceFileName, string> = { +export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = { "AGENTS.md": "How should this agent work? Priorities, rules, and habits.", "SOUL.md": "Tone, personality, boundaries, and how it should sound.", - "IDENTITY.md": "Name, vibe, emoji, and a one‑line identity.", + "IDENTITY.md": "Name, vibe, emoji, and a one-line identity.", "USER.md": "How should it address you? Preferences and context.", "TOOLS.md": "Local tool notes, conventions, and shortcuts.", "HEARTBEAT.md": "A tiny checklist for periodic runs.", "MEMORY.md": "Durable facts, decisions, and preferences to remember.", }; + +export const createAgentFilesState = () => + Object.fromEntries( + AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }]) + ) as Record<AgentFileName, { content: string; exists: boolean }>; diff --git a/src/lib/agents/personalityBuilder.ts b/src/lib/agents/personalityBuilder.ts new file mode 100644 index 00000000..b3e5eb15 --- /dev/null +++ b/src/lib/agents/personalityBuilder.ts @@ -0,0 +1,307 @@ +import type { AgentFileName } from "@/lib/agents/agentFiles"; + +export type PersonalityBuilderDraft = { + identity: { + name: string; + creature: string; + vibe: string; + emoji: string; + avatar: string; + }; + user: { + name: string; + callThem: string; + pronouns: string; + timezone: string; + notes: string; + context: string; + }; + soul: { + coreTruths: string; + boundaries: string; + vibe: string; + continuity: string; + }; + agents: string; + tools: string; + heartbeat: string; + memory: string; +}; + +type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>; + +const createEmptyDraft = (): PersonalityBuilderDraft => ({ + identity: { + name: "", + creature: "", + vibe: "", + emoji: "", + avatar: "", + }, + user: { + name: "", + callThem: "", + pronouns: "", + timezone: "", + notes: "", + context: "", + }, + soul: { + coreTruths: "", + boundaries: "", + vibe: "", + continuity: "", + }, + agents: "", + tools: "", + heartbeat: "", + memory: "", +}); + +const cleanLabel = (value: string) => value.replace(/[*_]/g, "").trim().toLowerCase(); + +const cleanValue = (value: string) => { + let next = value.trim(); + next = next.replace(/^[*_]+|[*_]+$/g, "").trim(); + return next; +}; + +const normalizeTemplateValue = (value: string) => { + let normalized = value.trim(); + normalized = normalized.replace(/^[*_]+|[*_]+$/g, "").trim(); + if (normalized.startsWith("(") && normalized.endsWith(")")) { + normalized = normalized.slice(1, -1).trim(); + } + normalized = normalized.replace(/[\u2013\u2014]/g, "-"); + normalized = normalized.replace(/\s+/g, " ").toLowerCase(); + return normalized; +}; + +const IDENTITY_PLACEHOLDER_VALUES = new Set([ + "pick something you like", + "ai? robot? familiar? ghost in the machine? something weirder?", + "how do you come across? sharp? warm? chaotic? calm?", + "your signature - pick one that feels right", + "workspace-relative path, http(s) url, or data uri", +]); + +const USER_PLACEHOLDER_VALUES = new Set([ + "optional", +]); + +const USER_CONTEXT_PLACEHOLDER_VALUES = new Set([ + "what do they care about? what projects are they working on? what annoys them? what makes them laugh? build this over time.", +]); + +const isIdentityPlaceholder = (value: string) => + IDENTITY_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value)); + +const isUserPlaceholder = (value: string) => USER_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value)); + +const isUserContextPlaceholder = (value: string) => + USER_CONTEXT_PLACEHOLDER_VALUES.has(normalizeTemplateValue(value)); + +const parseLabelMap = (content: string): Map<string, string> => { + const map = new Map<string, string>(); + const lines = content.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (/^##\s+/.test(trimmed)) { + break; + } + const normalized = trimmed.replace(/^[-*]\s*/, ""); + const colonIndex = normalized.indexOf(":"); + if (colonIndex < 0) { + continue; + } + const label = cleanLabel(normalized.slice(0, colonIndex)); + if (!label) { + continue; + } + if (map.has(label)) { + continue; + } + const value = cleanValue(normalized.slice(colonIndex + 1)); + map.set(label, value); + } + return map; +}; + +const readFirst = (map: Map<string, string>, labels: string[]) => { + for (const label of labels) { + const value = map.get(label); + if (typeof value === "string") { + return value; + } + } + return ""; +}; + +const isSectionHeading = (line: string) => /^##\s+/.test(line.trim()); + +const parseSection = (content: string, sectionTitle: string): string => { + const lines = content.split(/\r?\n/); + const target = `## ${sectionTitle}`.toLowerCase(); + let startIndex = -1; + + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].trim().toLowerCase() === target) { + startIndex = index + 1; + break; + } + } + + if (startIndex < 0) { + return ""; + } + + let endIndex = lines.length; + for (let index = startIndex; index < lines.length; index += 1) { + if (isSectionHeading(lines[index])) { + endIndex = index; + break; + } + } + + while (startIndex < endIndex && lines[startIndex].trim().length === 0) { + startIndex += 1; + } + while (endIndex > startIndex && lines[endIndex - 1].trim().length === 0) { + endIndex -= 1; + } + + if (startIndex >= endIndex) { + return ""; + } + + return lines.slice(startIndex, endIndex).join("\n"); +}; + +const normalizeText = (value: string) => value.replace(/\r\n/g, "\n").trim(); + +const normalizeListField = (value: string) => value.replace(/\r\n/g, "\n").trim(); + +const serializeIdentityMarkdown = (draft: PersonalityBuilderDraft["identity"]) => { + const name = normalizeListField(draft.name); + const creature = normalizeListField(draft.creature); + const vibe = normalizeListField(draft.vibe); + const emoji = normalizeListField(draft.emoji); + const avatar = normalizeListField(draft.avatar); + + return [ + "# IDENTITY.md - Who Am I?", + "", + `- Name: ${name}`, + `- Creature: ${creature}`, + `- Vibe: ${vibe}`, + `- Emoji: ${emoji}`, + `- Avatar: ${avatar}`, + "", + ].join("\n"); +}; + +const serializeUserMarkdown = (draft: PersonalityBuilderDraft["user"]) => { + const name = normalizeListField(draft.name); + const callThem = normalizeListField(draft.callThem); + const pronouns = normalizeListField(draft.pronouns); + const timezone = normalizeListField(draft.timezone); + const notes = normalizeListField(draft.notes); + const context = normalizeText(draft.context); + + return [ + "# USER.md - About Your Human", + "", + `- Name: ${name}`, + `- What to call them: ${callThem}`, + `- Pronouns: ${pronouns}`, + `- Timezone: ${timezone}`, + `- Notes: ${notes}`, + "", + "## Context", + "", + ...(context ? context.split("\n") : []), + "", + ].join("\n"); +}; + +const serializeSoulMarkdown = (draft: PersonalityBuilderDraft["soul"]) => { + const coreTruths = normalizeText(draft.coreTruths); + const boundaries = normalizeText(draft.boundaries); + const vibe = normalizeText(draft.vibe); + const continuity = normalizeText(draft.continuity); + + return [ + "# SOUL.md - Who You Are", + "", + "## Core Truths", + "", + ...(coreTruths ? coreTruths.split("\n") : []), + "", + "## Boundaries", + "", + ...(boundaries ? boundaries.split("\n") : []), + "", + "## Vibe", + "", + ...(vibe ? vibe.split("\n") : []), + "", + "## Continuity", + "", + ...(continuity ? continuity.split("\n") : []), + "", + ].join("\n"); +}; + +export const parsePersonalityFiles = (files: AgentFilesInput): PersonalityBuilderDraft => { + const draft = createEmptyDraft(); + + const identity = parseLabelMap(files["IDENTITY.md"].content); + const identityName = readFirst(identity, ["name"]); + const identityCreature = readFirst(identity, ["creature"]); + const identityVibe = readFirst(identity, ["vibe"]); + const identityEmoji = readFirst(identity, ["emoji"]); + const identityAvatar = readFirst(identity, ["avatar"]); + draft.identity.name = isIdentityPlaceholder(identityName) ? "" : identityName; + draft.identity.creature = isIdentityPlaceholder(identityCreature) ? "" : identityCreature; + draft.identity.vibe = isIdentityPlaceholder(identityVibe) ? "" : identityVibe; + draft.identity.emoji = isIdentityPlaceholder(identityEmoji) ? "" : identityEmoji; + draft.identity.avatar = isIdentityPlaceholder(identityAvatar) ? "" : identityAvatar; + + const user = parseLabelMap(files["USER.md"].content); + const userName = readFirst(user, ["name"]); + const userCallThem = readFirst(user, ["what to call them", "preferred address", "how to address them"]); + const userPronouns = readFirst(user, ["pronouns"]); + const userTimezone = readFirst(user, ["timezone", "time zone"]); + const userNotes = readFirst(user, ["notes"]); + const userContext = parseSection(files["USER.md"].content, "Context"); + draft.user.name = isUserPlaceholder(userName) ? "" : userName; + draft.user.callThem = isUserPlaceholder(userCallThem) ? "" : userCallThem; + draft.user.pronouns = isUserPlaceholder(userPronouns) ? "" : userPronouns; + draft.user.timezone = isUserPlaceholder(userTimezone) ? "" : userTimezone; + draft.user.notes = isUserPlaceholder(userNotes) ? "" : userNotes; + draft.user.context = isUserContextPlaceholder(userContext) ? "" : userContext; + + draft.soul.coreTruths = parseSection(files["SOUL.md"].content, "Core Truths"); + draft.soul.boundaries = parseSection(files["SOUL.md"].content, "Boundaries"); + draft.soul.vibe = parseSection(files["SOUL.md"].content, "Vibe"); + draft.soul.continuity = parseSection(files["SOUL.md"].content, "Continuity"); + + draft.agents = files["AGENTS.md"].content; + draft.tools = files["TOOLS.md"].content; + draft.heartbeat = files["HEARTBEAT.md"].content; + draft.memory = files["MEMORY.md"].content; + + return draft; +}; + +export const serializePersonalityFiles = ( + draft: PersonalityBuilderDraft +): Record<AgentFileName, string> => ({ + "AGENTS.md": draft.agents, + "SOUL.md": serializeSoulMarkdown(draft.soul), + "IDENTITY.md": serializeIdentityMarkdown(draft.identity), + "USER.md": serializeUserMarkdown(draft.user), + "TOOLS.md": draft.tools, + "HEARTBEAT.md": draft.heartbeat, + "MEMORY.md": draft.memory, +}); diff --git a/src/lib/canvasDefaults.ts b/src/lib/canvasDefaults.ts deleted file mode 100644 index c2136695..00000000 --- a/src/lib/canvasDefaults.ts +++ /dev/null @@ -1 +0,0 @@ -export const CANVAS_BASE_ZOOM = 1.1; diff --git a/src/lib/clawdbot/config.ts b/src/lib/clawdbot/config.ts deleted file mode 100644 index 041f68b4..00000000 --- a/src/lib/clawdbot/config.ts +++ /dev/null @@ -1,157 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { env } from "@/lib/env"; - -type ClawdbotConfig = Record<string, unknown>; - -type AgentEntry = { - id: string; - name?: string; - workspace?: string; -}; - -const LEGACY_STATE_DIRNAME = ".clawdbot"; -const NEW_STATE_DIRNAME = ".moltbot"; -const CONFIG_FILENAME = "moltbot.json"; - -const resolveUserPath = (input: string) => { - const trimmed = input.trim(); - if (!trimmed) return trimmed; - if (trimmed.startsWith("~")) { - const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir()); - return path.resolve(expanded); - } - return path.resolve(trimmed); -}; - -const resolveStateDir = () => { - const raw = env.MOLTBOT_STATE_DIR ?? env.CLAWDBOT_STATE_DIR; - if (raw?.trim()) { - return resolveUserPath(raw); - } - return path.join(os.homedir(), LEGACY_STATE_DIRNAME); -}; - -const resolveConfigPathCandidates = () => { - const explicit = env.MOLTBOT_CONFIG_PATH ?? env.CLAWDBOT_CONFIG_PATH; - if (explicit?.trim()) { - return [resolveUserPath(explicit)]; - } - const candidates: string[] = []; - if (env.MOLTBOT_STATE_DIR?.trim()) { - candidates.push(path.join(resolveUserPath(env.MOLTBOT_STATE_DIR), CONFIG_FILENAME)); - } - if (env.CLAWDBOT_STATE_DIR?.trim()) { - candidates.push(path.join(resolveUserPath(env.CLAWDBOT_STATE_DIR), CONFIG_FILENAME)); - } - candidates.push(path.join(os.homedir(), NEW_STATE_DIRNAME, CONFIG_FILENAME)); - candidates.push(path.join(os.homedir(), LEGACY_STATE_DIRNAME, CONFIG_FILENAME)); - return candidates; -}; - -const parseJsonLoose = (raw: string) => { - try { - return JSON.parse(raw) as ClawdbotConfig; - } catch { - const cleaned = raw.replace(/,(\s*[}\]])/g, "$1"); - return JSON.parse(cleaned) as ClawdbotConfig; - } -}; - -export const loadClawdbotConfig = (): { config: ClawdbotConfig; configPath: string } => { - const candidates = resolveConfigPathCandidates(); - const fallbackPath = path.join(resolveStateDir(), CONFIG_FILENAME); - const configPath = candidates.find((candidate) => fs.existsSync(candidate)) ?? fallbackPath; - if (!fs.existsSync(configPath)) { - throw new Error(`Missing config at ${configPath}.`); - } - const raw = fs.readFileSync(configPath, "utf8"); - return { config: parseJsonLoose(raw), configPath }; -}; - -export const saveClawdbotConfig = (configPath: string, config: ClawdbotConfig) => { - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8"); -}; - -const readAgentList = (config: ClawdbotConfig): AgentEntry[] => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - const list = Array.isArray(agents.list) ? agents.list : []; - return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); -}; - -const writeAgentList = (config: ClawdbotConfig, list: AgentEntry[]) => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - agents.list = list; - config.agents = agents; -}; - -export const upsertAgentEntry = ( - config: ClawdbotConfig, - entry: { agentId: string; agentName: string; workspaceDir: string } -): boolean => { - const list = readAgentList(config); - let changed = false; - let found = false; - const next = list.map((item) => { - if (item.id !== entry.agentId) return item; - found = true; - const nextItem: AgentEntry = { ...item }; - if (entry.agentName && entry.agentName !== item.name) { - nextItem.name = entry.agentName; - changed = true; - } - if (entry.workspaceDir !== item.workspace) { - nextItem.workspace = entry.workspaceDir; - changed = true; - } - return nextItem; - }); - if (!found) { - next.push({ id: entry.agentId, name: entry.agentName, workspace: entry.workspaceDir }); - changed = true; - } - if (changed) { - writeAgentList(config, next); - } - return changed; -}; - -export const renameAgentEntry = ( - config: ClawdbotConfig, - entry: { fromAgentId: string; toAgentId: string; agentName: string; workspaceDir: string } -): boolean => { - const list = readAgentList(config); - let changed = false; - let found = false; - const next = list.map((item) => { - if (item.id !== entry.fromAgentId) return item; - found = true; - const nextItem: AgentEntry = { ...item, id: entry.toAgentId }; - if (entry.agentName && entry.agentName !== item.name) { - nextItem.name = entry.agentName; - } - if (entry.workspaceDir !== item.workspace) { - nextItem.workspace = entry.workspaceDir; - } - changed = true; - return nextItem; - }); - if (!found) { - next.push({ id: entry.toAgentId, name: entry.agentName, workspace: entry.workspaceDir }); - changed = true; - } - if (changed) { - writeAgentList(config, next); - } - return changed; -}; - -export const removeAgentEntry = (config: ClawdbotConfig, agentId: string): boolean => { - const list = readAgentList(config); - const next = list.filter((item) => item.id !== agentId); - if (next.length === list.length) return false; - writeAgentList(config, next); - return true; -}; diff --git a/src/lib/clawdbot/gateway.ts b/src/lib/clawdbot/gateway.ts deleted file mode 100644 index 6e0e95f1..00000000 --- a/src/lib/clawdbot/gateway.ts +++ /dev/null @@ -1,16 +0,0 @@ -type GatewayConfig = { - gatewayUrl: string; - token: string; -}; - -export const resolveGatewayConfig = (config: Record<string, unknown>): GatewayConfig => { - const gateway = (config.gateway ?? {}) as Record<string, unknown>; - const port = typeof gateway.port === "number" ? gateway.port : 18789; - const host = - typeof gateway.host === "string" && gateway.host.trim() - ? gateway.host.trim() - : "127.0.0.1"; - const auth = (gateway.auth ?? {}) as Record<string, unknown>; - const token = typeof auth.token === "string" ? auth.token : ""; - return { gatewayUrl: `ws://${host}:${port}`, token }; -}; diff --git a/src/lib/clawdbot/paths.ts b/src/lib/clawdbot/paths.ts new file mode 100644 index 00000000..3771c693 --- /dev/null +++ b/src/lib/clawdbot/paths.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moltbot"] as const; +const NEW_STATE_DIRNAME = ".openclaw"; +const CONFIG_FILENAME = "openclaw.json"; +const LEGACY_CONFIG_FILENAMES = ["clawdbot.json", "moltbot.json"] as const; + +const resolveDefaultHomeDir = (homedir: () => string = os.homedir): string => { + const home = homedir(); + if (home) { + try { + if (fs.existsSync(home)) { + return home; + } + } catch { + // ignore + } + } + return os.tmpdir(); +}; + +export const resolveUserPath = ( + input: string, + homedir: () => string = os.homedir +): string => { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + const expanded = trimmed.replace(/^~(?=$|[\\/])/, homedir()); + return path.resolve(expanded); + } + return path.resolve(trimmed); +}; + +export const resolveStateDir = ( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir +): string => { + const override = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override, homedir); + const defaultHome = resolveDefaultHomeDir(homedir); + const newDir = path.join(defaultHome, NEW_STATE_DIRNAME); + const legacyDirs = LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir)); + const hasNew = fs.existsSync(newDir); + if (hasNew) return newDir; + const existingLegacy = legacyDirs.find((dir) => { + try { + return fs.existsSync(dir); + } catch { + return false; + } + }); + return existingLegacy ?? newDir; +}; + +export const resolveConfigPathCandidates = ( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir +): string[] => { + const explicit = + env.OPENCLAW_CONFIG_PATH?.trim() || + env.MOLTBOT_CONFIG_PATH?.trim() || + env.CLAWDBOT_CONFIG_PATH?.trim(); + if (explicit) return [resolveUserPath(explicit, homedir)]; + + const defaultHome = resolveDefaultHomeDir(homedir); + const candidates: string[] = []; + const stateDir = + env.OPENCLAW_STATE_DIR?.trim() || + env.MOLTBOT_STATE_DIR?.trim() || + env.CLAWDBOT_STATE_DIR?.trim(); + if (stateDir) { + const resolved = resolveUserPath(stateDir, homedir); + candidates.push(path.join(resolved, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); + } + + const defaultDirs = [ + path.join(defaultHome, NEW_STATE_DIRNAME), + ...LEGACY_STATE_DIRNAMES.map((dir) => path.join(defaultHome, dir)), + ]; + for (const dir of defaultDirs) { + candidates.push(path.join(dir, CONFIG_FILENAME)); + candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); + } + return candidates; +}; diff --git a/src/lib/cron/createPayloadBuilder.ts b/src/lib/cron/createPayloadBuilder.ts new file mode 100644 index 00000000..fbed6c47 --- /dev/null +++ b/src/lib/cron/createPayloadBuilder.ts @@ -0,0 +1,314 @@ +import type { + CronJobCreateInput, + CronPayload, + CronSchedule, + CronSessionTarget, + CronWakeMode, +} from "@/lib/cron/types"; + +export type CronCreateTemplateId = + | "morning-brief" + | "reminder" + | "weekly-review" + | "inbox-triage" + | "custom"; + +export type CronCreateDraft = { + templateId: CronCreateTemplateId; + name: string; + taskText: string; + scheduleKind: "at" | "every"; + scheduleAt?: string; + everyAmount?: number; + everyUnit?: "minutes" | "hours" | "days"; + everyAtTime?: string; + everyTimeZone?: string; + deliveryMode?: "announce" | "none"; + deliveryChannel?: string; + deliveryTo?: string; + advancedSessionTarget?: CronSessionTarget; + advancedWakeMode?: CronWakeMode; +}; + +type TimeOfDay = { hour: number; minute: number }; +type ZonedParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +const resolveName = (name: string) => { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Cron job name is required."); + } + return trimmed; +}; + +const resolveAgentId = (agentId: string) => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Agent id is required."); + } + return trimmed; +}; + +const resolveTaskText = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) { + throw new Error("Task text is required."); + } + return trimmed; +}; + +const resolveAtSchedule = (raw: string): CronSchedule => { + const ms = Date.parse(raw); + if (!Number.isFinite(ms)) { + throw new Error("Invalid run time."); + } + return { kind: "at", at: new Date(ms).toISOString() }; +}; + +const resolveTimeZone = (timeZoneRaw: string | undefined): string => { + const trimmed = (timeZoneRaw ?? "").trim(); + const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const timeZone = trimmed || fallback; + try { + // Validate IANA timezone. + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + } catch { + throw new Error("Invalid timezone."); + } + return timeZone; +}; + +const resolveTimeOfDay = (raw: string | undefined): TimeOfDay => { + const value = (raw ?? "").trim(); + const match = value.match(/^(\d{2}):(\d{2})$/); + if (!match) { + throw new Error("Daily schedule time is required."); + } + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + throw new Error("Daily schedule time is required."); + } + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + throw new Error("Daily schedule time is required."); + } + return { hour, minute }; +}; + +const formatterCache = new Map<string, Intl.DateTimeFormat>(); + +const getFormatter = (timeZone: string) => { + const cached = formatterCache.get(timeZone); + if (cached) { + return cached; + } + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + formatterCache.set(timeZone, formatter); + return formatter; +}; + +const resolveZonedParts = (ms: number, timeZone: string): ZonedParts => { + const parts = getFormatter(timeZone).formatToParts(new Date(ms)); + const values: Partial<ZonedParts> = {}; + for (const part of parts) { + if (part.type === "year") values.year = Number.parseInt(part.value, 10); + if (part.type === "month") values.month = Number.parseInt(part.value, 10); + if (part.type === "day") values.day = Number.parseInt(part.value, 10); + if (part.type === "hour") values.hour = Number.parseInt(part.value, 10); + if (part.type === "minute") values.minute = Number.parseInt(part.value, 10); + if (part.type === "second") values.second = Number.parseInt(part.value, 10); + } + if ( + !values.year || + !values.month || + !values.day || + values.hour === undefined || + values.minute === undefined || + values.second === undefined + ) { + throw new Error("Invalid timezone."); + } + return values as ZonedParts; +}; + +const resolveTimeZoneOffsetMs = (utcMs: number, timeZone: string): number => { + const zoned = resolveZonedParts(utcMs, timeZone); + const zonedAsUtcMs = Date.UTC( + zoned.year, + zoned.month - 1, + zoned.day, + zoned.hour, + zoned.minute, + zoned.second, + 0 + ); + return zonedAsUtcMs - Math.floor(utcMs / 1000) * 1000; +}; + +const resolveZonedDateTimeToUtcMs = ( + local: { year: number; month: number; day: number; hour: number; minute: number }, + timeZone: string +): number => { + const localAsUtcMs = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, 0, 0); + let guess = localAsUtcMs; + for (let attempt = 0; attempt < 4; attempt += 1) { + const offsetMs = resolveTimeZoneOffsetMs(guess, timeZone); + const nextGuess = localAsUtcMs - offsetMs; + if (nextGuess === guess) { + return nextGuess; + } + guess = nextGuess; + } + return guess; +}; + +const addDays = (year: number, month: number, day: number, days: number) => { + const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); + date.setUTCDate(date.getUTCDate() + days); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }; +}; + +const resolveNextDailyAnchorMs = (params: { + nowMs: number; + timeZone: string; + timeOfDay: TimeOfDay; +}): number => { + const { nowMs, timeZone, timeOfDay } = params; + const nowZoned = resolveZonedParts(nowMs, timeZone); + let candidate = resolveZonedDateTimeToUtcMs( + { + year: nowZoned.year, + month: nowZoned.month, + day: nowZoned.day, + hour: timeOfDay.hour, + minute: timeOfDay.minute, + }, + timeZone + ); + + if (candidate > nowMs) { + return candidate; + } + + const tomorrow = addDays(nowZoned.year, nowZoned.month, nowZoned.day, 1); + candidate = resolveZonedDateTimeToUtcMs( + { + year: tomorrow.year, + month: tomorrow.month, + day: tomorrow.day, + hour: timeOfDay.hour, + minute: timeOfDay.minute, + }, + timeZone + ); + return candidate; +}; + +const resolveEverySchedule = ( + draft: Pick<CronCreateDraft, "everyAmount" | "everyUnit" | "everyAtTime" | "everyTimeZone">, + nowMs: number +): CronSchedule => { + const amount = Number.isFinite(draft.everyAmount) ? Math.floor(draft.everyAmount ?? 0) : 0; + if (amount <= 0) { + throw new Error("Invalid interval amount."); + } + + const unit = draft.everyUnit ?? "minutes"; + const multiplier = + unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000; + + if (unit !== "days") { + return { kind: "every", everyMs: amount * multiplier }; + } + + const timeZone = resolveTimeZone(draft.everyTimeZone); + const timeOfDay = resolveTimeOfDay(draft.everyAtTime); + const anchorMs = resolveNextDailyAnchorMs({ nowMs, timeZone, timeOfDay }); + return { + kind: "every", + everyMs: amount * multiplier, + anchorMs, + }; +}; + +const resolveSchedule = (draft: CronCreateDraft, nowMs: number): CronSchedule => { + if (draft.scheduleKind === "at") { + return resolveAtSchedule(draft.scheduleAt ?? ""); + } + return resolveEverySchedule(draft, nowMs); +}; + +const resolvePayload = (sessionTarget: CronSessionTarget, text: string): CronPayload => { + if (sessionTarget === "main") { + return { kind: "systemEvent", text }; + } + return { kind: "agentTurn", message: text }; +}; + +export const buildCronJobCreateInput = ( + agentIdRaw: string, + draft: CronCreateDraft, + nowMs = Date.now() +): CronJobCreateInput => { + const agentId = resolveAgentId(agentIdRaw); + const name = resolveName(draft.name); + const taskText = resolveTaskText(draft.taskText); + const sessionTarget = draft.advancedSessionTarget ?? "isolated"; + const wakeMode = draft.advancedWakeMode ?? "now"; + const schedule = resolveSchedule(draft, nowMs); + const payload = resolvePayload(sessionTarget, taskText); + + if (sessionTarget === "main") { + return { + name, + agentId, + enabled: true, + schedule, + sessionTarget, + wakeMode, + payload, + }; + } + + const deliveryMode = draft.deliveryMode ?? "none"; + const deliveryChannel = (draft.deliveryChannel ?? "").trim() || "last"; + const deliveryTo = (draft.deliveryTo ?? "").trim(); + + return { + name, + agentId, + enabled: true, + schedule, + sessionTarget, + wakeMode, + payload, + delivery: + deliveryMode === "none" + ? { mode: "none" } + : { + mode: "announce", + channel: deliveryChannel, + to: deliveryTo || undefined, + }, + }; +}; diff --git a/src/lib/cron/types.ts b/src/lib/cron/types.ts new file mode 100644 index 00000000..c797e368 --- /dev/null +++ b/src/lib/cron/types.ts @@ -0,0 +1,293 @@ +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +export type CronSchedule = + | { kind: "at"; at: string } + | { kind: "every"; everyMs: number; anchorMs?: number } + | { kind: "cron"; expr: string; tz?: string }; + +export type CronSessionTarget = "main" | "isolated"; +export type CronWakeMode = "next-heartbeat" | "now"; + +export type CronDeliveryMode = "none" | "announce"; +export type CronDelivery = { + mode: CronDeliveryMode; + channel?: string; + to?: string; + bestEffort?: boolean; +}; + +export type CronPayload = + | { kind: "systemEvent"; text: string } + | { + kind: "agentTurn"; + message: string; + model?: string; + thinking?: string; + timeoutSeconds?: number; + allowUnsafeExternalContent?: boolean; + deliver?: boolean; + channel?: string; + to?: string; + bestEffortDeliver?: boolean; + }; + +export type CronJobState = { + nextRunAtMs?: number; + runningAtMs?: number; + lastRunAtMs?: number; + lastStatus?: "ok" | "error" | "skipped"; + lastError?: string; + lastDurationMs?: number; +}; + +export type CronJobSummary = { + id: string; + name: string; + agentId?: string; + sessionKey?: string; + description?: string; + enabled: boolean; + deleteAfterRun?: boolean; + updatedAtMs: number; + schedule: CronSchedule; + sessionTarget: CronSessionTarget; + wakeMode: CronWakeMode; + payload: CronPayload; + state: CronJobState; + delivery?: CronDelivery; +}; + +export type CronJobsResult = { + jobs: CronJobSummary[]; +}; + +export const sortCronJobsByUpdatedAt = (jobs: CronJobSummary[]) => + [...jobs].sort((a, b) => b.updatedAtMs - a.updatedAtMs); + +export type CronJobCreateInput = { + name: string; + agentId: string; + sessionKey?: string; + description?: string; + enabled?: boolean; + deleteAfterRun?: boolean; + schedule: CronSchedule; + sessionTarget: CronSessionTarget; + wakeMode: CronWakeMode; + payload: CronPayload; + delivery?: CronDelivery; +}; + +export const filterCronJobsForAgent = (jobs: CronJobSummary[], agentId: string): CronJobSummary[] => { + const trimmedAgentId = agentId.trim(); + if (!trimmedAgentId) return []; + return jobs.filter((job) => job.agentId?.trim() === trimmedAgentId); +}; + +export const resolveLatestCronJobForAgent = ( + jobs: CronJobSummary[], + agentId: string +): CronJobSummary | null => { + const filtered = filterCronJobsForAgent(jobs, agentId); + if (filtered.length === 0) return null; + return [...filtered].sort((a, b) => b.updatedAtMs - a.updatedAtMs)[0] ?? null; +}; + +const formatEveryMs = (everyMs: number) => { + if (everyMs % 3600000 === 0) { + return `${everyMs / 3600000}h`; + } + if (everyMs % 60000 === 0) { + return `${everyMs / 60000}m`; + } + if (everyMs % 1000 === 0) { + return `${everyMs / 1000}s`; + } + return `${everyMs}ms`; +}; + +export const formatCronSchedule = (schedule: CronSchedule) => { + if (schedule.kind === "every") { + return `Every ${formatEveryMs(schedule.everyMs)}`; + } + if (schedule.kind === "cron") { + return schedule.tz ? `Cron: ${schedule.expr} (${schedule.tz})` : `Cron: ${schedule.expr}`; + } + const atDate = new Date(schedule.at); + if (Number.isNaN(atDate.getTime())) return `At: ${schedule.at}`; + return `At: ${atDate.toLocaleString()}`; +}; + +export const formatCronPayload = (payload: CronPayload) => { + if (payload.kind === "systemEvent") return payload.text; + return payload.message; +}; + +export const formatCronJobDisplay = (job: CronJobSummary) => { + const lines = [job.name, formatCronSchedule(job.schedule), formatCronPayload(job.payload)].filter( + Boolean + ); + return lines.join("\n"); +}; + +export type CronListParams = { + includeDisabled?: boolean; +}; + +export type CronRunResult = + | { ok: true; ran: true } + | { ok: true; ran: false; reason: "not-due" } + | { ok: false }; + +export type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false }; + +export type CronJobRestoreInput = { + name: string; + agentId: string; + sessionKey?: string; + description?: string; + enabled: boolean; + deleteAfterRun?: boolean; + schedule: CronSchedule; + sessionTarget: CronSessionTarget; + wakeMode: CronWakeMode; + payload: CronPayload; + delivery?: CronDelivery; +}; + +const resolveJobId = (jobId: string): string => { + const trimmed = jobId.trim(); + if (!trimmed) { + throw new Error("Cron job id is required."); + } + return trimmed; +}; + +const resolveAgentId = (agentId: string): string => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Agent id is required."); + } + return trimmed; +}; + +const resolveCronJobName = (name: string): string => { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Cron job name is required."); + } + return trimmed; +}; + +export const listCronJobs = async ( + client: GatewayClient, + params: CronListParams = {} +): Promise<CronJobsResult> => { + const includeDisabled = params.includeDisabled ?? true; + return client.call<CronJobsResult>("cron.list", { + includeDisabled, + }); +}; + +export const runCronJobNow = async (client: GatewayClient, jobId: string): Promise<CronRunResult> => { + const id = resolveJobId(jobId); + return client.call<CronRunResult>("cron.run", { + id, + mode: "force", + }); +}; + +export const removeCronJob = async ( + client: GatewayClient, + jobId: string +): Promise<CronRemoveResult> => { + const id = resolveJobId(jobId); + return client.call<CronRemoveResult>("cron.remove", { + id, + }); +}; + +export const createCronJob = async ( + client: GatewayClient, + input: CronJobCreateInput +): Promise<CronJobSummary> => { + const name = resolveCronJobName(input.name); + const agentId = resolveAgentId(input.agentId); + return client.call<CronJobSummary>("cron.add", { + ...input, + name, + agentId, + }); +}; + +const toCronJobRestoreInput = (job: CronJobSummary, agentId: string): CronJobRestoreInput => ({ + name: job.name, + agentId, + sessionKey: job.sessionKey, + description: job.description, + enabled: job.enabled, + deleteAfterRun: job.deleteAfterRun, + schedule: job.schedule, + sessionTarget: job.sessionTarget, + wakeMode: job.wakeMode, + payload: job.payload, + delivery: job.delivery, +}); + +const restoreRemovedJobsBestEffort = async ( + client: GatewayClient, + removedJobs: CronJobRestoreInput[] +): Promise<void> => { + if (removedJobs.length === 0) return; + try { + await restoreCronJobs(client, removedJobs); + } catch (restoreErr) { + console.error("Failed to restore cron jobs after partial deletion failure.", restoreErr); + } +}; + +export const restoreCronJobs = async ( + client: GatewayClient, + jobs: CronJobRestoreInput[] +): Promise<void> => { + for (const job of jobs) { + try { + await createCronJob(client, job); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to restore cron job "${job.name}" (${job.agentId}): ${message}`); + } + } +}; + +export const removeCronJobsForAgentWithBackup = async ( + client: GatewayClient, + agentId: string +): Promise<CronJobRestoreInput[]> => { + const id = resolveAgentId(agentId); + const result = await listCronJobs(client, { includeDisabled: true }); + const jobs = result.jobs.filter((job) => job.agentId?.trim() === id); + const removedJobs: CronJobRestoreInput[] = []; + for (const job of jobs) { + let removeResult: CronRemoveResult; + try { + removeResult = await removeCronJob(client, job.id); + } catch (err) { + await restoreRemovedJobsBestEffort(client, removedJobs); + throw err; + } + if (!removeResult.ok) { + await restoreRemovedJobsBestEffort(client, removedJobs); + throw new Error(`Failed to delete cron job "${job.name}" (${job.id}).`); + } + if (removeResult.removed) { + removedJobs.push(toCronJobRestoreInput(job, id)); + } + } + return removedJobs; +}; + +export const removeCronJobsForAgent = async (client: GatewayClient, agentId: string): Promise<number> => { + const removedJobs = await removeCronJobsForAgentWithBackup(client, agentId); + return removedJobs.length; +}; diff --git a/src/lib/discord/discordChannel.ts b/src/lib/discord/discordChannel.ts deleted file mode 100644 index 6793b737..00000000 --- a/src/lib/discord/discordChannel.ts +++ /dev/null @@ -1,300 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { slugifyProjectName } from "../ids/slugify"; -import { loadClawdbotConfig, saveClawdbotConfig } from "../clawdbot/config"; - -type DiscordChannelCreateResult = { - channelId: string; - channelName: string; - guildId: string; - agentId: string; - warnings: string[]; -}; - -const ENV_PATH = path.join(os.homedir(), ".clawdbot", ".env"); - -const readEnvValue = (key: string) => { - if (!fs.existsSync(ENV_PATH)) return null; - const lines = fs.readFileSync(ENV_PATH, "utf8").split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const idx = trimmed.indexOf("="); - if (idx === -1) continue; - const envKey = trimmed.slice(0, idx).trim(); - if (envKey !== key) continue; - return trimmed.slice(idx + 1).trim(); - } - return null; -}; - -const loadConfig = () => { - return loadClawdbotConfig(); -}; - -const resolveGuildId = (config: Record<string, unknown>, guildId?: string) => { - if (guildId) return guildId; - const channels = config.channels as Record<string, unknown> | undefined; - const discord = channels?.discord as Record<string, unknown> | undefined; - const guilds = discord?.guilds as Record<string, unknown> | undefined; - if (!guilds) { - throw new Error("No Discord guilds configured in clawdbot.json."); - } - const guildIds = Object.keys(guilds).filter((key) => key !== "*"); - if (guildIds.length === 1) { - return guildIds[0]; - } - if (guildIds.length === 0) { - throw new Error("No Discord guild id found in clawdbot.json."); - } - throw new Error("Multiple Discord guilds configured; specify a guild id."); -}; - -const ensureWorkspaceDir = (workspaceDir: string) => { - if (fs.existsSync(workspaceDir)) { - const stat = fs.statSync(workspaceDir); - if (!stat.isDirectory()) { - throw new Error(`Workspace path is not a directory: ${workspaceDir}`); - } - return; - } - fs.mkdirSync(workspaceDir, { recursive: true }); -}; - -const ensureAgentConfig = ( - config: Record<string, unknown>, - agentId: string, - agentName: string, - workspaceDir: string -) => { - const agents = (config.agents ?? {}) as Record<string, unknown>; - const list = Array.isArray(agents.list) ? [...agents.list] : []; - const exists = list.some((entry) => { - if (!entry || typeof entry !== "object") return false; - return (entry as Record<string, unknown>).id === agentId; - }); - if (!exists) { - list.push({ id: agentId, name: agentName, workspace: workspaceDir }); - agents.list = list; - config.agents = agents; - return true; - } - return false; -}; - -const resolveGuildChannelMap = (config: Record<string, unknown>, guildId: string) => { - const channels = config.channels as Record<string, unknown> | undefined; - const discord = channels?.discord as Record<string, unknown> | undefined; - const guilds = discord?.guilds as Record<string, unknown> | undefined; - const guildEntry = guilds?.[guildId] as Record<string, unknown> | undefined; - const channelMap = guildEntry?.channels as Record<string, unknown> | undefined; - return channelMap; -}; - -const fetchDiscordChannel = async (token: string, channelId: string) => { - const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { - headers: { Authorization: `Bot ${token}` }, - }); - const payload = (await response.json()) as { - id?: string; - parent_id?: string | null; - }; - if (!response.ok || !payload.id) { - return null; - } - return payload; -}; - -const fetchDiscordGuildChannels = async (token: string, guildId: string) => { - const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, { - headers: { Authorization: `Bot ${token}` }, - }); - const payload = (await response.json()) as Array<{ - id?: string; - type?: number; - name?: string; - parent_id?: string | null; - }>; - if (!response.ok || !Array.isArray(payload)) { - return null; - } - return payload; -}; - -const updateDiscordChannelParent = async ( - token: string, - channelId: string, - parentId: string -) => { - const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { - method: "PATCH", - headers: { - Authorization: `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ parent_id: parentId }), - }); - if (!response.ok) { - return false; - } - return true; -}; - -const resolveChannelParentId = async ( - config: Record<string, unknown>, - token: string, - guildId: string, - warnings: string[] -) => { - const channelMap = resolveGuildChannelMap(config, guildId); - if (!channelMap) return null; - const channelIds = Object.keys(channelMap).filter((key) => /^\d+$/.test(key)); - for (const channelId of channelIds) { - const channel = await fetchDiscordChannel(token, channelId); - if (channel?.parent_id) { - return channel.parent_id; - } - } - if (channelIds.length > 0) { - warnings.push("Discord channel category not resolved; created channel is uncategorized."); - } - return null; -}; - -const ensureDiscordChannelConfig = ( - config: Record<string, unknown>, - guildId: string, - channelId: string -) => { - const channels = (config.channels ?? {}) as Record<string, unknown>; - const discord = (channels.discord ?? {}) as Record<string, unknown>; - const guilds = (discord.guilds ?? {}) as Record<string, unknown>; - const guildEntry = (guilds[guildId] ?? {}) as Record<string, unknown>; - const channelMap = (guildEntry.channels ?? {}) as Record<string, unknown>; - - channelMap[channelId] = { allow: true, requireMention: false }; - guildEntry.channels = channelMap; - guilds[guildId] = guildEntry; - discord.guilds = guilds; - channels.discord = discord; - config.channels = channels; -}; - -const ensureDiscordBinding = ( - config: Record<string, unknown>, - channelId: string, - agentId: string -) => { - const bindings = Array.isArray(config.bindings) ? config.bindings : []; - const filtered = bindings.filter((binding) => { - if (!binding || typeof binding !== "object") return false; - const match = (binding as Record<string, unknown>).match as Record<string, unknown> | undefined; - if (!match || match.channel !== "discord") return true; - const peer = match.peer as Record<string, unknown> | undefined; - if (!peer || peer.kind !== "channel") return true; - return peer.id !== channelId; - }); - filtered.push({ - agentId, - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: channelId }, - }, - }); - config.bindings = filtered; -}; - -export const createDiscordChannelForAgent = async ({ - agentId, - agentName, - guildId, - workspaceDir, -}: { - agentId: string; - agentName: string; - guildId?: string; - workspaceDir: string; -}): Promise<DiscordChannelCreateResult> => { - const token = readEnvValue("DISCORD_BOT_TOKEN"); - if (!token) { - throw new Error("DISCORD_BOT_TOKEN not found in ~/.clawdbot/.env."); - } - const { config, configPath } = loadConfig(); - const resolvedGuildId = resolveGuildId(config, guildId); - const channelName = slugifyProjectName(agentName); - const warnings: string[] = []; - ensureWorkspaceDir(workspaceDir); - const addedAgent = ensureAgentConfig(config, agentId, agentName, workspaceDir); - if (addedAgent) { - warnings.push(`Registered agent ${agentId} in clawdbot.json.`); - } - let parentId = await resolveChannelParentId(config, token, resolvedGuildId, warnings); - - const guildChannels = await fetchDiscordGuildChannels(token, resolvedGuildId); - if (!guildChannels) { - warnings.push("Unable to inspect existing Discord channels; creating a new one."); - } - if (!parentId && guildChannels) { - const withParent = guildChannels.find( - (channel) => channel?.type === 0 && channel?.parent_id - ); - parentId = withParent?.parent_id ?? null; - } - const existing = guildChannels?.find( - (channel) => channel?.type === 0 && channel?.name === channelName - ); - if (existing?.id) { - if (parentId && existing.parent_id !== parentId) { - const updated = await updateDiscordChannelParent(token, existing.id, parentId); - if (!updated) { - warnings.push("Failed to set Discord channel category."); - } - } - ensureDiscordChannelConfig(config, resolvedGuildId, existing.id); - ensureDiscordBinding(config, existing.id, agentId); - saveClawdbotConfig(configPath, config); - warnings.push("Reused existing Discord channel."); - return { - channelId: existing.id, - channelName, - guildId: resolvedGuildId, - agentId, - warnings, - }; - } - - const body: Record<string, unknown> = { name: channelName, type: 0 }; - if (parentId) { - body.parent_id = parentId; - } - - const response = await fetch(`https://discord.com/api/v10/guilds/${resolvedGuildId}/channels`, { - method: "POST", - headers: { - Authorization: `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - const payload = (await response.json()) as { id?: string; message?: string; code?: number }; - if (!response.ok || !payload.id) { - const msg = payload?.message ? `Discord error: ${payload.message}` : "Discord API error."; - throw new Error(msg); - } - - ensureDiscordChannelConfig(config, resolvedGuildId, payload.id); - ensureDiscordBinding(config, payload.id, agentId); - saveClawdbotConfig(configPath, config); - - return { - channelId: payload.id, - channelName, - guildId: resolvedGuildId, - agentId, - warnings, - }; -}; diff --git a/src/lib/dom/index.ts b/src/lib/dom/index.ts new file mode 100644 index 00000000..8e452961 --- /dev/null +++ b/src/lib/dom/index.ts @@ -0,0 +1,34 @@ +export type RafBatcher = { + schedule: () => void; + cancel: () => void; +}; + +export const createRafBatcher = (flush: () => void): RafBatcher => { + let rafId: number | null = null; + return { + schedule: () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + flush(); + }); + }, + cancel: () => { + if (rafId === null) return; + cancelAnimationFrame(rafId); + rafId = null; + }, + }; +}; + +export type ScrollMetrics = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +export const isNearBottom = (metrics: ScrollMetrics, thresholdPx: number = 40): boolean => { + const remaining = metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop; + return remaining <= thresholdPx; +}; + diff --git a/src/lib/env.ts b/src/lib/env.ts deleted file mode 100644 index 2f5d1fc7..00000000 --- a/src/lib/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod"; - -const envSchema = z.object({ - MOLTBOT_STATE_DIR: z.string().optional(), - CLAWDBOT_STATE_DIR: z.string().optional(), - MOLTBOT_CONFIG_PATH: z.string().optional(), - CLAWDBOT_CONFIG_PATH: z.string().optional(), - NEXT_PUBLIC_GATEWAY_URL: z.string().optional(), -}); - -export const env = envSchema.parse(process.env); diff --git a/src/lib/fs/git.ts b/src/lib/fs/git.ts deleted file mode 100644 index daa428a4..00000000 --- a/src/lib/fs/git.ts +++ /dev/null @@ -1,42 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -const GITIGNORE_LINES = [".env", ".env.*", "!.env.example"]; - -export const ensureGitRepo = (dir: string): { warnings: string[] } => { - if (fs.existsSync(dir)) { - const stat = fs.statSync(dir); - if (!stat.isDirectory()) { - throw new Error(`${dir} exists and is not a directory.`); - } - } else { - fs.mkdirSync(dir, { recursive: true }); - } - - const gitDir = path.join(dir, ".git"); - if (!fs.existsSync(gitDir)) { - const result = spawnSync("git", ["init"], { cwd: dir, encoding: "utf8" }); - if (result.status !== 0) { - const stderr = result.stderr?.trim(); - throw new Error( - stderr ? `git init failed in ${dir}: ${stderr}` : `git init failed in ${dir}.` - ); - } - } - - const gitignorePath = path.join(dir, ".gitignore"); - const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : ""; - const existingLines = existing.split(/\r?\n/); - const missing = GITIGNORE_LINES.filter((line) => !existingLines.includes(line)); - if (missing.length > 0) { - let next = existing; - if (next.length > 0 && !next.endsWith("\n")) { - next += "\n"; - } - next += `${missing.join("\n")}\n`; - fs.writeFileSync(gitignorePath, next, "utf8"); - } - - return { warnings: [] }; -}; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index f8e96a4d..14d9654c 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -1,54 +1,141 @@ -import { logger } from "@/lib/logger"; -import { EventFrame, GatewayFrame, ReqFrame, ResFrame } from "./frames"; +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + GatewayBrowserClient, + type GatewayHelloOk, +} from "./openclaw/GatewayBrowserClient"; +import type { + StudioGatewaySettings, + StudioSettings, + StudioSettingsPatch, +} from "@/lib/studio/settings"; +import type { StudioSettingsResponse } from "@/lib/studio/coordinator"; +import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url"; +import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode"; +import { GatewayResponseError } from "@/lib/gateway/errors"; + +export type ReqFrame = { + type: "req"; + id: string; + method: string; + params: unknown; +}; + +export type ResFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; + }; +}; + +export type GatewayStateVersion = { + presence: number; + health: number; +}; + +export type EventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: GatewayStateVersion; +}; + +export type GatewayFrame = ReqFrame | ResFrame | EventFrame; + +export const parseGatewayFrame = (raw: string): GatewayFrame | null => { + try { + return JSON.parse(raw) as GatewayFrame; + } catch { + return null; + } +}; + +export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => { + const trimmedAgent = agentId.trim(); + const trimmedKey = mainKey.trim() || "main"; + return `agent:${trimmedAgent}:${trimmedKey}`; +}; + +export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => { + const match = sessionKey.match(/^agent:([^:]+):/); + return match ? match[1] : null; +}; -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timeoutId: ReturnType<typeof setTimeout>; +export const isSameSessionKey = (a: string, b: string) => { + const left = a.trim(); + const right = b.trim(); + return left.length > 0 && left === right; +}; + +const CONNECT_FAILED_CLOSE_CODE = 4008; + +const parseConnectFailedCloseReason = ( + reason: string +): { code: string; message: string } | null => { + const trimmed = reason.trim(); + if (!trimmed.toLowerCase().startsWith("connect failed:")) return null; + const remainder = trimmed.slice("connect failed:".length).trim(); + if (!remainder) return null; + const idx = remainder.indexOf(" "); + const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim(); + if (!code) return null; + const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim(); + return { code, message: message || "connect failed" }; +}; + +const DEFAULT_UPSTREAM_GATEWAY_URL = + process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789"; + +const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => { + if (!value || typeof value !== "object") return null; + const raw = value as { url?: unknown; token?: unknown }; + const url = typeof raw.url === "string" ? raw.url.trim() : ""; + const token = typeof raw.token === "string" ? raw.token.trim() : ""; + if (!url || !token) return null; + return { url, token }; }; type StatusHandler = (status: GatewayStatus) => void; type EventHandler = (event: EventFrame) => void; +export type GatewayGapInfo = { expected: number; received: number }; + +type GapHandler = (info: GatewayGapInfo) => void; + export type GatewayStatus = "disconnected" | "connecting" | "connected"; export type GatewayConnectOptions = { gatewayUrl: string; token?: string; + authScopeKey?: string; + clientName?: string; + disableDeviceAuth?: boolean; }; -export type GatewayErrorPayload = { - code: string; - message: string; - details?: unknown; - retryable?: boolean; - retryAfterMs?: number; -}; - -export class GatewayResponseError extends Error { - code: string; - details?: unknown; - retryable?: boolean; - retryAfterMs?: number; - - constructor(payload: GatewayErrorPayload) { - super(payload.message || "Gateway request failed"); - this.name = "GatewayResponseError"; - this.code = payload.code; - this.details = payload.details; - this.retryable = payload.retryable; - this.retryAfterMs = payload.retryAfterMs; - } -} +export { GatewayResponseError } from "@/lib/gateway/errors"; +export type { GatewayErrorPayload } from "@/lib/gateway/errors"; export class GatewayClient { - private socket: WebSocket | null = null; - private pending = new Map<string, PendingRequest>(); + private client: GatewayBrowserClient | null = null; private statusHandlers = new Set<StatusHandler>(); private eventHandlers = new Set<EventHandler>(); + private gapHandlers = new Set<GapHandler>(); private status: GatewayStatus = "disconnected"; - private lastChallenge: unknown = null; + private pendingConnect: Promise<void> | null = null; + private resolveConnect: (() => void) | null = null; + private rejectConnect: ((error: Error) => void) | null = null; + private manualDisconnect = false; + private lastHello: GatewayHelloOk | null = null; onStatus(handler: StatusHandler) { this.statusHandlers.add(handler); @@ -65,97 +152,120 @@ export class GatewayClient { }; } + onGap(handler: GapHandler) { + this.gapHandlers.add(handler); + return () => { + this.gapHandlers.delete(handler); + }; + } + async connect(options: GatewayConnectOptions) { if (!options.gatewayUrl.trim()) { throw new Error("Gateway URL is required."); } - if (this.socket) { + if (this.client) { throw new Error("Gateway is already connected or connecting."); } - this.lastChallenge = null; + this.manualDisconnect = false; this.updateStatus("connecting"); - const socket = new WebSocket(options.gatewayUrl); - this.socket = socket; - - socket.addEventListener("message", (event) => { - this.handleMessage(event.data); + this.pendingConnect = new Promise<void>((resolve, reject) => { + this.resolveConnect = resolve; + this.rejectConnect = reject; }); - socket.addEventListener("close", () => { - this.handleClose(); + const nextClient = new GatewayBrowserClient({ + url: options.gatewayUrl, + token: options.token, + authScopeKey: options.authScopeKey, + clientName: options.clientName, + disableDeviceAuth: options.disableDeviceAuth, + onHello: (hello) => { + if (this.client !== nextClient) return; + this.lastHello = hello; + this.updateStatus("connected"); + this.resolveConnect?.(); + this.clearConnectPromise(); + }, + onEvent: (event) => { + if (this.client !== nextClient) return; + this.eventHandlers.forEach((handler) => handler(event)); + }, + onClose: ({ code, reason }) => { + if (this.client !== nextClient) return; + const connectFailed = + code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null; + const err = connectFailed + ? new GatewayResponseError({ + code: connectFailed.code, + message: connectFailed.message, + }) + : new Error(`Gateway closed (${code}): ${reason}`); + if (this.rejectConnect) { + this.rejectConnect(err); + this.clearConnectPromise(); + } + if (!this.manualDisconnect) { + nextClient.stop(); + } + if (this.client === nextClient) { + this.client = null; + } + this.updateStatus("disconnected"); + if (this.manualDisconnect) { + console.info("Gateway disconnected."); + } + }, + onGap: ({ expected, received }) => { + if (this.client !== nextClient) return; + this.gapHandlers.forEach((handler) => handler({ expected, received })); + }, }); - socket.addEventListener("error", () => { - logger.error("Gateway socket error."); - }); + this.client = nextClient; + nextClient.start(); try { - await this.waitForOpen(socket); - - const connectParams: Record<string, unknown> = { - minProtocol: 3, - maxProtocol: 3, - role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], - client: { - id: "moltbot-control-ui", - version: "0.1.0", - platform: navigator.platform ?? "web", - mode: "ui", - }, - caps: [], - userAgent: navigator.userAgent, - locale: navigator.language, - }; - - if (options.token) { - connectParams.auth = { token: options.token }; + await this.pendingConnect; + } catch (err) { + const activeClient = this.client; + activeClient?.stop(); + if (this.client === activeClient) { + this.client = null; } - - await this.sendRequest("connect", connectParams); - - this.updateStatus("connected"); - logger.info("Gateway connected."); - } catch (error) { - const reason = - error instanceof Error ? error : new Error("Gateway connect failed."); - this.socket?.close(); - this.socket = null; - this.clearPending(reason); this.updateStatus("disconnected"); - throw error; + throw err; } } disconnect() { - if (!this.socket) { + if (!this.client) { return; } - this.socket.close(); - this.socket = null; - this.lastChallenge = null; - this.clearPending(new Error("Gateway disconnected.")); + this.manualDisconnect = true; + this.client.stop(); + this.client = null; + this.clearConnectPromise(); this.updateStatus("disconnected"); - logger.info("Gateway disconnected."); + console.info("Gateway disconnected."); } async call<T = unknown>(method: string, params: unknown): Promise<T> { if (!method.trim()) { throw new Error("Gateway method is required."); } - if (!this.socket || this.status !== "connected") { + if (!this.client || !this.client.connected) { throw new Error("Gateway is not connected."); } - const payload = await this.sendRequest(method, params); + const payload = await this.client.request<T>(method, params); return payload as T; } - getLastChallenge() { - return this.lastChallenge; + getLastHello() { + return this.lastHello; } private updateStatus(status: GatewayStatus) { @@ -163,148 +273,417 @@ export class GatewayClient { this.statusHandlers.forEach((handler) => handler(status)); } - private async waitForOpen(socket: WebSocket) { - if (socket.readyState === WebSocket.OPEN) { - return; - } - - await new Promise<void>((resolve, reject) => { - const handleOpen = () => { - cleanup(); - resolve(); - }; - - const handleError = () => { - cleanup(); - reject(new Error("Gateway connection failed.")); - }; - - const handleClose = () => { - cleanup(); - reject(new Error("Gateway closed before handshake.")); - }; - - const cleanup = () => { - socket.removeEventListener("open", handleOpen); - socket.removeEventListener("error", handleError); - socket.removeEventListener("close", handleClose); - }; - - socket.addEventListener("open", handleOpen); - socket.addEventListener("error", handleError); - socket.addEventListener("close", handleClose); - }); + private clearConnectPromise() { + this.pendingConnect = null; + this.resolveConnect = null; + this.rejectConnect = null; } +} - private async waitForChallenge(timeoutMs: number) { - if (this.lastChallenge !== null) { - return this.lastChallenge; - } - return await new Promise<unknown | null>((resolve) => { - const timeoutId = setTimeout(() => { - resolve(null); - }, timeoutMs); - - const unsubscribe = this.onEvent((event) => { - if (event.event !== "connect.challenge") { - return; - } - clearTimeout(timeoutId); - unsubscribe(); - resolve(event.payload ?? null); - }); - }); +export const isGatewayDisconnectLikeError = (err: unknown): boolean => { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + if (!msg) return false; + if ( + msg.includes("gateway not connected") || + msg.includes("gateway is not connected") || + msg.includes("gateway client stopped") + ) { + return true; } - private async sendRequest(method: string, params: unknown) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new Error("Gateway socket is not open."); - } + const match = msg.match(/gateway closed \\((\\d+)\\)/); + if (!match) return false; + const code = Number(match[1]); + return Number.isFinite(code) && code === 1012; +}; - const id = crypto.randomUUID(); - const frame: ReqFrame = { type: "req", id, method, params }; +const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i; +const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i; + +export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => { + if (!(error instanceof GatewayResponseError)) return false; + if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; + const message = error.message.trim(); + if (!message) return false; + return ( + WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) && + WEBCHAT_SESSION_MUTATION_HINT_RE.test(message) + ); +}; - const payload = await new Promise<unknown>((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Gateway request timed out: ${method}`)); - }, 20000); +type SessionSettingsPatchPayload = { + key: string; + model?: string | null; + thinkingLevel?: string | null; + execHost?: "sandbox" | "gateway" | "node" | null; + execSecurity?: "deny" | "allowlist" | "full" | null; + execAsk?: "off" | "on-miss" | "always" | null; +}; - this.pending.set(id, { resolve, reject, timeoutId }); +export type GatewaySessionsPatchResult = { + ok: true; + key: string; + entry?: { + thinkingLevel?: string; + }; + resolved?: { + modelProvider?: string; + model?: string; + }; +}; - this.socket?.send(JSON.stringify(frame)); - }); +export type SyncGatewaySessionSettingsParams = { + client: GatewayClient; + sessionKey: string; + model?: string | null; + thinkingLevel?: string | null; + execHost?: "sandbox" | "gateway" | "node" | null; + execSecurity?: "deny" | "allowlist" | "full" | null; + execAsk?: "off" | "on-miss" | "always" | null; +}; - return payload; +export const syncGatewaySessionSettings = async ({ + client, + sessionKey, + model, + thinkingLevel, + execHost, + execSecurity, + execAsk, +}: SyncGatewaySessionSettingsParams) => { + const key = sessionKey.trim(); + if (!key) { + throw new Error("Session key is required."); + } + const includeModel = model !== undefined; + const includeThinkingLevel = thinkingLevel !== undefined; + const includeExecHost = execHost !== undefined; + const includeExecSecurity = execSecurity !== undefined; + const includeExecAsk = execAsk !== undefined; + if ( + !includeModel && + !includeThinkingLevel && + !includeExecHost && + !includeExecSecurity && + !includeExecAsk + ) { + throw new Error("At least one session setting must be provided."); + } + const payload: SessionSettingsPatchPayload = { key }; + if (includeModel) { + payload.model = model ?? null; + } + if (includeThinkingLevel) { + payload.thinkingLevel = thinkingLevel ?? null; } + if (includeExecHost) { + payload.execHost = execHost ?? null; + } + if (includeExecSecurity) { + payload.execSecurity = execSecurity ?? null; + } + if (includeExecAsk) { + payload.execAsk = execAsk ?? null; + } + return await client.call<GatewaySessionsPatchResult>("sessions.patch", payload); +}; - private handleMessage(data: unknown) { - if (typeof data !== "string") { - return; +const doctorFixHint = + "Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout)."; + +const formatGatewayError = (error: unknown) => { + if (error instanceof GatewayResponseError) { + if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) { + return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`; } + return `Gateway error (${error.code}): ${error.message}`; + } + if (error instanceof Error) { + return error.message; + } + return "Unknown gateway error."; +}; - let parsed: GatewayFrame | null = null; +export type GatewayConnectionState = { + client: GatewayClient; + status: GatewayStatus; + gatewayUrl: string; + token: string; + localGatewayDefaults: StudioGatewaySettings | null; + error: string | null; + connect: () => Promise<void>; + disconnect: () => void; + useLocalGatewayDefaults: () => void; + setGatewayUrl: (value: string) => void; + setToken: (value: string) => void; + clearError: () => void; +}; - try { - parsed = JSON.parse(data) as GatewayFrame; - } catch { - logger.error("Failed to parse gateway frame."); - return; - } +type StudioSettingsCoordinatorLike = { + loadSettings: () => Promise<StudioSettings | null>; + loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>; + schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; + flushPending: () => Promise<void>; +}; - if (parsed.type === "event") { - if (parsed.event === "connect.challenge") { - this.lastChallenge = parsed.payload ?? null; +const isAuthError = (errorMessage: string | null): boolean => { + if (!errorMessage) return false; + const lower = errorMessage.toLowerCase(); + return ( + lower.includes("auth") || + lower.includes("unauthorized") || + lower.includes("forbidden") || + lower.includes("invalid token") || + lower.includes("token required") || + (lower.includes("token") && lower.includes("not configured")) || + lower.includes("gateway_token_missing") + ); +}; + +const MAX_AUTO_RETRY_ATTEMPTS = 20; +const INITIAL_RETRY_DELAY_MS = 2_000; +const MAX_RETRY_DELAY_MS = 30_000; + +const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([ + "studio.gateway_url_missing", + "studio.gateway_token_missing", + "studio.gateway_url_invalid", + "studio.settings_load_failed", +]); + +const isNonRetryableConnectErrorCode = (code: string | null): boolean => { + const normalized = code?.trim().toLowerCase() ?? ""; + if (!normalized) return false; + return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized); +}; + +export const resolveGatewayAutoRetryDelayMs = (params: { + status: GatewayStatus; + didAutoConnect: boolean; + wasManualDisconnect: boolean; + gatewayUrl: string; + errorMessage: string | null; + connectErrorCode: string | null; + attempt: number; +}): number | null => { + if (params.status !== "disconnected") return null; + if (!params.didAutoConnect) return null; + if (params.wasManualDisconnect) return null; + if (!params.gatewayUrl.trim()) return null; + if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null; + if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null; + if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null; + + return Math.min( + INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt), + MAX_RETRY_DELAY_MS + ); +}; + +export const useGatewayConnection = ( + settingsCoordinator: StudioSettingsCoordinatorLike +): GatewayConnectionState => { + const [client] = useState(() => new GatewayClient()); + const didAutoConnect = useRef(false); + const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null); + const retryAttemptRef = useRef(0); + const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const wasManualDisconnectRef = useRef(false); + + const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); + const [token, setToken] = useState(""); + const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>( + null + ); + const [status, setStatus] = useState<GatewayStatus>("disconnected"); + const [error, setError] = useState<string | null>(null); + const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null); + const [settingsLoaded, setSettingsLoaded] = useState(false); + + useEffect(() => { + let cancelled = false; + const loadSettings = async () => { + try { + const envelope = + typeof settingsCoordinator.loadSettingsEnvelope === "function" + ? await settingsCoordinator.loadSettingsEnvelope() + : { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null }; + const settings = envelope.settings ?? null; + const gateway = settings?.gateway ?? null; + if (cancelled) return; + setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults)); + const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL; + const nextToken = typeof gateway?.token === "string" ? gateway.token : ""; + loadedGatewaySettings.current = { + gatewayUrl: nextGatewayUrl.trim(), + token: nextToken, + }; + setGatewayUrl(nextGatewayUrl); + setToken(nextToken); + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : "Failed to load gateway settings."; + setError(message); + } + } finally { + if (!cancelled) { + if (!loadedGatewaySettings.current) { + loadedGatewaySettings.current = { + gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(), + token: "", + }; + } + setSettingsLoaded(true); + } } - this.eventHandlers.forEach((handler) => handler(parsed)); - return; - } + }; + void loadSettings(); + return () => { + cancelled = true; + }; + }, [settingsCoordinator]); + + useEffect(() => { + return client.onStatus((nextStatus) => { + setStatus(nextStatus); + if (nextStatus !== "connecting") { + setError(null); + if (nextStatus === "connected") { + setConnectErrorCode(null); + } + } + }); + }, [client]); - if (parsed.type === "res") { - this.handleResponse(parsed); - return; - } - } + useEffect(() => { + return () => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + client.disconnect(); + }; + }, [client]); - private handleResponse(frame: ResFrame) { - const pending = this.pending.get(frame.id); - if (!pending) { - return; + const connect = useCallback(async () => { + setError(null); + setConnectErrorCode(null); + wasManualDisconnectRef.current = false; + try { + await settingsCoordinator.flushPending(); + await client.connect({ + gatewayUrl: resolveStudioProxyGatewayUrl(), + token, + authScopeKey: gatewayUrl, + clientName: "openclaw-control-ui", + disableDeviceAuth: true, + }); + await ensureGatewayReloadModeHotForLocalStudio({ + client, + upstreamGatewayUrl: gatewayUrl, + }); + retryAttemptRef.current = 0; + } catch (err) { + setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null); + setError(formatGatewayError(err)); } + }, [client, gatewayUrl, settingsCoordinator, token]); + + useEffect(() => { + if (didAutoConnect.current) return; + if (!settingsLoaded) return; + if (!gatewayUrl.trim()) return; + didAutoConnect.current = true; + void connect(); + }, [connect, gatewayUrl, settingsLoaded]); + + // Auto-retry on disconnect (gateway busy, network blip, etc.) + useEffect(() => { + const attempt = retryAttemptRef.current; + const delay = resolveGatewayAutoRetryDelayMs({ + status, + didAutoConnect: didAutoConnect.current, + wasManualDisconnect: wasManualDisconnectRef.current, + gatewayUrl, + errorMessage: error, + connectErrorCode, + attempt, + }); + if (delay === null) return; + retryTimerRef.current = setTimeout(() => { + retryAttemptRef.current = attempt + 1; + void connect(); + }, delay); - this.pending.delete(frame.id); - clearTimeout(pending.timeoutId); + return () => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + }; + }, [connect, connectErrorCode, error, gatewayUrl, status]); - if (frame.ok) { - pending.resolve(frame.payload); - return; + // Reset retry count on successful connection + useEffect(() => { + if (status === "connected") { + retryAttemptRef.current = 0; } - - if (frame.error) { - pending.reject(new GatewayResponseError(frame.error)); + }, [status]); + + useEffect(() => { + if (!settingsLoaded) return; + const baseline = loadedGatewaySettings.current; + if (!baseline) return; + const nextGatewayUrl = gatewayUrl.trim(); + if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) { return; } + settingsCoordinator.schedulePatch( + { + gateway: { + url: nextGatewayUrl, + token, + }, + }, + 400 + ); + }, [gatewayUrl, settingsCoordinator, settingsLoaded, token]); - pending.reject(new Error("Gateway request failed.")); - } - - private handleClose() { - if (!this.socket) { + const useLocalGatewayDefaults = useCallback(() => { + if (!localGatewayDefaults) { return; } - - this.socket = null; - this.lastChallenge = null; - this.clearPending(new Error("Gateway disconnected.")); - this.updateStatus("disconnected"); - logger.info("Gateway socket closed."); - } - - private clearPending(error: Error) { - this.pending.forEach((pending) => { - clearTimeout(pending.timeoutId); - pending.reject(error); - }); - this.pending.clear(); - } -} + setGatewayUrl(localGatewayDefaults.url); + setToken(localGatewayDefaults.token); + setError(null); + setConnectErrorCode(null); + }, [localGatewayDefaults]); + + const disconnect = useCallback(() => { + setError(null); + setConnectErrorCode(null); + wasManualDisconnectRef.current = true; + client.disconnect(); + }, [client]); + + const clearError = useCallback(() => { + setError(null); + setConnectErrorCode(null); + }, []); + + return { + client, + status, + gatewayUrl, + token, + localGatewayDefaults, + error, + connect, + disconnect, + useLocalGatewayDefaults, + setGatewayUrl, + setToken, + clearError, + }; +}; diff --git a/src/lib/gateway/agentConfig.ts b/src/lib/gateway/agentConfig.ts new file mode 100644 index 00000000..a56eabb7 --- /dev/null +++ b/src/lib/gateway/agentConfig.ts @@ -0,0 +1,761 @@ +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; + +export type AgentHeartbeatActiveHours = { + start: string; + end: string; +}; + +export type AgentHeartbeat = { + every: string; + target: string; + includeReasoning: boolean; + ackMaxChars?: number | null; + activeHours?: AgentHeartbeatActiveHours | null; +}; + +export type AgentHeartbeatResult = { + heartbeat: AgentHeartbeat; + hasOverride: boolean; +}; + +export type AgentHeartbeatUpdatePayload = { + override: boolean; + heartbeat: AgentHeartbeat; +}; + +export type AgentHeartbeatSummary = { + id: string; + agentId: string; + source: "override" | "default"; + enabled: boolean; + heartbeat: AgentHeartbeat; +}; + +export type HeartbeatListResult = { + heartbeats: AgentHeartbeatSummary[]; +}; + +export type HeartbeatWakeResult = { ok: true } | { ok: false }; + +export type GatewayConfigSnapshot = { + config?: Record<string, unknown>; + hash?: string; + exists?: boolean; + path?: string | null; +}; + +type HeartbeatBlock = Record<string, unknown> | null | undefined; + +const DEFAULT_EVERY = "30m"; +const DEFAULT_TARGET = "last"; +const DEFAULT_ACK_MAX_CHARS = 300; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +export type ConfigAgentEntry = Record<string, unknown> & { id: string }; + +export type GatewayAgentSandboxOverrides = { + mode?: "off" | "non-main" | "all"; + workspaceAccess?: "none" | "ro" | "rw"; +}; + +export type GatewayAgentToolsOverrides = { + profile?: "minimal" | "coding" | "messaging" | "full"; + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; + sandbox?: { + tools?: { + allow?: string[]; + deny?: string[]; + }; + }; +}; + +export type GatewayAgentOverrides = { + sandbox?: GatewayAgentSandboxOverrides; + tools?: GatewayAgentToolsOverrides; +}; + +const DEFAULT_AGENT_ID = "main"; + +export const readConfigAgentList = ( + config: Record<string, unknown> | undefined +): ConfigAgentEntry[] => { + if (!config) return []; + const agents = isRecord(config.agents) ? config.agents : null; + const list = Array.isArray(agents?.list) ? agents.list : []; + return list.filter((entry): entry is ConfigAgentEntry => { + if (!isRecord(entry)) return false; + if (typeof entry.id !== "string") return false; + return entry.id.trim().length > 0; + }); +}; + +export const resolveDefaultConfigAgentId = ( + config: Record<string, unknown> | undefined +): string => { + const list = readConfigAgentList(config); + if (list.length === 0) { + return DEFAULT_AGENT_ID; + } + const defaults = list.filter((entry) => entry.default === true); + const selected = defaults[0] ?? list[0]; + const resolved = selected.id.trim(); + return resolved || DEFAULT_AGENT_ID; +}; + +export const writeConfigAgentList = ( + config: Record<string, unknown>, + list: ConfigAgentEntry[] +): Record<string, unknown> => { + const agents = isRecord(config.agents) ? { ...config.agents } : {}; + return { ...config, agents: { ...agents, list } }; +}; + +export const upsertConfigAgentEntry = ( + list: ConfigAgentEntry[], + agentId: string, + updater: (entry: ConfigAgentEntry) => ConfigAgentEntry +): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => { + let updatedEntry: ConfigAgentEntry | null = null; + const nextList = list.map((entry) => { + if (entry.id !== agentId) return entry; + const next = updater({ ...entry, id: agentId }); + updatedEntry = next; + return next; + }); + if (!updatedEntry) { + updatedEntry = updater({ id: agentId }); + nextList.push(updatedEntry); + } + return { list: nextList, entry: updatedEntry }; +}; + +export const slugifyAgentName = (name: string): string => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) { + throw new Error("Name produced an empty folder name."); + } + return slug; +}; + +const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined); +const coerceBoolean = (value: unknown) => + typeof value === "boolean" ? value : undefined; +const coerceNumber = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const coerceActiveHours = (value: unknown) => { + if (!isRecord(value)) return undefined; + const start = coerceString(value.start); + const end = coerceString(value.end); + if (!start || !end) return undefined; + return { start, end }; +}; + +const mergeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => { + const merged = { + ...(defaults ?? {}), + ...(override ?? {}), + } as Record<string, unknown>; + if (override && typeof override === "object" && "activeHours" in override) { + merged.activeHours = (override as Record<string, unknown>).activeHours; + } else if (defaults && typeof defaults === "object" && "activeHours" in defaults) { + merged.activeHours = (defaults as Record<string, unknown>).activeHours; + } + return merged; +}; + +const normalizeHeartbeat = ( + defaults: HeartbeatBlock, + override: HeartbeatBlock +): AgentHeartbeatResult => { + const resolved = mergeHeartbeat(defaults, override); + const every = coerceString(resolved.every) ?? DEFAULT_EVERY; + const target = coerceString(resolved.target) ?? DEFAULT_TARGET; + const includeReasoning = coerceBoolean(resolved.includeReasoning) ?? false; + const ackMaxChars = coerceNumber(resolved.ackMaxChars) ?? DEFAULT_ACK_MAX_CHARS; + const activeHours = coerceActiveHours(resolved.activeHours) ?? null; + return { + heartbeat: { + every, + target, + includeReasoning, + ackMaxChars, + activeHours, + }, + hasOverride: Boolean(override && typeof override === "object"), + }; +}; + +const readHeartbeatDefaults = (config: Record<string, unknown>): HeartbeatBlock => { + const agents = isRecord(config.agents) ? config.agents : null; + const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null; + return (defaults?.heartbeat ?? null) as HeartbeatBlock; +}; + +const buildHeartbeatOverride = (payload: AgentHeartbeat): Record<string, unknown> => { + const nextHeartbeat: Record<string, unknown> = { + every: payload.every, + target: payload.target, + includeReasoning: payload.includeReasoning, + }; + if (payload.ackMaxChars !== undefined && payload.ackMaxChars !== null) { + nextHeartbeat.ackMaxChars = payload.ackMaxChars; + } + if (payload.activeHours) { + nextHeartbeat.activeHours = { + start: payload.activeHours.start, + end: payload.activeHours.end, + }; + } + return nextHeartbeat; +}; + +export const resolveHeartbeatSettings = ( + config: Record<string, unknown>, + agentId: string +): AgentHeartbeatResult => { + const list = readConfigAgentList(config); + const entry = list.find((item) => item.id === agentId) ?? null; + const defaults = readHeartbeatDefaults(config); + const override = + entry && typeof entry === "object" + ? ((entry as Record<string, unknown>).heartbeat as HeartbeatBlock) + : null; + return normalizeHeartbeat(defaults, override); +}; + +type GatewayStatusHeartbeatAgent = { + agentId?: string; + enabled?: boolean; + every?: string; + everyMs?: number | null; +}; + +type GatewayStatusSnapshot = { + heartbeat?: { + agents?: GatewayStatusHeartbeatAgent[]; + }; +}; + +const resolveHeartbeatAgentId = (agentId: string) => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Agent id is required."); + } + return trimmed; +}; + +const resolveStatusHeartbeatAgent = ( + status: GatewayStatusSnapshot, + agentId: string +): GatewayStatusHeartbeatAgent | null => { + const list = Array.isArray(status.heartbeat?.agents) ? status.heartbeat?.agents : []; + for (const entry of list) { + if (!entry || typeof entry.agentId !== "string") continue; + if (entry.agentId.trim() !== agentId) continue; + return entry; + } + return null; +}; + +export const listHeartbeatsForAgent = async ( + client: GatewayClient, + agentId: string +): Promise<HeartbeatListResult> => { + const resolvedAgentId = resolveHeartbeatAgentId(agentId); + const [snapshot, status] = await Promise.all([ + client.call<GatewayConfigSnapshot>("config.get", {}), + client.call<GatewayStatusSnapshot>("status", {}), + ]); + const config = isRecord(snapshot.config) ? snapshot.config : {}; + const resolved = resolveHeartbeatSettings(config, resolvedAgentId); + const statusHeartbeat = resolveStatusHeartbeatAgent(status, resolvedAgentId); + const enabled = Boolean(statusHeartbeat?.enabled); + const every = typeof statusHeartbeat?.every === "string" ? statusHeartbeat.every.trim() : ""; + const heartbeat = every ? { ...resolved.heartbeat, every } : resolved.heartbeat; + if (!enabled && !resolved.hasOverride) { + return { heartbeats: [] }; + } + return { + heartbeats: [ + { + id: resolvedAgentId, + agentId: resolvedAgentId, + source: resolved.hasOverride ? "override" : "default", + enabled, + heartbeat, + }, + ], + }; +}; + +export const triggerHeartbeatNow = async ( + client: GatewayClient, + agentId: string +): Promise<HeartbeatWakeResult> => { + const resolvedAgentId = resolveHeartbeatAgentId(agentId); + return client.call<HeartbeatWakeResult>("wake", { + mode: "now", + text: `OpenClaw Studio heartbeat trigger (${resolvedAgentId}).`, + }); +}; + +const shouldRetryConfigWrite = (err: unknown) => { + if (!(err instanceof GatewayResponseError)) return false; + return /re-run config\.get|config changed since last load/i.test(err.message); +}; + +const applyGatewayConfigPatch = async (params: { + client: GatewayClient; + patch: Record<string, unknown>; + baseHash?: string | null; + exists?: boolean; + attempt?: number; +}): Promise<void> => { + const attempt = params.attempt ?? 0; + const requiresBaseHash = params.exists !== false; + const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + const payload: Record<string, unknown> = { + raw: JSON.stringify(params.patch, null, 2), + }; + if (baseHash) payload.baseHash = baseHash; + try { + await params.client.call("config.patch", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigWrite(err)) { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + return applyGatewayConfigPatch({ + ...params, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + attempt: attempt + 1, + }); + } + throw err; + } +}; + +export const renameGatewayAgent = async (params: { + client: GatewayClient; + agentId: string; + name: string; +}) => { + const trimmed = params.name.trim(); + if (!trimmed) { + throw new Error("Agent name is required."); + } + await params.client.call("agents.update", { agentId: params.agentId, name: trimmed }); + return { id: params.agentId, name: trimmed }; +}; + +const dirnameLike = (value: string): string => { + const lastSlash = value.lastIndexOf("/"); + const lastBackslash = value.lastIndexOf("\\"); + const idx = Math.max(lastSlash, lastBackslash); + if (idx < 0) return ""; + return value.slice(0, idx); +}; + +const joinPathLike = (dir: string, leaf: string): string => { + const sep = dir.includes("\\") ? "\\" : "/"; + const trimmedDir = dir.endsWith("/") || dir.endsWith("\\") ? dir.slice(0, -1) : dir; + return `${trimmedDir}${sep}${leaf}`; +}; + +export const createGatewayAgent = async (params: { + client: GatewayClient; + name: string; +}): Promise<ConfigAgentEntry> => { + const trimmed = params.name.trim(); + if (!trimmed) { + throw new Error("Agent name is required."); + } + + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : ""; + if (!configPath) { + throw new Error( + 'Gateway did not return a config path; cannot compute a default workspace for "agents.create".', + ); + } + const stateDir = dirnameLike(configPath); + if (!stateDir) { + throw new Error( + `Gateway config path "${configPath}" is missing a directory; cannot compute workspace.`, + ); + } + const idGuess = slugifyAgentName(trimmed); + const workspace = joinPathLike(stateDir, `workspace-${idGuess}`); + + const result = (await params.client.call("agents.create", { + name: trimmed, + workspace, + })) as { ok?: boolean; agentId?: string; name?: string; workspace?: string }; + const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : ""; + if (!agentId) { + throw new Error("Gateway returned an invalid agents.create response (missing agentId)."); + } + return { id: agentId, name: trimmed }; +}; + +export const deleteGatewayAgent = async (params: { + client: GatewayClient; + agentId: string; +}) => { + try { + const result = (await params.client.call("agents.delete", { + agentId: params.agentId, + })) as { ok?: boolean; removedBindings?: unknown }; + const removedBindings = + typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings) + ? Math.max(0, Math.floor(result.removedBindings)) + : 0; + return { removed: true, removedBindings }; + } catch (err) { + if (err instanceof GatewayResponseError && /not found/i.test(err.message)) { + return { removed: false, removedBindings: 0 }; + } + throw err; + } +}; + +export const updateGatewayHeartbeat = async (params: { + client: GatewayClient; + agentId: string; + payload: AgentHeartbeatUpdatePayload; +}): Promise<AgentHeartbeatResult> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => { + const next = { ...entry }; + if (params.payload.override) { + next.heartbeat = buildHeartbeatOverride(params.payload.heartbeat); + } else if ("heartbeat" in next) { + delete next.heartbeat; + } + return next; + }); + const nextConfig = writeConfigAgentList(baseConfig, nextList); + await applyGatewayConfigPatch({ + client: params.client, + patch: { agents: { list: nextList } }, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + }); + return resolveHeartbeatSettings(nextConfig, params.agentId); +}; + +export const removeGatewayHeartbeatOverride = async (params: { + client: GatewayClient; + agentId: string; +}): Promise<AgentHeartbeatResult> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const nextList = list.map((entry) => { + if (entry.id !== params.agentId) return entry; + if (!("heartbeat" in entry)) return entry; + const next = { ...entry }; + delete next.heartbeat; + return next; + }); + const changed = nextList.some((entry, index) => entry !== list[index]); + if (!changed) { + return resolveHeartbeatSettings(baseConfig, params.agentId); + } + const nextConfig = writeConfigAgentList(baseConfig, nextList); + await applyGatewayConfigPatch({ + client: params.client, + patch: { agents: { list: nextList } }, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + }); + return resolveHeartbeatSettings(nextConfig, params.agentId); +}; + +export type AgentSkillsAccessMode = "all" | "none" | "allowlist"; + +const resolveRequiredAgentId = (agentId: string): string => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Agent id is required."); + } + return trimmed; +}; + +const normalizeSkillAllowlistInput = (values: ReadonlyArray<unknown>): string[] => { + const next = values + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b)); +}; + +const normalizeSkillAllowlist = (values: string[]): string[] => { + return normalizeSkillAllowlistInput(values); +}; + +const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => { + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) return false; + } + return true; +}; + +const buildAgentSkillsConfig = (params: { + baseConfig: Record<string, unknown>; + agentId: string; + mode: AgentSkillsAccessMode; + skillNames?: string[]; +}): Record<string, unknown> => { + const list = readConfigAgentList(params.baseConfig); + const currentEntry = list.find((entry) => entry.id === params.agentId); + const hasEntry = Boolean(currentEntry); + const currentRawSkills = currentEntry?.skills; + + if (params.mode === "all") { + if (!hasEntry) { + return params.baseConfig; + } + if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) { + return params.baseConfig; + } + } + + if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) { + return params.baseConfig; + } + + if (params.mode === "allowlist") { + const rawSkills = params.skillNames; + if (!rawSkills) { + throw new Error("Skills allowlist is required when mode is allowlist."); + } + const normalizedNext = normalizeSkillAllowlist(rawSkills); + if (Array.isArray(currentRawSkills)) { + const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills); + if (areStringArraysEqual(normalizedCurrent, normalizedNext)) { + return params.baseConfig; + } + } + } + + const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => { + const next: ConfigAgentEntry = { ...entry, id: params.agentId }; + if (params.mode === "all") { + if ("skills" in next) { + delete next.skills; + } + return next; + } + if (params.mode === "none") { + next.skills = []; + return next; + } + const rawSkills = params.skillNames; + if (!rawSkills) { + throw new Error("Skills allowlist is required when mode is allowlist."); + } + next.skills = normalizeSkillAllowlist(rawSkills); + return next; + }); + return writeConfigAgentList(params.baseConfig, nextList); +}; + +export const readGatewayAgentSkillsAllowlist = async (params: { + client: GatewayClient; + agentId: string; +}): Promise<string[] | undefined> => { + const agentId = resolveRequiredAgentId(params.agentId); + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const list = readConfigAgentList(baseConfig); + const entry = list.find((item) => item.id === agentId); + if (!entry) { + return undefined; + } + const raw = entry.skills; + if (!Array.isArray(raw)) { + return undefined; + } + return normalizeSkillAllowlistInput(raw); +}; + +export const updateGatewayAgentSkillsAllowlist = async (params: { + client: GatewayClient; + agentId: string; + mode: AgentSkillsAccessMode; + skillNames?: string[]; +}): Promise<void> => { + const agentId = resolveRequiredAgentId(params.agentId); + if (params.mode === "allowlist" && !params.skillNames) { + throw new Error("Skills allowlist is required when mode is allowlist."); + } + + const attemptWrite = async (attempt: number): Promise<void> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const nextConfig = buildAgentSkillsConfig({ + baseConfig, + agentId, + mode: params.mode, + skillNames: params.skillNames, + }); + if (nextConfig === baseConfig) { + return; + } + const payload: Record<string, unknown> = { + raw: JSON.stringify(nextConfig, null, 2), + }; + const requiresBaseHash = snapshot.exists !== false; + const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + if (baseHash) { + payload.baseHash = baseHash; + } + try { + await params.client.call("config.set", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigWrite(err)) { + return attemptWrite(attempt + 1); + } + throw err; + } + }; + + await attemptWrite(0); +}; + +const normalizeToolList = (values: string[] | undefined): string[] | undefined => { + if (!values) return undefined; + const next = values + .map((value) => value.trim()) + .filter((value) => value.length > 0); + return Array.from(new Set(next)); +}; + +export const updateGatewayAgentOverrides = async (params: { + client: GatewayClient; + agentId: string; + overrides: GatewayAgentOverrides; +}): Promise<void> => { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + if (params.overrides.tools?.allow !== undefined && params.overrides.tools?.alsoAllow !== undefined) { + throw new Error("Agent tools overrides cannot set both allow and alsoAllow."); + } + const hasSandboxOverrides = + Boolean(params.overrides.sandbox?.mode) || Boolean(params.overrides.sandbox?.workspaceAccess); + const hasToolsOverrides = + Boolean(params.overrides.tools?.profile) || + params.overrides.tools?.allow !== undefined || + params.overrides.tools?.alsoAllow !== undefined || + params.overrides.tools?.deny !== undefined || + params.overrides.tools?.sandbox?.tools?.allow !== undefined || + params.overrides.tools?.sandbox?.tools?.deny !== undefined; + if (!hasSandboxOverrides && !hasToolsOverrides) { + return; + } + + const buildNextConfig = (baseConfig: Record<string, unknown>): Record<string, unknown> => { + const list = readConfigAgentList(baseConfig); + const { list: nextList } = upsertConfigAgentEntry(list, agentId, (entry) => { + const next: ConfigAgentEntry = { ...entry, id: agentId }; + + if (hasSandboxOverrides) { + const currentSandbox = isRecord(next.sandbox) ? { ...next.sandbox } : {}; + if (params.overrides.sandbox?.mode) { + currentSandbox.mode = params.overrides.sandbox.mode; + } + if (params.overrides.sandbox?.workspaceAccess) { + currentSandbox.workspaceAccess = params.overrides.sandbox.workspaceAccess; + } + next.sandbox = currentSandbox; + } + + if (hasToolsOverrides) { + const currentTools = isRecord(next.tools) ? { ...next.tools } : {}; + if (params.overrides.tools?.profile) { + currentTools.profile = params.overrides.tools.profile; + } + const allow = normalizeToolList(params.overrides.tools?.allow); + if (allow !== undefined) { + currentTools.allow = allow; + delete currentTools.alsoAllow; + } + const alsoAllow = normalizeToolList(params.overrides.tools?.alsoAllow); + if (alsoAllow !== undefined) { + currentTools.alsoAllow = alsoAllow; + delete currentTools.allow; + } + const deny = normalizeToolList(params.overrides.tools?.deny); + if (deny !== undefined) { + currentTools.deny = deny; + } + + const sandboxAllow = normalizeToolList(params.overrides.tools?.sandbox?.tools?.allow); + const sandboxDeny = normalizeToolList(params.overrides.tools?.sandbox?.tools?.deny); + if (sandboxAllow !== undefined || sandboxDeny !== undefined) { + const sandboxRaw = (currentTools as Record<string, unknown>).sandbox; + const sandbox = isRecord(sandboxRaw) ? { ...sandboxRaw } : {}; + const sandboxToolsRaw = (sandbox as Record<string, unknown>).tools; + const sandboxTools = isRecord(sandboxToolsRaw) ? { ...sandboxToolsRaw } : {}; + if (sandboxAllow !== undefined) { + (sandboxTools as Record<string, unknown>).allow = sandboxAllow; + } + if (sandboxDeny !== undefined) { + (sandboxTools as Record<string, unknown>).deny = sandboxDeny; + } + (sandbox as Record<string, unknown>).tools = sandboxTools; + (currentTools as Record<string, unknown>).sandbox = sandbox; + } + next.tools = currentTools; + } + + return next; + }); + return writeConfigAgentList(baseConfig, nextList); + }; + + const attemptWrite = async (attempt: number): Promise<void> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const nextConfig = buildNextConfig(baseConfig); + const payload: Record<string, unknown> = { + raw: JSON.stringify(nextConfig, null, 2), + }; + const requiresBaseHash = snapshot.exists !== false; + const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + if (baseHash) payload.baseHash = baseHash; + try { + await params.client.call("config.set", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigWrite(err)) { + return attemptWrite(attempt + 1); + } + throw err; + } + }; + + await attemptWrite(0); +}; diff --git a/src/lib/gateway/agentFiles.ts b/src/lib/gateway/agentFiles.ts new file mode 100644 index 00000000..c2de7104 --- /dev/null +++ b/src/lib/gateway/agentFiles.ts @@ -0,0 +1,64 @@ +import type { AgentFileName } from "@/lib/agents/agentFiles"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +type AgentsFilesGetResponse = { + file?: { missing?: unknown; content?: unknown }; +}; + +const resolveAgentId = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("agentId is required."); + } + return trimmed; +}; + +export const readGatewayAgentFile = async (params: { + client: GatewayClient; + agentId: string; + name: AgentFileName; +}): Promise<{ exists: boolean; content: string }> => { + const agentId = resolveAgentId(params.agentId); + const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", { + agentId, + name: params.name, + }); + const file = response?.file; + const fileRecord = file && typeof file === "object" ? (file as Record<string, unknown>) : null; + const missing = fileRecord?.missing === true; + const content = + fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : ""; + return { exists: !missing, content }; +}; + +export const writeGatewayAgentFile = async (params: { + client: GatewayClient; + agentId: string; + name: AgentFileName; + content: string; +}): Promise<void> => { + const agentId = resolveAgentId(params.agentId); + await params.client.call("agents.files.set", { + agentId, + name: params.name, + content: params.content, + }); +}; + +export const writeGatewayAgentFiles = async (params: { + client: GatewayClient; + agentId: string; + files: Partial<Record<AgentFileName, string>>; +}): Promise<void> => { + const agentId = resolveAgentId(params.agentId); + const entries = Object.entries(params.files).filter( + (entry): entry is [AgentFileName, string] => typeof entry[1] === "string" + ); + for (const [name, content] of entries) { + await params.client.call("agents.files.set", { + agentId, + name, + content, + }); + } +}; diff --git a/src/lib/gateway/errors.ts b/src/lib/gateway/errors.ts new file mode 100644 index 00000000..ebe1627d --- /dev/null +++ b/src/lib/gateway/errors.ts @@ -0,0 +1,24 @@ +export type GatewayErrorPayload = { + code: string; + message: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; +}; + +export class GatewayResponseError extends Error { + code: string; + details?: unknown; + retryable?: boolean; + retryAfterMs?: number; + + constructor(payload: GatewayErrorPayload) { + super(payload.message || "Gateway request failed"); + this.name = "GatewayResponseError"; + this.code = payload.code; + this.details = payload.details; + this.retryable = payload.retryable; + this.retryAfterMs = payload.retryAfterMs; + } +} + diff --git a/src/lib/gateway/execApprovals.ts b/src/lib/gateway/execApprovals.ts new file mode 100644 index 00000000..15dc86c5 --- /dev/null +++ b/src/lib/gateway/execApprovals.ts @@ -0,0 +1,178 @@ +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; + +export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full"; +export type GatewayExecApprovalAsk = "off" | "on-miss" | "always"; + +type ExecAllowlistEntry = { + id?: string; + pattern: string; + lastUsedAt?: number; + lastUsedCommand?: string; + lastResolvedPath?: string; +}; + +type ExecApprovalsAgent = { + security?: GatewayExecApprovalSecurity; + ask?: GatewayExecApprovalAsk; + askFallback?: string; + autoAllowSkills?: boolean; + allowlist?: ExecAllowlistEntry[]; +}; + +type ExecApprovalsFile = { + version: 1; + socket?: { + path?: string; + token?: string; + }; + defaults?: { + security?: string; + ask?: string; + askFallback?: string; + autoAllowSkills?: boolean; + }; + agents?: Record<string, ExecApprovalsAgent>; +}; + +type ExecApprovalsSnapshot = { + path: string; + exists: boolean; + hash: string; + file?: ExecApprovalsFile; +}; + +const shouldRetrySet = (err: unknown): boolean => { + if (!(err instanceof GatewayResponseError)) return false; + return /re-run exec\.approvals\.get|changed since last load/i.test(err.message); +}; + +const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ pattern: string }> => { + const next = patterns + .map((entry) => entry.pattern.trim()) + .filter((pattern) => pattern.length > 0); + return Array.from(new Set(next)).map((pattern) => ({ pattern })); +}; + +const setExecApprovalsWithRetry = async (params: { + client: GatewayClient; + file: ExecApprovalsFile; + baseHash?: string | null; + exists?: boolean; + attempt?: number; +}): Promise<void> => { + const attempt = params.attempt ?? 0; + const requiresBaseHash = params.exists !== false; + const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined; + if (requiresBaseHash && !baseHash) { + throw new Error("Exec approvals hash unavailable; re-run exec.approvals.get."); + } + const payload: Record<string, unknown> = { file: params.file }; + if (baseHash) payload.baseHash = baseHash; + try { + await params.client.call("exec.approvals.set", payload); + } catch (err) { + if (attempt < 1 && shouldRetrySet(err)) { + const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {}); + return setExecApprovalsWithRetry({ + ...params, + baseHash: snapshot.hash ?? undefined, + exists: snapshot.exists, + attempt: attempt + 1, + }); + } + throw err; + } +}; + +export async function upsertGatewayAgentExecApprovals(params: { + client: GatewayClient; + agentId: string; + policy: { + security: GatewayExecApprovalSecurity; + ask: GatewayExecApprovalAsk; + allowlist: Array<{ pattern: string }>; + } | null; +}): Promise<void> { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + + const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {}); + const baseFile: ExecApprovalsFile = + snapshot.file && typeof snapshot.file === "object" + ? { + version: 1, + socket: snapshot.file.socket, + defaults: snapshot.file.defaults, + agents: { ...(snapshot.file.agents ?? {}) }, + } + : { version: 1, agents: {} }; + + const nextAgents = { ...(baseFile.agents ?? {}) }; + if (!params.policy) { + if (!(agentId in nextAgents)) { + return; + } + delete nextAgents[agentId]; + } else { + const existing = nextAgents[agentId] ?? {}; + nextAgents[agentId] = { + ...existing, + security: params.policy.security, + ask: params.policy.ask, + allowlist: normalizeAllowlist(params.policy.allowlist), + }; + } + + const nextFile: ExecApprovalsFile = { + ...baseFile, + version: 1, + agents: nextAgents, + }; + + await setExecApprovalsWithRetry({ + client: params.client, + file: nextFile, + baseHash: snapshot.hash, + exists: snapshot.exists, + }); +} + +export async function readGatewayAgentExecApprovals(params: { + client: GatewayClient; + agentId: string; +}): Promise<{ + security: GatewayExecApprovalSecurity | null; + ask: GatewayExecApprovalAsk | null; + allowlist: Array<{ pattern: string }>; +} | null> { + const agentId = params.agentId.trim(); + if (!agentId) { + throw new Error("Agent id is required."); + } + + const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {}); + const entry = snapshot.file?.agents?.[agentId]; + if (!entry) return null; + + const security = + entry.security === "deny" || entry.security === "allowlist" || entry.security === "full" + ? entry.security + : null; + const ask = entry.ask === "off" || entry.ask === "on-miss" || entry.ask === "always" ? entry.ask : null; + const allowlist = Array.isArray(entry.allowlist) + ? entry.allowlist + .map((item) => (item && typeof item === "object" ? (item as ExecAllowlistEntry).pattern : "")) + .filter((pattern): pattern is string => typeof pattern === "string") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0) + .map((pattern) => ({ pattern })) + : []; + + return { + security, + ask, + allowlist, + }; +} diff --git a/src/lib/gateway/frames.ts b/src/lib/gateway/frames.ts deleted file mode 100644 index 6de514c9..00000000 --- a/src/lib/gateway/frames.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ReqFrame = { - type: "req"; - id: string; - method: string; - params: unknown; -}; - -export type ResFrame = { - type: "res"; - id: string; - ok: boolean; - payload?: unknown; - error?: { - code: string; - message: string; - details?: unknown; - retryable?: boolean; - retryAfterMs?: number; - }; -}; - -export type EventFrame = { - type: "event"; - event: string; - payload: unknown; - seq?: number; - stateVersion?: number; -}; - -export type GatewayFrame = ReqFrame | ResFrame | EventFrame; diff --git a/src/lib/gateway/gatewayReloadMode.ts b/src/lib/gateway/gatewayReloadMode.ts new file mode 100644 index 00000000..0aa27f06 --- /dev/null +++ b/src/lib/gateway/gatewayReloadMode.ts @@ -0,0 +1,107 @@ +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; +import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; + +type GatewayConfigSnapshot = { + config?: Record<string, unknown>; + hash?: string; + exists?: boolean; +}; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const shouldRetryConfigWrite = (err: unknown) => { + if (!(err instanceof GatewayResponseError)) return false; + return /re-run config\.get|config changed since last load/i.test(err.message); +}; + +const resolveReloadModeFromConfig = (config: unknown): string | null => { + if (!isRecord(config)) return null; + const gateway = isRecord(config.gateway) ? config.gateway : null; + const reload = gateway && isRecord(gateway.reload) ? gateway.reload : null; + if (!reload || typeof reload.mode !== "string") return "hybrid"; + const mode = reload.mode.trim().toLowerCase(); + return mode.length > 0 ? mode : "hybrid"; +}; + +export const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean => + mode !== "hot" && mode !== "off" && mode !== "hybrid"; + +export async function shouldAwaitDisconnectRestartForRemoteMutation(params: { + client: GatewayClient; + cachedConfigSnapshot: { config?: unknown } | null; + logError?: (message: string, error: unknown) => void; +}): Promise<boolean> { + const cachedMode = resolveReloadModeFromConfig(params.cachedConfigSnapshot?.config); + if (cachedMode) { + return shouldAwaitDisconnectRestartForReloadMode(cachedMode); + } + try { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const mode = resolveReloadModeFromConfig(snapshot.config); + return shouldAwaitDisconnectRestartForReloadMode(mode); + } catch (err) { + params.logError?.( + "Failed to determine gateway reload mode; defaulting to restart wait.", + err + ); + return true; + } +} + +export async function ensureGatewayReloadModeHotForLocalStudio(params: { + client: GatewayClient; + upstreamGatewayUrl: string; +}): Promise<void> { + if (!isLocalGatewayUrl(params.upstreamGatewayUrl)) { + return; + } + + const attemptWrite = async (attempt: number): Promise<void> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const exists = snapshot.exists !== false; + const baseHash = exists ? snapshot.hash?.trim() : undefined; + if (exists && !baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + const gateway = isRecord(baseConfig.gateway) ? baseConfig.gateway : {}; + const reload = isRecord(gateway.reload) ? gateway.reload : {}; + const mode = typeof reload.mode === "string" ? reload.mode.trim() : ""; + + if (mode === "hot" || mode === "off") { + return; + } + + const nextConfig: Record<string, unknown> = { + ...baseConfig, + gateway: { + ...gateway, + reload: { + ...reload, + mode: "hot", + }, + }, + }; + + const payload: Record<string, unknown> = { + raw: JSON.stringify(nextConfig, null, 2), + }; + if (baseHash) { + payload.baseHash = baseHash; + } + + try { + await params.client.call("config.set", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigWrite(err)) { + await attemptWrite(attempt + 1); + return; + } + throw err; + } + }; + + await attemptWrite(0); +} diff --git a/src/lib/gateway/local-gateway.ts b/src/lib/gateway/local-gateway.ts new file mode 100644 index 00000000..02fea481 --- /dev/null +++ b/src/lib/gateway/local-gateway.ts @@ -0,0 +1,22 @@ +const parseHostname = (gatewayUrl: string): string | null => { + const trimmed = gatewayUrl.trim(); + if (!trimmed) return null; + try { + return new URL(trimmed).hostname; + } catch { + return null; + } +}; + +export const isLocalGatewayUrl = (gatewayUrl: string): boolean => { + const hostname = parseHostname(gatewayUrl); + if (!hostname) return false; + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "0.0.0.0" + ); +}; + diff --git a/src/lib/gateway/models.ts b/src/lib/gateway/models.ts new file mode 100644 index 00000000..5e4f5e32 --- /dev/null +++ b/src/lib/gateway/models.ts @@ -0,0 +1,92 @@ +export type GatewayModelChoice = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; +}; + +type GatewayModelAliasEntry = { + alias?: string; +}; + +type GatewayModelDefaults = { + model?: string | { primary?: string; fallbacks?: string[] }; + models?: Record<string, GatewayModelAliasEntry>; +}; + +export type GatewayModelPolicySnapshot = { + config?: { + agents?: { + defaults?: GatewayModelDefaults; + list?: Array<{ + id?: string; + model?: string | { primary?: string; fallbacks?: string[] }; + }>; + }; + }; +}; + +export const resolveConfiguredModelKey = ( + raw: string, + models?: Record<string, GatewayModelAliasEntry> +) => { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (trimmed.includes("/")) return trimmed; + if (models) { + const target = Object.entries(models).find( + ([, entry]) => entry?.alias?.trim().toLowerCase() === trimmed.toLowerCase() + ); + if (target?.[0]) return target[0]; + } + return `anthropic/${trimmed}`; +}; + +export const buildAllowedModelKeys = (snapshot: GatewayModelPolicySnapshot | null) => { + const allowedList: string[] = []; + const allowedSet = new Set<string>(); + const defaults = snapshot?.config?.agents?.defaults; + const modelDefaults = defaults?.model; + const modelAliases = defaults?.models; + const pushKey = (raw?: string | null) => { + if (!raw) return; + const resolved = resolveConfiguredModelKey(raw, modelAliases); + if (!resolved) return; + if (allowedSet.has(resolved)) return; + allowedSet.add(resolved); + allowedList.push(resolved); + }; + if (typeof modelDefaults === "string") { + pushKey(modelDefaults); + } else if (modelDefaults && typeof modelDefaults === "object") { + pushKey(modelDefaults.primary ?? null); + for (const fallback of modelDefaults.fallbacks ?? []) { + pushKey(fallback); + } + } + if (modelAliases) { + for (const key of Object.keys(modelAliases)) { + pushKey(key); + } + } + return allowedList; +}; + +export const buildGatewayModelChoices = ( + catalog: GatewayModelChoice[], + snapshot: GatewayModelPolicySnapshot | null +) => { + const allowedKeys = buildAllowedModelKeys(snapshot); + if (allowedKeys.length === 0) return catalog; + const filtered = catalog.filter((entry) => allowedKeys.includes(`${entry.provider}/${entry.id}`)); + const filteredKeys = new Set(filtered.map((entry) => `${entry.provider}/${entry.id}`)); + const extras: GatewayModelChoice[] = []; + for (const key of allowedKeys) { + if (filteredKeys.has(key)) continue; + const [provider, id] = key.split("/"); + if (!provider || !id) continue; + extras.push({ provider, id, name: key }); + } + return [...filtered, ...extras]; +}; diff --git a/src/lib/gateway/openclaw/GatewayBrowserClient.ts b/src/lib/gateway/openclaw/GatewayBrowserClient.ts new file mode 100644 index 00000000..7965d271 --- /dev/null +++ b/src/lib/gateway/openclaw/GatewayBrowserClient.ts @@ -0,0 +1,643 @@ +import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519"; +import { GatewayResponseError } from "@/lib/gateway/errors"; + +const GATEWAY_CLIENT_NAMES = { + CONTROL_UI: "openclaw-control-ui", +} as const; + +const GATEWAY_CLIENT_MODES = { + WEBCHAT: "webchat", +} as const; + +type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +let warnedWeakCrypto = false; + +function uuidFromBytes(bytes: Uint8Array): string { + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, "0"); + } + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; +} + +function weakRandomBytes(): Uint8Array { + const bytes = new Uint8Array(16); + const now = Date.now(); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[0] ^= now & 0xff; + bytes[1] ^= (now >>> 8) & 0xff; + bytes[2] ^= (now >>> 16) & 0xff; + bytes[3] ^= (now >>> 24) & 0xff; + return bytes; +} + +function warnWeakCryptoOnce() { + if (warnedWeakCrypto) return; + warnedWeakCrypto = true; + console.warn("[uuid] crypto API missing; falling back to weak randomness"); +} + +function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + return uuidFromBytes(bytes); + } + + warnWeakCryptoOnce(); + return uuidFromBytes(weakRandomBytes()); +} + +type DeviceAuthPayloadParams = { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token?: string | null; + nonce?: string | null; + version?: "v1" | "v2"; +}; + +function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string { + const version = params.version ?? (params.nonce ? "v2" : "v1"); + const scopes = params.scopes.join(","); + const token = params.token ?? ""; + const base = [ + version, + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ]; + if (version === "v2") { + base.push(params.nonce ?? ""); + } + return base.join("|"); +} + +type DeviceAuthEntry = { + token: string; + role: string; + scopes: string[]; + updatedAtMs: number; +}; + +type DeviceAuthStore = { + version: 1; + deviceId: string; + tokens: Record<string, DeviceAuthEntry>; +}; + +const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1"; + +function normalizeAuthScope(scope: string | undefined): string { + const trimmed = scope?.trim(); + if (!trimmed) return "default"; + return trimmed.toLowerCase(); +} + +function buildScopedTokenKey(scope: string, role: string): string { + return `${scope}::${role}`; +} + +function normalizeRole(role: string): string { + return role.trim(); +} + +function normalizeScopes(scopes: string[] | undefined): string[] { + if (!Array.isArray(scopes)) return []; + const out = new Set<string>(); + for (const scope of scopes) { + const trimmed = scope.trim(); + if (trimmed) out.add(trimmed); + } + return [...out].sort(); +} + +function readDeviceAuthStore(): DeviceAuthStore | null { + try { + const raw = window.localStorage.getItem(DEVICE_AUTH_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as DeviceAuthStore; + if (!parsed || parsed.version !== 1) return null; + if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null; + if (!parsed.tokens || typeof parsed.tokens !== "object") return null; + return parsed; + } catch { + return null; + } +} + +function writeDeviceAuthStore(store: DeviceAuthStore) { + try { + window.localStorage.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store)); + } catch { + // best-effort + } +} + +function loadDeviceAuthToken(params: { deviceId: string; role: string; scope: string }): DeviceAuthEntry | null { + const store = readDeviceAuthStore(); + if (!store || store.deviceId !== params.deviceId) return null; + const role = normalizeRole(params.role); + const scope = normalizeAuthScope(params.scope); + const key = buildScopedTokenKey(scope, role); + const entry = store.tokens[key]; + if (!entry || typeof entry.token !== "string") return null; + return entry; +} + +function storeDeviceAuthToken(params: { + deviceId: string; + role: string; + scope: string; + token: string; + scopes?: string[]; +}): DeviceAuthEntry { + const role = normalizeRole(params.role); + const scope = normalizeAuthScope(params.scope); + const key = buildScopedTokenKey(scope, role); + const next: DeviceAuthStore = { + version: 1, + deviceId: params.deviceId, + tokens: {}, + }; + const existing = readDeviceAuthStore(); + if (existing && existing.deviceId === params.deviceId) { + next.tokens = { ...existing.tokens }; + } + const entry: DeviceAuthEntry = { + token: params.token, + role, + scopes: normalizeScopes(params.scopes), + updatedAtMs: Date.now(), + }; + next.tokens[key] = entry; + writeDeviceAuthStore(next); + return entry; +} + +function clearDeviceAuthToken(params: { deviceId: string; role: string; scope: string }) { + const store = readDeviceAuthStore(); + if (!store || store.deviceId !== params.deviceId) return; + const role = normalizeRole(params.role); + const scope = normalizeAuthScope(params.scope); + const key = buildScopedTokenKey(scope, role); + const hasScoped = Boolean(store.tokens[key]); + const hasLegacy = Boolean(store.tokens[role]); + if (!hasScoped && !hasLegacy) return; + const next = { ...store, tokens: { ...store.tokens } }; + delete next.tokens[key]; + delete next.tokens[role]; + writeDeviceAuthStore(next); +} + +type StoredIdentity = { + version: 1; + deviceId: string; + publicKey: string; + privateKey: string; + createdAtMs: number; +}; + +type DeviceIdentity = { + deviceId: string; + publicKey: string; + privateKey: string; +}; + +const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1"; + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function base64UrlDecode(input: string): Uint8Array { + const normalized = input.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> { + const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey)); + return bytesToHex(new Uint8Array(hash)); +} + +async function generateIdentity(): Promise<DeviceIdentity> { + const privateKey = utils.randomSecretKey(); + const publicKey = await getPublicKeyAsync(privateKey); + const deviceId = await fingerprintPublicKey(publicKey); + return { + deviceId, + publicKey: base64UrlEncode(publicKey), + privateKey: base64UrlEncode(privateKey), + }; +} + +async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> { + try { + const raw = localStorage.getItem(DEVICE_IDENTITY_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as StoredIdentity; + if ( + parsed?.version === 1 && + typeof parsed.deviceId === "string" && + typeof parsed.publicKey === "string" && + typeof parsed.privateKey === "string" + ) { + const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey)); + if (derivedId !== parsed.deviceId) { + const updated: StoredIdentity = { + ...parsed, + deviceId: derivedId, + }; + localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated)); + return { + deviceId: derivedId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + return { + deviceId: parsed.deviceId, + publicKey: parsed.publicKey, + privateKey: parsed.privateKey, + }; + } + } + } catch { + // fall through to regenerate + } + + const identity = await generateIdentity(); + const stored: StoredIdentity = { + version: 1, + deviceId: identity.deviceId, + publicKey: identity.publicKey, + privateKey: identity.privateKey, + createdAtMs: Date.now(), + }; + localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored)); + return identity; +} + +async function signDevicePayload(privateKeyBase64Url: string, payload: string) { + const key = base64UrlDecode(privateKeyBase64Url); + const data = new TextEncoder().encode(payload); + const sig = await signAsync(data, key); + return base64UrlEncode(sig); +} + +export type GatewayEventFrame = { + type: "event"; + event: string; + payload?: unknown; + seq?: number; + stateVersion?: { presence: number; health: number }; +}; + +export type GatewayResponseFrame = { + type: "res"; + id: string; + ok: boolean; + payload?: unknown; + error?: { code: string; message: string; details?: unknown }; +}; + +export type GatewayHelloOk = { + type: "hello-ok"; + protocol: number; + features?: { methods?: string[]; events?: string[] }; + snapshot?: unknown; + auth?: { + deviceToken?: string; + role?: string; + scopes?: string[]; + issuedAtMs?: number; + }; + policy?: { tickIntervalMs?: number }; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +}; + +export type GatewayBrowserClientOptions = { + url: string; + token?: string; + password?: string; + authScopeKey?: string; + disableDeviceAuth?: boolean; + clientName?: string; + clientVersion?: string; + platform?: string; + mode?: string; + instanceId?: string; + onHello?: (hello: GatewayHelloOk) => void; + onEvent?: (evt: GatewayEventFrame) => void; + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; +}; + +const CONNECT_FAILED_CLOSE_CODE = 4008; +const WS_CLOSE_REASON_MAX_BYTES = 123; + +function truncateWsCloseReason(reason: string, maxBytes = WS_CLOSE_REASON_MAX_BYTES): string { + const trimmed = reason.trim(); + if (!trimmed) return "connect failed"; + const encoder = new TextEncoder(); + if (encoder.encode(trimmed).byteLength <= maxBytes) return trimmed; + + let out = ""; + for (const char of trimmed) { + const next = out + char; + if (encoder.encode(next).byteLength > maxBytes) break; + out = next; + } + return out.trimEnd() || "connect failed"; +} + +export class GatewayBrowserClient { + private ws: WebSocket | null = null; + private pending = new Map<string, Pending>(); + private closed = false; + private lastSeq: number | null = null; + private connectNonce: string | null = null; + private connectSent = false; + private connectTimer: number | null = null; + private backoffMs = 800; + + constructor(private opts: GatewayBrowserClientOptions) {} + + start() { + this.closed = false; + this.connect(); + } + + stop() { + this.closed = true; + this.ws?.close(); + this.ws = null; + this.flushPending(new Error("gateway client stopped")); + } + + get connected() { + return this.ws?.readyState === WebSocket.OPEN; + } + + private connect() { + if (this.closed) return; + this.ws = new WebSocket(this.opts.url); + this.ws.onopen = () => this.queueConnect(); + this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); + this.ws.onclose = (ev) => { + const reason = String(ev.reason ?? ""); + this.ws = null; + this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); + this.opts.onClose?.({ code: ev.code, reason }); + this.scheduleReconnect(); + }; + this.ws.onerror = () => { + // ignored; close handler will fire + }; + } + + private scheduleReconnect() { + if (this.closed) return; + const delay = this.backoffMs; + this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); + window.setTimeout(() => this.connect(), delay); + } + + private flushPending(err: Error) { + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + } + + private async sendConnect() { + if (this.connectSent) return; + this.connectSent = true; + if (this.connectTimer !== null) { + window.clearTimeout(this.connectTimer); + this.connectTimer = null; + } + + const isSecureContext = + !this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle; + + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; + const role = "operator"; + const authScopeKey = normalizeAuthScope(this.opts.authScopeKey ?? this.opts.url); + let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null; + let canFallbackToShared = false; + let authToken = this.opts.token; + + if (isSecureContext) { + deviceIdentity = await loadOrCreateDeviceIdentity(); + const storedToken = loadDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role, + scope: authScopeKey, + })?.token; + authToken = storedToken ?? this.opts.token; + canFallbackToShared = Boolean(storedToken && this.opts.token); + } + const auth = + authToken || this.opts.password + ? { + token: authToken, + password: this.opts.password, + } + : undefined; + + let device: + | { + id: string; + publicKey: string; + signature: string; + signedAt: number; + nonce: string | undefined; + } + | undefined; + + if (isSecureContext && deviceIdentity) { + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + const payload = buildDeviceAuthPayload({ + deviceId: deviceIdentity.deviceId, + clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + role, + scopes, + signedAtMs, + token: authToken ?? null, + nonce, + }); + const signature = await signDevicePayload(deviceIdentity.privateKey, payload); + device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKey, + signature, + signedAt: signedAtMs, + nonce, + }; + } + const params = { + minProtocol: 3, + maxProtocol: 3, + client: { + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: this.opts.clientVersion ?? "dev", + platform: this.opts.platform ?? navigator.platform ?? "web", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, + instanceId: this.opts.instanceId, + }, + role, + scopes, + device, + caps: [], + auth, + userAgent: navigator.userAgent, + locale: navigator.language, + }; + + void this.request<GatewayHelloOk>("connect", params) + .then((hello) => { + if (hello?.auth?.deviceToken && deviceIdentity) { + storeDeviceAuthToken({ + deviceId: deviceIdentity.deviceId, + role: hello.auth.role ?? role, + scope: authScopeKey, + token: hello.auth.deviceToken, + scopes: hello.auth.scopes ?? [], + }); + } + this.backoffMs = 800; + this.opts.onHello?.(hello); + }) + .catch((err) => { + if (canFallbackToShared && deviceIdentity) { + clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey }); + } + const rawReason = + err instanceof GatewayResponseError + ? `connect failed: ${err.code} ${err.message}` + : "connect failed"; + const reason = truncateWsCloseReason(rawReason); + if (reason !== rawReason) { + console.warn("[gateway] connect close reason truncated to 123 UTF-8 bytes"); + } + this.ws?.close(CONNECT_FAILED_CLOSE_CODE, reason); + }); + } + + private handleMessage(raw: string) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + const frame = parsed as { type?: unknown }; + if (frame.type === "event") { + const evt = parsed as GatewayEventFrame; + if (evt.event === "connect.challenge") { + const payload = evt.payload as { nonce?: unknown } | undefined; + const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; + if (nonce) { + this.connectNonce = nonce; + void this.sendConnect(); + } + return; + } + const seq = typeof evt.seq === "number" ? evt.seq : null; + if (seq !== null) { + if (this.lastSeq !== null && seq > this.lastSeq + 1) { + this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq }); + } + this.lastSeq = seq; + } + try { + this.opts.onEvent?.(evt); + } catch (err) { + console.error("[gateway] event handler error:", err); + } + return; + } + + if (frame.type === "res") { + const res = parsed as GatewayResponseFrame; + const pending = this.pending.get(res.id); + if (!pending) return; + this.pending.delete(res.id); + if (res.ok) pending.resolve(res.payload); + else { + if (res.error && typeof res.error.code === "string") { + pending.reject( + new GatewayResponseError({ + code: res.error.code, + message: res.error.message ?? "request failed", + details: res.error.details, + }) + ); + return; + } + pending.reject(new Error(res.error?.message ?? "request failed")); + } + return; + } + } + + request<T = unknown>(method: string, params?: unknown): Promise<T> { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("gateway not connected")); + } + const id = generateUUID(); + const frame = { type: "req", id, method, params }; + const p = new Promise<T>((resolve, reject) => { + this.pending.set(id, { resolve: (v) => resolve(v as T), reject }); + }); + this.ws.send(JSON.stringify(frame)); + return p; + } + + private queueConnect() { + this.connectNonce = null; + this.connectSent = false; + if (this.connectTimer !== null) window.clearTimeout(this.connectTimer); + this.connectTimer = window.setTimeout(() => { + void this.sendConnect(); + }, 750); + } +} diff --git a/src/lib/gateway/proxy-url.ts b/src/lib/gateway/proxy-url.ts new file mode 100644 index 00000000..d5561045 --- /dev/null +++ b/src/lib/gateway/proxy-url.ts @@ -0,0 +1,6 @@ +export const resolveStudioProxyGatewayUrl = (): string => { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const host = window.location.host; + return `${protocol}://${host}/api/gateway/ws`; +}; + diff --git a/src/lib/gateway/sandboxEnvAllowlist.ts b/src/lib/gateway/sandboxEnvAllowlist.ts new file mode 100644 index 00000000..cb79a2b5 --- /dev/null +++ b/src/lib/gateway/sandboxEnvAllowlist.ts @@ -0,0 +1,109 @@ +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; +import type { GatewayConfigSnapshot } from "@/lib/gateway/agentConfig"; +import { fetchJson } from "@/lib/http"; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + +const shouldRetryConfigWrite = (err: unknown) => { + if (!(err instanceof GatewayResponseError)) return false; + return /re-run config\.get|config changed since last load/i.test(err.message); +}; + +const readDotEnvKeys = async (): Promise<string[]> => { + if (typeof window === "undefined") { + return []; + } + const url = new URL("/api/gateway/dotenv-keys", window.location.origin).toString(); + const { keys } = await fetchJson<{ keys: string[] }>(url); + return Array.isArray(keys) ? keys : []; +}; + +const readDefaultSandboxEnvMap = (config: Record<string, unknown>): Record<string, string> => { + const agents = isRecord(config.agents) ? config.agents : null; + const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null; + const sandbox = defaults && isRecord(defaults.sandbox) ? defaults.sandbox : null; + const docker = sandbox && isRecord(sandbox.docker) ? sandbox.docker : null; + const env = docker && isRecord(docker.env) ? docker.env : null; + + const result: Record<string, string> = {}; + if (!env) return result; + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; +}; + +const writeDefaultSandboxEnvMap = ( + config: Record<string, unknown>, + env: Record<string, string>, +): Record<string, unknown> => { + const agents = isRecord(config.agents) ? { ...config.agents } : {}; + const defaults = isRecord(agents.defaults) ? { ...(agents.defaults as Record<string, unknown>) } : {}; + const sandbox = isRecord(defaults.sandbox) ? { ...(defaults.sandbox as Record<string, unknown>) } : {}; + const docker = isRecord(sandbox.docker) ? { ...(sandbox.docker as Record<string, unknown>) } : {}; + + docker.env = env; + sandbox.docker = docker; + defaults.sandbox = sandbox; + (agents as Record<string, unknown>).defaults = defaults; + + return { ...config, agents }; +}; + +export const ensureGatewaySandboxEnvAllowlistFromDotEnv = async (params: { + client: GatewayClient; +}): Promise<void> => { + let keys: string[] = []; + try { + keys = await readDotEnvKeys(); + } catch (err) { + const message = err instanceof Error ? err.message : ""; + if (message.includes("status 404")) { + return; + } + console.warn("Failed to sync sandbox env allowlist from dotenv keys.", err); + return; + } + if (keys.length === 0) return; + + const tryOnce = async (attempt: number): Promise<void> => { + const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {}); + const baseConfig = isRecord(snapshot.config) ? snapshot.config : {}; + + const currentEnv = readDefaultSandboxEnvMap(baseConfig); + const nextEnv: Record<string, string> = { ...currentEnv }; + + let changed = false; + for (const key of keys) { + if (key in nextEnv) continue; + nextEnv[key] = `\${${key}}`; + changed = true; + } + if (!changed) return; + + const nextConfig = writeDefaultSandboxEnvMap(baseConfig, nextEnv); + const payload: Record<string, unknown> = { + raw: JSON.stringify(nextConfig, null, 2), + }; + const baseHash = typeof snapshot.hash === "string" ? snapshot.hash.trim() : ""; + if (snapshot.exists !== false) { + if (!baseHash) { + throw new Error("Gateway config hash unavailable; re-run config.get."); + } + payload.baseHash = baseHash; + } + try { + await params.client.call("config.set", payload); + } catch (err) { + if (attempt < 1 && shouldRetryConfigWrite(err)) { + return tryOnce(attempt + 1); + } + throw err; + } + }; + + await tryOnce(0); +}; diff --git a/src/lib/gateway/useGatewayConnection.ts b/src/lib/gateway/useGatewayConnection.ts deleted file mode 100644 index ab1ef641..00000000 --- a/src/lib/gateway/useGatewayConnection.ts +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - GatewayClient, - GatewayResponseError, - GatewayStatus, -} from "./GatewayClient"; -import { env } from "@/lib/env"; - -const DEFAULT_GATEWAY_URL = env.NEXT_PUBLIC_GATEWAY_URL ?? "ws://127.0.0.1:18789"; -const formatGatewayError = (error: unknown) => { - if (error instanceof GatewayResponseError) { - return `Gateway error (${error.code}): ${error.message}`; - } - if (error instanceof Error) { - return error.message; - } - return "Unknown gateway error."; -}; - -export type GatewayConnectionState = { - client: GatewayClient; - status: GatewayStatus; - gatewayUrl: string; - token: string; - error: string | null; - connect: () => Promise<void>; - disconnect: () => void; - setGatewayUrl: (value: string) => void; - setToken: (value: string) => void; - clearError: () => void; -}; - -export const useGatewayConnection = (): GatewayConnectionState => { - const [client] = useState(() => new GatewayClient()); - const didAutoConnect = useRef(false); - - const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_GATEWAY_URL); - const [token, setToken] = useState(""); - const [status, setStatus] = useState<GatewayStatus>("disconnected"); - const [error, setError] = useState<string | null>(null); - const [configLoaded, setConfigLoaded] = useState(false); - - useEffect(() => { - let cancelled = false; - const loadConfig = async () => { - try { - const res = await fetch("/api/gateway", { cache: "no-store" }); - if (!res.ok) return; - const data = (await res.json()) as { gatewayUrl?: string; token?: string }; - if (cancelled) return; - if (typeof data.gatewayUrl === "string" && data.gatewayUrl.trim()) { - setGatewayUrl(data.gatewayUrl); - } - if (typeof data.token === "string") { - setToken(data.token); - } - } catch { - if (!cancelled) { - setError("Failed to load gateway config."); - } - } finally { - if (!cancelled) { - setConfigLoaded(true); - } - } - }; - void loadConfig(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - return client.onStatus((nextStatus) => { - setStatus(nextStatus); - if (nextStatus !== "connecting") { - setError(null); - } - }); - }, [client]); - - useEffect(() => { - return () => { - client.disconnect(); - }; - }, [client]); - - const connect = useCallback(async () => { - setError(null); - try { - await client.connect({ gatewayUrl, token }); - } catch (err) { - setError(formatGatewayError(err)); - } - }, [client, gatewayUrl, token]); - - useEffect(() => { - if (didAutoConnect.current) return; - if (!configLoaded) return; - if (!gatewayUrl.trim()) return; - didAutoConnect.current = true; - void connect(); - }, [connect, configLoaded, gatewayUrl]); - - const disconnect = useCallback(() => { - setError(null); - client.disconnect(); - }, [client]); - - const clearError = useCallback(() => { - setError(null); - }, []); - - return { - client, - status, - gatewayUrl, - token, - error, - connect, - disconnect, - setGatewayUrl, - setToken, - clearError, - }; -}; diff --git a/src/lib/ids/agentId.ts b/src/lib/ids/agentId.ts deleted file mode 100644 index abc5618b..00000000 --- a/src/lib/ids/agentId.ts +++ /dev/null @@ -1,36 +0,0 @@ -const normalizeSegment = (value: string) => - value - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - -export const generateAgentId = ({ - projectSlug, - tileName, -}: { - projectSlug: string; - tileName: string; -}): string => { - const projectSegment = normalizeSegment(projectSlug); - const tileSegment = normalizeSegment(tileName); - if (!tileSegment) { - throw new Error("Agent name produced an empty id."); - } - const base = projectSegment ? `proj-${projectSegment}-${tileSegment}` : `proj-${tileSegment}`; - if (base.length <= 64) { - return base; - } - const suffix = `-${tileSegment}`; - const maxProjectLength = Math.max(0, 64 - ("proj".length + suffix.length + 1)); - const trimmedProject = projectSegment.slice(0, maxProjectLength); - const trimmedBase = trimmedProject - ? `proj-${trimmedProject}-${tileSegment}` - : `proj-${tileSegment}`; - if (trimmedBase.length <= 64) { - return trimmedBase; - } - const maxTileLength = Math.max(1, 64 - ("proj".length + 1)); - const trimmedTile = tileSegment.slice(0, maxTileLength); - return `proj-${trimmedTile}`; -}; diff --git a/src/lib/ids/slugify.ts b/src/lib/ids/slugify.ts deleted file mode 100644 index 224dd912..00000000 --- a/src/lib/ids/slugify.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const slugifyProjectName = (name: string): string => { - const slug = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - if (!slug) { - throw new Error("Workspace name produced an empty folder name."); - } - return slug; -}; diff --git a/src/lib/logger.ts b/src/lib/logger.ts deleted file mode 100644 index 8baae69d..00000000 --- a/src/lib/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const logger = { - info: (...args: unknown[]) => console.info(...args), - warn: (...args: unknown[]) => console.warn(...args), - error: (...args: unknown[]) => console.error(...args), - debug: (...args: unknown[]) => console.debug(...args), -}; diff --git a/src/lib/names/agentNames.ts b/src/lib/names/agentNames.ts deleted file mode 100644 index cfe0faae..00000000 --- a/src/lib/names/agentNames.ts +++ /dev/null @@ -1,117 +0,0 @@ -const FIRST_NAMES = [ - "Ziggy", - "Nova", - "Quill", - "Sable", - "Axel", - "Moxie", - "Pip", - "Nyx", - "Rogue", - "Orbit", - "Blip", - "Vex", - "Jinx", - "Echo", - "Pixel", - "Rune", - "Comet", - "Holo", - "Gizmo", - "Fuzz", - "Bramble", - "Kite", - "Pogo", - "Lumen", - "Sprocket", - "Doodle", - "Quasar", - "Nimbus", - "Bop", - "Zazu", - "Tango", - "Wisp", - "Zebra", - "Taffy", - "Glimmer", - "Marble", - "Rocket", - "Flint", - "Sprout", - "Velvet", - "Thimble", - "Skipper", - "Bolt", - "Pickle", - "Yodel", - "Mango", - "Fable", - "Biscuit", - "Jazz", - "Drift", -]; - -const LAST_NAMES = [ - "Moonpickle", - "Stormbucket", - "Cloudwhisk", - "Glitterfax", - "Bumbleforge", - "Snickerdash", - "Velcrobeard", - "Quasaroo", - "Marmalade", - "Thunderhat", - "Gobbleton", - "Zapleton", - "Banjosmith", - "Cosmobean", - "Wobbleton", - "Flipstick", - "Noodleton", - "Crumblepot", - "Razzlefin", - "Lemonbyte", - "Frothwick", - "Pebbleford", - "Tinseltoe", - "Gadgetson", - "Wormwood", - "Bananafax", - "Picklebarrel", - "Spindlepop", - "Rockett", - "Chimneyspark", - "Frostpunch", - "Echoquill", - "Glimmerfox", - "Scootles", - "Pancake", - "Thunderflip", - "Wafflehorn", - "Sporkle", - "Muffin", - "Zigzagger", - "Taterchip", - "Dandelion", - "Snortle", - "Velvetshoe", - "Cosmicus", - "Gobbler", - "Zapwhirl", - "Glimmergloop", - "Bramblebat", - "Suncrumb", -]; - -const pickRandom = (values: string[]) => { - return values[Math.floor(Math.random() * values.length)]; -}; - -export const createRandomAgentName = () => { - return `${pickRandom(FIRST_NAMES)}-${pickRandom(LAST_NAMES)}`; -}; - -export const normalizeAgentName = (input: string) => { - return input.trim().replace(/\s+/g, "-"); -}; diff --git a/src/lib/projects/agentWorkspace.ts b/src/lib/projects/agentWorkspace.ts deleted file mode 100644 index 54bca749..00000000 --- a/src/lib/projects/agentWorkspace.ts +++ /dev/null @@ -1,10 +0,0 @@ -import os from "node:os"; -import path from "node:path"; - -export const resolveProjectAgentsRoot = (projectId: string) => { - return path.join(os.homedir(), ".clawdbot", "agent-canvas", "workspaces", projectId, "agents"); -}; - -export const resolveAgentWorkspaceDir = (projectId: string, agentId: string) => { - return path.join(resolveProjectAgentsRoot(projectId), agentId); -}; diff --git a/src/lib/projects/client.ts b/src/lib/projects/client.ts deleted file mode 100644 index de5f76ac..00000000 --- a/src/lib/projects/client.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { - ProjectCreatePayload, - ProjectCreateResult, - ProjectDeleteResult, - ProjectDiscordChannelCreatePayload, - ProjectDiscordChannelCreateResult, - ProjectOpenPayload, - ProjectOpenResult, - ProjectTileCreatePayload, - ProjectTileCreateResult, - ProjectTileDeleteResult, - ProjectTileRenamePayload, - ProjectTileRenameResult, - ProjectTileUpdatePayload, - ProjectTileUpdateResult, - ProjectTileWorkspaceFilesResult, - ProjectTileWorkspaceFilesUpdatePayload, - ProjectsStore, -} from "./types"; -import { fetchJson } from "@/lib/http"; - -export const fetchProjectsStore = async (): Promise<ProjectsStore> => { - return fetchJson<ProjectsStore>("/api/projects", { cache: "no-store" }); -}; - -export const createProject = async ( - payload: ProjectCreatePayload -): Promise<ProjectCreateResult> => { - return fetchJson<ProjectCreateResult>("/api/projects", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const openProject = async ( - payload: ProjectOpenPayload -): Promise<ProjectOpenResult> => { - return fetchJson<ProjectOpenResult>("/api/projects/open", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const saveProjectsStore = async (store: ProjectsStore): Promise<ProjectsStore> => { - return fetchJson<ProjectsStore>("/api/projects", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(store), - }); -}; - -export const deleteProject = async (projectId: string): Promise<ProjectDeleteResult> => { - return fetchJson<ProjectDeleteResult>(`/api/projects/${projectId}`, { - method: "DELETE", - }); -}; - -export const createProjectDiscordChannel = async ( - projectId: string, - payload: ProjectDiscordChannelCreatePayload -): Promise<ProjectDiscordChannelCreateResult> => { - return fetchJson<ProjectDiscordChannelCreateResult>(`/api/projects/${projectId}/discord`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const createProjectTile = async ( - projectId: string, - payload: ProjectTileCreatePayload -): Promise<ProjectTileCreateResult> => { - return fetchJson<ProjectTileCreateResult>(`/api/projects/${projectId}/tiles`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const deleteProjectTile = async ( - projectId: string, - tileId: string -): Promise<ProjectTileDeleteResult> => { - return fetchJson<ProjectTileDeleteResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "DELETE", - }); -}; - -export const renameProjectTile = async ( - projectId: string, - tileId: string, - payload: ProjectTileRenamePayload -): Promise<ProjectTileRenameResult> => { - return fetchJson<ProjectTileRenameResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const updateProjectTile = async ( - projectId: string, - tileId: string, - payload: ProjectTileUpdatePayload -): Promise<ProjectTileUpdateResult> => { - return fetchJson<ProjectTileUpdateResult>(`/api/projects/${projectId}/tiles/${tileId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -}; - -export const fetchProjectTileWorkspaceFiles = async ( - projectId: string, - tileId: string -): Promise<ProjectTileWorkspaceFilesResult> => { - return fetchJson<ProjectTileWorkspaceFilesResult>( - `/api/projects/${projectId}/tiles/${tileId}/workspace-files`, - { cache: "no-store" } - ); -}; - -export const updateProjectTileWorkspaceFiles = async ( - projectId: string, - tileId: string, - payload: ProjectTileWorkspaceFilesUpdatePayload -): Promise<ProjectTileWorkspaceFilesResult> => { - return fetchJson<ProjectTileWorkspaceFilesResult>( - `/api/projects/${projectId}/tiles/${tileId}/workspace-files`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); -}; diff --git a/src/lib/projects/fs.server.ts b/src/lib/projects/fs.server.ts deleted file mode 100644 index 197446ea..00000000 --- a/src/lib/projects/fs.server.ts +++ /dev/null @@ -1,44 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { resolveAgentWorkspaceDir } from "./agentWorkspace"; - -export const resolveHomePath = (inputPath: string) => { - if (inputPath === "~") { - return os.homedir(); - } - if (inputPath.startsWith("~/")) { - return path.join(os.homedir(), inputPath.slice(2)); - } - return inputPath; -}; - -export const resolveClawdbotStateDir = () => { - const stateDirRaw = process.env.CLAWDBOT_STATE_DIR ?? "~/.clawdbot"; - return resolveHomePath(stateDirRaw); -}; - -export const resolveAgentStateDir = (agentId: string) => { - return path.join(resolveClawdbotStateDir(), "agents", agentId); -}; - -export const deleteDirIfExists = (targetPath: string, label: string, warnings: string[]) => { - if (!fs.existsSync(targetPath)) { - warnings.push(`${label} not found at ${targetPath}.`); - return; - } - const stat = fs.statSync(targetPath); - if (!stat.isDirectory()) { - throw new Error(`${label} path is not a directory: ${targetPath}`); - } - fs.rmSync(targetPath, { recursive: true, force: false }); -}; - -export const deleteAgentArtifacts = (projectId: string, agentId: string, warnings: string[]) => { - const workspaceDir = resolveAgentWorkspaceDir(projectId, agentId); - deleteDirIfExists(workspaceDir, "Agent workspace", warnings); - - const agentDir = resolveAgentStateDir(agentId); - deleteDirIfExists(agentDir, "Agent state", warnings); -}; diff --git a/src/lib/projects/types.ts b/src/lib/projects/types.ts deleted file mode 100644 index ead495ce..00000000 --- a/src/lib/projects/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -export type ProjectTileRole = "coding" | "research" | "marketing"; - -export type ProjectTile = { - id: string; - name: string; - agentId: string; - role: ProjectTileRole; - sessionKey: string; - model?: string | null; - thinkingLevel?: string | null; - avatarSeed?: string | null; - position: { x: number; y: number }; - size: { width: number; height: number }; -}; - -export type Project = { - id: string; - name: string; - repoPath: string; - createdAt: number; - updatedAt: number; - tiles: ProjectTile[]; -}; - -export type ProjectsStore = { - version: 2; - activeProjectId: string | null; - projects: Project[]; -}; - -export type ProjectCreatePayload = { - name: string; -}; - -export type ProjectCreateResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectOpenPayload = { - path: string; -}; - -export type ProjectOpenResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectDeleteResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectDiscordChannelCreatePayload = { - guildId?: string; - agentId: string; - agentName: string; -}; - -export type ProjectDiscordChannelCreateResult = { - channelId: string; - channelName: string; - guildId: string; - agentId: string; - warnings: string[]; -}; - -export type ProjectTileCreatePayload = { - name: string; - role: ProjectTileRole; -}; - -export type ProjectTileCreateResult = { - store: ProjectsStore; - tile: ProjectTile; - warnings: string[]; -}; - -export type ProjectTileDeleteResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileRenamePayload = { - name: string; -}; - -export type ProjectTileRenameResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileUpdatePayload = { - name?: string; - avatarSeed?: string | null; -}; - -export type ProjectTileUpdateResult = { - store: ProjectsStore; - warnings: string[]; -}; - -export type ProjectTileWorkspaceFile = { - name: string; - content: string; - exists: boolean; -}; - -export type ProjectTileWorkspaceFilesResult = { - files: ProjectTileWorkspaceFile[]; -}; - -export type ProjectTileWorkspaceFilesUpdatePayload = { - files: Array<{ name: string; content: string }>; -}; diff --git a/src/lib/skills/presentation.ts b/src/lib/skills/presentation.ts new file mode 100644 index 00000000..3b9ddb12 --- /dev/null +++ b/src/lib/skills/presentation.ts @@ -0,0 +1,280 @@ +import type { + RemovableSkillSource, + SkillInstallOption, + SkillStatusEntry, +} from "@/lib/skills/types"; + +export type SkillSourceGroupId = "workspace" | "built-in" | "installed" | "extra" | "other"; + +export type SkillSourceGroup = { + id: SkillSourceGroupId; + label: string; + skills: SkillStatusEntry[]; +}; + +export type SkillReadinessState = + | "ready" + | "needs-setup" + | "unavailable" + | "disabled-globally"; + +export type AgentSkillDisplayState = "ready" | "setup-required" | "not-supported"; + +export type AgentSkillsAccessMode = "all" | "none" | "selected"; + +const GROUP_DEFINITIONS: Array<{ id: Exclude<SkillSourceGroupId, "other">; label: string }> = [ + { id: "workspace", label: "Workspace Skills" }, + { id: "built-in", label: "Built-in Skills" }, + { id: "installed", label: "Installed Skills" }, + { id: "extra", label: "Extra Skills" }, +]; + +const WORKSPACE_SOURCES = new Set(["openclaw-workspace", "agents-skills-personal", "agents-skills-project"]); +const REMOVABLE_SOURCES = new Set<RemovableSkillSource>([ + "openclaw-managed", + "openclaw-workspace", +]); + +const trimNonEmpty = (value: string): string | null => { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const OS_LABELS: Record<string, string> = { + darwin: "macOS", + linux: "Linux", + win32: "Windows", + windows: "Windows", +}; + +const toOsLabel = (value: string): string => { + const normalized = value.trim().toLowerCase(); + return OS_LABELS[normalized] ?? value.trim(); +}; + +const normalizeStringList = (values: string[] | undefined): string[] => { + if (!Array.isArray(values)) { + return []; + } + const normalized: string[] = []; + for (const value of values) { + const trimmed = trimNonEmpty(value); + if (trimmed) { + normalized.push(trimmed); + } + } + return normalized; +}; + +export const normalizeAgentSkillsAllowlist = (values: string[] | undefined): string[] => { + const normalized = normalizeStringList(values); + return Array.from(new Set(normalized)); +}; + +export const deriveAgentSkillsAccessMode = ( + values: string[] | undefined +): AgentSkillsAccessMode => { + if (!Array.isArray(values)) { + return "all"; + } + return normalizeAgentSkillsAllowlist(values).length === 0 ? "none" : "selected"; +}; + +export const buildAgentSkillsAllowlistSet = (values: string[] | undefined): Set<string> => + new Set(normalizeAgentSkillsAllowlist(values)); + +const resolveGroupId = (skill: SkillStatusEntry): SkillSourceGroupId => { + const source = trimNonEmpty(skill.source) ?? ""; + const bundled = skill.bundled || source === "openclaw-bundled"; + if (bundled) return "built-in"; + if (WORKSPACE_SOURCES.has(source)) return "workspace"; + if (source === "openclaw-managed") return "installed"; + if (source === "openclaw-extra") return "extra"; + return "other"; +}; + +export const groupSkillsBySource = (skills: SkillStatusEntry[]): SkillSourceGroup[] => { + const grouped = new Map<SkillSourceGroupId, SkillSourceGroup>(); + for (const def of GROUP_DEFINITIONS) { + grouped.set(def.id, { id: def.id, label: def.label, skills: [] }); + } + grouped.set("other", { id: "other", label: "Other Skills", skills: [] }); + + for (const skill of skills) { + const groupId = resolveGroupId(skill); + grouped.get(groupId)?.skills.push(skill); + } + + const ordered: SkillSourceGroup[] = []; + for (const def of GROUP_DEFINITIONS) { + const group = grouped.get(def.id); + if (group && group.skills.length > 0) { + ordered.push(group); + } + } + const other = grouped.get("other"); + if (other && other.skills.length > 0) { + ordered.push(other); + } + return ordered; +}; + +export const canRemoveSkillSource = (source: string): source is RemovableSkillSource => { + const trimmed = trimNonEmpty(source); + if (!trimmed) { + return false; + } + return REMOVABLE_SOURCES.has(trimmed as RemovableSkillSource); +}; + +export const canRemoveSkill = (skill: SkillStatusEntry): boolean => { + return canRemoveSkillSource(skill.source); +}; + +export const buildSkillMissingDetails = (skill: SkillStatusEntry): string[] => { + const details: string[] = []; + const bins = normalizeStringList(skill.missing.bins); + if (bins.length > 0) { + details.push(`Missing tools: ${bins.join(", ")}`); + } + + const anyBins = normalizeStringList(skill.missing.anyBins); + if (anyBins.length > 0) { + details.push(`Missing one-of tools (install any): ${anyBins.join(" | ")}`); + } + + const env = normalizeStringList(skill.missing.env); + if (env.length > 0) { + details.push(`Missing env vars (set in gateway env): ${env.join(", ")}`); + } + + const config = normalizeStringList(skill.missing.config); + if (config.length > 0) { + details.push(`Missing config values (set in openclaw.json): ${config.join(", ")}`); + } + + const os = normalizeStringList(skill.missing.os); + if (os.length > 0) { + details.push(`Requires OS: ${os.map((value) => toOsLabel(value)).join(", ")}`); + } + + return details; +}; + +export const buildSkillReasons = (skill: SkillStatusEntry): string[] => { + const reasons: string[] = []; + if (skill.disabled) { + reasons.push("disabled"); + } + if (skill.blockedByAllowlist) { + reasons.push("blocked by allowlist"); + } + if (normalizeStringList(skill.missing.bins).length > 0) { + reasons.push("missing tools"); + } + if (normalizeStringList(skill.missing.anyBins).length > 0) { + reasons.push("missing one-of tools"); + } + if (normalizeStringList(skill.missing.env).length > 0) { + reasons.push("missing env vars"); + } + if (normalizeStringList(skill.missing.config).length > 0) { + reasons.push("missing config values"); + } + if (normalizeStringList(skill.missing.os).length > 0) { + reasons.push("unsupported OS"); + } + return reasons; +}; + +export const isSkillOsIncompatible = (skill: SkillStatusEntry): boolean => { + return normalizeStringList(skill.missing.os).length > 0; +}; + +export const filterOsCompatibleSkills = (skills: SkillStatusEntry[]): SkillStatusEntry[] => { + return skills.filter((skill) => !isSkillOsIncompatible(skill)); +}; + +export const deriveSkillReadinessState = (skill: SkillStatusEntry): SkillReadinessState => { + if (skill.disabled) { + return "disabled-globally"; + } + if (isSkillOsIncompatible(skill) || skill.blockedByAllowlist) { + return "unavailable"; + } + if (skill.eligible) { + return "ready"; + } + return "needs-setup"; +}; + +export const deriveAgentSkillDisplayState = ( + readiness: SkillReadinessState +): AgentSkillDisplayState => { + if (readiness === "ready") { + return "ready"; + } + if (readiness === "unavailable") { + return "not-supported"; + } + return "setup-required"; +}; + +export const isBundledBlockedSkill = (skill: SkillStatusEntry): boolean => { + const source = trimNonEmpty(skill.source) ?? ""; + return (skill.bundled || source === "openclaw-bundled") && !skill.eligible; +}; + +export const hasInstallableMissingBinary = (skill: SkillStatusEntry): boolean => { + const installOptions = Array.isArray(skill.install) ? skill.install : []; + if (installOptions.length === 0) { + return false; + } + + const missingBinarySet = new Set([ + ...normalizeStringList(skill.missing.bins), + ...normalizeStringList(skill.missing.anyBins), + ]); + + if (missingBinarySet.size === 0) { + return false; + } + + for (const option of installOptions) { + const bins = normalizeStringList(option.bins); + if (bins.length === 0) { + return true; + } + for (const bin of bins) { + if (missingBinarySet.has(bin)) { + return true; + } + } + } + + return false; +}; + +export const resolvePreferredInstallOption = ( + skill: SkillStatusEntry +): SkillInstallOption | null => { + if (!hasInstallableMissingBinary(skill)) { + return null; + } + const missingBinarySet = new Set([ + ...normalizeStringList(skill.missing.bins), + ...normalizeStringList(skill.missing.anyBins), + ]); + for (const option of skill.install) { + const bins = normalizeStringList(option.bins); + if (bins.length === 0) { + return option; + } + for (const bin of bins) { + if (missingBinarySet.has(bin)) { + return option; + } + } + } + return skill.install[0] ?? null; +}; diff --git a/src/lib/skills/remove-local.ts b/src/lib/skills/remove-local.ts new file mode 100644 index 00000000..8773c568 --- /dev/null +++ b/src/lib/skills/remove-local.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { resolveUserPath } from "@/lib/clawdbot/paths"; +import type { RemovableSkillSource, SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types"; + +const resolveComparablePath = (input: string): string => { + const resolved = path.resolve(input); + if (!fs.existsSync(resolved)) { + return resolved; + } + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +}; + +const isPathInside = (root: string, candidate: string): boolean => { + const resolvedRoot = resolveComparablePath(root); + const resolvedCandidate = resolveComparablePath(candidate); + if (resolvedCandidate === resolvedRoot) { + return true; + } + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + return resolvedCandidate.startsWith(rootPrefix); +}; + +const normalizeRequiredPath = (value: string, field: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${field} is required.`); + } + return resolveUserPath(trimmed, os.homedir); +}; + +const resolveAllowedRoot = (params: { + source: RemovableSkillSource; + workspaceDir: string; + managedSkillsDir: string; +}): string => { + if (params.source === "openclaw-managed") { + return params.managedSkillsDir; + } + return path.join(params.workspaceDir, "skills"); +}; + +export const removeSkillLocally = (params: SkillRemoveRequest): SkillRemoveResult => { + const skillKey = params.skillKey.trim(); + if (!skillKey) { + throw new Error("skillKey is required."); + } + + const source = params.source; + const baseDir = normalizeRequiredPath(params.baseDir, "baseDir"); + const workspaceDir = normalizeRequiredPath(params.workspaceDir, "workspaceDir"); + const managedSkillsDir = normalizeRequiredPath(params.managedSkillsDir, "managedSkillsDir"); + + const allowedRoot = resolveAllowedRoot({ + source, + workspaceDir, + managedSkillsDir, + }); + + if (!isPathInside(allowedRoot, baseDir)) { + throw new Error(`Refusing to remove skill outside allowed root: ${baseDir}`); + } + if (resolveComparablePath(allowedRoot) === resolveComparablePath(baseDir)) { + throw new Error(`Refusing to remove the skills root directory: ${baseDir}`); + } + + const exists = fs.existsSync(baseDir); + if (exists) { + const stats = fs.statSync(baseDir); + if (!stats.isDirectory()) { + throw new Error(`Skill path is not a directory: ${baseDir}`); + } + const skillDocPath = path.join(baseDir, "SKILL.md"); + if (!fs.existsSync(skillDocPath) || !fs.statSync(skillDocPath).isFile()) { + throw new Error(`Refusing to remove non-skill directory: ${baseDir}`); + } + fs.rmSync(baseDir, { recursive: true, force: false }); + } + + return { + removed: exists, + removedPath: baseDir, + source, + }; +}; diff --git a/src/lib/skills/remove.ts b/src/lib/skills/remove.ts new file mode 100644 index 00000000..1425bb89 --- /dev/null +++ b/src/lib/skills/remove.ts @@ -0,0 +1,29 @@ +import { fetchJson } from "@/lib/http"; +import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types"; + +const normalizeRequired = (value: string, field: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${field} is required.`); + } + return trimmed; +}; + +export const removeSkillFromGateway = async ( + request: SkillRemoveRequest +): Promise<SkillRemoveResult> => { + const payload: SkillRemoveRequest = { + skillKey: normalizeRequired(request.skillKey, "skillKey"), + source: request.source, + baseDir: normalizeRequired(request.baseDir, "baseDir"), + workspaceDir: normalizeRequired(request.workspaceDir, "workspaceDir"), + managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"), + }; + + const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + return response.result; +}; diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts new file mode 100644 index 00000000..491e7a43 --- /dev/null +++ b/src/lib/skills/types.ts @@ -0,0 +1,141 @@ +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +export type SkillStatusConfigCheck = { + path: string; + satisfied: boolean; +}; + +export type SkillRequirementSet = { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; +}; + +export type SkillInstallOption = { + id: string; + kind: "brew" | "node" | "go" | "uv" | "download"; + label: string; + bins: string[]; +}; + +export type RemovableSkillSource = "openclaw-managed" | "openclaw-workspace"; + +export type SkillStatusEntry = { + name: string; + description: string; + source: string; + bundled: boolean; + filePath: string; + baseDir: string; + skillKey: string; + primaryEnv?: string; + emoji?: string; + homepage?: string; + always: boolean; + disabled: boolean; + blockedByAllowlist: boolean; + eligible: boolean; + requirements: SkillRequirementSet; + missing: SkillRequirementSet; + configChecks: SkillStatusConfigCheck[]; + install: SkillInstallOption[]; +}; + +export type SkillStatusReport = { + workspaceDir: string; + managedSkillsDir: string; + skills: SkillStatusEntry[]; +}; + +export type SkillInstallRequest = { + name: string; + installId: string; + timeoutMs?: number; +}; + +export type SkillInstallResult = { + ok: boolean; + message: string; + stdout: string; + stderr: string; + code: number | null; + warnings?: string[]; +}; + +export type SkillUpdateRequest = { + skillKey: string; + enabled?: boolean; + apiKey?: string; +}; + +export type SkillUpdateResult = { + ok: boolean; + skillKey: string; + config: Record<string, unknown>; +}; + +export type SkillRemoveRequest = { + skillKey: string; + source: RemovableSkillSource; + baseDir: string; + workspaceDir: string; + managedSkillsDir: string; +}; + +export type SkillRemoveResult = { + removed: boolean; + removedPath: string; + source: RemovableSkillSource; +}; + +const resolveAgentId = (agentId: string): string => { + const trimmed = agentId.trim(); + if (!trimmed) { + throw new Error("Agent id is required to load skill status."); + } + return trimmed; +}; + +const resolveRequiredValue = (value: string, message: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(message); + } + return trimmed; +}; + +export const loadAgentSkillStatus = async ( + client: GatewayClient, + agentId: string +): Promise<SkillStatusReport> => { + return client.call<SkillStatusReport>("skills.status", { + agentId: resolveAgentId(agentId), + }); +}; + +export const installSkill = async ( + client: GatewayClient, + params: SkillInstallRequest +): Promise<SkillInstallResult> => { + return client.call<SkillInstallResult>("skills.install", { + name: resolveRequiredValue(params.name, "Skill name is required to install dependencies."), + installId: resolveRequiredValue( + params.installId, + "Install option id is required to install dependencies." + ), + ...(typeof params.timeoutMs === "number" ? { timeoutMs: params.timeoutMs } : {}), + }); +}; + +export const updateSkill = async ( + client: GatewayClient, + params: SkillUpdateRequest +): Promise<SkillUpdateResult> => { + return client.call<SkillUpdateResult>("skills.update", { + skillKey: resolveRequiredValue(params.skillKey, "Skill key is required to update skill setup."), + ...(typeof params.enabled === "boolean" ? { enabled: params.enabled } : {}), + ...(typeof params.apiKey === "string" ? { apiKey: params.apiKey } : {}), + }); +}; diff --git a/src/lib/ssh/agent-state.ts b/src/lib/ssh/agent-state.ts new file mode 100644 index 00000000..bc39f660 --- /dev/null +++ b/src/lib/ssh/agent-state.ts @@ -0,0 +1,138 @@ +import { runSshJson } from "@/lib/ssh/gateway-host"; + +export type GatewayAgentStateMove = { from: string; to: string }; + +export type TrashAgentStateResult = { + trashDir: string; + moved: GatewayAgentStateMove[]; +}; + +export type RestoreAgentStateResult = { + restored: GatewayAgentStateMove[]; +}; + +const TRASH_SCRIPT = ` +set -euo pipefail + +python3 - "$1" <<'PY' +import datetime +import json +import os +import pathlib +import re +import shutil +import sys +import uuid + +agent_id = sys.argv[1].strip() +if not agent_id: + raise SystemExit("agentId is required.") +if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id): + raise SystemExit(f"Invalid agentId: {agent_id}") + +base = pathlib.Path.home() / ".openclaw" +trash_root = base / "trash" / "studio-delete-agent" +stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") +trash_dir = trash_root / f"{stamp}-{agent_id}-{uuid.uuid4()}" +(trash_dir / "agents").mkdir(parents=True, exist_ok=True) +(trash_dir / "workspaces").mkdir(parents=True, exist_ok=True) + +moves = [] + +def move_if_exists(src: pathlib.Path, dest: pathlib.Path): + if not src.exists(): + return + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dest)) + moves.append({"from": str(src), "to": str(dest)}) + +move_if_exists(base / f"workspace-{agent_id}", trash_dir / "workspaces" / f"workspace-{agent_id}") +move_if_exists(base / "agents" / agent_id, trash_dir / "agents" / agent_id) + +print(json.dumps({"trashDir": str(trash_dir), "moved": moves})) +PY +`; + +const RESTORE_SCRIPT = ` +set -euo pipefail + +python3 - "$1" "$2" <<'PY' +import json +import pathlib +import re +import shutil +import sys + +agent_id = sys.argv[1].strip() +trash_dir_raw = sys.argv[2].strip() + +if not agent_id: + raise SystemExit("agentId is required.") +if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id): + raise SystemExit(f"Invalid agentId: {agent_id}") +if not trash_dir_raw: + raise SystemExit("trashDir is required.") + +base = pathlib.Path.home() / ".openclaw" +trash_dir = pathlib.Path(trash_dir_raw).expanduser() + +try: + resolved_trash = trash_dir.resolve(strict=True) +except FileNotFoundError: + raise SystemExit(f"trashDir does not exist: {trash_dir_raw}") + +resolved_base = base.resolve(strict=False) +if resolved_base not in resolved_trash.parents: + raise SystemExit(f"trashDir is not under {base}: {trash_dir_raw}") + +moves = [] + +def restore_if_exists(src: pathlib.Path, dest: pathlib.Path): + if not src.exists(): + return + if dest.exists(): + raise SystemExit(f"Refusing to restore over existing path: {dest}") + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dest)) + moves.append({"from": str(src), "to": str(dest)}) + +restore_if_exists( + resolved_trash / "workspaces" / f"workspace-{agent_id}", + base / f"workspace-{agent_id}", +) +restore_if_exists( + resolved_trash / "agents" / agent_id, + base / "agents" / agent_id, +) + +print(json.dumps({"restored": moves})) +PY +`; + +export const trashAgentStateOverSsh = (params: { + sshTarget: string; + agentId: string; +}): TrashAgentStateResult => { + const result = runSshJson({ + sshTarget: params.sshTarget, + argv: ["bash", "-s", "--", params.agentId], + input: TRASH_SCRIPT, + label: `trash agent state (${params.agentId})`, + }); + return result as TrashAgentStateResult; +}; + +export const restoreAgentStateOverSsh = (params: { + sshTarget: string; + agentId: string; + trashDir: string; +}): RestoreAgentStateResult => { + const result = runSshJson({ + sshTarget: params.sshTarget, + argv: ["bash", "-s", "--", params.agentId, params.trashDir], + input: RESTORE_SCRIPT, + label: `restore agent state (${params.agentId})`, + }); + return result as RestoreAgentStateResult; +}; + diff --git a/src/lib/ssh/gateway-host.ts b/src/lib/ssh/gateway-host.ts new file mode 100644 index 00000000..92eeaeb9 --- /dev/null +++ b/src/lib/ssh/gateway-host.ts @@ -0,0 +1,124 @@ +import { loadStudioSettings } from "@/lib/studio/settings-store"; +import * as childProcess from "node:child_process"; + +const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET"; +const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER"; + +export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => { + const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? ""; + const configuredUser = env[SSH_USER_ENV]?.trim() ?? ""; + + if (configuredTarget) { + if (configuredTarget.includes("@")) return configuredTarget; + if (configuredUser) return `${configuredUser}@${configuredTarget}`; + return configuredTarget; + } + + return null; +}; + +export const resolveGatewaySshTargetFromGatewayUrl = ( + gatewayUrl: string, + env: NodeJS.ProcessEnv = process.env +): string => { + const configured = resolveConfiguredSshTarget(env); + if (configured) return configured; + + const trimmed = gatewayUrl.trim(); + if (!trimmed) { + throw new Error( + `Gateway URL is missing. Set it in Studio settings or set ${SSH_TARGET_ENV}.` + ); + } + let hostname: string; + try { + hostname = new URL(trimmed).hostname; + } catch { + throw new Error(`Invalid gateway URL: ${trimmed}`); + } + if (!hostname) { + throw new Error(`Invalid gateway URL: ${trimmed}`); + } + + const configuredUser = env[SSH_USER_ENV]?.trim() ?? ""; + const user = configuredUser || "ubuntu"; + return `${user}@${hostname}`; +}; + +export const resolveGatewaySshTarget = (env: NodeJS.ProcessEnv = process.env): string => { + const configured = resolveConfiguredSshTarget(env); + if (configured) return configured; + + const settings = loadStudioSettings(); + return resolveGatewaySshTargetFromGatewayUrl(settings.gateway?.url?.trim() ?? "", env); +}; + +export const extractJsonErrorMessage = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as Record<string, unknown>; + const direct = record.error; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + if (direct && typeof direct === "object") { + const nested = (direct as Record<string, unknown>).message; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + } + return null; + } catch { + return null; + } +}; + +export const parseJsonOutput = (raw: string, label: string): unknown => { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error(`Command produced empty JSON output (${label}).`); + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + throw new Error(`Command produced invalid JSON output (${label}).`); + } +}; + +export const runSshJson = (params: { + sshTarget: string; + argv: string[]; + label: string; + input?: string; + fallbackMessage?: string; + maxBuffer?: number; +}): unknown => { + const options: childProcess.SpawnSyncOptionsWithStringEncoding = { + encoding: "utf8", + input: params.input, + }; + if (params.maxBuffer !== undefined) { + options.maxBuffer = params.maxBuffer; + } + + const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], { + ...options, + }); + if (result.error) { + throw new Error(`Failed to execute ssh: ${result.error.message}`); + } + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + if (result.status !== 0) { + const stderrText = stderr.trim(); + const stdoutText = stdout.trim(); + const message = + extractJsonErrorMessage(stdout) ?? + extractJsonErrorMessage(stderr) ?? + (stderrText || + stdoutText || + params.fallbackMessage || + `Command failed (${params.label}).`); + throw new Error(message); + } + return parseJsonOutput(stdout, params.label); +}; diff --git a/src/lib/ssh/skills-remove.ts b/src/lib/ssh/skills-remove.ts new file mode 100644 index 00000000..97e423da --- /dev/null +++ b/src/lib/ssh/skills-remove.ts @@ -0,0 +1,88 @@ +import { runSshJson } from "@/lib/ssh/gateway-host"; +import type { SkillRemoveRequest, SkillRemoveResult } from "@/lib/skills/types"; + +const REMOVE_SKILL_SCRIPT = ` +set -euo pipefail + +python3 - "$1" "$2" "$3" "$4" "$5" <<'PY' +import json +import pathlib +import shutil +import sys + +skill_key = sys.argv[1].strip() +source = sys.argv[2].strip() +base_dir_raw = sys.argv[3].strip() +workspace_dir_raw = sys.argv[4].strip() +managed_skills_dir_raw = sys.argv[5].strip() + +if not skill_key: + raise SystemExit("skillKey is required.") +if not source: + raise SystemExit("source is required.") +if not base_dir_raw: + raise SystemExit("baseDir is required.") +if not workspace_dir_raw: + raise SystemExit("workspaceDir is required.") +if not managed_skills_dir_raw: + raise SystemExit("managedSkillsDir is required.") + +allowed_sources = { + "openclaw-managed", + "openclaw-workspace", +} +if source not in allowed_sources: + raise SystemExit(f"Unsupported skill source for removal: {source}") + +base_dir = pathlib.Path(base_dir_raw).expanduser().resolve(strict=False) +workspace_dir = pathlib.Path(workspace_dir_raw).expanduser().resolve(strict=False) +managed_skills_dir = pathlib.Path(managed_skills_dir_raw).expanduser().resolve(strict=False) + +if source == "openclaw-managed": + allowed_root = managed_skills_dir +else: + allowed_root = (workspace_dir / "skills").resolve(strict=False) + +try: + base_dir.relative_to(allowed_root) +except ValueError: + raise SystemExit(f"Refusing to remove skill outside allowed root: {base_dir}") + +if base_dir == allowed_root: + raise SystemExit(f"Refusing to remove the skills root directory: {base_dir}") + +removed = False +if base_dir.exists(): + if not base_dir.is_dir(): + raise SystemExit(f"Skill path is not a directory: {base_dir}") + skill_doc = base_dir / "SKILL.md" + if not skill_doc.exists() or not skill_doc.is_file(): + raise SystemExit(f"Refusing to remove non-skill directory: {base_dir}") + shutil.rmtree(base_dir) + removed = True + +print(json.dumps({"removed": removed, "removedPath": str(base_dir), "source": source})) +PY +`; + +export const removeSkillOverSsh = (params: { + sshTarget: string; + request: SkillRemoveRequest; +}): SkillRemoveResult => { + const result = runSshJson({ + sshTarget: params.sshTarget, + argv: [ + "bash", + "-s", + "--", + params.request.skillKey, + params.request.source, + params.request.baseDir, + params.request.workspaceDir, + params.request.managedSkillsDir, + ], + input: REMOVE_SKILL_SCRIPT, + label: `remove skill (${params.request.skillKey})`, + }); + return result as SkillRemoveResult; +}; diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts new file mode 100644 index 00000000..faab16be --- /dev/null +++ b/src/lib/studio/coordinator.ts @@ -0,0 +1,174 @@ +import { fetchJson } from "@/lib/http"; +import type { + StudioFocusedPreference, + StudioGatewaySettings, + StudioSettings, + StudioSettingsPatch, +} from "@/lib/studio/settings"; + +export type StudioSettingsResponse = { + settings: StudioSettings; + localGatewayDefaults?: StudioGatewaySettings | null; +}; + +type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>; +type AvatarsPatch = Record<string, Record<string, string | null> | null>; + +export type StudioSettingsCoordinatorTransport = { + fetchSettings: () => Promise<StudioSettingsResponse>; + updateSettings: (patch: StudioSettingsPatch) => Promise<StudioSettingsResponse>; +}; + +const mergeFocusedPatch = ( + current: FocusedPatch | undefined, + next: FocusedPatch | undefined +): FocusedPatch | undefined => { + if (!current && !next) return undefined; + return { + ...(current ?? {}), + ...(next ?? {}), + }; +}; + +const mergeAvatarsPatch = ( + current: AvatarsPatch | undefined, + next: AvatarsPatch | undefined +): AvatarsPatch | undefined => { + if (!current && !next) return undefined; + const merged: AvatarsPatch = { ...(current ?? {}) }; + for (const [gatewayKey, value] of Object.entries(next ?? {})) { + if (value === null) { + merged[gatewayKey] = null; + continue; + } + const existing = merged[gatewayKey]; + if (existing && existing !== null) { + merged[gatewayKey] = { ...existing, ...value }; + continue; + } + merged[gatewayKey] = { ...value }; + } + return merged; +}; + +const mergeStudioPatch = ( + current: StudioSettingsPatch | null, + next: StudioSettingsPatch +): StudioSettingsPatch => { + if (!current) { + return { + ...(next.gateway !== undefined ? { gateway: next.gateway } : {}), + ...(next.focused ? { focused: { ...next.focused } } : {}), + ...(next.avatars ? { avatars: { ...next.avatars } } : {}), + }; + } + const focused = mergeFocusedPatch(current.focused, next.focused); + const avatars = mergeAvatarsPatch(current.avatars, next.avatars); + return { + ...(next.gateway !== undefined + ? { gateway: next.gateway } + : current.gateway !== undefined + ? { gateway: current.gateway } + : {}), + ...(focused ? { focused } : {}), + ...(avatars ? { avatars } : {}), + }; +}; + +export class StudioSettingsCoordinator { + private pendingPatch: StudioSettingsPatch | null = null; + private timer: ReturnType<typeof setTimeout> | null = null; + private queue: Promise<void> = Promise.resolve(); + private disposed = false; + + constructor( + private readonly transport: StudioSettingsCoordinatorTransport, + private readonly defaultDebounceMs: number = 350 + ) {} + + async loadSettings(): Promise<StudioSettings | null> { + const result = await this.loadSettingsEnvelope(); + return result.settings ?? null; + } + + async loadSettingsEnvelope(): Promise<StudioSettingsResponse> { + return await this.transport.fetchSettings(); + } + + schedulePatch(patch: StudioSettingsPatch, debounceMs: number = this.defaultDebounceMs): void { + if (this.disposed) return; + this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch); + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + void this.flushPending().catch((err) => { + console.error("Failed to flush pending studio settings patch.", err); + }); + }, debounceMs); + } + + async applyPatchNow(patch: StudioSettingsPatch): Promise<void> { + if (this.disposed) return; + this.pendingPatch = mergeStudioPatch(this.pendingPatch, patch); + await this.flushPending(); + } + + async flushPending(): Promise<void> { + if (this.disposed) { + return this.queue; + } + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + const patch = this.pendingPatch; + this.pendingPatch = null; + if (!patch) { + return this.queue; + } + const write = this.queue.then(async () => { + await this.transport.updateSettings(patch); + }); + this.queue = write.catch((err) => { + console.error("Failed to persist studio settings patch.", err); + }); + return write; + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.pendingPatch = null; + this.disposed = true; + } +} + +export const fetchStudioSettings = async (): Promise<StudioSettingsResponse> => { + return fetchJson<StudioSettingsResponse>("/api/studio", { cache: "no-store" }); +}; + +export const updateStudioSettings = async ( + patch: StudioSettingsPatch +): Promise<StudioSettingsResponse> => { + return fetchJson<StudioSettingsResponse>("/api/studio", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); +}; + +export const createStudioSettingsCoordinator = (options?: { + debounceMs?: number; +}): StudioSettingsCoordinator => { + return new StudioSettingsCoordinator( + { + fetchSettings: fetchStudioSettings, + updateSettings: updateStudioSettings, + }, + options?.debounceMs + ); +}; diff --git a/src/lib/studio/settings-store.ts b/src/lib/studio/settings-store.ts new file mode 100644 index 00000000..ff050f32 --- /dev/null +++ b/src/lib/studio/settings-store.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveStateDir } from "@/lib/clawdbot/paths"; +import { + defaultStudioSettings, + mergeStudioSettings, + normalizeStudioSettings, + type StudioSettings, + type StudioSettingsPatch, +} from "@/lib/studio/settings"; + +const SETTINGS_DIRNAME = "openclaw-studio"; +const SETTINGS_FILENAME = "settings.json"; +const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; + +export const resolveStudioSettingsPath = () => + path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME); + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object"); + +const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => { + try { + const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME); + if (!fs.existsSync(configPath)) return null; + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) return null; + const gateway = isRecord(parsed.gateway) ? parsed.gateway : null; + if (!gateway) return null; + const auth = isRecord(gateway.auth) ? gateway.auth : null; + const token = typeof auth?.token === "string" ? auth.token.trim() : ""; + const port = typeof gateway.port === "number" && Number.isFinite(gateway.port) ? gateway.port : null; + if (!token) return null; + const url = port ? `ws://localhost:${port}` : ""; + if (!url) return null; + return { url, token }; + } catch { + return null; + } +}; + +export const loadLocalGatewayDefaults = () => { + return readOpenclawGatewayDefaults(); +}; + +export const loadStudioSettings = (): StudioSettings => { + const settingsPath = resolveStudioSettingsPath(); + if (!fs.existsSync(settingsPath)) { + const defaults = defaultStudioSettings(); + const gateway = loadLocalGatewayDefaults(); + return gateway ? { ...defaults, gateway } : defaults; + } + const raw = fs.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const settings = normalizeStudioSettings(parsed); + if (!settings.gateway?.token) { + const gateway = loadLocalGatewayDefaults(); + if (gateway) { + return { + ...settings, + gateway: settings.gateway?.url?.trim() + ? { url: settings.gateway.url.trim(), token: gateway.token } + : gateway, + }; + } + } + return settings; +}; + +export const saveStudioSettings = (next: StudioSettings) => { + const settingsPath = resolveStudioSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8"); +}; + +export const applyStudioSettingsPatch = (patch: StudioSettingsPatch): StudioSettings => { + const current = loadStudioSettings(); + const next = mergeStudioSettings(current, patch); + saveStudioSettings(next); + return next; +}; diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts new file mode 100644 index 00000000..7e52f5f7 --- /dev/null +++ b/src/lib/studio/settings.ts @@ -0,0 +1,252 @@ +export type StudioGatewaySettings = { + url: string; + token: string; +}; + +export type FocusFilter = "all" | "running" | "approvals"; +export type StudioViewMode = "focused"; + +export type StudioFocusedPreference = { + mode: StudioViewMode; + selectedAgentId: string | null; + filter: FocusFilter; +}; + +export type StudioSettings = { + version: 1; + gateway: StudioGatewaySettings | null; + focused: Record<string, StudioFocusedPreference>; + avatars: Record<string, Record<string, string>>; +}; + +export type StudioSettingsPatch = { + gateway?: StudioGatewaySettings | null; + focused?: Record<string, Partial<StudioFocusedPreference> | null>; + avatars?: Record<string, Record<string, string | null> | null>; +}; + +const SETTINGS_VERSION = 1 as const; + +const isRecord = (value: unknown): value is Record<string, unknown> => + Boolean(value && typeof value === "object"); + +const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : ""); +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "0.0.0.0"]); + +const normalizeGatewayUrl = (value: unknown) => { + const url = coerceString(value); + if (!url) return ""; + try { + const parsed = new URL(url); + if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase())) { + return url; + } + const auth = + parsed.username || parsed.password + ? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@` + : ""; + const host = parsed.port ? `localhost:${parsed.port}` : "localhost"; + const dropDefaultPath = + parsed.pathname === "/" && !url.endsWith("/") && !parsed.search && !parsed.hash; + const pathname = dropDefaultPath ? "" : parsed.pathname; + return `${parsed.protocol}//${auth}${host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } +}; + +const normalizeGatewayKey = (value: unknown) => { + const key = coerceString(value); + return key ? key : null; +}; + +const normalizeFocusFilter = ( + value: unknown, + fallback: FocusFilter = "all" +): FocusFilter => { + const filter = coerceString(value); + if (filter === "needs-attention") return "all"; + if (filter === "idle") return "approvals"; + if ( + filter === "all" || + filter === "running" || + filter === "approvals" + ) { + return filter; + } + return fallback; +}; + +const normalizeViewMode = ( + value: unknown, + fallback: StudioViewMode = "focused" +): StudioViewMode => { + const mode = coerceString(value); + if (mode === "focused") { + return mode; + } + return fallback; +}; + +const normalizeSelectedAgentId = (value: unknown, fallback: string | null = null) => { + if (value === null) return null; + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +}; + +const defaultFocusedPreference = (): StudioFocusedPreference => ({ + mode: "focused", + selectedAgentId: null, + filter: "all", +}); + +const normalizeFocusedPreference = ( + value: unknown, + fallback: StudioFocusedPreference = defaultFocusedPreference() +): StudioFocusedPreference => { + if (!isRecord(value)) return fallback; + return { + mode: normalizeViewMode(value.mode, fallback.mode), + selectedAgentId: normalizeSelectedAgentId( + value.selectedAgentId, + fallback.selectedAgentId + ), + filter: normalizeFocusFilter(value.filter, fallback.filter), + }; +}; + +const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => { + if (!isRecord(value)) return null; + const url = normalizeGatewayUrl(value.url); + if (!url) return null; + const token = coerceString(value.token); + return { url, token }; +}; + +const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => { + if (!isRecord(value)) return {}; + const focused: Record<string, StudioFocusedPreference> = {}; + for (const [gatewayKeyRaw, focusedRaw] of Object.entries(value)) { + const gatewayKey = normalizeGatewayKey(gatewayKeyRaw); + if (!gatewayKey) continue; + focused[gatewayKey] = normalizeFocusedPreference(focusedRaw); + } + return focused; +}; + +const normalizeAvatars = (value: unknown): Record<string, Record<string, string>> => { + if (!isRecord(value)) return {}; + const avatars: Record<string, Record<string, string>> = {}; + for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) { + const gatewayKey = normalizeGatewayKey(gatewayKeyRaw); + if (!gatewayKey) continue; + if (!isRecord(gatewayRaw)) continue; + const entries: Record<string, string> = {}; + for (const [agentIdRaw, seedRaw] of Object.entries(gatewayRaw)) { + const agentId = coerceString(agentIdRaw); + if (!agentId) continue; + const seed = coerceString(seedRaw); + if (!seed) continue; + entries[agentId] = seed; + } + avatars[gatewayKey] = entries; + } + return avatars; +}; + +export const defaultStudioSettings = (): StudioSettings => ({ + version: SETTINGS_VERSION, + gateway: null, + focused: {}, + avatars: {}, +}); + +export const normalizeStudioSettings = (raw: unknown): StudioSettings => { + if (!isRecord(raw)) return defaultStudioSettings(); + const gateway = normalizeGatewaySettings(raw.gateway); + const focused = normalizeFocused(raw.focused); + const avatars = normalizeAvatars(raw.avatars); + return { + version: SETTINGS_VERSION, + gateway, + focused, + avatars, + }; +}; + +export const mergeStudioSettings = ( + current: StudioSettings, + patch: StudioSettingsPatch +): StudioSettings => { + const nextGateway = + patch.gateway === undefined ? current.gateway : normalizeGatewaySettings(patch.gateway); + const nextFocused = { ...current.focused }; + const nextAvatars = { ...current.avatars }; + if (patch.focused) { + for (const [keyRaw, value] of Object.entries(patch.focused)) { + const key = normalizeGatewayKey(keyRaw); + if (!key) continue; + if (value === null) { + delete nextFocused[key]; + continue; + } + const fallback = nextFocused[key] ?? defaultFocusedPreference(); + nextFocused[key] = normalizeFocusedPreference(value, fallback); + } + } + if (patch.avatars) { + for (const [gatewayKeyRaw, gatewayPatch] of Object.entries(patch.avatars)) { + const gatewayKey = normalizeGatewayKey(gatewayKeyRaw); + if (!gatewayKey) continue; + if (gatewayPatch === null) { + delete nextAvatars[gatewayKey]; + continue; + } + if (!isRecord(gatewayPatch)) continue; + const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {}; + for (const [agentIdRaw, seedPatchRaw] of Object.entries(gatewayPatch)) { + const agentId = coerceString(agentIdRaw); + if (!agentId) continue; + if (seedPatchRaw === null) { + delete existing[agentId]; + continue; + } + const seed = coerceString(seedPatchRaw); + if (!seed) { + delete existing[agentId]; + continue; + } + existing[agentId] = seed; + } + nextAvatars[gatewayKey] = existing; + } + } + return { + version: SETTINGS_VERSION, + gateway: nextGateway ?? null, + focused: nextFocused, + avatars: nextAvatars, + }; +}; + +export const resolveFocusedPreference = ( + settings: StudioSettings, + gatewayUrl: string +): StudioFocusedPreference | null => { + const key = normalizeGatewayKey(gatewayUrl); + if (!key) return null; + return settings.focused[key] ?? null; +}; + +export const resolveAgentAvatarSeed = ( + settings: StudioSettings, + gatewayUrl: string, + agentId: string +): string | null => { + const gatewayKey = normalizeGatewayKey(gatewayUrl); + if (!gatewayKey) return null; + const agentKey = coerceString(agentId); + if (!agentKey) return null; + return settings.avatars[gatewayKey]?.[agentKey] ?? null; +}; diff --git a/src/lib/text/assistantText.ts b/src/lib/text/assistantText.ts new file mode 100644 index 00000000..54ae39f0 --- /dev/null +++ b/src/lib/text/assistantText.ts @@ -0,0 +1,17 @@ +export const normalizeAssistantDisplayText = (value: string): string => { + const lines = value.replace(/\r\n?/g, "\n").split("\n"); + const normalized: string[] = []; + let lastWasBlank = false; + for (const rawLine of lines) { + const line = rawLine.replace(/[ \t]+$/g, ""); + if (line.trim().length === 0) { + if (lastWasBlank) continue; + normalized.push(""); + lastWasBlank = true; + continue; + } + normalized.push(line); + lastWasBlank = false; + } + return normalized.join("\n").trim(); +}; diff --git a/src/lib/text/extractText.ts b/src/lib/text/extractText.ts deleted file mode 100644 index e73f6839..00000000 --- a/src/lib/text/extractText.ts +++ /dev/null @@ -1,96 +0,0 @@ -const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; -const ENVELOPE_CHANNELS = [ - "WebChat", - "WhatsApp", - "Telegram", - "Signal", - "Slack", - "Discord", - "iMessage", - "Teams", - "Matrix", - "Zalo", - "Zalo Personal", - "BlueBubbles", -]; - -const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi; -const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; -const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; - -const looksLikeEnvelopeHeader = (header: string): boolean => { - if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; - if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; - return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); -}; - -const stripEnvelope = (text: string): string => { - const match = text.match(ENVELOPE_PREFIX); - if (!match) return text; - const header = match[1] ?? ""; - if (!looksLikeEnvelopeHeader(header)) return text; - return text.slice(match[0].length); -}; - -const stripThinkingTags = (value: string): string => { - if (!value) return value; - const hasOpen = THINKING_OPEN_RE.test(value); - const hasClose = THINKING_CLOSE_RE.test(value); - if (!hasOpen && !hasClose) return value; - if (hasOpen !== hasClose) { - if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); - return value.replace(THINKING_OPEN_RE, "").trimStart(); - } - - if (!THINKING_TAG_RE.test(value)) return value; - THINKING_TAG_RE.lastIndex = 0; - - let result = ""; - let lastIndex = 0; - let inThinking = false; - for (const match of value.matchAll(THINKING_TAG_RE)) { - const idx = match.index ?? 0; - if (!inThinking) { - result += value.slice(lastIndex, idx); - } - const tag = match[0].toLowerCase(); - inThinking = !tag.includes("/"); - lastIndex = idx + match[0].length; - } - if (!inThinking) { - result += value.slice(lastIndex); - } - return result.trimStart(); -}; - -export const extractText = (message: unknown): string | null => { - if (!message || typeof message !== "object") { - return null; - } - const m = message as Record<string, unknown>; - const role = typeof m.role === "string" ? m.role : ""; - const content = m.content; - if (typeof content === "string") { - const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content); - return processed; - } - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record<string, unknown>; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) { - const joined = parts.join("\n"); - const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined); - return processed; - } - } - if (typeof m.text === "string") { - const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text); - return processed; - } - return null; -}; diff --git a/src/lib/text/extractThinking.ts b/src/lib/text/extractThinking.ts deleted file mode 100644 index 0afb8e3d..00000000 --- a/src/lib/text/extractThinking.ts +++ /dev/null @@ -1,67 +0,0 @@ -const THINKING_BLOCK_RE = - /<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi; -const TRACE_MARKDOWN_PREFIX = "[[trace]]"; - -const extractRawText = (message: unknown): string | null => { - if (!message || typeof message !== "object") return null; - const m = message as Record<string, unknown>; - const content = m.content; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts = content - .map((p) => { - const item = p as Record<string, unknown>; - if (item.type === "text" && typeof item.text === "string") return item.text; - return null; - }) - .filter((v): v is string => typeof v === "string"); - if (parts.length > 0) return parts.join("\n"); - } - if (typeof m.text === "string") return m.text; - return null; -}; - -export const extractThinking = (message: unknown): string | null => { - if (!message || typeof message !== "object") return null; - const m = message as Record<string, unknown>; - const content = m.content; - const parts: string[] = []; - if (Array.isArray(content)) { - for (const p of content) { - const item = p as Record<string, unknown>; - if (item.type === "thinking" && typeof item.thinking === "string") { - const cleaned = item.thinking.trim(); - if (cleaned) parts.push(cleaned); - } - } - } - if (parts.length > 0) return parts.join("\n"); - - const rawText = extractRawText(message); - if (!rawText) return null; - const matches = [...rawText.matchAll(THINKING_BLOCK_RE)]; - const extracted = matches - .map((match) => (match[1] ?? "").trim()) - .filter(Boolean); - return extracted.length > 0 ? extracted.join("\n") : null; -}; - -export const formatThinkingMarkdown = (text: string): string => { - const trimmed = text.trim(); - if (!trimmed) return ""; - const lines = trimmed - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => `_${line}_`); - if (lines.length === 0) return ""; - return `${TRACE_MARKDOWN_PREFIX}\n${lines.join("\n")}`; -}; - -export const isTraceMarkdown = (line: string): boolean => - line.startsWith(TRACE_MARKDOWN_PREFIX); - -export const stripTraceMarkdown = (line: string): string => { - if (!isTraceMarkdown(line)) return line; - return line.slice(TRACE_MARKDOWN_PREFIX.length).trimStart(); -}; diff --git a/src/lib/text/media-markdown.ts b/src/lib/text/media-markdown.ts new file mode 100644 index 00000000..937f34d7 --- /dev/null +++ b/src/lib/text/media-markdown.ts @@ -0,0 +1,80 @@ +const MEDIA_LINE_RE = /^\s*MEDIA:\s*(.+?)\s*$/; +const MEDIA_ONLY_RE = /^\s*MEDIA:\s*$/; + +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); + +const isImagePath = (value: string): boolean => { + const trimmed = value.trim(); + if (!trimmed) return false; + const lower = trimmed.toLowerCase(); + for (const ext of IMAGE_EXTENSIONS) { + if (lower.endsWith(ext)) return true; + } + return false; +}; + +const toMediaUrl = (path: string): string => { + return `/api/gateway/media?path=${encodeURIComponent(path)}`; +}; + +/** + * Rewrites tool-style media lines like: + * MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png + * into markdown image links so the chat UI can render them inline. + * + * - Skips replacements inside fenced code blocks. + */ +export const rewriteMediaLinesToMarkdown = (text: string): string => { + if (!text) return text; + + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + const out: string[] = []; + let inFence = false; + + for (let idx = 0; idx < lines.length; idx += 1) { + const line = lines[idx] ?? ""; + const trimmed = line.trimStart(); + if (trimmed.startsWith("```")) { + inFence = !inFence; + out.push(line); + continue; + } + if (inFence) { + out.push(line); + continue; + } + + let mediaPath: string | null = null; + let consumesNextLine = false; + const match = line.match(MEDIA_LINE_RE); + if (match) { + mediaPath = (match[1] ?? "").trim() || null; + } else if (MEDIA_ONLY_RE.test(line)) { + const next = (lines[idx + 1] ?? "").trim(); + if (isImagePath(next)) { + mediaPath = next; + consumesNextLine = true; + } + } + if (!mediaPath) { + out.push(line); + continue; + } + + const url = toMediaUrl(mediaPath); + + if (isImagePath(mediaPath)) { + out.push(`![](${url})`); + out.push(""); + out.push(`MEDIA: ${mediaPath}`); + if (consumesNextLine) { + idx += 1; + } + continue; + } + + out.push(line); + } + + return out.join("\n"); +}; diff --git a/src/lib/text/message-extract.ts b/src/lib/text/message-extract.ts new file mode 100644 index 00000000..f8ab7f9c --- /dev/null +++ b/src/lib/text/message-extract.ts @@ -0,0 +1,552 @@ +const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/; +const ENVELOPE_CHANNELS = [ + "WebChat", + "WhatsApp", + "Telegram", + "Signal", + "Slack", + "Discord", + "iMessage", + "Teams", + "Matrix", + "Zalo", + "Zalo Personal", + "BlueBubbles", +]; + +const textCache = new WeakMap<object, string | null>(); +const thinkingCache = new WeakMap<object, string | null>(); + +const THINKING_TAG_RE = /<\s*\/?\s*(think(?:ing)?|analysis)\s*>/gi; +const THINKING_OPEN_RE = /<\s*(think(?:ing)?|analysis)\s*>/i; +const THINKING_CLOSE_RE = /<\s*\/\s*(think(?:ing)?|analysis)\s*>/i; + +const THINKING_BLOCK_RE = + /<\s*(think(?:ing)?|analysis)\s*>([\s\S]*?)<\s*\/\s*\1\s*>/gi; +const THINKING_STREAM_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; +const TRACE_MARKDOWN_PREFIX = "[[trace]]"; + +const TOOL_CALL_PREFIX = "[[tool]]"; +const TOOL_RESULT_PREFIX = "[[tool-result]]"; +const META_PREFIX = "[[meta]]"; + +export type AgentInstructionParams = { + message: string; +}; + +const EXEC_APPROVAL_WAIT_POLICY = [ + "Execution approval policy:", + "- If any tool result says approval is required or pending, stop immediately.", + "- Do not call additional tools and do not switch to alternate approaches.", + 'If approved command output is unavailable, reply exactly: "Waiting for approved command result."', +].join("\n"); + +const stripAppendedExecApprovalPolicy = (text: string): string => { + const suffix = `\n\n${EXEC_APPROVAL_WAIT_POLICY}`; + if (!text.endsWith(suffix)) return text; + return text.slice(0, -suffix.length); +}; + +const ASSISTANT_PREFIX_RE = /^(?:\[\[reply_to_current\]\]|\[reply_to_current\])\s*(?:\|\s*)?/i; +const stripAssistantPrefix = (text: string): string => { + if (!text) return text; + if (!ASSISTANT_PREFIX_RE.test(text)) return text; + return text.replace(ASSISTANT_PREFIX_RE, "").trimStart(); +}; + +type ToolCallRecord = { + id?: string; + name?: string; + arguments?: unknown; +}; + +type ToolResultRecord = { + toolCallId?: string; + toolName?: string; + details?: Record<string, unknown> | null; + isError?: boolean; + text?: string | null; +}; + +const looksLikeEnvelopeHeader = (header: string): boolean => { + if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true; + if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + if (/[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true; + return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); +}; + +const stripEnvelope = (text: string): string => { + const match = text.match(ENVELOPE_PREFIX); + if (!match) return text; + const header = match[1] ?? ""; + if (!looksLikeEnvelopeHeader(header)) return text; + return text.slice(match[0].length); +}; + +const stripThinkingTagsFromAssistantText = (value: string): string => { + if (!value) return value; + const hasOpen = THINKING_OPEN_RE.test(value); + const hasClose = THINKING_CLOSE_RE.test(value); + if (!hasOpen && !hasClose) return value; + if (hasOpen !== hasClose) { + if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart(); + return value.replace(THINKING_OPEN_RE, "").trimStart(); + } + + if (!THINKING_TAG_RE.test(value)) return value; + THINKING_TAG_RE.lastIndex = 0; + + let result = ""; + let lastIndex = 0; + let inThinking = false; + for (const match of value.matchAll(THINKING_TAG_RE)) { + const idx = match.index ?? 0; + if (!inThinking) { + result += value.slice(lastIndex, idx); + } + const tag = match[0].toLowerCase(); + inThinking = !tag.includes("/"); + lastIndex = idx + match[0].length; + } + if (!inThinking) { + result += value.slice(lastIndex); + } + return result.trimStart(); +}; + +const extractRawText = (message: unknown): string | null => { + if (!message || typeof message !== "object") return null; + const m = message as Record<string, unknown>; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record<string, unknown>; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +}; + +export const extractText = (message: unknown): string | null => { + if (!message || typeof message !== "object") { + return null; + } + const m = message as Record<string, unknown>; + const role = typeof m.role === "string" ? m.role : ""; + const content = m.content; + + const postProcess = (value: string): string => { + if (role === "assistant") { + return stripAssistantPrefix(stripThinkingTagsFromAssistantText(value)); + } + return stripAppendedExecApprovalPolicy(stripEnvelope(value)); + }; + + if (typeof content === "string") { + return postProcess(content); + } + + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record<string, unknown>; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + + if (parts.length > 0) { + return postProcess(parts.join("\n")); + } + } + + if (typeof m.text === "string") { + return postProcess(m.text); + } + + return null; +}; + +export const extractTextCached = (message: unknown): string | null => { + if (!message || typeof message !== "object") return extractText(message); + const obj = message as object; + if (textCache.has(obj)) return textCache.get(obj) ?? null; + const value = extractText(message); + textCache.set(obj, value); + return value; +}; + +export const extractThinking = (message: unknown): string | null => { + if (!message || typeof message !== "object") return null; + const m = message as Record<string, unknown>; + const content = m.content; + const parts: string[] = []; + + const extractFromRecord = (record: Record<string, unknown>): string | null => { + const directKeys = [ + "thinking", + "analysis", + "reasoning", + "thinkingText", + "analysisText", + "reasoningText", + "thinking_text", + "analysis_text", + "reasoning_text", + "thinkingDelta", + "analysisDelta", + "reasoningDelta", + "thinking_delta", + "analysis_delta", + "reasoning_delta", + ] as const; + for (const key of directKeys) { + const value = record[key]; + if (typeof value === "string") { + const cleaned = value.trim(); + if (cleaned) return cleaned; + } + if (value && typeof value === "object") { + const nested = value as Record<string, unknown>; + const nestedKeys = [ + "text", + "delta", + "content", + "summary", + "analysis", + "reasoning", + "thinking", + ] as const; + for (const nestedKey of nestedKeys) { + const nestedValue = nested[nestedKey]; + if (typeof nestedValue === "string") { + const cleaned = nestedValue.trim(); + if (cleaned) return cleaned; + } + } + } + } + return null; + }; + + if (Array.isArray(content)) { + for (const p of content) { + const item = p as Record<string, unknown>; + const type = typeof item.type === "string" ? item.type : ""; + if (type === "thinking" || type === "analysis" || type === "reasoning") { + const extracted = extractFromRecord(item); + if (extracted) { + parts.push(extracted); + } else if (typeof item.text === "string") { + const cleaned = item.text.trim(); + if (cleaned) parts.push(cleaned); + } + } else if (typeof item.thinking === "string") { + const cleaned = item.thinking.trim(); + if (cleaned) parts.push(cleaned); + } + } + } + if (parts.length > 0) return parts.join("\n"); + + const direct = extractFromRecord(m); + if (direct) return direct; + + const rawText = extractRawText(message); + if (!rawText) return null; + const matches = [...rawText.matchAll(THINKING_BLOCK_RE)]; + const extracted = matches + .map((match) => (match[2] ?? "").trim()) + .filter(Boolean); + if (extracted.length > 0) return extracted.join("\n"); + const openTagged = extractThinkingFromTaggedStream(rawText); + return openTagged ? openTagged : null; +}; + +export function extractThinkingFromTaggedText(text: string): string { + if (!text) return ""; + let result = ""; + let lastIndex = 0; + let inThinking = false; + THINKING_STREAM_TAG_RE.lastIndex = 0; + for (const match of text.matchAll(THINKING_STREAM_TAG_RE)) { + const idx = match.index ?? 0; + if (inThinking) { + result += text.slice(lastIndex, idx); + } + const isClose = match[1] === "/"; + inThinking = !isClose; + lastIndex = idx + match[0].length; + } + return result.trim(); +} + +export function extractThinkingFromTaggedStream(text: string): string { + if (!text) return ""; + const closed = extractThinkingFromTaggedText(text); + if (closed) return closed; + const openRe = /<\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; + const closeRe = /<\s*\/\s*(?:think(?:ing)?|analysis|thought|antthinking)\s*>/gi; + const openMatches = [...text.matchAll(openRe)]; + if (openMatches.length === 0) return ""; + const closeMatches = [...text.matchAll(closeRe)]; + const lastOpen = openMatches[openMatches.length - 1]; + const lastClose = closeMatches[closeMatches.length - 1]; + if (lastClose && (lastClose.index ?? -1) > (lastOpen.index ?? -1)) { + return closed; + } + const start = (lastOpen.index ?? 0) + lastOpen[0].length; + return text.slice(start).trim(); +} + +export const extractThinkingCached = (message: unknown): string | null => { + if (!message || typeof message !== "object") return extractThinking(message); + const obj = message as object; + if (thinkingCache.has(obj)) return thinkingCache.get(obj) ?? null; + const value = extractThinking(message); + thinkingCache.set(obj, value); + return value; +}; + +export const formatThinkingMarkdown = (text: string): string => { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `_${line}_`); + if (lines.length === 0) return ""; + return `${TRACE_MARKDOWN_PREFIX}\n${lines.join("\n\n")}`; +}; + +export const isTraceMarkdown = (line: string): boolean => + line.startsWith(TRACE_MARKDOWN_PREFIX); + +export const stripTraceMarkdown = (line: string): string => { + if (!isTraceMarkdown(line)) return line; + return line.slice(TRACE_MARKDOWN_PREFIX.length).trimStart(); +}; + +const formatJson = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value, null, 2); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to stringify tool args."; + console.warn(message); + return String(value); + } +}; + +const formatToolResultMeta = (details?: Record<string, unknown> | null, isError?: boolean) => { + const parts: string[] = []; + if (details && typeof details === "object") { + const status = details.status; + if (typeof status === "string" && status.trim()) { + parts.push(status.trim()); + } + const exitCode = details.exitCode; + if (typeof exitCode === "number") { + parts.push(`exit ${exitCode}`); + } + const durationMs = details.durationMs; + if (typeof durationMs === "number") { + parts.push(`${durationMs}ms`); + } + const cwd = details.cwd; + if (typeof cwd === "string" && cwd.trim()) { + parts.push(cwd.trim()); + } + } + if (isError) { + parts.push("error"); + } + return parts.length ? parts.join(" · ") : ""; +}; + +export const extractToolCalls = (message: unknown): ToolCallRecord[] => { + if (!message || typeof message !== "object") return []; + const content = (message as Record<string, unknown>).content; + if (!Array.isArray(content)) return []; + const calls: ToolCallRecord[] = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const record = item as Record<string, unknown>; + if (record.type !== "toolCall") continue; + calls.push({ + id: typeof record.id === "string" ? record.id : undefined, + name: typeof record.name === "string" ? record.name : undefined, + arguments: record.arguments, + }); + } + return calls; +}; + +export const extractToolResult = (message: unknown): ToolResultRecord | null => { + if (!message || typeof message !== "object") return null; + const record = message as Record<string, unknown>; + const role = typeof record.role === "string" ? record.role : ""; + if (role !== "toolResult" && role !== "tool") return null; + const details = + record.details && typeof record.details === "object" + ? (record.details as Record<string, unknown>) + : null; + return { + toolCallId: typeof record.toolCallId === "string" ? record.toolCallId : undefined, + toolName: typeof record.toolName === "string" ? record.toolName : undefined, + details, + isError: typeof record.isError === "boolean" ? record.isError : undefined, + text: extractText(record), + }; +}; + +export const formatToolCallMarkdown = (call: ToolCallRecord): string => { + const name = call.name?.trim() || "tool"; + const suffix = call.id ? ` (${call.id})` : ""; + const args = formatJson(call.arguments).trim(); + if (!args) { + return `${TOOL_CALL_PREFIX} ${name}${suffix}`; + } + return `${TOOL_CALL_PREFIX} ${name}${suffix}\n\`\`\`json\n${args}\n\`\`\``; +}; + +export const formatToolResultMarkdown = (result: ToolResultRecord): string => { + const name = result.toolName?.trim() || "tool"; + const suffix = result.toolCallId ? ` (${result.toolCallId})` : ""; + const meta = formatToolResultMeta(result.details, result.isError); + const header = `${name}${suffix}`; + const bodyParts: string[] = []; + if (meta) { + bodyParts.push(meta); + } + const output = result.text?.trim(); + if (output) { + bodyParts.push(`\`\`\`text\n${output}\n\`\`\``); + } + return bodyParts.length === 0 + ? `${TOOL_RESULT_PREFIX} ${header}` + : `${TOOL_RESULT_PREFIX} ${header}\n${bodyParts.join("\n")}`; +}; + +export const extractToolLines = (message: unknown): string[] => { + const lines: string[] = []; + for (const call of extractToolCalls(message)) { + lines.push(formatToolCallMarkdown(call)); + } + const result = extractToolResult(message); + if (result) { + lines.push(formatToolResultMarkdown(result)); + } + return lines; +}; + +export const isToolMarkdown = (line: string): boolean => + line.startsWith(TOOL_CALL_PREFIX) || line.startsWith(TOOL_RESULT_PREFIX); + +export const isMetaMarkdown = (line: string): boolean => line.startsWith(META_PREFIX); + +export const formatMetaMarkdown = (meta: { + role: "user" | "assistant"; + timestamp: number; + thinkingDurationMs?: number | null; +}): string => { + return `${META_PREFIX}${JSON.stringify({ + role: meta.role, + timestamp: meta.timestamp, + ...(typeof meta.thinkingDurationMs === "number" ? { thinkingDurationMs: meta.thinkingDurationMs } : {}), + })}`; +}; + +export const parseMetaMarkdown = ( + line: string +): { role: "user" | "assistant"; timestamp: number; thinkingDurationMs?: number } | null => { + if (!isMetaMarkdown(line)) return null; + const raw = line.slice(META_PREFIX.length).trim(); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record<string, unknown>; + const role = parsed.role === "user" || parsed.role === "assistant" ? parsed.role : null; + const timestamp = typeof parsed.timestamp === "number" ? parsed.timestamp : null; + if (!role || !timestamp || !Number.isFinite(timestamp) || timestamp <= 0) return null; + const thinkingDurationMs = + typeof parsed.thinkingDurationMs === "number" && Number.isFinite(parsed.thinkingDurationMs) + ? parsed.thinkingDurationMs + : undefined; + return thinkingDurationMs !== undefined + ? { role, timestamp, thinkingDurationMs } + : { role, timestamp }; + } catch { + return null; + } +}; + +export const parseToolMarkdown = ( + line: string +): { kind: "call" | "result"; label: string; body: string } => { + const kind = line.startsWith(TOOL_RESULT_PREFIX) ? "result" : "call"; + const prefix = kind === "result" ? TOOL_RESULT_PREFIX : TOOL_CALL_PREFIX; + const content = line.slice(prefix.length).trimStart(); + const [labelLine, ...rest] = content.split(/\r?\n/); + return { + kind, + label: labelLine?.trim() || (kind === "result" ? "Tool result" : "Tool call"), + body: rest.join("\n").trim(), + }; +}; + +export const buildAgentInstruction = ({ + message, +}: AgentInstructionParams): string => { + return message.trim(); +}; + +const PROJECT_PROMPT_BLOCK_RE = /^(?:Project|Workspace) path:[\s\S]*?\n\s*\n/i; +const PROJECT_PROMPT_INLINE_RE = /^(?:Project|Workspace) path:[\s\S]*?memory_search\.\s*/i; +const RESET_PROMPT_RE = + /^A new session was started via \/new or \/reset[\s\S]*?reasoning\.\s*/i; +const SYSTEM_EVENT_BLOCK_RE = /^System:\s*\[[^\]]+\][\s\S]*?\n\s*\n/; +const MESSAGE_ID_RE = /\s*\[message_id:[^\]]+\]\s*/gi; +export const EXEC_APPROVAL_AUTO_RESUME_MARKER = "[[openclaw-studio:auto-resume-exec-approval]]"; +const LEGACY_EXEC_APPROVAL_AUTO_RESUME_RE = + /exec approval was granted[\s\S]*continue where you left off/i; +const UI_METADATA_PREFIX_RE = + /^(?:Project path:|Workspace path:|A new session was started via \/new or \/reset)/i; +const HEARTBEAT_PROMPT_RE = /^Read HEARTBEAT\.md if it exists\b/i; +const HEARTBEAT_PATH_RE = /Heartbeat file path:/i; + +export const stripUiMetadata = (text: string) => { + if (!text) return text; + if ( + text.includes(EXEC_APPROVAL_AUTO_RESUME_MARKER) || + LEGACY_EXEC_APPROVAL_AUTO_RESUME_RE.test(text) + ) { + return ""; + } + let cleaned = text.replace(RESET_PROMPT_RE, ""); + cleaned = cleaned.replace(SYSTEM_EVENT_BLOCK_RE, ""); + const beforeProjectStrip = cleaned; + cleaned = cleaned.replace(PROJECT_PROMPT_INLINE_RE, ""); + if (cleaned === beforeProjectStrip) { + cleaned = cleaned.replace(PROJECT_PROMPT_BLOCK_RE, ""); + } + cleaned = cleaned.replace(MESSAGE_ID_RE, "").trim(); + return stripEnvelope(cleaned); +}; + +export const isHeartbeatPrompt = (text: string) => { + if (!text) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + return HEARTBEAT_PROMPT_RE.test(trimmed) || HEARTBEAT_PATH_RE.test(trimmed); +}; + +export const isUiMetadataPrefix = (text: string) => UI_METADATA_PREFIX_RE.test(text); diff --git a/src/lib/tracing.ts b/src/lib/tracing.ts deleted file mode 100644 index be2fb191..00000000 --- a/src/lib/tracing.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { registerOTel } from "@vercel/otel"; - -export const registerTracing = () => { - registerOTel({ serviceName: "clawdbot-agent-ui" }); -}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts deleted file mode 100644 index bd0c391d..00000000 --- a/src/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 00000000..d1199cd7 --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,43 @@ +type CryptoLike = { + randomUUID?: (() => string) | undefined; + getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined; +}; + +function uuidFromBytes(bytes: Uint8Array): string { + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i]!.toString(16).padStart(2, "0"); + } + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; +} + +function weakRandomBytes(): Uint8Array { + const bytes = new Uint8Array(16); + const now = Date.now(); + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[0] ^= now & 0xff; + bytes[1] ^= (now >>> 8) & 0xff; + bytes[2] ^= (now >>> 16) & 0xff; + bytes[3] ^= (now >>> 24) & 0xff; + return bytes; +} + +export function randomUUID(cryptoLike: CryptoLike | null | undefined = globalThis.crypto): string { + if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID(); + + if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { + const bytes = new Uint8Array(16); + cryptoLike.getRandomValues(bytes); + return uuidFromBytes(bytes); + } + + return uuidFromBytes(weakRandomBytes()); +} + diff --git a/tests/e2e/agent-avatar.spec.ts b/tests/e2e/agent-avatar.spec.ts new file mode 100644 index 00000000..bf7064e0 --- /dev/null +++ b/tests/e2e/agent-avatar.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + }); +}); + +test("empty focused view shows zero agents when disconnected", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible(); +}); diff --git a/tests/e2e/agent-ia-split.spec.ts b/tests/e2e/agent-ia-split.spec.ts new file mode 100644 index 00000000..c6d0eece --- /dev/null +++ b/tests/e2e/agent-ia-split.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + }); +}); + +test("shows_connection_settings_control_in_header", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0); + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); +}); + +test("mobile_header_shows_connection_control", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + + await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0); + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); +}); diff --git a/tests/e2e/agent-inspect-panel.spec.ts b/tests/e2e/agent-inspect-panel.spec.ts new file mode 100644 index 00000000..63a9a5f6 --- /dev/null +++ b/tests/e2e/agent-inspect-panel.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; + +test.beforeEach(async ({ page }) => { + await stubStudioRoute(page); +}); + +test("connection panel reflects disconnected state", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("studio-menu-toggle").click(); + await page.getByTestId("gateway-settings-toggle").click(); + await expect(page.getByLabel("Upstream URL")).toBeVisible(); + await expect( + page.getByRole("button", { name: /^(Connect|Disconnect)$/ }) + ).toBeVisible(); +}); diff --git a/tests/e2e/agent-tile-avatar.spec.ts b/tests/e2e/agent-tile-avatar.spec.ts deleted file mode 100644 index c8be3702..00000000 --- a/tests/e2e/agent-tile-avatar.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const store = { - version: 2, - activeProjectId: "project-1", - projects: [ - { - id: "project-1", - name: "Demo Workspace", - repoPath: "/Users/demo", - createdAt: 1, - updatedAt: 1, - tiles: [ - { - id: "tile-1", - name: "Agent A", - agentId: "agent-1", - role: "coding", - sessionKey: "agent:agent-1:main", - model: null, - thinkingLevel: "low", - position: { x: 400, y: 300 }, - size: { width: 420, height: 520 }, - }, - ], - }, - ], -}; - -test.beforeEach(async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET" && request.method() !== "PUT") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(store), - }); - }); -}); - -test("new agent tile shows avatar and input without transcript", async ({ page }) => { - await page.goto("/"); - - await expect(page.getByAltText("Avatar for Agent A")).toBeVisible(); - await expect(page.getByPlaceholder("Send a command")).toBeVisible(); - await expect(page.locator("[data-testid='agent-transcript']")).toHaveCount(0); - - await page.locator("[data-tile]").click({ force: true }); - await page.getByTestId("agent-options-toggle").dispatchEvent("click"); - await expect(page.getByText("Model")).toBeVisible(); - await expect(page.getByText("Thinking")).toBeVisible(); -}); diff --git a/tests/e2e/canvas-smoke.spec.ts b/tests/e2e/canvas-smoke.spec.ts deleted file mode 100644 index adb6382a..00000000 --- a/tests/e2e/canvas-smoke.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("loads canvas empty state", async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ version: 2, activeProjectId: null, projects: [] }), - }); - }); - await page.goto("/"); - await expect(page.getByText("Create a workspace to begin.")).toBeVisible(); -}); diff --git a/tests/e2e/canvas-zoom-pan.spec.ts b/tests/e2e/canvas-zoom-pan.spec.ts deleted file mode 100644 index a666cc95..00000000 --- a/tests/e2e/canvas-zoom-pan.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const store = { - version: 2, - activeProjectId: "project-1", - projects: [ - { - id: "project-1", - name: "Demo Workspace", - repoPath: "/Users/demo", - createdAt: 1, - updatedAt: 1, - tiles: [ - { - id: "tile-1", - name: "Agent A", - agentId: "agent-1", - role: "coding", - sessionKey: "agent:agent-1:main", - model: null, - thinkingLevel: "low", - position: { x: 400, y: 300 }, - size: { width: 560, height: 440 }, - }, - ], - }, - ], -}; - -test.beforeEach(async ({ page }) => { - await page.route("**/api/projects", async (route, request) => { - if (request.method() !== "GET" && request.method() !== "PUT") { - await route.fallback(); - return; - } - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(store), - }); - }); -}); - -test("wheel zoom updates zoom readout", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const zoomReadout = page.locator("[data-zoom-readout]"); - await expect(zoomReadout).toBeVisible(); - const beforeText = (await zoomReadout.textContent()) ?? ""; - - const box = await pane.boundingBox(); - expect(box).not.toBeNull(); - const clientX = box!.x + box!.width / 2; - const clientY = box!.y + box!.height / 2; - - await page.dispatchEvent(".react-flow__pane", "wheel", { - deltaY: 240, - deltaMode: 0, - clientX, - clientY, - }); - - await expect(zoomReadout).not.toHaveText(beforeText); -}); - -test("wheel changes tile bounds on the canvas", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - const beforeWidth = beforeBox!.width; - - const paneBox = await pane.boundingBox(); - expect(paneBox).not.toBeNull(); - const clientX = paneBox!.x + paneBox!.width / 2; - const clientY = paneBox!.y + paneBox!.height / 2; - - await page.dispatchEvent(".react-flow__pane", "wheel", { - deltaY: 240, - deltaMode: 0, - clientX, - clientY, - }); - - await page.waitForFunction( - ({ selector, previousWidth }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return Math.abs(rect.width - previousWidth) > 0.5; - }, - { selector: "[data-tile]", previousWidth: beforeWidth } - ); -}); - -test("pan-drag-shifts-tiles", async ({ page }) => { - await page.goto("/"); - - const pane = page.locator(".react-flow__pane"); - await expect(pane).toBeVisible(); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - - const paneBox = await pane.boundingBox(); - expect(paneBox).not.toBeNull(); - - const startX = paneBox!.x + 40; - const startY = paneBox!.y + 40; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(startX + 160, startY + 120, { steps: 10 }); - await page.mouse.up(); - - await page.waitForFunction( - ({ selector, startX, startY }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return Math.abs(rect.x - startX) > 5 || Math.abs(rect.y - startY) > 5; - }, - { selector: "[data-tile]", startX: beforeBox!.x, startY: beforeBox!.y } - ); -}); - -test("resize-handle-updates-tile-size", async ({ page }) => { - await page.goto("/"); - - const tile = page.locator("[data-tile]"); - await expect(tile).toBeVisible(); - await tile.click(); - - const handle = page.locator(".tile-resize-handle.bottom.right"); - await expect(handle).toBeVisible(); - - const beforeBox = await tile.boundingBox(); - expect(beforeBox).not.toBeNull(); - - const handleBox = await handle.boundingBox(); - expect(handleBox).not.toBeNull(); - - const startX = handleBox!.x + handleBox!.width / 2; - const startY = handleBox!.y + handleBox!.height / 2; - - await page.mouse.move(startX, startY); - await page.mouse.down(); - await page.mouse.move(startX + 120, startY + 100, { steps: 10 }); - await page.mouse.up(); - - await page.waitForFunction( - ({ selector, previousWidth, previousHeight }) => { - const el = document.querySelector(selector); - if (!el) return false; - const rect = el.getBoundingClientRect(); - return rect.width > previousWidth + 10 && rect.height > previousHeight + 10; - }, - { - selector: "[data-tile]", - previousWidth: beforeBox!.width, - previousHeight: beforeBox!.height, - } - ); -}); diff --git a/tests/e2e/connection-settings.spec.ts b/tests/e2e/connection-settings.spec.ts new file mode 100644 index 00000000..248ead34 --- /dev/null +++ b/tests/e2e/connection-settings.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; + +test("connection settings persist to the studio settings API", async ({ page }) => { + await stubStudioRoute(page); + + await page.goto("/"); + await page.getByTestId("studio-menu-toggle").click(); + await page.getByTestId("gateway-settings-toggle").click(); + await expect(page.getByLabel("Upstream URL")).toBeVisible(); + + await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789"); + await page.getByLabel("Upstream token").fill("token-123"); + + const request = await page.waitForRequest((req) => { + if (!req.url().includes("/api/studio") || req.method() !== "PUT") { + return false; + } + const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123"; + }); + + const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + expect(gateway.url).toBe("ws://gateway.example:18789"); + expect(gateway.token).toBe("token-123"); + await expect( + page.getByRole("button", { name: /^(Connect|Disconnect)$/ }) + ).toBeVisible(); +}); diff --git a/tests/e2e/fleet-sidebar.spec.ts b/tests/e2e/fleet-sidebar.spec.ts new file mode 100644 index 00000000..189972a7 --- /dev/null +++ b/tests/e2e/fleet-sidebar.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; + +test.beforeEach(async ({ page }) => { + await stubStudioRoute(page); +}); + +test("shows_disconnected_connect_surface", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByLabel("Upstream URL")).toBeVisible(); + await expect(page.getByRole("button", { name: /^(Connect|Connecting…)$/ })).toBeVisible(); +}); + +test("persists_gateway_fields_to_studio_settings", async ({ page }) => { + await page.goto("/"); + + await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789"); + await page.getByLabel("Upstream token").fill("token-123"); + + const request = await page.waitForRequest((req) => { + if (!req.url().includes("/api/studio") || req.method() !== "PUT") { + return false; + } + const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123"; + }); + const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const gateway = (payload.gateway ?? {}) as { url?: string; token?: string }; + expect(gateway.url).toBe("ws://gateway.example:18789"); + expect(gateway.token).toBe("token-123"); +}); + +test("focused_preferences_persist_across_reload", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); + + await page.reload(); + + await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); +}); + +test("clears_unseen_indicator_on_focus", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("studio-menu-toggle").click(); + await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible(); +}); diff --git a/tests/e2e/focused-smoke.spec.ts b/tests/e2e/focused-smoke.spec.ts new file mode 100644 index 00000000..17f0e7f3 --- /dev/null +++ b/tests/e2e/focused-smoke.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +test("loads focused studio empty state", async ({ page }) => { + await page.route("**/api/studio", async (route, request) => { + if (request.method() === "PUT") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + return; + } + if (request.method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + settings: { version: 1, gateway: null, focused: {}, avatars: {} }, + }), + }); + }); + + await page.goto("/"); + + await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); + await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible(); +}); diff --git a/tests/e2e/helpers/studioRoute.ts b/tests/e2e/helpers/studioRoute.ts new file mode 100644 index 00000000..4dc0a005 --- /dev/null +++ b/tests/e2e/helpers/studioRoute.ts @@ -0,0 +1,107 @@ +import type { Page, Route, Request } from "@playwright/test"; + +export type StudioSettingsFixture = { + version: 1; + gateway: { url: string; token: string } | null; + focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>; + avatars: Record<string, Record<string, string>>; +}; + +const DEFAULT_SETTINGS: StudioSettingsFixture = { + version: 1, + gateway: null, + focused: {}, + avatars: {}, +}; + +const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) => { + let settings: StudioSettingsFixture = { + version: 1, + gateway: initial.gateway ?? null, + focused: { ...(initial.focused ?? {}) }, + avatars: { ...(initial.avatars ?? {}) }, + }; + + return async (route: Route, request: Request) => { + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + return; + } + if (request.method() !== "PUT") { + await route.fallback(); + return; + } + + const patch = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>; + const next = { ...settings }; + + if ("gateway" in patch) { + next.gateway = (patch.gateway as StudioSettingsFixture["gateway"]) ?? null; + } + + if (patch.focused && typeof patch.focused === "object") { + const focusedPatch = patch.focused as Record<string, Record<string, unknown>>; + const focusedNext = { ...next.focused }; + for (const [key, value] of Object.entries(focusedPatch)) { + const existing = focusedNext[key] ?? { + mode: "focused" as const, + filter: "all", + selectedAgentId: null, + }; + focusedNext[key] = { + mode: (value.mode as "focused") ?? existing.mode, + filter: (value.filter as string) ?? existing.filter, + selectedAgentId: + "selectedAgentId" in value + ? ((value.selectedAgentId as string | null) ?? null) + : existing.selectedAgentId, + }; + } + next.focused = focusedNext; + } + + if (patch.avatars && typeof patch.avatars === "object") { + const avatarsPatch = patch.avatars as Record<string, Record<string, string | null> | null>; + const avatarsNext: StudioSettingsFixture["avatars"] = { ...next.avatars }; + for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch)) { + if (gatewayPatch === null) { + delete avatarsNext[gatewayKey]; + continue; + } + const existing = avatarsNext[gatewayKey] ? { ...avatarsNext[gatewayKey] } : {}; + for (const [agentId, seedPatch] of Object.entries(gatewayPatch)) { + if (seedPatch === null) { + delete existing[agentId]; + continue; + } + const seed = typeof seedPatch === "string" ? seedPatch.trim() : ""; + if (!seed) { + delete existing[agentId]; + continue; + } + existing[agentId] = seed; + } + avatarsNext[gatewayKey] = existing; + } + next.avatars = avatarsNext; + } + + settings = next; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ settings }), + }); + }; +}; + +export const stubStudioRoute = async ( + page: Page, + initial: StudioSettingsFixture = DEFAULT_SETTINGS +) => { + await page.route("**/api/studio", createStudioRoute(initial)); +}; diff --git a/tests/e2e/invalid-route-redirect.spec.ts b/tests/e2e/invalid-route-redirect.spec.ts new file mode 100644 index 00000000..c79e9466 --- /dev/null +++ b/tests/e2e/invalid-route-redirect.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; + +test.beforeEach(async ({ page }) => { + await stubStudioRoute(page); +}); + +test("redirects unknown app routes to root", async ({ page }) => { + await page.goto("/not-a-real-route"); + await expect + .poll(() => new URL(page.url()).pathname, { + message: "Expected invalid route to redirect to root path.", + }) + .toBe("/"); + await expect(page.getByTestId("studio-menu-toggle")).toBeVisible(); +}); diff --git a/tests/e2e/settings-route-disconnected.spec.ts b/tests/e2e/settings-route-disconnected.spec.ts new file mode 100644 index 00000000..66cd2f30 --- /dev/null +++ b/tests/e2e/settings-route-disconnected.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; +import { stubStudioRoute } from "./helpers/studioRoute"; + +test.beforeEach(async ({ page }) => { + await stubStudioRoute(page); +}); + +test("settings route shows connect UI while disconnected and can return to chat", async ({ page }) => { + await page.goto("/agents/main/settings"); + + await expect(page.getByRole("button", { name: "Back to chat" })).toBeVisible(); + await expect(page.getByLabel("Upstream URL")).toBeVisible(); + + await page.getByRole("button", { name: "Back to chat" }).click(); + await expect + .poll(() => new URL(page.url()).pathname, { + message: "Expected back button to return to chat route.", + }) + .toBe("/"); +}); diff --git a/tests/fixtures/openclaw-empty-state/.gitkeep b/tests/fixtures/openclaw-empty-state/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/openclaw-empty-state/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/setup.ts b/tests/setup.ts index f149f27a..8de7f4f9 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1 +1,44 @@ import "@testing-library/jest-dom/vitest"; + +const ensureLocalStorage = () => { + if (typeof window === "undefined") return; + const existing = window.localStorage as unknown as Record<string, unknown> | undefined; + if ( + existing && + typeof existing.getItem === "function" && + typeof existing.setItem === "function" && + typeof existing.removeItem === "function" && + typeof existing.clear === "function" + ) { + return; + } + + const store = new Map<string, string>(); + const storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.has(String(key)) ? store.get(String(key)) ?? null : null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(String(key)); + }, + setItem(key: string, value: string) { + store.set(String(key), String(value)); + }, + }; + + Object.defineProperty(window, "localStorage", { + value: storage, + configurable: true, + }); +}; + +ensureLocalStorage(); diff --git a/tests/unit/accessGate.test.ts b/tests/unit/accessGate.test.ts new file mode 100644 index 00000000..afd8a26b --- /dev/null +++ b/tests/unit/accessGate.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +describe("createAccessGate", () => { + it("allows when token is unset", async () => { + const { createAccessGate } = await import("../../server/access-gate"); + const gate = createAccessGate({ token: "" }); + expect(gate.allowUpgrade({ headers: {} })).toBe(true); + }); + + it("rejects /api requests without cookie when enabled", async () => { + const { createAccessGate } = await import("../../server/access-gate"); + const gate = createAccessGate({ token: "abc" }); + + let statusCode = 0; + let ended = false; + const res = { + setHeader: () => {}, + end: () => { + ended = true; + }, + get statusCode() { + return statusCode; + }, + set statusCode(value: number) { + statusCode = value; + }, + }; + + const handled = gate.handleHttp( + { url: "/api/studio", headers: { host: "example.test" } }, + res + ); + + expect(handled).toBe(true); + expect(statusCode).toBe(401); + expect(ended).toBe(true); + }); + + it("allows upgrades when cookie matches", async () => { + const { createAccessGate } = await import("../../server/access-gate"); + const gate = createAccessGate({ token: "abc" }); + expect( + gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } }) + ).toBe(true); + }); +}); diff --git a/tests/unit/agentBrainPanel.test.ts b/tests/unit/agentBrainPanel.test.ts new file mode 100644 index 00000000..d4c20f89 --- /dev/null +++ b/tests/unit/agentBrainPanel.test.ts @@ -0,0 +1,218 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import type { AgentState } from "@/features/agents/state/store"; +import { AgentBrainPanel } from "@/features/agents/components/AgentInspectPanels"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +const createAgent = (agentId: string, name: string, sessionKey: string): AgentState => ({ + agentId, + name, + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: null, + thinkingLevel: null, + avatarSeed: `seed-${agentId}`, + avatarUrl: null, +}); + +const createMockClient = () => { + const filesByAgent: Record<string, Record<string, string>> = { + "agent-1": { + "AGENTS.md": "alpha agents", + "SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.", + "IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n", + "USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding OpenClaw Studio.", + "TOOLS.md": "tool notes", + "HEARTBEAT.md": "heartbeat notes", + "MEMORY.md": "durable memory", + }, + "agent-2": { + "AGENTS.md": "beta agents", + }, + }; + + const calls: Array<{ method: string; params: unknown }> = []; + + const client = { + call: vi.fn(async (method: string, params: unknown) => { + calls.push({ method, params }); + if (method === "agents.files.get") { + const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {}; + const agentId = typeof record.agentId === "string" ? record.agentId : ""; + const name = typeof record.name === "string" ? record.name : ""; + const content = filesByAgent[agentId]?.[name]; + if (typeof content !== "string") { + return { file: { name, missing: true } }; + } + return { file: { name, missing: false, content } }; + } + if (method === "agents.files.set") { + const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {}; + const agentId = typeof record.agentId === "string" ? record.agentId : ""; + const name = typeof record.name === "string" ? record.name : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!filesByAgent[agentId]) { + filesByAgent[agentId] = {}; + } + filesByAgent[agentId][name] = content; + return { ok: true }; + } + return {}; + }), + } as unknown as GatewayClient; + + return { client, calls, filesByAgent }; +}; + +describe("AgentBrainPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders_behavior_sections_and_loads_agent_files", async () => { + const { client } = createMockClient(); + const agents = [ + createAgent("agent-1", "Alpha", "session-1"), + createAgent("agent-2", "Beta", "session-2"), + ]; + + render( + createElement(AgentBrainPanel, { + client, + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument(); + }); + + expect(screen.getByRole("heading", { name: "Directives" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Context" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Identity" })).toBeInTheDocument(); + expect(screen.getByLabelText("Directives")).toHaveValue("alpha agents"); + expect(screen.getByLabelText("Persona")).toHaveValue( + "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful." + ); + expect(screen.getByLabelText("Name")).toHaveValue("Alpha"); + }); + + it("shows_actionable_message_when_session_key_missing", async () => { + const { client } = createMockClient(); + const agents = [createAgent("", "Alpha", "session-1")]; + + render( + createElement(AgentBrainPanel, { + client, + agents, + selectedAgentId: "", + }) + ); + + await waitFor(() => { + expect(screen.getByText("Agent ID is missing for this agent.")).toBeInTheDocument(); + }); + }); + + it("saves_updated_behavior_files", async () => { + const { client, calls, filesByAgent } = createMockClient(); + const agents = [createAgent("agent-1", "Alpha", "session-1")]; + + render( + createElement(AgentBrainPanel, { + client, + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(screen.getByLabelText("Directives")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText("Directives"), { + target: { value: "alpha directives updated" }, + }); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).not.toBeDisabled(); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(true); + }); + expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated"); + }); + + it("discards_unsaved_changes_without_writing_files", async () => { + const { client, calls } = createMockClient(); + const agents = [createAgent("agent-1", "Alpha", "session-1")]; + + render( + createElement(AgentBrainPanel, { + client, + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Alpha Prime" }, + }); + expect(screen.getByLabelText("Name")).toHaveValue("Alpha Prime"); + + fireEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(screen.getByLabelText("Name")).toHaveValue("Alpha"); + expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false); + }); + + it("does_not_render_name_editor_in_personality_panel", async () => { + const { client } = createMockClient(); + const agents = [createAgent("agent-1", "Alpha", "session-1")]; + + render( + createElement(AgentBrainPanel, { + client, + agents, + selectedAgentId: "agent-1", + }) + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument(); + }); + expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/agentChatPanel-approvals.test.ts b/tests/unit/agentChatPanel-approvals.test.ts new file mode 100644 index 00000000..cdb46023 --- /dev/null +++ b/tests/unit/agentChatPanel-approvals.test.ts @@ -0,0 +1,176 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +const createApproval = (overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id: "approval-1", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/bin/npm", + createdAtMs: 1, + expiresAtMs: 1_700_000_000_000, + resolving: false, + error: null, + ...overrides, +}); + +describe("AgentChatPanel exec approvals", () => { + afterEach(() => { + cleanup(); + }); + + it("renders pending approval card with metadata", () => { + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models: [], + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + pendingExecApprovals: [createApproval()], + }) + ); + + expect(screen.getByTestId("exec-approval-card-approval-1")).toBeInTheDocument(); + expect(screen.getByText("Exec approval required")).toBeInTheDocument(); + expect(screen.getByText("npm run test")).toBeInTheDocument(); + expect(screen.getByText("Host: gateway")).toBeInTheDocument(); + expect(screen.getByText("CWD: /repo")).toBeInTheDocument(); + }); + + it("renders pending approvals after transcript content", () => { + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + outputLines: ["> inspect approvals", "assistant says hello"], + }, + isSelected: true, + canSend: true, + models: [], + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + pendingExecApprovals: [createApproval()], + }) + ); + + const transcriptText = screen.getByText("assistant says hello"); + const approvalCard = screen.getByTestId("exec-approval-card-approval-1"); + expect(transcriptText.compareDocumentPosition(approvalCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("invokes resolve callback for all approval decisions", () => { + const onResolveExecApproval = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models: [], + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + pendingExecApprovals: [createApproval()], + onResolveExecApproval, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Allow once for exec approval approval-1" })); + fireEvent.click(screen.getByRole("button", { name: "Always allow for exec approval approval-1" })); + fireEvent.click(screen.getByRole("button", { name: "Deny exec approval approval-1" })); + + expect(onResolveExecApproval).toHaveBeenNthCalledWith(1, "approval-1", "allow-once"); + expect(onResolveExecApproval).toHaveBeenNthCalledWith(2, "approval-1", "allow-always"); + expect(onResolveExecApproval).toHaveBeenNthCalledWith(3, "approval-1", "deny"); + }); + + it("disables actions while approval is resolving", () => { + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models: [], + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + pendingExecApprovals: [createApproval({ resolving: true })], + onResolveExecApproval: vi.fn(), + }) + ); + + expect(screen.getByRole("button", { name: "Allow once for exec approval approval-1" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Always allow for exec approval approval-1" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Deny exec approval approval-1" })).toBeDisabled(); + }); +}); diff --git a/tests/unit/agentChatPanel-composer-autoresize.test.ts b/tests/unit/agentChatPanel-composer-autoresize.test.ts new file mode 100644 index 00000000..9fe00735 --- /dev/null +++ b/tests/unit/agentChatPanel-composer-autoresize.test.ts @@ -0,0 +1,130 @@ +import { createElement, useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; + +const createAgent = (patch?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: null, + thinkingLevel: null, + avatarSeed: "seed-1", + avatarUrl: null, + }; + const merged = { ...base, ...(patch ?? {}) }; + + return { + ...merged, + historyFetchLimit: merged.historyFetchLimit ?? null, + historyFetchedCount: merged.historyFetchedCount ?? null, + historyMaybeTruncated: merged.historyMaybeTruncated ?? false, + }; +}; + +describe("AgentChatPanel composer autoresize", () => { + const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }]; + let originalScrollHeightDescriptor: PropertyDescriptor | undefined; + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + if (originalScrollHeightDescriptor) { + Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", originalScrollHeightDescriptor); + } else { + delete (HTMLTextAreaElement.prototype as unknown as { scrollHeight?: unknown }).scrollHeight; + } + originalScrollHeightDescriptor = undefined; + }); + + it("resets_textarea_height_after_send_when_draft_is_cleared", async () => { + vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { + cb(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + + originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "scrollHeight" + ); + Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", { + configurable: true, + get() { + return this.value.trim().length > 0 ? 200 : 20; + }, + }); + + const Harness = () => { + const [agent, setAgent] = useState( + createAgent({ + draft: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8", + }) + ); + + return createElement(AgentChatPanel, { + agent, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: (value: string) => { + setAgent((prev) => ({ ...prev, draft: value })); + }, + onSend: () => { + setAgent((prev) => ({ ...prev, draft: "" })); + }, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }); + }; + + render(createElement(Harness)); + + const textarea = screen.getByPlaceholderText("type a message") as HTMLTextAreaElement; + + await waitFor(() => { + expect(textarea.style.height).toBe("200px"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(textarea.value).toBe(""); + }); + + expect(textarea.style.height).toBe("20px"); + }); +}); diff --git a/tests/unit/agentChatPanel-controls.test.ts b/tests/unit/agentChatPanel-controls.test.ts new file mode 100644 index 00000000..8463a17f --- /dev/null +++ b/tests/unit/agentChatPanel-controls.test.ts @@ -0,0 +1,618 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; +import { formatThinkingMarkdown } from "@/lib/text/message-extract"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: null, + thinkingLevel: null, + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("AgentChatPanel controls", () => { + const models: GatewayModelChoice[] = [ + { provider: "openai", id: "gpt-5", name: "gpt-5", reasoning: true }, + { provider: "openai", id: "gpt-5-mini", name: "gpt-5-mini", reasoning: false }, + ]; + + afterEach(() => { + cleanup(); + }); + + it("renders_runtime_controls_in_agent_header_and_no_inline_name_editor", () => { + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onRename: vi.fn(async () => true), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + expect(screen.getByLabelText("Model")).toBeInTheDocument(); + expect(screen.getByLabelText("Thinking")).toBeInTheDocument(); + expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument(); + expect(screen.getByTestId("agent-rename-toggle")).toBeInTheDocument(); + expect(screen.getByLabelText("Rename agent")).toBeInTheDocument(); + expect(screen.getByTestId("agent-new-session-toggle")).toBeInTheDocument(); + expect(screen.getByLabelText("Start new session")).toBeInTheDocument(); + expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument(); + expect(screen.getByLabelText("Open behavior")).toBeInTheDocument(); + expect(screen.queryByText("Inspect")).not.toBeInTheDocument(); + }); + + it("renames_agent_inline_from_header", async () => { + const onRename = vi.fn(async () => true); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onRename, + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByTestId("agent-rename-toggle")); + const input = screen.getByTestId("agent-rename-input") as HTMLInputElement; + + await waitFor(() => { + expect(input).toHaveFocus(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe("Agent One".length); + }); + + fireEvent.change(input, { target: { value: " Agent Prime " } }); + fireEvent.click(screen.getByTestId("agent-rename-save")); + + await waitFor(() => { + expect(onRename).toHaveBeenCalledWith("Agent Prime"); + }); + }); + + it("cancels_inline_rename_without_saving", () => { + const onRename = vi.fn(async () => true); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onRename, + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByTestId("agent-rename-toggle")); + fireEvent.change(screen.getByTestId("agent-rename-input"), { + target: { value: "Edited Name" }, + }); + fireEvent.click(screen.getByTestId("agent-rename-cancel")); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.queryByTestId("agent-rename-input")).not.toBeInTheDocument(); + }); + + it("invokes_on_new_session_when_control_clicked", () => { + const onNewSession = vi.fn(async () => {}); + + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onNewSession, + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByTestId("agent-new-session-toggle")); + expect(onNewSession).toHaveBeenCalledTimes(1); + }); + + it("does_not_render_inline_status_badge_markers", () => { + const { rerender, container } = render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const idleBadge = container.querySelector('[data-status="idle"]'); + expect(idleBadge).toBeNull(); + + rerender( + createElement(AgentChatPanel, { + agent: { ...createAgent(), status: "running" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const runningBadge = container.querySelector('[data-status="running"]'); + expect(runningBadge).toBeNull(); + }); + + it("invokes_on_model_change_when_model_select_changes_and_blurs_select", () => { + const onModelChange = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange, + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement; + modelSelect.focus(); + expect(modelSelect).toHaveFocus(); + + fireEvent.change(modelSelect, { + target: { value: "openai/gpt-5-mini" }, + }); + expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5-mini"); + expect(modelSelect).not.toHaveFocus(); + }); + + it("invokes_on_thinking_change_when_thinking_select_changes", () => { + const onThinkingChange = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange, + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.change(screen.getByLabelText("Thinking"), { + target: { value: "high" }, + }); + expect(onThinkingChange).toHaveBeenCalledWith("high"); + }); + + it("invokes_on_open_settings_when_control_clicked", () => { + const onOpenSettings = vi.fn(); + + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings, + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByTestId("agent-settings-toggle")); + expect(onOpenSettings).toHaveBeenCalledTimes(1); + }); + + it("shows_stop_button_while_running_and_invokes_stop_handler", () => { + const onStopRun = vi.fn(); + + render( + createElement(AgentChatPanel, { + agent: { ...createAgent(), status: "running" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun, + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Stop" })); + expect(onStopRun).toHaveBeenCalledTimes(1); + }); + + it("allows_send_while_running_so_follow_up_can_be_queued", () => { + const onSend = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: { ...createAgent(), status: "running" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const textarea = screen.getByPlaceholderText("type a message"); + fireEvent.change(textarea, { target: { value: "follow up" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect(onSend).toHaveBeenCalledWith("follow up"); + }); + + it("renders_queue_bar_and_supports_removing_queued_messages", () => { + const onRemoveQueuedMessage = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: { ...createAgent(), queuedMessages: ["first queued", "second queued"] }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onRemoveQueuedMessage, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + expect(screen.getByTestId("queued-messages-bar")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove queued message 1" })); + expect(onRemoveQueuedMessage).toHaveBeenCalledWith(0); + }); + + it("disables_stop_button_with_tooltip_when_stop_is_unavailable", () => { + const stopDisabledReason = + "This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon)."; + render( + createElement(AgentChatPanel, { + agent: { ...createAgent(), status: "running" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + stopDisabledReason, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const stopButton = screen.getByRole("button", { + name: `Stop unavailable: ${stopDisabledReason}`, + }); + expect(stopButton).toBeDisabled(); + expect(stopButton.parentElement).toHaveAttribute("title", stopDisabledReason); + }); + + it("shows_thinking_indicator_while_running_before_stream_text", () => { + render( + createElement(AgentChatPanel, { + agent: { ...createAgent(), status: "running", outputLines: ["> test"] }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument(); + expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument(); + }); + + it("shows_thinking_indicator_after_stream_starts", () => { + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + status: "running", + outputLines: ["> test"], + streamText: "working on it", + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument(); + expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument(); + }); + + it("does_not_render_duplicate_typing_indicator_when_internal_thinking_is_visible", () => { + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + status: "running", + outputLines: ["> test", formatThinkingMarkdown("thinking now")], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + expect(screen.queryByTestId("agent-typing-indicator")).not.toBeInTheDocument(); + expect(screen.getByText("Thinking (internal)")).toBeInTheDocument(); + }); + + it("renders thinking row collapsed by default", () => { + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + status: "running", + outputLines: ["> test", formatThinkingMarkdown("thinking now"), "final response"], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const details = screen.getByText("Thinking (internal)").closest("details"); + expect(details).toBeTruthy(); + expect(details).not.toHaveAttribute("open"); + }); + + it("does_not_overwrite_active_draft_with_stale_nonempty_agent_draft", () => { + const onDraftChange = vi.fn(); + const onSend = vi.fn(); + const { rerender } = render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange, + onSend, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const textarea = screen.getByPlaceholderText("type a message") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "hello world" } }); + expect(textarea.value).toBe("hello world"); + + rerender( + createElement(AgentChatPanel, { + agent: { ...createAgent(), draft: "hello" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange, + onSend, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + expect(textarea.value).toBe("hello world"); + + rerender( + createElement(AgentChatPanel, { + agent: { ...createAgent(), draft: "" }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange, + onSend, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + expect(textarea.value).toBe(""); + }); + + it("does_not_send_when_enter_is_pressed_during_composition", () => { + const onSend = vi.fn(); + render( + createElement(AgentChatPanel, { + agent: createAgent(), + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend, + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const textarea = screen.getByPlaceholderText("type a message"); + fireEvent.change(textarea, { target: { value: "draft text" } }); + + fireEvent.keyDown(textarea, { + key: "Enter", + code: "Enter", + keyCode: 229, + isComposing: true, + }); + expect(onSend).not.toHaveBeenCalled(); + + fireEvent.keyDown(textarea, { key: "Enter", code: "Enter" }); + expect(onSend).toHaveBeenCalledWith("draft text"); + }); +}); diff --git a/tests/unit/agentChatPanel-markdown-rendering.test.ts b/tests/unit/agentChatPanel-markdown-rendering.test.ts new file mode 100644 index 00000000..494d400a --- /dev/null +++ b/tests/unit/agentChatPanel-markdown-rendering.test.ts @@ -0,0 +1,178 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; +import { formatThinkingMarkdown, formatToolCallMarkdown } from "@/lib/text/message-extract"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: null, + thinkingLevel: null, + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("AgentChatPanel markdown rendering", () => { + const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }]; + + afterEach(() => { + cleanup(); + }); + + it("renders assistant markdown separately from tool detail cards", () => { + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + outputLines: [ + "> summarize rendering changes", + "Here is the output:\n- keep assistant markdown\n- keep tool boundaries\n\n```ts\nconst answer = 42;\n```", + "[[tool-result]] shell (call-2)\nok\n```text\ndone\n```", + ], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const assistantListItem = screen.getByText("keep assistant markdown"); + expect(assistantListItem).toBeInTheDocument(); + expect(screen.getByText("keep tool boundaries")).toBeInTheDocument(); + expect(screen.getByText("const answer = 42;")).toBeInTheDocument(); + expect(assistantListItem.closest("details")).toBeNull(); + + expect(screen.queryByText(/^Output$/)).not.toBeInTheDocument(); + expect(screen.queryByText("Extract output")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Thinking (internal)")); + const toolSummary = screen.getByText("SHELL · ok"); + const toolDetails = toolSummary.closest("details"); + expect(toolDetails).toBeTruthy(); + fireEvent.click(toolSummary); + expect(within(toolDetails as HTMLElement).getByText("done")).toBeInTheDocument(); + }); + + it("nests tool calls inside the associated thinking details block", () => { + const firstToolCall = formatToolCallMarkdown({ + id: "call_1", + name: "memory_search", + arguments: { query: "priority ledger" }, + }); + const secondToolCall = formatToolCallMarkdown({ + id: "call_2", + name: "memory_search", + arguments: { query: "youtube channel tasks" }, + }); + + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + outputLines: [ + "> how are you prioritizing this?", + firstToolCall, + secondToolCall, + formatThinkingMarkdown("Proposing multi-lane tracking system"), + "Short answer: a pinned priority ledger keeps the loop aligned.", + ], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const thinkingDetails = screen.getByText("Thinking (internal)").closest("details"); + expect(thinkingDetails).toBeTruthy(); + fireEvent.click(screen.getByText("Thinking (internal)")); + expect(within(thinkingDetails as HTMLElement).getByText(/proposing multi-lane tracking system/i)).toBeInTheDocument(); + + const memorySearchSummaries = screen.getAllByText(/MEMORY_SEARCH/); + expect(memorySearchSummaries.length).toBe(2); + for (const summary of memorySearchSummaries) { + expect(thinkingDetails).toContainElement(summary); + } + }); + + it("renders read tool calls as inline path labels instead of collapsible JSON blocks", () => { + const readToolCall = formatToolCallMarkdown({ + id: "call_read_1", + name: "read", + arguments: { file_path: "/tmp/README.md" }, + }); + + render( + createElement(AgentChatPanel, { + agent: { + ...createAgent(), + outputLines: [formatThinkingMarkdown("Reviewing docs"), readToolCall], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + fireEvent.click(screen.getByText("Thinking (internal)")); + expect(screen.getByText("read /tmp/README.md")).toBeInTheDocument(); + expect(screen.queryByText("read /tmp/README.md", { selector: "summary" })).toBeNull(); + expect(screen.queryByText(/"file_path"/)).toBeNull(); + }); +}); diff --git a/tests/unit/agentChatPanel-scroll.test.ts b/tests/unit/agentChatPanel-scroll.test.ts new file mode 100644 index 00000000..08e40201 --- /dev/null +++ b/tests/unit/agentChatPanel-scroll.test.ts @@ -0,0 +1,148 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel"; +import type { GatewayModelChoice } from "@/lib/gateway/models"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: null, + thinkingLevel: null, + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("AgentChatPanel scrolling", () => { + const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }]; + + afterEach(() => { + cleanup(); + delete (Element.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView; + }); + + it("shows jump-to-latest when unpinned and new output arrives, and jumps on click", async () => { + (Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = vi.fn(); + + const agent = createAgent(); + const { rerender } = render( + createElement(AgentChatPanel, { + agent: { ...agent, outputLines: ["> hello", "first answer"] }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const scrollEl = screen.getByTestId("agent-chat-scroll"); + Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true }); + Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(scrollEl, "scrollTop", { value: 0, writable: true, configurable: true }); + + fireEvent.scroll(scrollEl); + + rerender( + createElement(AgentChatPanel, { + agent: { ...agent, outputLines: ["> hello", "first answer", "second answer"] }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Jump to latest" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Jump to latest" })); + + expect( + (Element.prototype as unknown as { scrollIntoView: ReturnType<typeof vi.fn> }) + .scrollIntoView + ).toHaveBeenCalled(); + }); + + it("shows history truncation banner only when scrolled to top", () => { + const agent = createAgent(); + render( + createElement(AgentChatPanel, { + agent: { + ...agent, + historyMaybeTruncated: true, + historyFetchedCount: 200, + historyFetchLimit: 200, + outputLines: ["> hello", "response"], + }, + isSelected: true, + canSend: true, + models, + stopBusy: false, + onLoadMoreHistory: vi.fn(), + onOpenSettings: vi.fn(), + onModelChange: vi.fn(), + onThinkingChange: vi.fn(), + onDraftChange: vi.fn(), + onSend: vi.fn(), + onStopRun: vi.fn(), + onAvatarShuffle: vi.fn(), + }) + ); + + const scrollEl = screen.getByTestId("agent-chat-scroll"); + Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true }); + Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(scrollEl, "scrollTop", { value: 120, writable: true, configurable: true }); + + fireEvent.scroll(scrollEl); + expect(screen.queryByText(/Showing most recent 200 messages/i)).not.toBeInTheDocument(); + + scrollEl.scrollTop = 0; + fireEvent.scroll(scrollEl); + expect(screen.getByText(/Showing most recent 200 messages/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/agentCreateModal.test.ts b/tests/unit/agentCreateModal.test.ts new file mode 100644 index 00000000..bc198c8a --- /dev/null +++ b/tests/unit/agentCreateModal.test.ts @@ -0,0 +1,130 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal"; + +const openModal = (overrides?: { + busy?: boolean; + onClose?: () => void; + onSubmit?: (payload: unknown) => void; +}) => { + const onClose = overrides?.onClose ?? vi.fn(); + const onSubmit = overrides?.onSubmit ?? vi.fn(); + render( + createElement(AgentCreateModal, { + open: true, + suggestedName: "New Agent", + busy: overrides?.busy, + onClose, + onSubmit, + }) + ); + return { onClose, onSubmit }; +}; + +describe("AgentCreateModal", () => { + afterEach(() => { + cleanup(); + }); + + it("submits simple payload with name and avatar seed", () => { + const onSubmit = vi.fn(); + openModal({ onSubmit }); + + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: "Execution Operator" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Launch agent" })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Execution Operator", + avatarSeed: expect.any(String), + }) + ); + }); + + it("submits when the form is submitted from keyboard flow", () => { + const onSubmit = vi.fn(); + openModal({ onSubmit }); + + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: "Keyboard Agent" }, + }); + fireEvent.submit(screen.getByTestId("agent-create-modal")); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Keyboard Agent", + }) + ); + }); + + it("renders one-step create form without guided wizard copy", () => { + openModal(); + + expect(screen.getByRole("button", { name: "Launch agent" })).toBeInTheDocument(); + expect(screen.getByLabelText("Agent name")).toBeInTheDocument(); + expect(screen.getByText("Choose avatar")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Shuffle avatar selection" })).toBeInTheDocument(); + expect(screen.queryByText("Define Ownership")).not.toBeInTheDocument(); + expect(screen.queryByText("Set Authority Level")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); + }); + + it("disables launch when the name is blank", () => { + const onSubmit = vi.fn(); + openModal({ onSubmit }); + + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: " " }, + }); + const launchButton = screen.getByRole("button", { name: "Launch agent" }); + expect(launchButton).toBeDisabled(); + fireEvent.click(launchButton); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("shows launching state while busy", () => { + openModal({ busy: true }); + + expect(screen.getByRole("button", { name: "Launching..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + }); + + it("calls onClose when close is pressed", () => { + const onClose = vi.fn(); + openModal({ onClose }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not reset typed name when suggestedName changes while open", () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + const view = render( + createElement(AgentCreateModal, { + open: true, + suggestedName: "New Agent", + onClose, + onSubmit, + }) + ); + + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: "My Draft Name" }, + }); + + view.rerender( + createElement(AgentCreateModal, { + open: true, + suggestedName: "New Agent 2", + onClose, + onSubmit, + }) + ); + + expect(screen.getByLabelText("Agent name")).toHaveValue("My Draft Name"); + }); +}); diff --git a/tests/unit/agentFilesBootstrap.test.ts b/tests/unit/agentFilesBootstrap.test.ts new file mode 100644 index 00000000..cf88193e --- /dev/null +++ b/tests/unit/agentFilesBootstrap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +describe("writeGatewayAgentFiles", () => { + it("writes each provided file to agents.files.set", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await writeGatewayAgentFiles({ + client, + agentId: "agent-1", + files: { + "AGENTS.md": "# mission", + "SOUL.md": "# tone", + }, + }); + + expect(client.call).toHaveBeenCalledTimes(2); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([ + "agents.files.set", + { agentId: "agent-1", name: "AGENTS.md", content: "# mission" }, + ]); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls[1]).toEqual([ + "agents.files.set", + { agentId: "agent-1", name: "SOUL.md", content: "# tone" }, + ]); + }); + + it("fails fast for empty agent id", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await expect( + writeGatewayAgentFiles({ + client, + agentId: " ", + files: { "AGENTS.md": "# mission" }, + }) + ).rejects.toThrow("agentId is required."); + expect(client.call).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/agentFleetHydration.test.ts b/tests/unit/agentFleetHydration.test.ts new file mode 100644 index 00000000..9a2c6452 --- /dev/null +++ b/tests/unit/agentFleetHydration.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; + +import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration"; +import type { StudioSettings } from "@/lib/studio/settings"; + +describe("hydrateAgentFleetFromGateway", () => { + it("maps_gateway_results_into_seeds_and_selects_latest_assistant_agent", async () => { + const gatewayUrl = "ws://127.0.0.1:18789"; + + const settings: StudioSettings = { + version: 1, + gateway: null, + focused: {}, + avatars: { + [gatewayUrl]: { + "agent-1": "persisted-seed", + }, + }, + }; + + const call = vi.fn(async (method: string, params: unknown) => { + if (method === "config.get") { + return { + hash: "hash-1", + config: { + agents: { + defaults: { + model: "openai/gpt-5", + }, + list: [], + }, + }, + }; + } + if (method === "agents.list") { + return { + defaultId: "agent-1", + mainKey: "main", + agents: [ + { + id: "agent-1", + name: "One", + identity: { avatarUrl: "https://example.com/one.png" }, + }, + { + id: "agent-2", + name: "Two", + identity: { avatarUrl: "https://example.com/two.png" }, + }, + ], + }; + } + if (method === "exec.approvals.get") { + return { + file: { + agents: { + "agent-1": { security: "allowlist", ask: "always" }, + "agent-2": { security: "full", ask: "off" }, + }, + }, + }; + } + if (method === "sessions.list") { + const { agentId, search } = params as Record<string, unknown>; + return { + sessions: [ + { + key: search, + updatedAt: 1, + displayName: "Main", + thinkingLevel: "medium", + modelProvider: "openai", + model: agentId === "agent-2" ? "gpt-5" : "gpt-4.1", + }, + ], + }; + } + if (method === "status") { + return { + sessions: { + recent: [], + byAgent: [], + }, + }; + } + if (method === "sessions.preview") { + return { + ts: 1, + previews: [ + { + key: "agent:agent-1:main", + status: "ok", + items: [ + { role: "assistant", text: "one", timestamp: "2026-02-10T00:00:00Z" }, + ], + }, + { + key: "agent:agent-2:main", + status: "ok", + items: [ + { role: "assistant", text: "two", timestamp: "2026-02-10T01:00:00Z" }, + ], + }, + ], + }; + } + throw new Error(`Unhandled method: ${method}`); + }); + + const result = await hydrateAgentFleetFromGateway({ + client: { call }, + gatewayUrl, + cachedConfigSnapshot: null, + loadStudioSettings: async () => settings, + isDisconnectLikeError: () => false, + }); + + expect(call).toHaveBeenCalledWith("agents.list", {}); + expect(call).toHaveBeenCalledWith("exec.approvals.get", {}); + expect(result.seeds).toHaveLength(2); + expect(result.seeds[0]).toEqual( + expect.objectContaining({ + agentId: "agent-1", + name: "One", + sessionKey: "agent:agent-1:main", + avatarSeed: "persisted-seed", + avatarUrl: "https://example.com/one.png", + model: "openai/gpt-4.1", + thinkingLevel: "medium", + sessionExecHost: "gateway", + sessionExecSecurity: "allowlist", + sessionExecAsk: "always", + }) + ); + expect(result.seeds[1]).toEqual( + expect.objectContaining({ + agentId: "agent-2", + sessionExecHost: "gateway", + sessionExecSecurity: "full", + sessionExecAsk: "off", + }) + ); + expect(result.sessionCreatedAgentIds).toEqual(["agent-1", "agent-2"]); + expect(result.sessionSettingsSyncedAgentIds).toEqual([]); + expect(result.suggestedSelectedAgentId).toBe("agent-2"); + expect(result.summaryPatches.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/agentFleetHydrationDerivation.test.ts b/tests/unit/agentFleetHydrationDerivation.test.ts new file mode 100644 index 00000000..53ebb96c --- /dev/null +++ b/tests/unit/agentFleetHydrationDerivation.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation"; +import type { StudioSettings } from "@/lib/studio/settings"; + +describe("deriveHydrateAgentFleetResult", () => { + it("derives_seeds_and_sync_sets_from_snapshots", () => { + const gatewayUrl = "ws://127.0.0.1:18789"; + + const settings: StudioSettings = { + version: 1, + gateway: null, + focused: {}, + avatars: { + [gatewayUrl]: { + "agent-1": "persisted-seed", + }, + }, + }; + + const result = deriveHydrateAgentFleetResult({ + gatewayUrl, + configSnapshot: { + config: { + agents: { + defaults: { + model: "openai/gpt-5", + }, + list: [], + }, + }, + }, + settings, + execApprovalsSnapshot: { + file: { + agents: { + "agent-1": { security: "allowlist", ask: "always" }, + "agent-2": { security: "full", ask: "off" }, + }, + }, + }, + agentsResult: { + defaultId: "agent-1", + mainKey: "main", + agents: [ + { + id: "agent-1", + name: "One", + identity: { avatarUrl: "https://example.com/one.png" }, + }, + { + id: "agent-2", + name: "Two", + identity: { avatarUrl: "https://example.com/two.png" }, + }, + ], + }, + mainSessionByAgentId: new Map([ + [ + "agent-1", + { + key: "agent:agent-1:main", + updatedAt: 1, + displayName: "Main", + thinkingLevel: "medium", + modelProvider: "openai", + model: "gpt-4.1", + }, + ], + [ + "agent-2", + { + key: "agent:agent-2:main", + updatedAt: 1, + displayName: "Main", + thinkingLevel: "medium", + modelProvider: "openai", + model: "gpt-5", + }, + ], + ]), + statusSummary: null, + previewResult: null, + }); + + expect(result.seeds).toHaveLength(2); + expect(result.seeds[0]).toEqual( + expect.objectContaining({ + agentId: "agent-1", + name: "One", + sessionKey: "agent:agent-1:main", + avatarSeed: "persisted-seed", + avatarUrl: "https://example.com/one.png", + model: "openai/gpt-4.1", + thinkingLevel: "medium", + sessionExecHost: "gateway", + sessionExecSecurity: "allowlist", + sessionExecAsk: "always", + }) + ); + expect(result.seeds[1]).toEqual( + expect.objectContaining({ + agentId: "agent-2", + sessionExecHost: "gateway", + sessionExecSecurity: "full", + sessionExecAsk: "off", + }) + ); + expect(result.sessionCreatedAgentIds).toEqual(["agent-1", "agent-2"]); + expect(result.sessionSettingsSyncedAgentIds).toEqual([]); + expect(result.suggestedSelectedAgentId).toBe(null); + expect(result.summaryPatches).toEqual([]); + }); + + it("derives_summary_patches_and_suggested_agent_when_preview_present", () => { + const gatewayUrl = "ws://127.0.0.1:18789"; + + const result = deriveHydrateAgentFleetResult({ + gatewayUrl, + configSnapshot: null, + settings: null, + execApprovalsSnapshot: null, + agentsResult: { + defaultId: "agent-1", + mainKey: "main", + agents: [ + { id: "agent-1", name: "One", identity: {} }, + { id: "agent-2", name: "Two", identity: {} }, + ], + }, + mainSessionByAgentId: new Map([ + ["agent-1", { key: "agent:agent-1:main" }], + ["agent-2", { key: "agent:agent-2:main" }], + ]), + statusSummary: { + sessions: { + recent: [], + byAgent: [], + }, + }, + previewResult: { + ts: 1, + previews: [ + { + key: "agent:agent-1:main", + status: "ok", + items: [ + { role: "assistant", text: "one", timestamp: "2026-02-10T00:00:00Z" }, + ], + }, + { + key: "agent:agent-2:main", + status: "ok", + items: [ + { role: "assistant", text: "two", timestamp: "2026-02-10T01:00:00Z" }, + ], + }, + ], + }, + }); + + expect(result.summaryPatches.length).toBeGreaterThan(0); + expect(result.suggestedSelectedAgentId).toBe("agent-2"); + }); +}); diff --git a/tests/unit/agentPermissionsOperation.test.ts b/tests/unit/agentPermissionsOperation.test.ts new file mode 100644 index 00000000..c128e630 --- /dev/null +++ b/tests/unit/agentPermissionsOperation.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + isPermissionsCustom, + resolveAgentPermissionsDraft, + resolveCommandModeFromRole, + resolvePresetDefaultsForRole, + resolveRoleForCommandMode, + resolveToolGroupOverrides, + resolveToolGroupStateFromConfigEntry, +} from "@/features/agents/operations/agentPermissionsOperation"; + +describe("agentPermissionsOperation", () => { + it("maps command mode and preset role in both directions", () => { + expect(resolveRoleForCommandMode("off")).toBe("conservative"); + expect(resolveRoleForCommandMode("ask")).toBe("collaborative"); + expect(resolveRoleForCommandMode("auto")).toBe("autonomous"); + + expect(resolveCommandModeFromRole("conservative")).toBe("off"); + expect(resolveCommandModeFromRole("collaborative")).toBe("ask"); + expect(resolveCommandModeFromRole("autonomous")).toBe("auto"); + }); + + it("resolves autonomous preset defaults to permissive capabilities", () => { + expect(resolvePresetDefaultsForRole("autonomous")).toEqual({ + commandMode: "auto", + webAccess: true, + fileTools: true, + }); + }); + + it("derives tool-group state from allow and deny with deny precedence", () => { + const state = resolveToolGroupStateFromConfigEntry({ + allow: ["group:web", "group:runtime"], + deny: ["group:web"], + }); + + expect(state.usesAllow).toBe(true); + expect(state.runtime).toBe(true); + expect(state.web).toBe(false); + expect(state.fs).toBeNull(); + }); + + it("merges group toggles while preserving allow mode", () => { + const overrides = resolveToolGroupOverrides({ + existingTools: { + allow: ["group:web", "custom:tool"], + deny: ["group:runtime", "group:fs"], + }, + runtimeEnabled: true, + webEnabled: false, + fsEnabled: true, + }); + + expect(overrides.tools.allow).toEqual( + expect.arrayContaining(["custom:tool", "group:runtime", "group:fs"]) + ); + expect(overrides.tools.allow).not.toEqual(expect.arrayContaining(["group:web"])); + expect(overrides.tools.deny).toEqual(expect.arrayContaining(["group:web"])); + expect(overrides.tools.deny).not.toEqual( + expect.arrayContaining(["group:runtime", "group:fs"]) + ); + }); + + it("merges group toggles while preserving alsoAllow mode", () => { + const overrides = resolveToolGroupOverrides({ + existingTools: { + alsoAllow: ["group:web"], + deny: [], + }, + runtimeEnabled: true, + webEnabled: true, + fsEnabled: false, + }); + + expect(overrides.tools).not.toHaveProperty("allow"); + expect(overrides.tools.alsoAllow).toEqual( + expect.arrayContaining(["group:web", "group:runtime"]) + ); + expect(overrides.tools.deny).toEqual(expect.arrayContaining(["group:fs"])); + }); + + it("resolves draft from session role and config group overrides", () => { + const draft = resolveAgentPermissionsDraft({ + agent: { + sessionExecSecurity: "allowlist", + sessionExecAsk: "always", + }, + existingTools: { + allow: ["group:web"], + deny: ["group:fs"], + }, + }); + + expect(draft).toEqual({ + commandMode: "ask", + webAccess: true, + fileTools: false, + }); + }); + + it("flags custom draft when advanced values diverge from preset baseline", () => { + expect( + isPermissionsCustom({ + role: "autonomous", + draft: { + commandMode: "auto", + webAccess: false, + fileTools: true, + }, + }) + ).toBe(true); + }); +}); diff --git a/tests/unit/agentPermissionsRoleHelpers.test.ts b/tests/unit/agentPermissionsRoleHelpers.test.ts new file mode 100644 index 00000000..b6c1c65e --- /dev/null +++ b/tests/unit/agentPermissionsRoleHelpers.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveExecApprovalsPolicyForRole, + resolveRuntimeToolOverridesForRole, + resolveSessionExecSettingsForRole, +} from "@/features/agents/operations/agentPermissionsOperation"; + +describe("permissions role helpers", () => { + it("maps roles to exec approvals policy while preserving allowlist", () => { + const allowlist = [{ pattern: "a" }, { pattern: "b" }]; + + expect(resolveExecApprovalsPolicyForRole({ role: "conservative", allowlist })).toBeNull(); + + const collaborative = resolveExecApprovalsPolicyForRole({ + role: "collaborative", + allowlist, + }); + expect(collaborative).toEqual({ + security: "allowlist", + ask: "always", + allowlist, + }); + expect(collaborative?.allowlist).toBe(allowlist); + + const autonomous = resolveExecApprovalsPolicyForRole({ + role: "autonomous", + allowlist, + }); + expect(autonomous).toEqual({ + security: "full", + ask: "off", + allowlist, + }); + expect(autonomous?.allowlist).toBe(allowlist); + }); + + it("updates tool overrides using allow when existing tools.allow is present", () => { + const existingTools = { allow: ["group:web"], deny: ["group:runtime"] }; + + const collaborative = resolveRuntimeToolOverridesForRole({ + role: "collaborative", + existingTools, + }); + expect(collaborative.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); + expect(collaborative.tools).not.toHaveProperty("alsoAllow"); + expect(collaborative.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"])); + + const autonomous = resolveRuntimeToolOverridesForRole({ + role: "autonomous", + existingTools, + }); + expect(autonomous.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); + expect(autonomous.tools).not.toHaveProperty("alsoAllow"); + expect(autonomous.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"])); + + const conservative = resolveRuntimeToolOverridesForRole({ + role: "conservative", + existingTools, + }); + expect(conservative.tools.allow).toEqual(expect.arrayContaining(["group:web"])); + expect(conservative.tools.allow).not.toEqual(expect.arrayContaining(["group:runtime"])); + expect(conservative.tools.deny).toEqual(expect.arrayContaining(["group:runtime"])); + }); + + it("updates tool overrides using alsoAllow when tools.allow is absent", () => { + const existingTools = { alsoAllow: ["group:web"], deny: [] as string[] }; + + const collaborative = resolveRuntimeToolOverridesForRole({ + role: "collaborative", + existingTools, + }); + expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web", "group:runtime"])); + expect(collaborative.tools).not.toHaveProperty("allow"); + + const conservative = resolveRuntimeToolOverridesForRole({ + role: "conservative", + existingTools, + }); + expect(conservative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web"])); + expect(conservative.tools.alsoAllow).not.toEqual(expect.arrayContaining(["group:runtime"])); + expect(conservative.tools.deny).toEqual(expect.arrayContaining(["group:runtime"])); + }); + + it("resolves session exec settings from role and sandbox mode", () => { + expect(resolveSessionExecSettingsForRole({ role: "conservative", sandboxMode: "all" })).toEqual({ + execHost: null, + execSecurity: "deny", + execAsk: "off", + }); + + expect(resolveSessionExecSettingsForRole({ role: "collaborative", sandboxMode: "all" }).execHost).toBe( + "sandbox" + ); + expect(resolveSessionExecSettingsForRole({ role: "autonomous", sandboxMode: "all" }).execHost).toBe( + "sandbox" + ); + + expect(resolveSessionExecSettingsForRole({ role: "collaborative", sandboxMode: "none" }).execHost).toBe( + "gateway" + ); + expect(resolveSessionExecSettingsForRole({ role: "autonomous", sandboxMode: "none" }).execHost).toBe( + "gateway" + ); + }); + + it("treats missing tools config as empty lists and still enforces group:runtime semantics", () => { + const collaborative = resolveRuntimeToolOverridesForRole({ + role: "collaborative", + existingTools: null, + }); + expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:runtime"])); + expect(collaborative.tools).not.toHaveProperty("allow"); + }); +}); diff --git a/tests/unit/agentReconcileOperation.test.ts b/tests/unit/agentReconcileOperation.test.ts new file mode 100644 index 00000000..4d48f84e --- /dev/null +++ b/tests/unit/agentReconcileOperation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { buildReconcileTerminalPatch } from "@/features/agents/operations/fleetLifecycleWorkflow"; +import { + executeAgentReconcileCommands, + runAgentReconcileOperation, +} from "@/features/agents/operations/agentReconcileOperation"; + +describe("agentReconcileOperation", () => { + it("reconciles terminal runs and requests history refresh", async () => { + const call = vi.fn(async (method: string) => { + if (method === "agent.wait") { + return { status: "ok" }; + } + throw new Error(`unexpected method ${method}`); + }); + + const agent = { + agentId: "a1", + status: "running", + sessionCreated: true, + runId: "run-1", + } as unknown as AgentState; + + const commands = await runAgentReconcileOperation({ + client: { call }, + agents: [agent], + getLatestAgent: () => agent, + claimRunId: () => true, + releaseRunId: () => {}, + isDisconnectLikeError: () => false, + }); + + expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 1 }); + + expect(commands).toEqual( + expect.arrayContaining([ + { kind: "clearRunTracking", runId: "run-1" }, + { + kind: "dispatchUpdateAgent", + agentId: "a1", + patch: buildReconcileTerminalPatch({ outcome: "ok" }), + }, + { kind: "requestHistoryRefresh", agentId: "a1" }, + ]) + ); + }); + + it("skips when agent is not eligible", async () => { + const call = vi.fn(); + const agent = { + agentId: "a1", + status: "idle", + sessionCreated: true, + runId: "run-1", + } as unknown as AgentState; + + const commands = await runAgentReconcileOperation({ + client: { call }, + agents: [agent], + getLatestAgent: () => agent, + claimRunId: () => true, + releaseRunId: () => {}, + isDisconnectLikeError: () => false, + }); + + expect(call).not.toHaveBeenCalled(); + expect(commands).toEqual([]); + }); + + it("reconciles shared run only once and triggers one history refresh", async () => { + const call = vi.fn(async () => ({ status: "ok" })); + const agentOne = { + agentId: "a1", + status: "running", + sessionCreated: true, + runId: "run-shared", + } as unknown as AgentState; + const agentTwo = { + agentId: "a2", + status: "running", + sessionCreated: true, + runId: "run-shared", + } as unknown as AgentState; + + let claimed = false; + const commands = await runAgentReconcileOperation({ + client: { call }, + agents: [agentOne, agentTwo], + getLatestAgent: (agentId) => (agentId === "a1" ? agentOne : agentTwo), + claimRunId: () => { + if (claimed) return false; + claimed = true; + return true; + }, + releaseRunId: () => {}, + isDisconnectLikeError: () => false, + }); + + const historyRefreshes = commands.filter((entry) => entry.kind === "requestHistoryRefresh"); + expect(call).toHaveBeenCalledTimes(1); + expect(historyRefreshes).toEqual([{ kind: "requestHistoryRefresh", agentId: "a1" }]); + + const dispatch = vi.fn(); + const clearRunTracking = vi.fn(); + const requestHistoryRefresh = vi.fn(); + const logInfo = vi.fn(); + const logWarn = vi.fn(); + executeAgentReconcileCommands({ + commands, + dispatch, + clearRunTracking, + requestHistoryRefresh, + logInfo, + logWarn, + }); + + expect(requestHistoryRefresh).toHaveBeenCalledTimes(1); + expect(requestHistoryRefresh).toHaveBeenCalledWith("a1"); + }); +}); diff --git a/tests/unit/agentSettingsMutationWorkflow.test.ts b/tests/unit/agentSettingsMutationWorkflow.test.ts new file mode 100644 index 00000000..1a8f681b --- /dev/null +++ b/tests/unit/agentSettingsMutationWorkflow.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; + +import { + planAgentSettingsMutation, + type AgentSettingsMutationContext, +} from "@/features/agents/operations/agentSettingsMutationWorkflow"; + +const createContext = ( + overrides?: Partial<AgentSettingsMutationContext> +): AgentSettingsMutationContext => ({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + cronCreateBusy: false, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + ...(overrides ?? {}), +}); + +describe("agentSettingsMutationWorkflow", () => { + it("denies_guarded_actions_when_not_connected", () => { + const renameResult = planAgentSettingsMutation( + { kind: "rename-agent", agentId: "agent-1" }, + createContext({ status: "disconnected" }) + ); + const skillsResult = planAgentSettingsMutation( + { kind: "use-all-skills", agentId: "agent-1" }, + createContext({ status: "disconnected" }) + ); + const installResult = planAgentSettingsMutation( + { kind: "install-skill", agentId: "agent-1", skillKey: "browser" }, + createContext({ status: "disconnected" }) + ); + const allowlistResult = planAgentSettingsMutation( + { kind: "set-skills-allowlist", agentId: "agent-1" }, + createContext({ status: "disconnected" }) + ); + const globalToggleResult = planAgentSettingsMutation( + { kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: "browser" }, + createContext({ status: "disconnected" }) + ); + const removeResult = planAgentSettingsMutation( + { kind: "remove-skill", agentId: "agent-1", skillKey: "browser" }, + createContext({ status: "disconnected" }) + ); + + expect(renameResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(skillsResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(installResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(allowlistResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(globalToggleResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + expect(removeResult).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "not-connected", + }); + }); + + it("denies_delete_for_reserved_main_agent_with_actionable_message", () => { + const result = planAgentSettingsMutation( + { kind: "delete-agent", agentId: " main " }, + createContext() + ); + + expect(result).toEqual({ + kind: "deny", + reason: "reserved-main-delete", + message: "The main agent cannot be deleted.", + }); + }); + + it("denies_guarded_actions_when_mutation_block_is_active", () => { + const result = planAgentSettingsMutation( + { kind: "update-agent-permissions", agentId: "agent-1" }, + createContext({ hasCreateBlock: true }) + ); + + expect(result).toEqual({ + kind: "deny", + reason: "start-guard-deny", + message: null, + guardReason: "create-block-active", + }); + }); + + it("denies_cron_run_delete_when_other_cron_action_is_busy", () => { + const result = planAgentSettingsMutation( + { kind: "run-cron-job", agentId: "agent-1", jobId: "job-1" }, + createContext({ cronDeleteBusyJobId: "job-2" }) + ); + + expect(result).toEqual({ + kind: "deny", + reason: "cron-action-busy", + message: null, + }); + }); + + it("allows_with_normalized_agent_and_job_ids", () => { + const runResult = planAgentSettingsMutation( + { kind: "run-cron-job", agentId: " agent-1 ", jobId: " job-1 " }, + createContext() + ); + const deleteResult = planAgentSettingsMutation( + { kind: "delete-agent", agentId: " agent-2 " }, + createContext() + ); + + expect(runResult).toEqual({ + kind: "allow", + normalizedAgentId: "agent-1", + normalizedJobId: "job-1", + }); + expect(deleteResult).toEqual({ + kind: "allow", + normalizedAgentId: "agent-2", + }); + }); + + it("denies_skill_toggle_when_skill_name_is_missing", () => { + const result = planAgentSettingsMutation( + { kind: "set-skill-enabled", agentId: "agent-1", skillName: " " }, + createContext() + ); + + expect(result).toEqual({ + kind: "deny", + reason: "missing-skill-name", + message: null, + }); + }); + + it("denies_skill_setup_when_skill_key_is_missing", () => { + const installResult = planAgentSettingsMutation( + { kind: "install-skill", agentId: "agent-1", skillKey: " " }, + createContext() + ); + const saveResult = planAgentSettingsMutation( + { kind: "save-skill-api-key", agentId: "agent-1", skillKey: " " }, + createContext() + ); + const globalToggleResult = planAgentSettingsMutation( + { kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: " " }, + createContext() + ); + const removeResult = planAgentSettingsMutation( + { kind: "remove-skill", agentId: "agent-1", skillKey: " " }, + createContext() + ); + + expect(installResult).toEqual({ + kind: "deny", + reason: "missing-skill-key", + message: null, + }); + expect(saveResult).toEqual({ + kind: "deny", + reason: "missing-skill-key", + message: null, + }); + expect(globalToggleResult).toEqual({ + kind: "deny", + reason: "missing-skill-key", + message: null, + }); + expect(removeResult).toEqual({ + kind: "deny", + reason: "missing-skill-key", + message: null, + }); + }); + + it("allows_setting_skills_allowlist_with_normalized_agent_id", () => { + const result = planAgentSettingsMutation( + { kind: "set-skills-allowlist", agentId: " agent-1 " }, + createContext() + ); + + expect(result).toEqual({ + kind: "allow", + normalizedAgentId: "agent-1", + }); + }); +}); diff --git a/tests/unit/agentSettingsPanel-header.test.ts b/tests/unit/agentSettingsPanel-header.test.ts new file mode 100644 index 00000000..af01381a --- /dev/null +++ b/tests/unit/agentSettingsPanel-header.test.ts @@ -0,0 +1,69 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Web Researcher", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("AgentSettingsPanel header", () => { + afterEach(() => { + cleanup(); + }); + + it("uses inspect header style with agent title", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByText("Capabilities")).not.toBeInTheDocument(); + expect(screen.getByText("Web Researcher")).toBeInTheDocument(); + expect(screen.getByLabelText("Close panel")).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/agentSettingsPanel.test.ts b/tests/unit/agentSettingsPanel.test.ts new file mode 100644 index 00000000..e718e4f5 --- /dev/null +++ b/tests/unit/agentSettingsPanel.test.ts @@ -0,0 +1,1166 @@ +import { createElement, useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels"; +import type { CronJobSummary } from "@/lib/cron/types"; +import type { SkillStatusReport } from "@/lib/skills/types"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +const createCronJob = (id: string): CronJobSummary => ({ + id, + name: `Job ${id}`, + agentId: "agent-1", + enabled: true, + updatedAtMs: Date.now(), + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hi" }, + state: {}, +}); + +const createSkillsReport = (): SkillStatusReport => ({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [ + { + name: "github", + description: "GitHub integration", + source: "openclaw-workspace", + bundled: false, + filePath: "/tmp/workspace/skills/github/SKILL.md", + baseDir: "/tmp/workspace/skills/github", + skillKey: "github", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + }, + { + name: "browser", + description: "Browser automation", + source: "openclaw-bundled", + bundled: true, + filePath: "/tmp/skills/browser/SKILL.md", + baseDir: "/tmp/skills/browser", + skillKey: "browser", + primaryEnv: "BROWSER_API_KEY", + always: false, + disabled: true, + blockedByAllowlist: true, + eligible: false, + requirements: { bins: ["playwright"], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: ["playwright"], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [ + { + id: "install-playwright", + kind: "node", + label: "Install playwright", + bins: ["playwright"], + }, + ], + }, + ], +}); + +const createSkillsReportWithOsIncompatibleBrowser = (): SkillStatusReport => { + const report = createSkillsReport(); + return { + ...report, + skills: report.skills.map((entry) => + entry.skillKey === "browser" + ? { + ...entry, + requirements: { ...entry.requirements, os: ["darwin"] }, + missing: { ...entry.missing, os: ["darwin"] }, + } + : entry + ), + }; +}; + +describe("AgentSettingsPanel", () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("does_not_render_name_editor_in_capabilities_mode", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument(); + }); + + it("renders_icon_close_button_with_accessible_label", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByLabelText("Close panel")).toBeInTheDocument(); + expect(screen.getByTestId("agent-settings-close")).toBeInTheDocument(); + }); + + it("does_not_render_show_tool_calls_and_show_thinking_toggles_in_advanced_mode", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "advanced", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByRole("switch", { name: "Show tool calls" })).not.toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: "Show thinking" })).not.toBeInTheDocument(); + }); + + it("renders_permissions_controls", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByText("Capabilities")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run commands off" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run commands ask" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run commands auto" })).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Web access" })).toHaveAttribute( + "aria-checked", + "false" + ); + expect(screen.getByRole("switch", { name: "File tools" })).toHaveAttribute( + "aria-checked", + "false" + ); + }); + + it("updates_switch_aria_state_when_toggled", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + const webSwitch = screen.getByRole("switch", { name: "Web access" }); + fireEvent.click(webSwitch); + expect(webSwitch).toHaveAttribute("aria-checked", "true"); + }); + + it("autosaves_updated_permissions_draft", async () => { + const onUpdateAgentPermissions = vi.fn(async () => {}); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + permissionsDraft: { + commandMode: "off", + webAccess: false, + fileTools: false, + }, + onUpdateAgentPermissions, + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Run commands auto" })); + fireEvent.click(screen.getByRole("switch", { name: "Web access" })); + fireEvent.click(screen.getByRole("switch", { name: "File tools" })); + + await waitFor( + () => { + expect(onUpdateAgentPermissions).toHaveBeenCalledWith({ + commandMode: "auto", + webAccess: true, + fileTools: true, + }); + }, + { timeout: 2000 } + ); + }); + + it("preserves_pending_permissions_toggles_during_props_refresh", () => { + const onUpdateAgentPermissions = vi.fn(async () => {}); + + const props = { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + onUpdateAgentPermissions, + }; + + const { rerender } = render( + createElement(AgentSettingsPanel, { + ...props, + permissionsDraft: { + commandMode: "off", + webAccess: false, + fileTools: false, + }, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Run commands auto" })); + fireEvent.click(screen.getByRole("switch", { name: "Web access" })); + fireEvent.click(screen.getByRole("switch", { name: "File tools" })); + + rerender( + createElement(AgentSettingsPanel, { + ...props, + permissionsDraft: { + commandMode: "auto", + webAccess: false, + fileTools: false, + }, + }) + ); + + expect(screen.getByRole("switch", { name: "Web access" })).toHaveAttribute( + "aria-checked", + "true" + ); + expect(screen.getByRole("switch", { name: "File tools" })).toHaveAttribute( + "aria-checked", + "true" + ); + }); + + it("does_not_render_runtime_settings_section", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByText("Runtime settings")).not.toBeInTheDocument(); + expect(screen.queryByText("Personality")).not.toBeInTheDocument(); + }); + + it("does_not_render_new_session_control_in_advanced_mode", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "advanced", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument(); + }); + + it("renders_skills_mode_and_opens_system_setup_for_non_ready_skills", () => { + const onOpenSystemSetup = vi.fn(); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + skillsAllowlist: ["github"], + onOpenSystemSetup, + }) + ); + + expect(screen.getByTestId("agent-settings-skills")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open System Setup" })); + expect(onOpenSystemSetup).toHaveBeenCalledWith("browser"); + }); + + it("shows_selected_mode_hint_when_allowlist_mode_is_active", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + skillsAllowlist: ["github"], + }) + ); + + expect(screen.getByText("This agent is using selected skills only.")).toBeInTheDocument(); + }); + + it("filters_skills_list_from_search_input", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + }) + ); + + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "browse" }, + }); + + expect(screen.getByText("browser")).toBeInTheDocument(); + expect(screen.queryByText("github")).not.toBeInTheDocument(); + }); + + it("toggles_skill_access_with_explicit_allowlist_state", () => { + const onSetSkillEnabled = vi.fn(); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + skillsAllowlist: ["github"], + onSetSkillEnabled, + }) + ); + + const githubToggle = screen.getByRole("switch", { name: "Skill github" }); + const browserToggle = screen.getByRole("switch", { name: "Skill browser" }); + expect(githubToggle).toHaveAttribute("aria-checked", "true"); + expect(browserToggle).toHaveAttribute("aria-checked", "false"); + + fireEvent.click(githubToggle); + fireEvent.click(browserToggle); + expect(onSetSkillEnabled).toHaveBeenNthCalledWith(1, "github", false); + expect(onSetSkillEnabled).toHaveBeenNthCalledWith(2, "browser", true); + }); + + it("runs_system_setup_actions_from_modal", () => { + const onInstallSkill = vi.fn(); + const onSetSkillGlobalEnabled = vi.fn(); + const onSkillApiKeyChange = vi.fn(); + const onSaveSkillApiKey = vi.fn(); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "system", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + skillApiKeyDrafts: { browser: "seed-key" }, + onInstallSkill, + onSetSkillGlobalEnabled, + onSkillApiKeyChange, + onSaveSkillApiKey, + }) + ); + + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "browse" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); + expect(screen.getByRole("dialog", { name: "Setup browser" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Install playwright" })); + fireEvent.click(screen.getByRole("button", { name: "Enable globally" })); + fireEvent.change(screen.getByLabelText("API key for browser"), { + target: { value: "test-key" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save BROWSER_API_KEY" })); + + expect(onInstallSkill).toHaveBeenCalledWith("browser", "browser", "install-playwright"); + expect(onSetSkillGlobalEnabled).toHaveBeenCalledWith("browser", true); + expect(onSkillApiKeyChange).toHaveBeenCalledWith("browser", "test-key"); + expect(onSaveSkillApiKey).toHaveBeenCalledWith("browser"); + }); + + it("keeps_system_setup_modal_open_until_user_closes_and_then_clears_handoff", async () => { + const onSystemInitialSkillHandled = vi.fn(); + const Harness = () => { + const [initialSkillKey, setInitialSkillKey] = useState<string | null>("browser"); + return createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "system", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + systemInitialSkillKey: initialSkillKey, + onSystemInitialSkillHandled: () => { + onSystemInitialSkillHandled(); + setInitialSkillKey(null); + }, + }); + }; + + render(createElement(Harness)); + + const dialog = screen.getByRole("dialog", { name: "Setup browser" }); + expect(onSystemInitialSkillHandled).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(onSystemInitialSkillHandled).toHaveBeenCalledTimes(1); + }); + expect(screen.queryByRole("dialog", { name: "Setup browser" })).not.toBeInTheDocument(); + }); + + it("prompts_before_removing_skill_files_and_confirms_action", () => { + const onRemoveSkill = vi.fn(); + vi.spyOn(window, "confirm").mockReturnValue(true); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "system", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + onRemoveSkill, + }) + ); + + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "git" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); + fireEvent.click(screen.getByRole("button", { name: "Remove skill from gateway" })); + + expect(onRemoveSkill).toHaveBeenCalledWith({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: "/tmp/workspace/skills/github", + }); + }); + + it("disables_api_key_save_when_input_is_blank", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "system", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + }) + ); + + fireEvent.change(screen.getByLabelText("Search skills"), { + target: { value: "browse" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Configure" })); + + expect(screen.getByRole("button", { name: "Save BROWSER_API_KEY" })).toBeDisabled(); + }); + + it("shows_enabled_count_based_on_visible_skills_not_raw_allowlist_size", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: createSkillsReport(), + skillsAllowlist: ["github", "missing-skill"], + }) + ); + + expect(screen.getByText("1/2")).toBeInTheDocument(); + }); + + it("shows_os_incompatible_skills_for_visibility_in_agent_view", () => { + const report = createSkillsReportWithOsIncompatibleBrowser(); + report.skills = report.skills.map((entry) => + entry.skillKey === "browser" + ? { ...entry, disabled: false, blockedByAllowlist: false } + : entry + ); + + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsReport: report, + skillsAllowlist: ["github", "browser"], + }) + ); + + expect(screen.getByRole("switch", { name: "Skill browser" })).toBeDisabled(); + expect(screen.getByText("2/2")).toBeInTheDocument(); + expect(screen.getByText("Not supported")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open System Setup" })).not.toBeInTheDocument(); + }); + + it("shows_skills_loading_and_error_states", () => { + const { rerender } = render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsLoading: true, + }) + ); + + expect(screen.getByText("Loading skills...")).toBeInTheDocument(); + + rerender( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "skills", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + skillsLoading: false, + skillsError: "boom", + }) + ); + + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("renders_automations_section_when_mode_is_automations", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [createCronJob("job-1")], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + const cronSection = screen.getByTestId("agent-settings-cron"); + expect(cronSection).toBeInTheDocument(); + expect(screen.queryByTestId("agent-settings-session")).not.toBeInTheDocument(); + }); + + it("invokes_run_now_and_disables_play_while_pending", () => { + const onRunCronJob = vi.fn(); + const cronJobs = [createCronJob("job-1")]; + const { rerender } = render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs, + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob, + onDeleteCronJob: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Run timed automation Job job-1 now" })); + expect(onRunCronJob).toHaveBeenCalledWith("job-1"); + + rerender( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs, + cronLoading: false, + cronError: null, + cronRunBusyJobId: "job-1", + cronDeleteBusyJobId: null, + onRunCronJob, + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByRole("button", { name: "Run timed automation Job job-1 now" })).toBeDisabled(); + }); + + it("invokes_delete_and_disables_trash_while_pending", () => { + const onDeleteCronJob = vi.fn(); + const cronJobs = [createCronJob("job-1")]; + const { rerender } = render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs, + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Delete timed automation Job job-1" })); + expect(onDeleteCronJob).toHaveBeenCalledWith("job-1"); + + rerender( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs, + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: "job-1", + onRunCronJob: vi.fn(), + onDeleteCronJob, + }) + ); + + expect(screen.getByRole("button", { name: "Delete timed automation Job job-1" })).toBeDisabled(); + }); + + it("shows_empty_cron_state_when_agent_has_no_jobs", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByText("No timed automations for this agent.")).toBeInTheDocument(); + expect(screen.getByTestId("cron-empty-icon")).toBeInTheDocument(); + }); + + it("shows_create_button_when_no_cron_jobs", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); + }); + + it("opens_cron_create_modal_from_empty_state_button", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument(); + }); + + it("updates_template_defaults_when_switching_templates", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + fireEvent.click(screen.getByRole("button", { name: "Weekly Review" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByLabelText("Automation name")).toHaveValue("Weekly review"); + + fireEvent.click(screen.getByRole("button", { name: "Back" })); + fireEvent.click(screen.getByRole("button", { name: "Morning Brief" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByLabelText("Automation name")).toHaveValue("Morning brief"); + }); + + it("submits_modal_with_agent_scoped_draft", async () => { + const onCreateCronJob = vi.fn(async () => {}); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + onCreateCronJob, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + fireEvent.click(screen.getByRole("button", { name: "Custom" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.change(screen.getByLabelText("Automation name"), { + target: { value: "Nightly sync" }, + }); + fireEvent.change(screen.getByLabelText("Task"), { + target: { value: "Sync project status and report blockers." }, + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Create automation" })); + + await waitFor(() => { + expect(onCreateCronJob).toHaveBeenCalledWith({ + templateId: "custom", + name: "Nightly sync", + taskText: "Sync project status and report blockers.", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + deliveryMode: "none", + deliveryChannel: "last", + }); + }); + }); + + it("hides_create_submit_before_review_step_and_disables_next_when_busy", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + cronCreateBusy: true, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + expect(screen.queryByRole("button", { name: "Create automation" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + }); + + it("keeps_modal_open_and_shows_error_when_create_fails", async () => { + const onCreateCronJob = vi.fn(async () => { + throw new Error("Gateway exploded"); + }); + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + onCreateCronJob, + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Create" })); + fireEvent.click(screen.getByRole("button", { name: "Custom" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.change(screen.getByLabelText("Automation name"), { + target: { value: "Nightly sync" }, + }); + fireEvent.change(screen.getByLabelText("Task"), { + target: { value: "Sync project status and report blockers." }, + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled(); + }); + fireEvent.click(screen.getByRole("button", { name: "Create automation" })); + + await waitFor(() => { + expect(screen.getByText("Gateway exploded")).toBeInTheDocument(); + }); + expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument(); + }); + + it("shows_heartbeat_coming_soon_in_automations_mode", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "automations", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [createCronJob("job-1")], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByTestId("agent-settings-heartbeat-coming-soon")).toBeInTheDocument(); + expect(screen.getByText("Heartbeat automation controls are coming soon.")).toBeInTheDocument(); + }); + + it("shows_control_ui_section_in_advanced_mode", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "advanced", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + }) + ); + + expect(screen.getByTestId("agent-settings-control-ui")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open Full Control UI" })).toBeDisabled(); + }); + + it("renders_enabled_control_ui_link_when_available", () => { + render( + createElement(AgentSettingsPanel, { + agent: createAgent(), + mode: "advanced", + onClose: vi.fn(), + onDelete: vi.fn(), + onToolCallingToggle: vi.fn(), + onThinkingTracesToggle: vi.fn(), + cronJobs: [], + cronLoading: false, + cronError: null, + cronRunBusyJobId: null, + cronDeleteBusyJobId: null, + onRunCronJob: vi.fn(), + onDeleteCronJob: vi.fn(), + controlUiUrl: "http://localhost:3000/control", + }) + ); + + const link = screen.getByRole("link", { name: "Open Full Control UI" }); + expect(link).toHaveAttribute("href", "http://localhost:3000/control"); + }); +}); diff --git a/tests/unit/agentStateExecutor.test.ts b/tests/unit/agentStateExecutor.test.ts new file mode 100644 index 00000000..2cb860c2 --- /dev/null +++ b/tests/unit/agentStateExecutor.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { runSshJson } from "@/lib/ssh/gateway-host"; +import { + restoreAgentStateOverSsh, + trashAgentStateOverSsh, +} from "@/lib/ssh/agent-state"; + +vi.mock("@/lib/ssh/gateway-host", () => ({ + runSshJson: vi.fn(), +})); + +describe("agent state ssh executor", () => { + const mockedRunSshJson = vi.mocked(runSshJson); + + beforeEach(() => { + mockedRunSshJson.mockReset(); + }); + + it("trashes agent state via ssh", () => { + mockedRunSshJson.mockReturnValueOnce({ trashDir: "/tmp/trash", moved: [] }); + + const result = trashAgentStateOverSsh({ sshTarget: "me@host", agentId: "my-agent" }); + + expect(result).toEqual({ trashDir: "/tmp/trash", moved: [] }); + expect(runSshJson).toHaveBeenCalledTimes(1); + expect(runSshJson).toHaveBeenCalledWith( + expect.objectContaining({ + sshTarget: "me@host", + argv: ["bash", "-s", "--", "my-agent"], + label: "trash agent state (my-agent)", + input: expect.stringContaining('python3 - "$1"'), + }) + ); + const call = mockedRunSshJson.mock.calls[0]?.[0]; + expect(call?.input).toContain("workspace-{agent_id}"); + }); + + it("restores agent state via ssh", () => { + mockedRunSshJson.mockReturnValueOnce({ restored: [] }); + + const result = restoreAgentStateOverSsh({ + sshTarget: "me@host", + agentId: "my-agent", + trashDir: "/tmp/trash", + }); + + expect(result).toEqual({ restored: [] }); + expect(runSshJson).toHaveBeenCalledTimes(1); + expect(runSshJson).toHaveBeenCalledWith( + expect.objectContaining({ + sshTarget: "me@host", + argv: ["bash", "-s", "--", "my-agent", "/tmp/trash"], + label: "restore agent state (my-agent)", + input: expect.stringContaining('python3 - "$1" "$2"'), + }) + ); + }); +}); diff --git a/tests/unit/agentStateLocal.test.ts b/tests/unit/agentStateLocal.test.ts new file mode 100644 index 00000000..25f02ae5 --- /dev/null +++ b/tests/unit/agentStateLocal.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local"; + +const mkTmpStateDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-test-")); + +describe("agent state local", () => { + const originalStateDir = process.env.OPENCLAW_STATE_DIR; + + afterEach(() => { + if (originalStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR; + else process.env.OPENCLAW_STATE_DIR = originalStateDir; + }); + + it("trashes and restores agent workspace + state", () => { + const stateDir = mkTmpStateDir(); + process.env.OPENCLAW_STATE_DIR = stateDir; + + const agentId = "test-agent"; + const workspace = path.join(stateDir, `workspace-${agentId}`); + const agentDir = path.join(stateDir, "agents", agentId); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(workspace, "hello.txt"), "hi", "utf8"); + fs.writeFileSync(path.join(agentDir, "state.json"), "{}", "utf8"); + + const trashed = trashAgentStateLocally({ agentId }); + expect(fs.existsSync(workspace)).toBe(false); + expect(fs.existsSync(agentDir)).toBe(false); + expect(fs.existsSync(trashed.trashDir)).toBe(true); + + const restored = restoreAgentStateLocally({ agentId, trashDir: trashed.trashDir }); + expect(restored.restored.length).toBeGreaterThan(0); + expect(fs.existsSync(workspace)).toBe(true); + expect(fs.existsSync(agentDir)).toBe(true); + expect(fs.readFileSync(path.join(workspace, "hello.txt"), "utf8")).toBe("hi"); + }); +}); + diff --git a/tests/unit/agentStateRoute.test.ts b/tests/unit/agentStateRoute.test.ts new file mode 100644 index 00000000..607110b9 --- /dev/null +++ b/tests/unit/agentStateRoute.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { POST, PUT } from "@/app/api/gateway/agent-state/route"; + +const ORIGINAL_ENV = { ...process.env }; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual<typeof import("node:child_process")>( + "node:child_process" + ); + return { + default: actual, + ...actual, + spawnSync: vi.fn(), + }; +}); + +const mockedSpawnSync = vi.mocked(spawnSync); +const mockedConsoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + +const writeStudioSettings = (gatewayUrl: string) => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + + const settingsDir = path.join(stateDir, "openclaw-studio"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: gatewayUrl, token: "token-123" }, + focused: {}, + }, + null, + 2 + ), + "utf8" + ); +}; + +describe("agent state route", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.OPENCLAW_GATEWAY_SSH_TARGET; + delete process.env.OPENCLAW_GATEWAY_SSH_USER; + delete process.env.OPENCLAW_STATE_DIR; + mockedSpawnSync.mockReset(); + mockedConsoleError.mockClear(); + }); + + it("rejects missing agentId", async () => { + const response = await POST( + new Request("http://localhost/api/gateway/agent-state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + expect(response.status).toBe(400); + }); + + it("rejects unsafe agentId", async () => { + const response = await POST( + new Request("http://localhost/api/gateway/agent-state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "../nope" }), + }) + ); + expect(response.status).toBe(400); + }); + + it("moves agent state via ssh", async () => { + writeStudioSettings("ws://example.test:18789"); + + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ trashDir: "/home/ubuntu/.openclaw/trash/x", moved: [] }), + stderr: "", + error: undefined, + } as never); + + const response = await POST( + new Request("http://localhost/api/gateway/agent-state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "my-agent" }), + }) + ); + + expect(response.status).toBe(200); + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + + const [cmd, args, options] = mockedSpawnSync.mock.calls[0] as [ + string, + string[], + { encoding?: string; input?: string } + ]; + expect(cmd).toBe("ssh"); + expect(args).toEqual( + expect.arrayContaining([ + "-o", + "BatchMode=yes", + "ubuntu@example.test", + "bash", + "-s", + "--", + "my-agent", + ]) + ); + expect(options.encoding).toBe("utf8"); + expect(options.input).toContain("python3 - \"$1\""); + expect(options.input).toContain("workspace-{agent_id}"); + }); + + it("restores agent state via ssh", async () => { + writeStudioSettings("ws://example.test:18789"); + + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ restored: [] }), + stderr: "", + error: undefined, + } as never); + + const response = await PUT( + new Request("http://localhost/api/gateway/agent-state", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "my-agent", trashDir: "/tmp/trash" }), + }) + ); + + expect(response.status).toBe(200); + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + + const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]]; + expect(cmd).toBe("ssh"); + expect(args).toEqual( + expect.arrayContaining([ + "-o", + "BatchMode=yes", + "ubuntu@example.test", + "bash", + "-s", + "--", + "my-agent", + "/tmp/trash", + ]) + ); + }); + + it("uses configured ssh target without studio settings", async () => { + process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test"; + + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ trashDir: "/home/ubuntu/.openclaw/trash/x", moved: [] }), + stderr: "", + error: undefined, + } as never); + + const response = await POST( + new Request("http://localhost/api/gateway/agent-state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "my-agent" }), + }) + ); + + expect(response.status).toBe(200); + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + + const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]]; + expect(cmd).toBe("ssh"); + expect(args).toEqual(expect.arrayContaining(["me@host.test"])); + }); +}); diff --git a/tests/unit/agentStore.test.ts b/tests/unit/agentStore.test.ts new file mode 100644 index 00000000..c3289fb1 --- /dev/null +++ b/tests/unit/agentStore.test.ts @@ -0,0 +1,432 @@ +import { describe, expect, it } from "vitest"; + +import { + agentStoreReducer, + buildNewSessionAgentPatch, + getFilteredAgents, + initialAgentStoreState, + type AgentStoreSeed, +} from "@/features/agents/state/store"; + +describe("agent store", () => { + it("hydrates agents with defaults and selection", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + const next = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + expect(next.loading).toBe(false); + expect(next.selectedAgentId).toBe("agent-1"); + expect(next.agents).toHaveLength(1); + expect(next.agents[0].status).toBe("idle"); + expect(next.agents[0].thinkingLevel).toBe("high"); + expect(next.agents[0].sessionCreated).toBe(false); + expect(next.agents[0].outputLines).toEqual([]); + }); + + it("hydrates agents with a requested selection when present", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + const next = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + selectedAgentId: " agent-2 ", + }); + expect(next.selectedAgentId).toBe("agent-2"); + }); + + it("keeps existing selection when requested selection is invalid", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "selectAgent", + agentId: "agent-2", + }); + state = agentStoreReducer(state, { + type: "hydrateAgents", + agents: seeds, + selectedAgentId: "missing-agent", + }); + expect(state.selectedAgentId).toBe("agent-2"); + }); + + it("builds a patch that resets runtime state for a session reset", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:old-session", + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + status: "running", + awaitingUserInput: true, + hasUnseenActivity: true, + outputLines: ["> hello", "response"], + lastResult: "response", + lastDiff: "diff", + runId: "run-1", + streamText: "live", + thinkingTrace: "thinking", + latestOverride: "override", + latestOverrideKind: "heartbeat", + lastAssistantMessageAt: 1700000000000, + lastActivityAt: 1700000000001, + latestPreview: "preview", + lastUserMessage: "hello", + draft: "draft", + historyLoadedAt: 1700000000002, + }, + }); + + const agent = state.agents.find((entry) => entry.agentId === "agent-1")!; + const patch = buildNewSessionAgentPatch(agent); + + expect(patch.sessionKey).toBe("agent:agent-1:studio:old-session"); + expect(patch.status).toBe("idle"); + expect(patch.sessionCreated).toBe(true); + expect(patch.sessionSettingsSynced).toBe(true); + expect(patch.outputLines).toEqual([]); + expect(patch.streamText).toBeNull(); + expect(patch.thinkingTrace).toBeNull(); + expect(patch.lastResult).toBeNull(); + expect(patch.lastDiff).toBeNull(); + expect(patch.historyLoadedAt).toBeNull(); + expect(patch.lastUserMessage).toBeNull(); + expect(patch.runId).toBeNull(); + expect(patch.runStartedAt).toBeNull(); + expect(patch.latestPreview).toBeNull(); + expect(patch.latestOverride).toBeNull(); + expect(patch.latestOverrideKind).toBeNull(); + expect(patch.lastAssistantMessageAt).toBeNull(); + expect(patch.awaitingUserInput).toBe(false); + expect(patch.hasUnseenActivity).toBe(false); + expect(patch.draft).toBe(""); + }); + + it("preserves_session_created_state_across_hydration", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { sessionCreated: true }, + }); + state = agentStoreReducer(state, { + type: "hydrateAgents", + agents: [seed], + }); + expect(state.agents[0]?.sessionCreated).toBe(true); + }); + + it("resets_runtime_state_when_session_key_changes_on_hydration", () => { + const initialSeed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:legacy", + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [initialSeed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + sessionCreated: true, + outputLines: ["> old"], + lastResult: "old result", + runId: "run-1", + }, + }); + + const nextSeed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + state = agentStoreReducer(state, { + type: "hydrateAgents", + agents: [nextSeed], + }); + const next = state.agents[0]; + expect(next?.sessionKey).toBe("agent:agent-1:main"); + expect(next?.sessionCreated).toBe(false); + expect(next?.outputLines).toEqual([]); + expect(next?.lastResult).toBeNull(); + expect(next?.runId).toBeNull(); + }); + + it("keeps_transcript_references_for_non_transcript_agent_updates", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { outputLines: ["> hello", "response"] }, + }); + + const beforeDraftUpdate = state.agents[0]; + + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { draft: "x" }, + }); + const afterDraftUpdate = state.agents[0]; + + expect(afterDraftUpdate.outputLines).toBe(beforeDraftUpdate.outputLines); + expect(afterDraftUpdate.transcriptEntries).toBe(beforeDraftUpdate.transcriptEntries); + expect(afterDraftUpdate.transcriptSequenceCounter).toBe(beforeDraftUpdate.transcriptSequenceCounter); + }); + + it("tracks_unseen_activity_for_non_selected_agents", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + const hydrated = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + const withActivity = agentStoreReducer(hydrated, { + type: "markActivity", + agentId: "agent-2", + at: 1700000000000, + }); + const second = withActivity.agents.find((agent) => agent.agentId === "agent-2"); + expect(second?.hasUnseenActivity).toBe(true); + expect(second?.lastActivityAt).toBe(1700000000000); + + const selected = agentStoreReducer(withActivity, { + type: "selectAgent", + agentId: "agent-2", + }); + const cleared = selected.agents.find((agent) => agent.agentId === "agent-2"); + expect(cleared?.hasUnseenActivity).toBe(false); + }); + + it("filters_agents_by_status_and_approvals", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + { + agentId: "agent-3", + name: "Agent Three", + sessionKey: "agent:agent-3:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { status: "idle", awaitingUserInput: true }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-2", + patch: { status: "running" }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-3", + patch: { status: "error" }, + }); + + expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([ + "agent-2", + "agent-1", + "agent-3", + ]); + expect(getFilteredAgents(state, "running").map((agent) => agent.agentId)).toEqual([ + "agent-2", + ]); + expect(getFilteredAgents(state, "approvals").map((agent) => agent.agentId)).toEqual([ + "agent-1", + ]); + }); + + it("clears_unseen_indicator_on_focus", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "markActivity", + agentId: "agent-2", + at: 1700000000100, + }); + + const before = state.agents.find((agent) => agent.agentId === "agent-2"); + expect(before?.hasUnseenActivity).toBe(true); + + state = agentStoreReducer(state, { + type: "selectAgent", + agentId: "agent-2", + }); + const after = state.agents.find((agent) => agent.agentId === "agent-2"); + expect(after?.hasUnseenActivity).toBe(false); + }); + + it("sorts_filtered_agents_by_latest_assistant_message", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + { + agentId: "agent-3", + name: "Agent Three", + sessionKey: "agent:agent-3:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { status: "running", lastAssistantMessageAt: 200 }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-2", + patch: { status: "running", lastAssistantMessageAt: 500 }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-3", + patch: { status: "running", lastAssistantMessageAt: 300 }, + }); + + expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([ + "agent-2", + "agent-3", + "agent-1", + ]); + expect(getFilteredAgents(state, "running").map((agent) => agent.agentId)).toEqual([ + "agent-2", + "agent-3", + "agent-1", + ]); + }); + + it("prioritizes_running_agents_in_all_filter_even_without_assistant_reply", () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: seeds, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { status: "idle", lastAssistantMessageAt: 900 }, + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-2", + patch: { status: "running", runStartedAt: 1000, lastAssistantMessageAt: null }, + }); + + expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([ + "agent-2", + "agent-1", + ]); + }); +}); diff --git a/tests/unit/canvasTransform.test.ts b/tests/unit/canvasTransform.test.ts deleted file mode 100644 index 94ab04d6..00000000 --- a/tests/unit/canvasTransform.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - clampZoom, - screenToWorld, - worldToScreen, - zoomAtScreenPoint, -} from "@/features/canvas/lib/transform"; - -describe("canvas transform math", () => { - it("round-trips screen/world coordinates", () => { - const transform = { zoom: 1.5, offsetX: 120, offsetY: -80 }; - const world = { x: 300, y: -200 }; - - const screen = worldToScreen(transform, world); - const roundTrip = screenToWorld(transform, screen); - - expect(roundTrip.x).toBeCloseTo(world.x, 6); - expect(roundTrip.y).toBeCloseTo(world.y, 6); - }); - - it("keeps the world point under the cursor pinned during zoom", () => { - const transform = { zoom: 1, offsetX: 50, offsetY: 25 }; - const screenPoint = { x: 200, y: 150 }; - const worldPoint = screenToWorld(transform, screenPoint); - - const next = zoomAtScreenPoint(transform, 2, screenPoint); - const nextScreenPoint = worldToScreen(next, worldPoint); - - expect(nextScreenPoint.x).toBeCloseTo(screenPoint.x, 6); - expect(nextScreenPoint.y).toBeCloseTo(screenPoint.y, 6); - }); - - it("clamps zoom within the allowed range", () => { - expect(clampZoom(0.1)).toBeCloseTo(0.25, 6); - expect(clampZoom(5)).toBeCloseTo(3, 6); - expect(clampZoom(1.2)).toBeCloseTo(1.2, 6); - }); -}); diff --git a/tests/unit/canvasZoomToFit.test.ts b/tests/unit/canvasZoomToFit.test.ts deleted file mode 100644 index f7ebb3c7..00000000 --- a/tests/unit/canvasZoomToFit.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { worldToScreen, zoomToFit } from "@/features/canvas/lib/transform"; -import type { AgentTile } from "@/features/canvas/state/store"; - -type Bounds = { minX: number; minY: number; maxX: number; maxY: number }; - -const makeTile = (id: string, position: { x: number; y: number }, size: { width: number; height: number }): AgentTile => ({ - id, - name: `Tile ${id}`, - agentId: `agent-${id}`, - role: "coding", - sessionKey: `agent:${id}:main`, - model: null, - thinkingLevel: "low", - position, - size, - status: "idle", - outputLines: [], - lastResult: null, - lastDiff: null, - runId: null, - streamText: null, - thinkingTrace: null, - draft: "", - sessionSettingsSynced: false, -}); - -const getBounds = (tiles: AgentTile[]): Bounds => { - const minX = Math.min(...tiles.map((tile) => tile.position.x)); - const minY = Math.min(...tiles.map((tile) => tile.position.y)); - const maxX = Math.max( - ...tiles.map((tile) => tile.position.x + tile.size.width) - ); - const maxY = Math.max( - ...tiles.map((tile) => tile.position.y + tile.size.height) - ); - return { minX, minY, maxX, maxY }; -}; - -describe("zoomToFit", () => { - it("fits tile bounds within the viewport with padding", () => { - const tiles = [ - makeTile("1", { x: 120, y: 80 }, { width: 400, height: 300 }), - makeTile("2", { x: 700, y: 500 }, { width: 240, height: 200 }), - ]; - const viewportSize = { width: 1200, height: 800 }; - const padding = 60; - const currentTransform = { zoom: 1, offsetX: 0, offsetY: 0 }; - - const transform = zoomToFit(tiles, viewportSize, padding, currentTransform); - const bounds = getBounds(tiles); - const topLeft = worldToScreen(transform, { x: bounds.minX, y: bounds.minY }); - const bottomRight = worldToScreen(transform, { - x: bounds.maxX, - y: bounds.maxY, - }); - - expect(topLeft.x).toBeGreaterThanOrEqual(padding - 0.5); - expect(topLeft.y).toBeGreaterThanOrEqual(padding - 0.5); - expect(bottomRight.x).toBeLessThanOrEqual(viewportSize.width - padding + 0.5); - expect(bottomRight.y).toBeLessThanOrEqual(viewportSize.height - padding + 0.5); - }); - - it("returns the current transform when there are no tiles", () => { - const currentTransform = { zoom: 1.2, offsetX: 40, offsetY: -20 }; - const viewportSize = { width: 900, height: 700 }; - - expect(zoomToFit([], viewportSize, 40, currentTransform)).toEqual(currentTransform); - }); -}); diff --git a/tests/unit/chatInteractionWorkflow.test.ts b/tests/unit/chatInteractionWorkflow.test.ts new file mode 100644 index 00000000..b6dc3786 --- /dev/null +++ b/tests/unit/chatInteractionWorkflow.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; + +import { + planDraftFlushIntent, + planDraftTimerIntent, + planNewSessionIntent, + planStopRunIntent, +} from "@/features/agents/operations/chatInteractionWorkflow"; + +describe("chatInteractionWorkflow", () => { + it("denies stop-run when gateway is disconnected", () => { + const intent = planStopRunIntent({ + status: "disconnected", + agentId: "agent-1", + sessionKey: "session-1", + busyAgentId: null, + }); + + expect(intent).toEqual({ + kind: "deny", + reason: "not-connected", + message: "Connect to gateway before stopping a run.", + }); + }); + + it("denies stop-run when session key is missing", () => { + const intent = planStopRunIntent({ + status: "connected", + agentId: "agent-1", + sessionKey: " ", + busyAgentId: null, + }); + + expect(intent).toEqual({ + kind: "deny", + reason: "missing-session-key", + message: "Missing session key for agent.", + }); + }); + + it("skips duplicate stop-run requests while same agent is busy", () => { + const intent = planStopRunIntent({ + status: "connected", + agentId: "agent-1", + sessionKey: "session-1", + busyAgentId: "agent-1", + }); + + expect(intent).toEqual({ + kind: "skip-busy", + }); + }); + + it("allows stop-run with a connected gateway and normalized session key", () => { + const intent = planStopRunIntent({ + status: "connected", + agentId: "agent-1", + sessionKey: " session-1 ", + busyAgentId: "agent-2", + }); + + expect(intent).toEqual({ + kind: "allow", + sessionKey: "session-1", + }); + }); + + it("denies new-session when the agent cannot be found", () => { + const intent = planNewSessionIntent({ + hasAgent: false, + sessionKey: "session-1", + }); + + expect(intent).toEqual({ + kind: "deny", + reason: "missing-agent", + message: "Failed to start new session: agent not found.", + }); + }); + + it("denies new-session when session key is missing", () => { + const intent = planNewSessionIntent({ + hasAgent: true, + sessionKey: "", + }); + + expect(intent).toEqual({ + kind: "deny", + reason: "missing-session-key", + message: "Missing session key for agent.", + }); + }); + + it("allows new-session when agent exists and session key is present", () => { + const intent = planNewSessionIntent({ + hasAgent: true, + sessionKey: " session-1 ", + }); + + expect(intent).toEqual({ + kind: "allow", + sessionKey: "session-1", + }); + }); + + it("skips draft flush when agent id is missing", () => { + const intent = planDraftFlushIntent({ + agentId: null, + hasPendingValue: true, + }); + + expect(intent).toEqual({ + kind: "skip", + reason: "missing-agent-id", + }); + }); + + it("skips draft flush when there is no pending draft value", () => { + const intent = planDraftFlushIntent({ + agentId: "agent-1", + hasPendingValue: false, + }); + + expect(intent).toEqual({ + kind: "skip", + reason: "missing-pending-value", + }); + }); + + it("flushes draft when an agent id and pending value are present", () => { + const intent = planDraftFlushIntent({ + agentId: "agent-1", + hasPendingValue: true, + }); + + expect(intent).toEqual({ + kind: "flush", + agentId: "agent-1", + }); + }); + + it("schedules draft timer with default debounce", () => { + const intent = planDraftTimerIntent({ + agentId: "agent-1", + }); + + expect(intent).toEqual({ + kind: "schedule", + agentId: "agent-1", + delayMs: 250, + }); + }); + + it("allows overriding draft timer delay", () => { + const intent = planDraftTimerIntent({ + agentId: "agent-1", + delayMs: 500, + }); + + expect(intent).toEqual({ + kind: "schedule", + agentId: "agent-1", + delayMs: 500, + }); + }); + + it("skips draft timer scheduling when agent id is missing", () => { + const intent = planDraftTimerIntent({ + agentId: "", + delayMs: 250, + }); + + expect(intent).toEqual({ + kind: "skip", + reason: "missing-agent-id", + }); + }); +}); diff --git a/tests/unit/chatItems.test.ts b/tests/unit/chatItems.test.ts new file mode 100644 index 00000000..b3405339 --- /dev/null +++ b/tests/unit/chatItems.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentChatItems, + buildAgentChatRenderBlocks, + buildFinalAgentChatItems, + summarizeToolLabel, +} from "@/features/agents/components/chatItems"; +import { formatMetaMarkdown, formatThinkingMarkdown, formatToolCallMarkdown, formatToolResultMarkdown } from "@/lib/text/message-extract"; + +describe("buildAgentChatItems", () => { + it("keeps thinking traces aligned with each assistant turn", () => { + const items = buildAgentChatItems({ + outputLines: [ + "> first question", + formatThinkingMarkdown("first plan"), + "first answer", + "> second question", + formatThinkingMarkdown("second plan"), + "second answer", + ], + streamText: null, + liveThinkingTrace: "", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual([ + "user", + "thinking", + "assistant", + "user", + "thinking", + "assistant", + ]); + expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_" }); + expect(items[4]).toMatchObject({ kind: "thinking", text: "_second plan_" }); + }); + + it("does not include saved traces when thinking traces are disabled", () => { + const items = buildAgentChatItems({ + outputLines: [ + "> first question", + formatThinkingMarkdown("first plan"), + "first answer", + ], + streamText: null, + liveThinkingTrace: "live plan", + showThinkingTraces: false, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["user", "assistant"]); + }); + + it("adds a live trace before the live assistant stream", () => { + const items = buildAgentChatItems({ + outputLines: ["first answer"], + streamText: "stream answer", + liveThinkingTrace: "first plan", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["assistant", "thinking", "assistant"]); + expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_", live: true }); + }); + + it("merges adjacent thinking traces into a single item", () => { + const items = buildAgentChatItems({ + outputLines: [formatThinkingMarkdown("first plan"), formatThinkingMarkdown("second plan"), "answer"], + streamText: null, + liveThinkingTrace: "", + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["thinking", "assistant"]); + expect(items[0]).toMatchObject({ + kind: "thinking", + text: "_first plan_\n\n_second plan_", + }); + }); +}); + +describe("buildFinalAgentChatItems", () => { + it("does not include live thinking or live assistant items", () => { + const items = buildFinalAgentChatItems({ + outputLines: ["> question", formatThinkingMarkdown("plan"), "answer"], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items.map((item) => item.kind)).toEqual(["user", "thinking", "assistant"]); + }); + + it("propagates meta timestamps and thinking duration into subsequent items", () => { + const items = buildFinalAgentChatItems({ + outputLines: [ + formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), + "> hello", + formatMetaMarkdown({ role: "assistant", timestamp: 1700000001234, thinkingDurationMs: 1800 }), + formatThinkingMarkdown("plan"), + "answer", + ], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items[0]).toMatchObject({ kind: "user", text: "hello", timestampMs: 1700000000000 }); + expect(items[1]).toMatchObject({ + kind: "thinking", + text: "_plan_", + timestampMs: 1700000001234, + thinkingDurationMs: 1800, + }); + expect(items[2]).toMatchObject({ + kind: "assistant", + text: "answer", + timestampMs: 1700000001234, + thinkingDurationMs: 1800, + }); + }); + + it("collapses adjacent duplicate user items when optimistic and persisted turns match", () => { + const items = buildFinalAgentChatItems({ + outputLines: [ + "> hello\n\nworld", + formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), + "> hello world", + ], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items).toEqual([ + { + kind: "user", + text: "hello world", + timestampMs: 1700000000000, + }, + ]); + }); + + it("does_not_collapse_repeated_user_message_when_second_turn_is_only_optimistic", () => { + const items = buildFinalAgentChatItems({ + outputLines: [ + formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }), + "> repeat", + "> repeat", + ], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items).toEqual([ + { + kind: "user", + text: "repeat", + timestampMs: 1700000000000, + }, + { + kind: "user", + text: "repeat", + }, + ]); + }); + + it("keeps assistant markdown as assistant content", () => { + const items = buildFinalAgentChatItems({ + outputLines: ["- first item\n- second item"], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ kind: "assistant" }); + expect(items[0]?.text).toContain("- first item"); + }); + + it("classifies tool markdown as tool items when tool calling is enabled", () => { + const callLine = formatToolCallMarkdown({ + id: "call_123", + name: "exec", + arguments: { command: "pwd" }, + }); + const toolLine = formatToolResultMarkdown({ + toolCallId: "call_123", + toolName: "exec", + details: { status: "completed", exitCode: 0 }, + text: "pwd", + isError: false, + }); + const items = buildFinalAgentChatItems({ + outputLines: [callLine, toolLine], + showThinkingTraces: true, + toolCallingEnabled: true, + }); + + expect(items).toEqual([ + { + kind: "tool", + text: callLine, + }, + { + kind: "tool", + text: toolLine, + }, + ]); + }); + + it("hides tool results when tool calling is disabled", () => { + const toolLine = formatToolResultMarkdown({ + toolCallId: "call_456", + toolName: "exec", + details: { status: "completed", exitCode: 0 }, + text: "pwd", + isError: false, + }); + const items = buildFinalAgentChatItems({ + outputLines: [toolLine], + showThinkingTraces: true, + toolCallingEnabled: false, + }); + + expect(items).toEqual([]); + }); +}); + +describe("summarizeToolLabel", () => { + it("hides long tool call ids and prefers showing the command/path/url value", () => { + const toolCallLine = formatToolCallMarkdown({ + id: "call_ABC123|fc_456", + name: "functions.exec", + arguments: { command: "gh auth status" }, + }); + + const { summaryText: callSummary } = summarizeToolLabel(toolCallLine); + expect(callSummary).toContain("gh auth status"); + expect(callSummary).not.toContain("call_"); + + const toolResultLine = formatToolResultMarkdown({ + toolCallId: "call_ABC123|fc_456", + toolName: "functions.exec", + details: { status: "completed", exitCode: 0, durationMs: 168 }, + isError: false, + text: "ok", + }); + + const { summaryText: resultSummary } = summarizeToolLabel(toolResultLine); + expect(resultSummary).toContain("completed"); + expect(resultSummary).toContain("exit 0"); + expect(resultSummary).not.toContain("call_"); + }); + + it("renders read file calls as inline path labels without JSON body", () => { + const toolCallLine = formatToolCallMarkdown({ + id: "call_read_1", + name: "read", + arguments: { file_path: "/Users/georgepickett/openclaw/shared/openclaw-agent-home/README.md" }, + }); + + const summary = summarizeToolLabel(toolCallLine); + expect(summary.summaryText).toBe( + "read /Users/georgepickett/openclaw/shared/openclaw-agent-home/README.md" + ); + expect(summary.inlineOnly).toBe(true); + expect(summary.body).toBe(""); + }); +}); + +describe("buildAgentChatRenderBlocks", () => { + it("groups thinking and tool events into one assistant block in original order", () => { + const toolCallLine = formatToolCallMarkdown({ + id: "call_1", + name: "exec", + arguments: { command: "pwd" }, + }); + const toolResultLine = formatToolResultMarkdown({ + toolCallId: "call_1", + toolName: "exec", + details: { status: "completed", exitCode: 0 }, + text: "/repo", + isError: false, + }); + + const blocks = buildAgentChatRenderBlocks([ + { kind: "thinking", text: "_plan before tool_", timestampMs: 100 }, + { kind: "tool", text: toolCallLine, timestampMs: 101 }, + { kind: "thinking", text: "_plan after tool_", timestampMs: 102 }, + { kind: "tool", text: toolResultLine, timestampMs: 103 }, + { kind: "assistant", text: "done", timestampMs: 104 }, + ]); + + expect(blocks).toEqual([ + { + kind: "assistant", + text: "done", + timestampMs: 100, + traceEvents: [ + { kind: "thinking", text: "_plan before tool_" }, + { kind: "tool", text: toolCallLine }, + { kind: "thinking", text: "_plan after tool_" }, + { kind: "tool", text: toolResultLine }, + ], + }, + ]); + }); + + it("starts a new assistant block after a user turn", () => { + const blocks = buildAgentChatRenderBlocks([ + { kind: "thinking", text: "_first plan_", timestampMs: 10 }, + { kind: "assistant", text: "first answer", timestampMs: 11 }, + { kind: "user", text: "next question", timestampMs: 12 }, + { kind: "thinking", text: "_second plan_", timestampMs: 13 }, + { kind: "assistant", text: "second answer", timestampMs: 14 }, + ]); + + expect(blocks.map((block) => block.kind)).toEqual(["assistant", "user", "assistant"]); + }); + + it("merges adjacent incremental thinking updates", () => { + const blocks = buildAgentChatRenderBlocks([ + { kind: "thinking", text: "_a_", timestampMs: 10 }, + { kind: "thinking", text: "_a_\n\n_b_", timestampMs: 10 }, + { kind: "assistant", text: "answer", timestampMs: 10 }, + ]); + + expect(blocks).toEqual([ + { + kind: "assistant", + text: "answer", + timestampMs: 10, + traceEvents: [{ kind: "thinking", text: "_a_\n\n_b_" }], + }, + ]); + }); +}); diff --git a/tests/unit/chatSendOperation.test.ts b/tests/unit/chatSendOperation.test.ts new file mode 100644 index 00000000..52ff2a0e --- /dev/null +++ b/tests/unit/chatSendOperation.test.ts @@ -0,0 +1,665 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; +import { GatewayResponseError } from "@/lib/gateway/errors"; +import { formatMetaMarkdown } from "@/lib/text/message-extract"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: false, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + const merged = { ...base, ...(overrides ?? {}) }; + + return { + ...merged, + historyFetchLimit: merged.historyFetchLimit ?? null, + historyFetchedCount: merged.historyFetchedCount ?? null, + historyMaybeTruncated: merged.historyMaybeTruncated ?? false, + }; +}; + +const createWebchatBlockedPatchError = () => + new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates", + }); + +describe("sendChatMessageViaStudio", () => { + it("handles_reset_command", async () => { + const agent = createAgent({ + outputLines: ["old"], + streamText: "stream", + thinkingTrace: "thinking", + lastResult: "result", + sessionSettingsSynced: true, + }); + + const dispatch = vi.fn(); + const call = vi.fn(async () => ({})); + const clearRunTracking = vi.fn(); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "/reset", + clearRunTracking, + now: () => 1234, + generateRunId: () => "run-1", + }); + + expect(clearRunTracking).toHaveBeenCalledWith("run-1"); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateAgent", + agentId: agent.agentId, + patch: expect.objectContaining({ + outputLines: [], + streamText: null, + thinkingTrace: null, + lastResult: null, + transcriptEntries: [], + }), + }) + ); + }); + + it("syncs_session_settings_when_not_synced", async () => { + const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false }); + const dispatch = vi.fn(); + const call = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + return { + ok: true, + key: agent.sessionKey, + entry: { thinkingLevel: "medium" }, + resolved: { modelProvider: "openai", model: "gpt-5" }, + }; + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const methods = call.mock.calls.map((entry) => entry[0]); + expect(methods).toEqual(["sessions.patch", "chat.send"]); + expect(call).toHaveBeenCalledWith( + "sessions.patch", + expect.objectContaining({ + key: agent.sessionKey, + model: "openai/gpt-5", + thinkingLevel: "medium", + }) + ); + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: agent.agentId, + patch: { sessionSettingsSynced: true, sessionCreated: true }, + }); + }); + + it("continues_send_when_webchat_patch_is_blocked", async () => { + const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false }); + const dispatch = vi.fn(); + const call = vi.fn(async (method: string, payload?: unknown) => { + if (method === "sessions.patch") { + throw createWebchatBlockedPatchError(); + } + if (method === "chat.send") { + const runId = + payload && + typeof payload === "object" && + "idempotencyKey" in payload && + typeof payload.idempotencyKey === "string" + ? payload.idempotencyKey + : "run"; + return { runId, status: "started" }; + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const methods = call.mock.calls.map((entry) => entry[0]); + expect(methods).toEqual(["sessions.patch", "chat.send"]); + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: agent.agentId, + patch: { sessionSettingsSynced: true, sessionCreated: true }, + }); + + const errorLines = dispatch.mock.calls + .map((entry) => entry[0]) + .filter( + ( + action + ): action is { + type: "appendOutput"; + line: string; + } => + action && + typeof action === "object" && + "type" in action && + action.type === "appendOutput" && + "line" in action && + typeof action.line === "string" && + action.line.startsWith("Error:") + ) + .map((action) => action.line); + expect(errorLines).toEqual([]); + }); + + it("fails_send_when_patch_error_is_not_webchat_blocked", async () => { + const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false }); + const dispatch = vi.fn(); + const call = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "invalid model ref", + }); + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const methods = call.mock.calls.map((entry) => entry[0]); + expect(methods).toEqual(["sessions.patch"]); + expect(dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: agent.agentId, + line: "Error: invalid model ref", + }); + }); + + it("suppresses_patch_retry_after_webchat_blocked_patch_error", async () => { + let agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false }); + const dispatch = vi.fn( + (action: { type: string; agentId?: string; patch?: Partial<AgentState> }) => { + if (action.type !== "updateAgent" || action.agentId !== agent.agentId || !action.patch) { + return; + } + agent = { ...agent, ...action.patch }; + } + ); + const call = vi.fn(async (method: string, payload?: unknown) => { + if (method === "sessions.patch") { + throw createWebchatBlockedPatchError(); + } + if (method === "chat.send") { + const runId = + payload && + typeof payload === "object" && + "idempotencyKey" in payload && + typeof payload.idempotencyKey === "string" + ? payload.idempotencyKey + : "run"; + return { runId, status: "started" }; + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "first", + now: () => 1234, + generateRunId: () => "run-1", + }); + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "second", + now: () => 1240, + generateRunId: () => "run-2", + }); + + const methods = call.mock.calls.map((entry) => entry[0]); + expect(methods.filter((method) => method === "sessions.patch")).toHaveLength(1); + expect(methods.filter((method) => method === "chat.send")).toHaveLength(2); + expect(agent.sessionSettingsSynced).toBe(true); + expect(agent.sessionCreated).toBe(true); + }); + + it("syncs exec session overrides for ask-first agents", async () => { + const agent = createAgent({ + sessionSettingsSynced: false, + sessionCreated: false, + sessionExecHost: "gateway", + sessionExecSecurity: "allowlist", + sessionExecAsk: "always", + }); + const dispatch = vi.fn(); + const call = vi.fn(async (method: string) => { + if (method === "sessions.patch") { + return { + ok: true, + key: agent.sessionKey, + entry: { thinkingLevel: "medium" }, + resolved: { modelProvider: "openai", model: "gpt-5" }, + }; + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + expect(call).toHaveBeenCalledWith( + "sessions.patch", + expect.objectContaining({ + key: agent.sessionKey, + execHost: "gateway", + execSecurity: "allowlist", + execAsk: "always", + }) + ); + }); + + it("does_not_sync_session_settings_when_already_synced", async () => { + const agent = createAgent({ sessionSettingsSynced: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ runId: "run-1", status: "started" })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + expect(call).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ sessionKey: agent.sessionKey }) + ); + expect(call).not.toHaveBeenCalledWith( + "sessions.patch", + expect.anything() + ); + }); + + it("clears_running_state_for_unknown_success_payload_shape", async () => { + const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const idlePatchAction = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action?.type === "updateAgent" && + action?.agentId === agent.agentId && + action?.patch?.status === "idle" && + action?.patch?.runId === null + ); + expect(idlePatchAction).toBeTruthy(); + }); + + it("clears_running_state_for_stop_style_immediate_success_payload", async () => { + const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true, aborted: false, runIds: [] })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "stop please", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const idlePatchAction = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action?.type === "updateAgent" && + action?.agentId === agent.agentId && + action?.patch?.status === "idle" && + action?.patch?.runId === null && + action?.patch?.runStartedAt === null && + action?.patch?.streamText === null && + action?.patch?.thinkingTrace === null + ); + expect(idlePatchAction).toBeTruthy(); + }); + + it("keeps_running_state_for_matching_streaming_status_payloads", async () => { + const payloads = [{ runId: "run-1", status: "started" }, { runId: "run-1", status: "in_flight" }]; + + for (const payload of payloads) { + const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => payload); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const idlePatchAction = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action?.type === "updateAgent" && + action?.agentId === agent.agentId && + action?.patch?.status === "idle" + ); + expect(idlePatchAction).toBeUndefined(); + } + }); + + it("clears_running_state_for_streaming_shape_with_mismatched_run_id", async () => { + const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ runId: "different-run", status: "started" })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const idlePatchAction = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action?.type === "updateAgent" && + action?.agentId === agent.agentId && + action?.patch?.status === "idle" && + action?.patch?.runId === null + ); + expect(idlePatchAction).toBeTruthy(); + }); + + it("supports_internal_send_without_local_user_echo", async () => { + const agent = createAgent({ sessionSettingsSynced: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "internal follow-up", + echoUserMessage: false, + now: () => 1234, + generateRunId: () => "run-1", + }); + + const dispatchedActions = dispatch.mock.calls.map((entry) => entry[0]); + expect( + dispatchedActions.some( + (action) => action.type === "appendOutput" && action.line === "> internal follow-up" + ) + ).toBe(false); + const runningUpdate = dispatchedActions.find( + (action) => action.type === "updateAgent" && action.patch?.status === "running" + ); + expect(runningUpdate).toBeTruthy(); + if (runningUpdate && runningUpdate.type === "updateAgent") { + expect(runningUpdate.patch.lastUserMessage).toBeUndefined(); + } + }); + + it("marks_error_on_gateway_failure", async () => { + const agent = createAgent({ sessionSettingsSynced: true }); + const dispatch = vi.fn(); + const call = vi.fn(async (method: string) => { + if (method === "chat.send") { + throw new Error("boom"); + } + return { ok: true }; + }); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "hello", + now: () => 1234, + generateRunId: () => "run-1", + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: agent.agentId, + patch: { status: "error", runId: null, runStartedAt: null, streamText: null, thinkingTrace: null }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: agent.agentId, + line: "Error: boom", + }); + }); + + it("optimistically_appends_only_user_content_line", async () => { + const agent = createAgent({ sessionSettingsSynced: true }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "Hello world", + now: () => 1234, + generateRunId: () => "run-1", + }); + + const appendLines = dispatch.mock.calls + .map((entry) => entry[0]) + .filter((action): action is { type: "appendOutput"; line: string } => { + return Boolean( + action && + typeof action === "object" && + "type" in action && + action.type === "appendOutput" && + "line" in action && + typeof action.line === "string" + ); + }) + .map((action) => action.line); + + expect(appendLines).toContain("> Hello world"); + expect(appendLines.some((line) => line.startsWith("[[meta]]"))).toBe(false); + }); + + it("uses_monotonic_timestamp_for_optimistic_user_turn_ordering", async () => { + const sessionKey = "agent:agent-1:studio:test-session"; + const agent = createAgent({ + sessionSettingsSynced: true, + transcriptEntries: [ + { + entryId: "history:assistant:1", + role: "assistant", + kind: "assistant", + text: "previous assistant", + sessionKey, + runId: null, + source: "history", + timestampMs: 5000, + sequenceKey: 10, + confirmed: true, + fingerprint: "fp-prev-assistant", + }, + ], + }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey: agent.sessionKey, + message: "new message", + now: () => 1000, + generateRunId: () => "run-1", + }); + + const optimisticUserAppend = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action && + typeof action === "object" && + "type" in action && + action.type === "appendOutput" && + "line" in action && + action.line === "> new message" + ); + expect(optimisticUserAppend).toBeTruthy(); + expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(5001); + }); + + it("uses_output_meta_timestamps_when_transcript_entries_are_missing", async () => { + const sessionKey = "agent:agent-1:studio:test-session"; + const agent = createAgent({ + sessionSettingsSynced: true, + transcriptEntries: undefined, + outputLines: [ + formatMetaMarkdown({ role: "assistant", timestamp: 12_000 }), + "previous assistant", + ], + }); + const dispatch = vi.fn(); + const call = vi.fn(async () => ({ ok: true })); + + await sendChatMessageViaStudio({ + client: { call }, + dispatch, + getAgent: () => agent, + agentId: agent.agentId, + sessionKey, + message: "new message", + now: () => 1000, + generateRunId: () => "run-1", + }); + + const optimisticUserAppend = dispatch.mock.calls + .map((entry) => entry[0]) + .find( + (action) => + action && + typeof action === "object" && + "type" in action && + action.type === "appendOutput" && + "line" in action && + action.line === "> new message" + ); + expect(optimisticUserAppend).toBeTruthy(); + expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(12_001); + }); +}); diff --git a/tests/unit/colorSemantics.test.ts b/tests/unit/colorSemantics.test.ts new file mode 100644 index 00000000..b477e2ed --- /dev/null +++ b/tests/unit/colorSemantics.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_STATUS_BADGE_CLASS, + AGENT_STATUS_LABEL, + GATEWAY_STATUS_BADGE_CLASS, + GATEWAY_STATUS_LABEL, + NEEDS_APPROVAL_BADGE_CLASS, + resolveAgentStatusBadgeClass, + resolveAgentStatusLabel, + resolveGatewayStatusBadgeClass, + resolveGatewayStatusLabel, +} from "@/features/agents/components/colorSemantics"; + +describe("colorSemantics", () => { + it("maps agent statuses to semantic badge classes and labels", () => { + expect(AGENT_STATUS_LABEL.idle).toBe("Idle"); + expect(AGENT_STATUS_LABEL.running).toBe("Running"); + expect(AGENT_STATUS_LABEL.error).toBe("Error"); + + expect(AGENT_STATUS_BADGE_CLASS.idle).toBe("ui-badge-status-idle"); + expect(AGENT_STATUS_BADGE_CLASS.running).toBe("ui-badge-status-running"); + expect(AGENT_STATUS_BADGE_CLASS.error).toBe("ui-badge-status-error"); + + expect(resolveAgentStatusLabel("idle")).toBe("Idle"); + expect(resolveAgentStatusBadgeClass("running")).toBe("ui-badge-status-running"); + }); + + it("maps gateway statuses to semantic badge classes and labels", () => { + expect(GATEWAY_STATUS_LABEL.disconnected).toBe("Disconnected"); + expect(GATEWAY_STATUS_LABEL.connecting).toBe("Connecting"); + expect(GATEWAY_STATUS_LABEL.connected).toBe("Connected"); + + expect(GATEWAY_STATUS_BADGE_CLASS.disconnected).toBe("ui-badge-status-disconnected"); + expect(GATEWAY_STATUS_BADGE_CLASS.connecting).toBe("ui-badge-status-connecting"); + expect(GATEWAY_STATUS_BADGE_CLASS.connected).toBe("ui-badge-status-connected"); + + expect(resolveGatewayStatusLabel("connected")).toBe("Connected"); + expect(resolveGatewayStatusBadgeClass("disconnected")).toBe("ui-badge-status-disconnected"); + }); + + it("keeps approval state on its own semantic class", () => { + expect(NEEDS_APPROVAL_BADGE_CLASS).toBe("ui-badge-approval"); + }); +}); diff --git a/tests/unit/colorSemanticsGuard.test.ts b/tests/unit/colorSemanticsGuard.test.ts new file mode 100644 index 00000000..42e54434 --- /dev/null +++ b/tests/unit/colorSemanticsGuard.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const TEST_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(TEST_DIR, "..", ".."); + +const COLOR_OWNED_FILES = [ + "src/features/agents/components/FleetSidebar.tsx", + "src/features/agents/components/AgentChatPanel.tsx", + "src/features/agents/components/ConnectionPanel.tsx", + "src/features/agents/components/AgentInspectPanels.tsx", + "src/features/agents/components/GatewayConnectScreen.tsx", + "src/features/agents/components/AgentCreateModal.tsx", + "src/features/agents/components/HeaderBar.tsx", + "src/app/page.tsx", +] as const; + +const RAW_HUE_UTILITY_PATTERN = + /\b(?:bg|text|border|from|to|via)-(?:amber|cyan|emerald|orange|violet|red|green|blue|zinc)-\d{2,3}(?:\/\d{1,3})?\b/g; + +describe("color semantic guard", () => { + it("blocks raw hue utility classes in color-owned UI files", () => { + const offenders: string[] = []; + + for (const relativePath of COLOR_OWNED_FILES) { + const source = readFileSync(resolve(REPO_ROOT, relativePath), "utf8"); + const matches = source.match(RAW_HUE_UTILITY_PATTERN) ?? []; + for (const match of matches) { + offenders.push(`${relativePath}: ${match}`); + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/tests/unit/configMutationGatePolicy.test.ts b/tests/unit/configMutationGatePolicy.test.ts new file mode 100644 index 00000000..ec38167e --- /dev/null +++ b/tests/unit/configMutationGatePolicy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy"; + +describe("shouldStartNextConfigMutation", () => { + it("returns_false_when_queue_empty", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 0, + }) + ).toBe(false); + }); + + it("returns_false_when_not_connected", () => { + expect( + shouldStartNextConfigMutation({ + status: "connecting", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(false); + }); + + it("returns_false_when_running_agents_and_next_mutation_requires_idle_agents", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: true, + nextMutationRequiresIdleAgents: true, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(false); + }); + + it("returns_true_when_running_agents_but_next_mutation_does_not_require_idle_agents", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: true, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(true); + }); + + it("returns_false_when_active_mutation", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: true, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(false); + }); + + it("returns_false_when_restart_block_in_progress", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: true, + queuedCount: 1, + }) + ).toBe(false); + }); + + it("returns_true_when_connected_idle_and_queue_non_empty", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(true); + }); +}); diff --git a/tests/unit/connectionPanel-close.test.ts b/tests/unit/connectionPanel-close.test.ts new file mode 100644 index 00000000..026b2289 --- /dev/null +++ b/tests/unit/connectionPanel-close.test.ts @@ -0,0 +1,84 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ConnectionPanel } from "@/features/agents/components/ConnectionPanel"; + +describe("ConnectionPanel close control", () => { + afterEach(() => { + cleanup(); + }); + + it("renders close control and calls handler when provided", () => { + const onClose = vi.fn(); + + render( + createElement(ConnectionPanel, { + gatewayUrl: "ws://127.0.0.1:18789", + token: "token", + status: "disconnected", + error: null, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onConnect: vi.fn(), + onDisconnect: vi.fn(), + onClose, + }) + ); + + fireEvent.click(screen.getByTestId("gateway-connection-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not render close control when handler is missing", () => { + render( + createElement(ConnectionPanel, { + gatewayUrl: "ws://127.0.0.1:18789", + token: "token", + status: "disconnected", + error: null, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onConnect: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + + expect(screen.queryByTestId("gateway-connection-close")).not.toBeInTheDocument(); + }); + + it("renders semantic gateway status class markers", () => { + const { rerender } = render( + createElement(ConnectionPanel, { + gatewayUrl: "ws://127.0.0.1:18789", + token: "token", + status: "disconnected", + error: null, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onConnect: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + + const disconnected = screen.getByText("Disconnected"); + expect(disconnected).toHaveAttribute("data-status", "disconnected"); + expect(disconnected).toHaveClass("ui-badge-status-disconnected"); + + rerender( + createElement(ConnectionPanel, { + gatewayUrl: "ws://127.0.0.1:18789", + token: "token", + status: "connected", + error: null, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onConnect: vi.fn(), + onDisconnect: vi.fn(), + }) + ); + + const connected = screen.getByText("Connected"); + expect(connected).toHaveAttribute("data-status", "connected"); + expect(connected).toHaveClass("ui-badge-status-connected"); + }); +}); diff --git a/tests/unit/createAgentBootstrapOperation.test.ts b/tests/unit/createAgentBootstrapOperation.test.ts new file mode 100644 index 00000000..1a5dd386 --- /dev/null +++ b/tests/unit/createAgentBootstrapOperation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CREATE_AGENT_DEFAULT_PERMISSIONS, + runCreateAgentBootstrapOperation, +} from "@/features/agents/operations/createAgentBootstrapOperation"; + +describe("createAgentBootstrapOperation", () => { + it("exports_autonomous_create_defaults", () => { + expect(CREATE_AGENT_DEFAULT_PERMISSIONS).toEqual({ + commandMode: "auto", + webAccess: true, + fileTools: true, + }); + }); + + it("retries load and lookup once before unresolved-created-agent disposition", async () => { + const loadAgents = vi.fn(async () => undefined); + const findAgentById = vi.fn(() => null); + const applyDefaultPermissions = vi.fn(async () => undefined); + const refreshGatewayConfigSnapshot = vi.fn(async () => undefined); + + const commands = await runCreateAgentBootstrapOperation({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + focusedAgentId: "focused-1", + loadAgents, + findAgentById, + applyDefaultPermissions, + refreshGatewayConfigSnapshot, + }); + + expect(loadAgents).toHaveBeenCalledTimes(2); + expect(findAgentById).toHaveBeenCalledTimes(2); + expect(findAgentById).toHaveBeenNthCalledWith(1, "agent-1"); + expect(findAgentById).toHaveBeenNthCalledWith(2, "agent-1"); + expect(applyDefaultPermissions).not.toHaveBeenCalled(); + expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled(); + expect(commands).toEqual([ + { + kind: "set-create-modal-error", + message: 'Agent "Agent One" was created, but Studio could not load it yet.', + }, + { + kind: "set-global-error", + message: 'Agent "Agent One" was created, but Studio could not load it yet.', + }, + { kind: "set-create-block", value: null }, + { kind: "set-create-modal-open", open: false }, + ]); + }); + + it("runs bootstrap success flow and refreshes gateway config snapshot", async () => { + const loadAgents = vi.fn(async () => undefined); + const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" })); + const applyDefaultPermissions = vi.fn(async () => undefined); + const refreshGatewayConfigSnapshot = vi.fn(async () => undefined); + + const commands = await runCreateAgentBootstrapOperation({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + focusedAgentId: "focused-1", + loadAgents, + findAgentById, + applyDefaultPermissions, + refreshGatewayConfigSnapshot, + }); + + const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft"); + const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent"); + + expect(loadAgents).toHaveBeenCalledTimes(1); + expect(findAgentById).toHaveBeenCalledTimes(1); + expect(applyDefaultPermissions).toHaveBeenCalledWith({ + agentId: "agent-1", + sessionKey: "session-1", + }); + expect(refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + expect(flushIndex).toBeGreaterThanOrEqual(0); + expect(selectIndex).toBeGreaterThan(flushIndex); + expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined(); + expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null }); + }); + + it("keeps create success disposition when bootstrap fails and skips snapshot refresh", async () => { + const loadAgents = vi.fn(async () => undefined); + const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" })); + const applyDefaultPermissions = vi.fn(async () => { + throw new Error("permissions exploded"); + }); + const refreshGatewayConfigSnapshot = vi.fn(async () => undefined); + + const commands = await runCreateAgentBootstrapOperation({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + focusedAgentId: "focused-1", + loadAgents, + findAgentById, + applyDefaultPermissions, + refreshGatewayConfigSnapshot, + }); + + const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft"); + const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent"); + + expect(loadAgents).toHaveBeenCalledTimes(1); + expect(findAgentById).toHaveBeenCalledTimes(1); + expect(applyDefaultPermissions).toHaveBeenCalledTimes(1); + expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled(); + expect(flushIndex).toBeGreaterThanOrEqual(0); + expect(selectIndex).toBeGreaterThan(flushIndex); + expect(commands).toContainEqual({ + kind: "set-global-error", + message: "Agent created, but default permissions could not be applied: permissions exploded", + }); + expect(commands).toContainEqual({ + kind: "set-create-modal-error", + message: "Default permissions failed: permissions exploded", + }); + expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" }); + }); + + it("uses fallback bootstrap error message for non-Error throws", async () => { + const commands = await runCreateAgentBootstrapOperation({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + focusedAgentId: "focused-1", + loadAgents: async () => undefined, + findAgentById: () => ({ agentId: "agent-1", sessionKey: "session-1" }), + applyDefaultPermissions: async () => { + throw "boom"; + }, + refreshGatewayConfigSnapshot: async () => undefined, + }); + + expect(commands).toContainEqual({ + kind: "set-global-error", + message: "Agent created, but default permissions could not be applied: Failed to apply default permissions.", + }); + expect(commands).toContainEqual({ + kind: "set-create-modal-error", + message: "Default permissions failed: Failed to apply default permissions.", + }); + }); +}); diff --git a/tests/unit/createAgentBootstrapWorkflow.test.ts b/tests/unit/createAgentBootstrapWorkflow.test.ts new file mode 100644 index 00000000..086fac4e --- /dev/null +++ b/tests/unit/createAgentBootstrapWorkflow.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { planCreateAgentBootstrapCommands } from "@/features/agents/operations/createAgentBootstrapWorkflow"; + +describe("createAgentBootstrapWorkflow", () => { + it("plans unresolved-created-agent failure disposition", () => { + const commands = planCreateAgentBootstrapCommands({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + createdAgent: null, + bootstrapErrorMessage: null, + focusedAgentId: "focused-1", + }); + + expect(commands).toEqual([ + { + kind: "set-create-modal-error", + message: 'Agent "Agent One" was created, but Studio could not load it yet.', + }, + { + kind: "set-global-error", + message: 'Agent "Agent One" was created, but Studio could not load it yet.', + }, + { kind: "set-create-block", value: null }, + { kind: "set-create-modal-open", open: false }, + ]); + }); + + it("plans bootstrap success disposition with draft flush before selection", () => { + const commands = planCreateAgentBootstrapCommands({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + createdAgent: { agentId: "agent-1", sessionKey: "session-1" }, + bootstrapErrorMessage: null, + focusedAgentId: "focused-1", + }); + + const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft"); + const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent"); + + expect(flushIndex).toBeGreaterThanOrEqual(0); + expect(selectIndex).toBeGreaterThan(flushIndex); + expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null }); + expect(commands).toContainEqual({ kind: "flush-pending-draft", agentId: "focused-1" }); + expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" }); + expect(commands).toContainEqual({ + kind: "set-inspect-sidebar", + agentId: "agent-1", + tab: "capabilities", + }); + expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" }); + expect(commands).toContainEqual({ kind: "set-create-block", value: null }); + expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false }); + expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined(); + }); + + it("plans bootstrap failure disposition without blocking selection flow", () => { + const commands = planCreateAgentBootstrapCommands({ + completion: { agentId: "agent-1", agentName: "Agent One" }, + createdAgent: { agentId: "agent-1", sessionKey: "session-1" }, + bootstrapErrorMessage: "permissions exploded", + focusedAgentId: "focused-1", + }); + + const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft"); + const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent"); + + expect(flushIndex).toBeGreaterThanOrEqual(0); + expect(selectIndex).toBeGreaterThan(flushIndex); + expect(commands).toContainEqual({ + kind: "set-global-error", + message: "Agent created, but default permissions could not be applied: permissions exploded", + }); + expect(commands).toContainEqual({ + kind: "set-create-modal-error", + message: "Default permissions failed: permissions exploded", + }); + expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" }); + expect(commands).toContainEqual({ + kind: "set-inspect-sidebar", + agentId: "agent-1", + tab: "capabilities", + }); + expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" }); + expect(commands).toContainEqual({ kind: "set-create-block", value: null }); + expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false }); + }); +}); diff --git a/tests/unit/cronCreateFlowState.test.ts b/tests/unit/cronCreateFlowState.test.ts new file mode 100644 index 00000000..446812f0 --- /dev/null +++ b/tests/unit/cronCreateFlowState.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; + +import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; +import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder"; +import type { CronJobCreateInput, CronJobSummary } from "@/lib/cron/types"; + +const createDraft = (): CronCreateDraft => ({ + templateId: "custom", + name: "Nightly sync", + taskText: "Sync project status and report blockers.", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + deliveryMode: "announce", + deliveryChannel: "last", +}); + +const createJob = (id: string, agentId: string, updatedAtMs: number): CronJobSummary => ({ + id, + name: id, + agentId, + enabled: true, + updatedAtMs, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run task" }, + state: {}, +}); + +const createInput = (): CronJobCreateInput => ({ + name: "Nightly sync", + agentId: "agent-1", + enabled: true, + schedule: { kind: "every", everyMs: 1_800_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Sync project status and report blockers." }, + delivery: { mode: "announce", channel: "last" }, +}); + +describe("cron create flow state", () => { + it("successful_create_refreshes_list_for_selected_agent", async () => { + const client = {} as never; + const onBusyChange = vi.fn(); + const onError = vi.fn(); + const onJobs = vi.fn(); + + const buildInput = vi.fn(() => createInput()); + const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15)); + const listCronJobs = vi.fn(async () => ({ + jobs: [ + createJob("older", "agent-1", 10), + createJob("newer", "agent-1", 20), + createJob("other-agent", "agent-2", 30), + ], + })); + + await expect( + performCronCreateFlow({ + client, + agentId: "agent-1", + draft: createDraft(), + busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null }, + onBusyChange, + onError, + onJobs, + deps: { buildInput, createCronJob, listCronJobs }, + }) + ).resolves.toBe("created"); + + expect(buildInput).toHaveBeenCalledWith("agent-1", expect.any(Object)); + expect(createCronJob).toHaveBeenCalledWith(client, createInput()); + expect(listCronJobs).toHaveBeenCalledWith(client, { includeDisabled: true }); + expect(onJobs).toHaveBeenCalledWith([ + createJob("newer", "agent-1", 20), + createJob("older", "agent-1", 10), + ]); + expect(onError).toHaveBeenCalledWith(null); + expect(onBusyChange).toHaveBeenNthCalledWith(1, true); + expect(onBusyChange).toHaveBeenNthCalledWith(2, false); + }); + + it("create_failure_surfaces_cron_error_message", async () => { + const onBusyChange = vi.fn(); + const onError = vi.fn(); + const onJobs = vi.fn(); + const expectedError = new Error("Gateway exploded"); + + await expect( + performCronCreateFlow({ + client: {} as never, + agentId: "agent-1", + draft: createDraft(), + busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null }, + onBusyChange, + onError, + onJobs, + deps: { + buildInput: vi.fn(() => createInput()), + createCronJob: vi.fn(async () => { + throw expectedError; + }), + listCronJobs: vi.fn(async () => ({ jobs: [] })), + }, + }) + ).rejects.toThrow("Gateway exploded"); + + expect(onError).toHaveBeenNthCalledWith(1, null); + expect(onError).toHaveBeenNthCalledWith(2, "Gateway exploded"); + expect(onBusyChange).toHaveBeenNthCalledWith(1, true); + expect(onBusyChange).toHaveBeenNthCalledWith(2, false); + expect(onJobs).not.toHaveBeenCalled(); + }); + + it("create_is_blocked_while_run_or_delete_busy", async () => { + const onBusyChange = vi.fn(); + const onError = vi.fn(); + const onJobs = vi.fn(); + const buildInput = vi.fn(() => createInput()); + const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15)); + const listCronJobs = vi.fn(async () => ({ jobs: [] })); + + await expect( + performCronCreateFlow({ + client: {} as never, + agentId: "agent-1", + draft: createDraft(), + busy: { createBusy: false, runBusyJobId: "job-1", deleteBusyJobId: null }, + onBusyChange, + onError, + onJobs, + deps: { buildInput, createCronJob, listCronJobs }, + }) + ).rejects.toThrow("Please wait for the current cron action to finish."); + + expect(onError).toHaveBeenCalledWith("Please wait for the current cron action to finish."); + expect(onBusyChange).not.toHaveBeenCalled(); + expect(onJobs).not.toHaveBeenCalled(); + expect(buildInput).not.toHaveBeenCalled(); + expect(createCronJob).not.toHaveBeenCalled(); + expect(listCronJobs).not.toHaveBeenCalled(); + }); + + it("fails_fast_when_agent_id_missing", async () => { + const onBusyChange = vi.fn(); + const onError = vi.fn(); + const onJobs = vi.fn(); + const buildInput = vi.fn(() => createInput()); + const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15)); + const listCronJobs = vi.fn(async () => ({ jobs: [] })); + + await expect( + performCronCreateFlow({ + client: {} as never, + agentId: " ", + draft: createDraft(), + busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null }, + onBusyChange, + onError, + onJobs, + deps: { buildInput, createCronJob, listCronJobs }, + }) + ).rejects.toThrow("Failed to create cron job: missing agent id."); + + expect(onError).toHaveBeenCalledWith("Failed to create cron job: missing agent id."); + expect(onBusyChange).not.toHaveBeenCalled(); + expect(onJobs).not.toHaveBeenCalled(); + expect(buildInput).not.toHaveBeenCalled(); + expect(createCronJob).not.toHaveBeenCalled(); + expect(listCronJobs).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/cronCreatePayloadBuilder.test.ts b/tests/unit/cronCreatePayloadBuilder.test.ts new file mode 100644 index 00000000..baa8d7c2 --- /dev/null +++ b/tests/unit/cronCreatePayloadBuilder.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { + buildCronJobCreateInput, + type CronCreateDraft, +} from "@/lib/cron/createPayloadBuilder"; + +describe("cron create payload builder", () => { + it("builds_agent_scoped_isolated_every_days_payload_with_anchor", () => { + const nowMs = Date.UTC(2026, 1, 11, 6, 30, 0); + const draft: CronCreateDraft = { + templateId: "morning-brief", + name: "Morning brief", + taskText: "Summarize overnight updates and priorities.", + scheduleKind: "every", + everyAmount: 1, + everyUnit: "days", + everyAtTime: "07:00", + everyTimeZone: "UTC", + }; + + const input = buildCronJobCreateInput("agent-1", draft, nowMs); + + expect(input).toEqual({ + name: "Morning brief", + agentId: "agent-1", + enabled: true, + schedule: { + kind: "every", + everyMs: 86_400_000, + anchorMs: Date.UTC(2026, 1, 11, 7, 0, 0), + }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message: "Summarize overnight updates and priorities.", + }, + delivery: { mode: "none" }, + }); + }); + + it("builds_main_system_event_payload_when_advanced_mode_selected", () => { + const draft: CronCreateDraft = { + templateId: "reminder", + name: "Standup reminder", + taskText: "Reminder: standup starts in 10 minutes.", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + advancedSessionTarget: "main", + advancedWakeMode: "next-heartbeat", + }; + + const input = buildCronJobCreateInput("agent-2", draft); + + expect(input).toEqual({ + name: "Standup reminder", + agentId: "agent-2", + enabled: true, + schedule: { kind: "every", everyMs: 1_800_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { + kind: "systemEvent", + text: "Reminder: standup starts in 10 minutes.", + }, + }); + }); + + it("rejects_invalid_one_time_schedule_input", () => { + const draft: CronCreateDraft = { + templateId: "custom", + name: "One time", + taskText: "Run once later.", + scheduleKind: "at", + scheduleAt: "not-a-date", + }; + + expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid run time."); + }); + + it("rejects_invalid_interval_amount_for_every_schedule", () => { + const draft: CronCreateDraft = { + templateId: "custom", + name: "Invalid interval", + taskText: "Run repeatedly.", + scheduleKind: "every", + everyAmount: 0, + everyUnit: "minutes", + }; + + expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid interval amount."); + }); + + it("rejects_every_days_without_time", () => { + const draft: CronCreateDraft = { + templateId: "custom", + name: "Daily report", + taskText: "Compile report.", + scheduleKind: "every", + everyAmount: 1, + everyUnit: "days", + everyTimeZone: "UTC", + }; + + expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow( + "Daily schedule time is required." + ); + }); + + it("rejects_invalid_timezone_for_every_days", () => { + const draft: CronCreateDraft = { + templateId: "custom", + name: "Daily report", + taskText: "Compile report.", + scheduleKind: "every", + everyAmount: 1, + everyUnit: "days", + everyAtTime: "07:00", + everyTimeZone: "Mars/OlympusMons", + }; + + expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid timezone."); + }); +}); diff --git a/tests/unit/cronGatewayClient.test.ts b/tests/unit/cronGatewayClient.test.ts new file mode 100644 index 00000000..3fc1e4b0 --- /dev/null +++ b/tests/unit/cronGatewayClient.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCronJob, + listCronJobs, + removeCronJob, + removeCronJobsForAgent, + removeCronJobsForAgentWithBackup, + restoreCronJobs, + runCronJobNow, + type CronJobSummary, +} from "@/lib/cron/types"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +const createListedJob = (params: { + id: string; + name: string; + agentId?: string; + updatedAtMs?: number; +}): CronJobSummary => ({ + id: params.id, + name: params.name, + agentId: params.agentId, + enabled: true, + updatedAtMs: params.updatedAtMs ?? 1_700_000_000_000, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + state: {}, +}); + +describe("cron gateway client", () => { + it("lists_jobs_via_cron_list_include_disabled_true", async () => { + const client = { + call: vi.fn(async () => ({ jobs: [] })), + } as unknown as GatewayClient; + + await listCronJobs(client); + + expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true }); + }); + + it("runs_job_now_with_force_mode", async () => { + const client = { + call: vi.fn(async () => ({ ok: true, ran: true })), + } as unknown as GatewayClient; + + await runCronJobNow(client, "job-1"); + + expect(client.call).toHaveBeenCalledWith("cron.run", { id: "job-1", mode: "force" }); + }); + + it("removes_job_by_id", async () => { + const client = { + call: vi.fn(async () => ({ ok: true, removed: true })), + } as unknown as GatewayClient; + + await removeCronJob(client, "job-1"); + + expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" }); + }); + + it("throws_when_job_id_missing_for_run_or_remove", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await expect(runCronJobNow(client, " ")).rejects.toThrow("Cron job id is required."); + await expect(removeCronJob(client, "")).rejects.toThrow("Cron job id is required."); + }); + + it("removes_all_jobs_for_agent", async () => { + const client = { + call: vi.fn(async (method: string, payload: { id?: string }) => { + if (method === "cron.list") { + return { + jobs: [ + createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), + createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }), + createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }), + ], + }; + } + if (method === "cron.remove") { + return { ok: true, removed: payload.id !== "job-3" }; + } + throw new Error(`Unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgent(client, "agent-1")).resolves.toBe(1); + expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true }); + expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" }); + expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-3" }); + }); + + it("throws_when_agent_id_missing_for_bulk_remove", async () => { + const client = { + call: vi.fn(async () => ({ jobs: [] })), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgent(client, " ")).rejects.toThrow("Agent id is required."); + }); + + it("throws_when_any_bulk_remove_call_fails", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "cron.list") { + return { + jobs: [createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" })], + }; + } + if (method === "cron.remove") { + return { ok: false, removed: false }; + } + throw new Error(`Unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgent(client, "agent-1")).rejects.toThrow( + 'Failed to delete cron job "Job 1" (job-1).' + ); + }); + + it("returns_restore_inputs_when_removing_jobs_with_backup", async () => { + const client = { + call: vi.fn(async (method: string, payload: { id?: string }) => { + if (method === "cron.list") { + return { + jobs: [ + createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), + createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }), + createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }), + ], + }; + } + if (method === "cron.remove") { + return { ok: true, removed: payload.id !== "job-3" }; + } + throw new Error(`Unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).resolves.toEqual([ + { + name: "Job 1", + agentId: "agent-1", + sessionKey: undefined, + description: undefined, + enabled: true, + deleteAfterRun: undefined, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + delivery: undefined, + }, + ]); + }); + + it("restores_removed_jobs_when_backup_remove_fails_midway", async () => { + const client = { + call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => { + if (method === "cron.list") { + return { + jobs: [ + createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), + createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }), + ], + }; + } + if (method === "cron.remove") { + if (payload.id === "job-1") return { ok: true, removed: true }; + return { ok: false, removed: false }; + } + if (method === "cron.add") { + return { id: "restored-job-1", name: payload.name }; + } + throw new Error(`Unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toThrow( + 'Failed to delete cron job "Job 2" (job-2).' + ); + + expect(client.call).toHaveBeenCalledWith("cron.add", { + name: "Job 1", + agentId: "agent-1", + sessionKey: undefined, + description: undefined, + enabled: true, + deleteAfterRun: undefined, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + delivery: undefined, + }); + }); + + it("restores_removed_jobs_when_remove_call_throws_midway", async () => { + const thrown = new Error("network interrupted"); + const client = { + call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => { + if (method === "cron.list") { + return { + jobs: [ + createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), + createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }), + ], + }; + } + if (method === "cron.remove") { + if (payload.id === "job-1") return { ok: true, removed: true }; + throw thrown; + } + if (method === "cron.add") { + return { id: "restored-job-1", name: payload.name }; + } + throw new Error(`Unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toBe(thrown); + + expect(client.call).toHaveBeenCalledWith("cron.add", { + name: "Job 1", + agentId: "agent-1", + sessionKey: undefined, + description: undefined, + enabled: true, + deleteAfterRun: undefined, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + delivery: undefined, + }); + }); + + it("throws_actionable_error_when_restore_fails", async () => { + const client = { + call: vi.fn(async (_method: string, payload: { name?: string }) => { + if (payload.name === "Job 2") { + throw new Error("cron.add failed"); + } + return { id: "job-restored", name: payload.name }; + }), + } as unknown as GatewayClient; + + await expect( + restoreCronJobs(client, [ + { + name: "Job 1", + agentId: "agent-1", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + }, + { + name: "Job 2", + agentId: "agent-1", + enabled: true, + schedule: { kind: "every", everyMs: 120_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks again." }, + }, + ]) + ).rejects.toThrow('Failed to restore cron job "Job 2" (agent-1): cron.add failed'); + }); + + it("creates_job_via_cron_add", async () => { + const client = { + call: vi.fn(async () => ({ id: "job-1", name: "Morning brief" })), + } as unknown as GatewayClient; + + const input = { + name: "Morning brief", + agentId: "agent-1", + enabled: true, + schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "America/Chicago" }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { kind: "agentTurn" as const, message: "Summarize overnight updates." }, + delivery: { mode: "announce" as const, channel: "last" }, + }; + + await createCronJob(client, input); + + expect(client.call).toHaveBeenCalledWith("cron.add", input); + }); + + it("throws_when_create_payload_missing_required_name", async () => { + const client = { + call: vi.fn(async () => ({ id: "job-1" })), + } as unknown as GatewayClient; + + await expect( + createCronJob(client, { + name: " ", + agentId: "agent-1", + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, + }) + ).rejects.toThrow("Cron job name is required."); + }); +}); diff --git a/tests/unit/cronSelectors.test.ts b/tests/unit/cronSelectors.test.ts new file mode 100644 index 00000000..46ddd417 --- /dev/null +++ b/tests/unit/cronSelectors.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + filterCronJobsForAgent, + formatCronJobDisplay, + formatCronPayload, + formatCronSchedule, + resolveLatestCronJobForAgent, +} from "@/lib/cron/types"; +import type { CronJobSummary } from "@/lib/cron/types"; + +const buildJob = (input: { + id: string; + agentId?: string; + updatedAtMs: number; +}): CronJobSummary => ({ + id: input.id, + name: input.id, + enabled: true, + updatedAtMs: input.updatedAtMs, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hello" }, + state: {}, + ...(input.agentId ? { agentId: input.agentId } : {}), +}); + +describe("cron selectors", () => { + it("filters_jobs_to_selected_agent", () => { + const jobs = [ + buildJob({ id: "one", agentId: "agent-1", updatedAtMs: 10 }), + buildJob({ id: "two", agentId: "agent-2", updatedAtMs: 20 }), + buildJob({ id: "three", updatedAtMs: 30 }), + ]; + + expect(filterCronJobsForAgent(jobs, "agent-1").map((job) => job.id)).toEqual(["one"]); + expect(filterCronJobsForAgent(jobs, "agent-2").map((job) => job.id)).toEqual(["two"]); + expect(filterCronJobsForAgent(jobs, "missing")).toEqual([]); + }); + + it("resolves_latest_agent_job_by_updated_at", () => { + const jobs = [ + buildJob({ id: "older", agentId: "agent-1", updatedAtMs: 10 }), + buildJob({ id: "newer", agentId: "agent-1", updatedAtMs: 30 }), + buildJob({ id: "other", agentId: "agent-2", updatedAtMs: 40 }), + ]; + + expect(resolveLatestCronJobForAgent(jobs, "agent-1")?.id).toBe("newer"); + expect(resolveLatestCronJobForAgent(jobs, "agent-2")?.id).toBe("other"); + expect(resolveLatestCronJobForAgent(jobs, "missing")).toBeNull(); + }); + + it("matches_agent_ids_after_trimming_whitespace", () => { + const jobs = [ + buildJob({ id: "trimmed", agentId: "agent-1", updatedAtMs: 20 }), + buildJob({ id: "other", agentId: "agent-2", updatedAtMs: 30 }), + ]; + + expect(filterCronJobsForAgent(jobs, " agent-1 ").map((job) => job.id)).toEqual(["trimmed"]); + expect(resolveLatestCronJobForAgent(jobs, " agent-1 ")?.id).toBe("trimmed"); + }); +}); + +describe("cron formatting", () => { + it("formats_every_schedule_with_h_m_s_ms_suffixes", () => { + expect(formatCronSchedule({ kind: "every", everyMs: 3_600_000 })).toBe("Every 1h"); + expect(formatCronSchedule({ kind: "every", everyMs: 60_000 })).toBe("Every 1m"); + expect(formatCronSchedule({ kind: "every", everyMs: 1_000 })).toBe("Every 1s"); + expect(formatCronSchedule({ kind: "every", everyMs: 1_500 })).toBe("Every 1500ms"); + }); + + it("formats_cron_schedule_with_optional_tz", () => { + expect(formatCronSchedule({ kind: "cron", expr: "0 0 * * *" })).toBe("Cron: 0 0 * * *"); + expect(formatCronSchedule({ kind: "cron", expr: "0 0 * * *", tz: "UTC" })).toBe( + "Cron: 0 0 * * * (UTC)" + ); + }); + + it("formats_at_schedule_as_raw_when_not_parseable", () => { + expect(formatCronSchedule({ kind: "at", at: "not-a-date" })).toBe("At: not-a-date"); + }); + + it("formats_cron_payload_text", () => { + expect(formatCronPayload({ kind: "systemEvent", text: "hello" })).toBe("hello"); + expect(formatCronPayload({ kind: "agentTurn", message: "hi" })).toBe("hi"); + }); + + it("formats_cron_job_display_as_three_lines", () => { + const job: CronJobSummary = { + id: "job-1", + name: "Job name", + enabled: true, + updatedAtMs: 10, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "hi" }, + state: {}, + }; + + expect(formatCronJobDisplay(job)).toBe("Job name\nEvery 1m\nhi"); + }); +}); diff --git a/tests/unit/deleteAgentOperation.test.ts b/tests/unit/deleteAgentOperation.test.ts new file mode 100644 index 00000000..5424d1d5 --- /dev/null +++ b/tests/unit/deleteAgentOperation.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { + removeCronJobsForAgentWithBackup, + restoreCronJobs, + type CronJobRestoreInput, +} from "@/lib/cron/types"; +import { deleteGatewayAgent } from "@/lib/gateway/agentConfig"; +import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; + +vi.mock("@/lib/cron/types", async () => { + const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types"); + return { + ...actual, + removeCronJobsForAgentWithBackup: vi.fn(), + restoreCronJobs: vi.fn(), + }; +}); + +vi.mock("@/lib/gateway/agentConfig", async () => { + const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>( + "@/lib/gateway/agentConfig" + ); + return { ...actual, deleteGatewayAgent: vi.fn() }; +}); + +type FetchJson = <T>(input: RequestInfo | URL, init?: RequestInit) => Promise<T>; + +const createTrashResult = (overrides?: { + trashDir?: string; + moved?: Array<{ from: string; to: string }>; +}) => ({ + trashDir: "/tmp/trash", + moved: [], + ...(overrides ?? {}), +}); + +const createCronRestoreInput = (name = "Job 1", agentId = "agent-1"): CronJobRestoreInput => ({ + name, + agentId, + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { kind: "agentTurn", message: "Run checks." }, +}); + +describe("delete agent via studio operation", () => { + const mockedRemoveCronJobsForAgentWithBackup = vi.mocked(removeCronJobsForAgentWithBackup); + const mockedRestoreCronJobs = vi.mocked(restoreCronJobs); + const mockedDeleteGatewayAgent = vi.mocked(deleteGatewayAgent); + + beforeEach(() => { + mockedRemoveCronJobsForAgentWithBackup.mockReset(); + mockedRestoreCronJobs.mockReset(); + mockedDeleteGatewayAgent.mockReset(); + }); + + it("runs_steps_in_order_on_success", async () => { + const calls: string[] = []; + const fetchJson: FetchJson = vi.fn(async (_input, init) => { + if (init?.method === "POST") { + calls.push("trash"); + return { result: createTrashResult() } as never; + } + throw new Error("Unexpected fetchJson call"); + }); + + mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { + calls.push("removeCron"); + return []; + }); + mockedRestoreCronJobs.mockImplementation(async () => { + calls.push("restoreCron"); + }); + mockedDeleteGatewayAgent.mockImplementation(async () => { + calls.push("deleteGatewayAgent"); + return { removed: true, removedBindings: 0 }; + }); + + await expect( + deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) + ).resolves.toEqual({ + trashed: createTrashResult(), + restored: null, + }); + + expect(calls).toEqual(["trash", "removeCron", "deleteGatewayAgent"]); + }); + + it("attempts_restore_when_remove_cron_fails_and_trash_moved_paths", async () => { + const calls: string[] = []; + const originalErr = new Error("boom"); + const trash = createTrashResult({ + trashDir: "/tmp/trash-2", + moved: [{ from: "/a", to: "/b" }], + }); + + const fetchJson: FetchJson = vi.fn(async (_input, init) => { + if (init?.method === "POST") { + calls.push("trash"); + return { result: trash } as never; + } + if (init?.method === "PUT") { + calls.push("restore:agent-1:/tmp/trash-2"); + return { result: { restored: [] } } as never; + } + throw new Error("Unexpected fetchJson call"); + }); + + mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { + calls.push("removeCron"); + throw originalErr; + }); + mockedRestoreCronJobs.mockImplementation(async () => { + calls.push("restoreCron"); + }); + mockedDeleteGatewayAgent.mockImplementation(async () => { + calls.push("deleteGatewayAgent"); + return { removed: true, removedBindings: 0 }; + }); + + await expect( + deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) + ).rejects.toBe(originalErr); + + expect(calls).toEqual(["trash", "removeCron", "restore:agent-1:/tmp/trash-2"]); + expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); + expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); + }); + + it("attempts_cron_restore_then_state_restore_when_gateway_delete_fails_and_trash_moved_paths", async () => { + const calls: string[] = []; + const originalErr = new Error("boom"); + const backups = [createCronRestoreInput("Job X", "agent-1")]; + + const fetchJson: FetchJson = vi.fn(async (_input, init) => { + if (init?.method === "POST") { + calls.push("trash"); + return { + result: createTrashResult({ + trashDir: "/tmp/trash-3", + moved: [{ from: "/a", to: "/b" }], + }), + } as never; + } + if (init?.method === "PUT") { + calls.push("restore:agent-1:/tmp/trash-3"); + return { result: { restored: [] } } as never; + } + throw new Error("Unexpected fetchJson call"); + }); + + mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { + calls.push("removeCron"); + return backups; + }); + mockedRestoreCronJobs.mockImplementation(async () => { + calls.push("restoreCron"); + }); + mockedDeleteGatewayAgent.mockImplementation(async () => { + calls.push("deleteGatewayAgent"); + throw originalErr; + }); + + await expect( + deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) + ).rejects.toBe(originalErr); + + expect(calls).toEqual([ + "trash", + "removeCron", + "deleteGatewayAgent", + "restoreCron", + "restore:agent-1:/tmp/trash-3", + ]); + expect(mockedRestoreCronJobs).toHaveBeenCalledWith(expect.anything(), backups); + }); + + it("does_not_restore_when_trash_moved_is_empty", async () => { + const originalErr = new Error("boom"); + const methods: string[] = []; + + const fetchJson: FetchJson = vi.fn(async (_input, init) => { + const method = init?.method ?? "GET"; + methods.push(method); + if (method === "POST") { + return { result: createTrashResult({ moved: [] }) } as never; + } + if (method === "PUT") { + throw new Error("restore should not be called"); + } + throw new Error("Unexpected fetchJson call"); + }); + + mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { + throw originalErr; + }); + mockedRestoreCronJobs.mockResolvedValue(undefined); + mockedDeleteGatewayAgent.mockImplementation(async () => { + return { removed: true, removedBindings: 0 }; + }); + + await expect( + deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) + ).rejects.toBe(originalErr); + + expect(methods).toEqual(["POST"]); + expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); + expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); + }); + + it("logs_cron_and_state_restore_failures_and_still_throws_original_error", async () => { + const originalErr = new Error("boom"); + const cronRestoreErr = new Error("cron-restore-failed"); + const restoreErr = new Error("restore-failed"); + const logError = vi.fn(); + const backups = [createCronRestoreInput("Job Z", "agent-1")]; + + const fetchJson: FetchJson = vi.fn(async (_input, init) => { + if (init?.method === "POST") { + return { + result: createTrashResult({ + trashDir: "/tmp/trash-4", + moved: [{ from: "/a", to: "/b" }], + }), + } as never; + } + if (init?.method === "PUT") { + throw restoreErr; + } + throw new Error("Unexpected fetchJson call"); + }); + + mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { + return backups; + }); + mockedRestoreCronJobs.mockImplementation(async () => { + throw cronRestoreErr; + }); + mockedDeleteGatewayAgent.mockImplementation(async () => { + throw originalErr; + }); + + await expect( + deleteAgentViaStudio({ + client: {} as never, + agentId: "agent-1", + fetchJson, + logError, + }) + ).rejects.toBe(originalErr); + + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 1, + "Failed to restore removed cron jobs.", + cronRestoreErr + ); + expect(logError).toHaveBeenNthCalledWith(2, "Failed to restore trashed agent state.", restoreErr); + }); + + it("fails_fast_when_agent_id_is_missing", async () => { + const fetchJson: FetchJson = vi.fn(async () => { + throw new Error("fetch should not be called"); + }); + + await expect( + deleteAgentViaStudio({ client: {} as never, agentId: " ", fetchJson }) + ).rejects.toThrow("Agent id is required."); + + expect(fetchJson).not.toHaveBeenCalled(); + expect(mockedRemoveCronJobsForAgentWithBackup).not.toHaveBeenCalled(); + expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); + expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/execApprovalControlLoopWorkflow.test.ts b/tests/unit/execApprovalControlLoopWorkflow.test.ts new file mode 100644 index 00000000..53aa4ccf --- /dev/null +++ b/tests/unit/execApprovalControlLoopWorkflow.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; + +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + planAutoResumeIntent, + planAwaitingUserInputPatches, + planIngressCommands, + planPausedRunMapCleanup, + planPauseRunIntent, + planPendingPruneDelay, + planPrunedPendingState, +} from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import type { ApprovalPendingState } from "@/features/agents/approvals/execApprovalRuntimeCoordinator"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 1, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + sessionExecAsk: "always", + ...overrides, +}); + +const createApproval = ( + id: string, + overrides?: Partial<PendingExecApproval> +): PendingExecApproval => ({ + id, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 10_000, + resolving: false, + error: null, + ...overrides, +}); + +const createPendingState = ( + overrides?: Partial<ApprovalPendingState> +): ApprovalPendingState => ({ + approvalsByAgentId: {}, + unscopedApprovals: [], + ...overrides, +}); + +describe("execApprovalControlLoopWorkflow", () => { + it("plans stale paused-run cleanup from paused map", () => { + const stale = planPausedRunMapCleanup({ + pausedRunIdByAgentId: new Map([ + ["agent-1", "run-1"], + ["agent-2", "run-old"], + ["missing-agent", "run-x"], + ]), + agents: [ + createAgent(), + createAgent({ + agentId: "agent-2", + sessionKey: "agent:agent-2:main", + runId: "run-2", + }), + ], + }); + + expect(stale).toEqual(["agent-2", "missing-agent"]); + }); + + it("plans pause intent for a running agent that needs exec approval", () => { + const intent = planPauseRunIntent({ + approval: createApproval("approval-1"), + preferredAgentId: "agent-1", + agents: [createAgent()], + pausedRunIdByAgentId: new Map(), + }); + + expect(intent).toEqual({ + kind: "pause", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + runId: "run-1", + }); + }); + + it("skips pause intent when the run is already paused", () => { + const intent = planPauseRunIntent({ + approval: createApproval("approval-1"), + preferredAgentId: "agent-1", + agents: [createAgent()], + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + }); + + expect(intent).toEqual({ kind: "skip", reason: "pause-policy-denied" }); + }); + + it("plans ingress commands for approval requested events", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-1", + request: { + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "agent-1", + resolvedPath: "/usr/bin/npm", + sessionKey: "agent:agent-1:main", + }, + createdAtMs: 100, + expiresAtMs: 200, + }, + }; + + const commands = planIngressCommands({ + event, + agents: [createAgent()], + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map(), + seenCronDedupeKeys: new Set(), + nowMs: 150, + }); + + expect(commands[0]).toMatchObject({ kind: "replacePendingState" }); + expect(commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "pauseRunForApproval", + preferredAgentId: "agent-1", + }), + { kind: "markActivity", agentId: "agent-1" }, + ]) + ); + }); + + it("plans cron ingress commands with dedupe and transcript append", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-1", + sessionId: "session-1", + runAtMs: 123, + status: "ok", + summary: "cron summary", + }, + }; + + const commands = planIngressCommands({ + event, + agents: [createAgent()], + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map(), + seenCronDedupeKeys: new Set(), + nowMs: 1000, + }); + + expect(commands).toEqual([ + { kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" }, + { + kind: "appendCronTranscript", + intent: { + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + dedupeKey: "cron:job-1:session-1", + line: "Cron finished (ok): job-1\n\ncron summary", + timestampMs: 123, + activityAtMs: 123, + }, + }, + ]); + }); + + it("plans prune delay, pruned state, and awaiting-user-input patches", () => { + const pendingState = createPendingState({ + approvalsByAgentId: { + "agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })], + }, + unscopedApprovals: [ + createApproval("a-2", { + agentId: null, + sessionKey: "agent:agent-2:main", + expiresAtMs: 7_000, + }), + ], + }); + + const delay = planPendingPruneDelay({ + pendingState, + nowMs: 5_000, + graceMs: 500, + }); + expect(delay).toBe(1_500); + + const pruned = planPrunedPendingState({ + pendingState: { + approvalsByAgentId: { + "agent-1": [ + createApproval("expired", { expiresAtMs: 4_000 }), + createApproval("active", { expiresAtMs: 6_000 }), + ], + }, + unscopedApprovals: [ + createApproval("active-unscoped", { + agentId: null, + sessionKey: "agent:agent-2:main", + expiresAtMs: 7_000, + }), + createApproval("expired-unscoped", { + agentId: null, + sessionKey: "agent:agent-2:main", + expiresAtMs: 4_100, + }), + ], + }, + nowMs: 5_000, + graceMs: 500, + }); + + expect(pruned.approvalsByAgentId).toEqual({ + "agent-1": [createApproval("active", { expiresAtMs: 6_000 })], + }); + expect(pruned.unscopedApprovals).toEqual([ + createApproval("active-unscoped", { + agentId: null, + sessionKey: "agent:agent-2:main", + expiresAtMs: 7_000, + }), + ]); + + const patches = planAwaitingUserInputPatches({ + agents: [ + createAgent({ agentId: "agent-1", awaitingUserInput: false }), + createAgent({ + agentId: "agent-2", + sessionKey: "agent:agent-2:main", + runId: "run-2", + awaitingUserInput: true, + }), + ], + approvalsByAgentId: { + "agent-1": [createApproval("a-1")], + }, + }); + + expect(patches).toEqual([ + { agentId: "agent-1", awaitingUserInput: true }, + { agentId: "agent-2", awaitingUserInput: false }, + ]); + }); + + it("plans auto-resume intent only when preflight and dispatch both pass", () => { + const skip = planAutoResumeIntent({ + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + pendingState: createPendingState({ + approvalsByAgentId: { + "agent-1": [createApproval("approval-1"), createApproval("sibling")], + }, + }), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + agents: [createAgent()], + }); + expect(skip).toEqual({ kind: "skip", reason: "blocking-pending-approvals" }); + + const resume = planAutoResumeIntent({ + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + agents: [createAgent({ status: "running", runId: "run-1" })], + }); + + expect(resume).toEqual({ + kind: "resume", + targetAgentId: "agent-1", + pausedRunId: "run-1", + sessionKey: "agent:agent-1:main", + }); + }); +}); diff --git a/tests/unit/execApprovalEvents.test.ts b/tests/unit/execApprovalEvents.test.ts new file mode 100644 index 00000000..903f15ee --- /dev/null +++ b/tests/unit/execApprovalEvents.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { + parseExecApprovalRequested, + parseExecApprovalResolved, + resolveExecApprovalAgentId, +} from "@/features/agents/approvals/execApprovalEvents"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (agentId: string, sessionKey: string): AgentState => ({ + agentId, + name: agentId, + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: agentId, + avatarUrl: null, +}); + +describe("execApprovalEvents", () => { + it("parses exec.approval.requested payload", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-1", + request: { + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "agent-1", + resolvedPath: "/bin/npm", + sessionKey: "agent:agent-1:main", + }, + createdAtMs: 123, + expiresAtMs: 456, + }, + }; + + expect(parseExecApprovalRequested(event)).toEqual({ + id: "approval-1", + request: { + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "agent-1", + resolvedPath: "/bin/npm", + sessionKey: "agent:agent-1:main", + }, + createdAtMs: 123, + expiresAtMs: 456, + }); + }); + + it("returns null for invalid requested payload", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-1", + request: { command: "" }, + createdAtMs: 0, + expiresAtMs: 0, + }, + }; + expect(parseExecApprovalRequested(event)).toBeNull(); + }); + + it("parses exec.approval.resolved payload", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.resolved", + payload: { + id: "approval-1", + decision: "allow-once", + resolvedBy: "studio", + ts: 987, + }, + }; + expect(parseExecApprovalResolved(event)).toEqual({ + id: "approval-1", + decision: "allow-once", + resolvedBy: "studio", + ts: 987, + }); + }); + + it("returns null for unknown resolved decision", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.resolved", + payload: { + id: "approval-1", + decision: "approve", + ts: 987, + }, + }; + expect(parseExecApprovalResolved(event)).toBeNull(); + }); + + it("resolves approval agent by explicit agent id", () => { + const requested = { + id: "approval-1", + request: { + command: "pwd", + cwd: null, + host: "gateway", + security: null, + ask: null, + agentId: "agent-2", + resolvedPath: null, + sessionKey: "agent:agent-2:main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }; + const agents = [ + createAgent("agent-1", "agent:agent-1:main"), + createAgent("agent-2", "agent:agent-2:main"), + ]; + expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-2"); + }); + + it("trusts explicit agent id even when the local agent list has not hydrated it yet", () => { + const requested = { + id: "approval-1", + request: { + command: "pwd", + cwd: null, + host: "gateway", + security: null, + ask: null, + agentId: "agent-prehydration", + resolvedPath: null, + sessionKey: "agent:agent-prehydration:main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }; + const agents = [createAgent("agent-1", "agent:agent-1:main")]; + expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-prehydration"); + }); + + it("falls back to session key when agent id missing", () => { + const requested = { + id: "approval-1", + request: { + command: "pwd", + cwd: null, + host: "gateway", + security: null, + ask: null, + agentId: null, + resolvedPath: null, + sessionKey: "agent:agent-3:main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }; + const agents = [createAgent("agent-3", "agent:agent-3:main")]; + expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-3"); + }); + + it("returns null when no agent mapping matches", () => { + const requested = { + id: "approval-1", + request: { + command: "pwd", + cwd: null, + host: "gateway", + security: null, + ask: null, + agentId: null, + resolvedPath: null, + sessionKey: "agent:missing:main", + }, + createdAtMs: 1, + expiresAtMs: 2, + }; + const agents = [createAgent("agent-1", "agent:agent-1:main")]; + expect(resolveExecApprovalAgentId({ requested, agents })).toBeNull(); + }); +}); diff --git a/tests/unit/execApprovalLifecycleWorkflow.test.ts b/tests/unit/execApprovalLifecycleWorkflow.test.ts new file mode 100644 index 00000000..d33428d6 --- /dev/null +++ b/tests/unit/execApprovalLifecycleWorkflow.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + resolveExecApprovalEventEffects, + resolveExecApprovalFollowUpIntent, + shouldTreatExecApprovalResolveErrorAsUnknownId, +} from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import { GatewayResponseError, type EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (agentId: string, sessionKey: string): AgentState => ({ + agentId, + name: agentId, + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: agentId, + avatarUrl: null, +}); + +const createApproval = (params?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id: "approval-1", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 2, + resolving: false, + error: null, + ...params, +}); + +describe("execApprovalLifecycleWorkflow", () => { + it("maps requested approval into scoped or unscoped upsert effect", () => { + const agents = [createAgent("agent-1", "agent:agent-1:main")]; + const scopedEvent: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-scoped", + request: { + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "agent-1", + resolvedPath: "/usr/bin/npm", + sessionKey: "agent:agent-1:main", + }, + createdAtMs: 123, + expiresAtMs: 456, + }, + }; + const unscopedEvent: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-unscoped", + request: { + command: "npm run lint", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: null, + resolvedPath: "/usr/bin/npm", + sessionKey: "agent:missing:main", + }, + createdAtMs: 222, + expiresAtMs: 333, + }, + }; + + const scopedEffects = resolveExecApprovalEventEffects({ + event: scopedEvent, + agents, + }); + expect(scopedEffects?.scopedUpserts.map((entry) => entry.agentId)).toEqual(["agent-1"]); + expect(scopedEffects?.unscopedUpserts).toEqual([]); + expect(scopedEffects?.markActivityAgentIds).toEqual(["agent-1"]); + + const unscopedEffects = resolveExecApprovalEventEffects({ + event: unscopedEvent, + agents, + }); + expect(unscopedEffects?.scopedUpserts).toEqual([]); + expect(unscopedEffects?.unscopedUpserts).toHaveLength(1); + expect(unscopedEffects?.markActivityAgentIds).toEqual([]); + }); + + it("maps resolved approval event into remove effects", () => { + const event: EventFrame = { + type: "event", + event: "exec.approval.resolved", + payload: { + id: "approval-1", + decision: "allow-once", + resolvedBy: "studio", + ts: 999, + }, + }; + + const effects = resolveExecApprovalEventEffects({ + event, + agents: [createAgent("agent-1", "agent:agent-1:main")], + }); + + expect(effects).toEqual({ + scopedUpserts: [], + unscopedUpserts: [], + removals: ["approval-1"], + markActivityAgentIds: [], + }); + }); + + it("returns follow-up intent only for allow decisions", () => { + const agents = [createAgent("agent-1", "agent:agent-1:main")]; + const approval = createApproval({ agentId: null, sessionKey: "agent:agent-1:main" }); + + expect( + resolveExecApprovalFollowUpIntent({ + decision: "allow-once", + approval, + agents, + followUpMessage: "approval granted", + }) + ).toEqual({ + shouldSend: true, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + message: "approval granted", + }); + + expect( + resolveExecApprovalFollowUpIntent({ + decision: "deny", + approval, + agents, + followUpMessage: "approval granted", + }) + ).toEqual({ + shouldSend: false, + agentId: null, + sessionKey: null, + message: null, + }); + }); + + it("maps unknown approval id gateway error to local removal intent", () => { + expect( + shouldTreatExecApprovalResolveErrorAsUnknownId( + new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "Unknown approval id", + }) + ) + ).toBe(true); + expect( + shouldTreatExecApprovalResolveErrorAsUnknownId( + new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "approval denied by policy", + }) + ) + ).toBe(false); + }); +}); diff --git a/tests/unit/execApprovalPausePolicy.test.ts b/tests/unit/execApprovalPausePolicy.test.ts new file mode 100644 index 00000000..2aef551b --- /dev/null +++ b/tests/unit/execApprovalPausePolicy.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 1, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "high", + avatarSeed: "seed", + avatarUrl: null, + sessionExecAsk: "always", + ...overrides, +}); + +const createApproval = (overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id: "approval-1", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "ls -la", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: null, + createdAtMs: 1, + expiresAtMs: 2, + resolving: false, + error: null, + ...overrides, +}); + +describe("execApprovalPausePolicy", () => { + it("pauses run when approval ask is always", () => { + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent(), + approval: createApproval({ ask: "always" }), + pausedRunId: null, + }) + ).toBe(true); + }); + + it("does not pause when approval ask is not always", () => { + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ sessionExecAsk: "always" }), + approval: createApproval({ ask: "on-miss" }), + pausedRunId: null, + }) + ).toBe(false); + }); + + it("falls back to agent ask when approval ask is missing", () => { + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ sessionExecAsk: "always" }), + approval: createApproval({ ask: null }), + pausedRunId: null, + }) + ).toBe(true); + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ sessionExecAsk: "on-miss" }), + approval: createApproval({ ask: null }), + pausedRunId: null, + }) + ).toBe(false); + }); + + it("does not pause when run is already paused for the same run id", () => { + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ runId: "run-1" }), + approval: createApproval({ ask: "always" }), + pausedRunId: "run-1", + }) + ).toBe(false); + }); + + it("does not pause without active running state", () => { + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ status: "idle" }), + approval: createApproval({ ask: "always" }), + pausedRunId: null, + }) + ).toBe(false); + expect( + shouldPauseRunForPendingExecApproval({ + agent: createAgent({ runId: null }), + approval: createApproval({ ask: "always" }), + pausedRunId: null, + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/execApprovalResolveOperation.test.ts b/tests/unit/execApprovalResolveOperation.test.ts new file mode 100644 index 00000000..b8b04dff --- /dev/null +++ b/tests/unit/execApprovalResolveOperation.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { GatewayResponseError } from "@/lib/gateway/errors"; +import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation"; + +type SetState<T> = (next: T | ((current: T) => T)) => void; + +const createState = <T,>(initial: T): { get: () => T; set: SetState<T> } => { + let value = initial; + return { + get: () => value, + set: (next) => { + value = typeof next === "function" ? (next as (current: T) => T)(value) : next; + }, + }; +}; + +describe("execApprovalResolveOperation", () => { + it("removes approval and refreshes history after allow-once", async () => { + const call = vi.fn(async (method: string) => { + if (method === "exec.approval.resolve") { + return { ok: true }; + } + if (method === "agent.wait") { + return { status: "ok" }; + } + throw new Error(`unexpected method ${method}`); + }); + + const approval: PendingExecApproval = { + id: "appr-1", + agentId: "a1", + sessionKey: "sess-1", + command: "echo hi", + cwd: null, + host: null, + security: null, + ask: null, + resolvedPath: null, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + resolving: false, + error: null, + }; + + const agent = { + agentId: "a1", + sessionKey: "sess-1", + sessionCreated: true, + status: "running", + runId: "run-1", + } as unknown as AgentState; + + const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({ + a1: [approval], + }); + const unscopedApprovals = createState<PendingExecApproval[]>([]); + const requestHistoryRefresh = vi.fn(); + const onAllowResolved = vi.fn(); + const onAllowed = vi.fn(); + + await resolveExecApprovalViaStudio({ + client: { call }, + approvalId: "appr-1", + decision: "allow-once", + getAgents: () => [agent], + getLatestAgent: () => agent, + getPendingState: () => ({ + approvalsByAgentId: approvalsByAgentId.get(), + unscopedApprovals: unscopedApprovals.get(), + }), + setPendingExecApprovalsByAgentId: approvalsByAgentId.set, + setUnscopedPendingExecApprovals: unscopedApprovals.set, + requestHistoryRefresh, + onAllowResolved, + onAllowed, + isDisconnectLikeError: () => false, + }); + + expect(call).toHaveBeenCalledWith("exec.approval.resolve", { id: "appr-1", decision: "allow-once" }); + expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 15_000 }); + + expect(approvalsByAgentId.get()).toEqual({}); + expect(unscopedApprovals.get()).toEqual([]); + expect(onAllowResolved).toHaveBeenCalledWith({ approval, targetAgentId: "a1" }); + expect(requestHistoryRefresh).toHaveBeenCalledWith("a1"); + expect(onAllowed).toHaveBeenCalledWith({ approval, targetAgentId: "a1" }); + expect(onAllowResolved.mock.invocationCallOrder[0]).toBeLessThan( + requestHistoryRefresh.mock.invocationCallOrder[0] + ); + }); + + it("treats unknown approval id as already removed", async () => { + const call = vi.fn(async (method: string) => { + if (method === "exec.approval.resolve") { + throw new GatewayResponseError({ + code: "NOT_FOUND", + message: "unknown approval id appr-1", + }); + } + throw new Error(`unexpected method ${method}`); + }); + + const approval: PendingExecApproval = { + id: "appr-1", + agentId: "a1", + sessionKey: "sess-1", + command: "echo hi", + cwd: null, + host: null, + security: null, + ask: null, + resolvedPath: null, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + resolving: false, + error: null, + }; + + const agent = { + agentId: "a1", + sessionKey: "sess-1", + sessionCreated: true, + status: "running", + runId: "run-1", + } as unknown as AgentState; + + const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({ + a1: [approval], + }); + const unscopedApprovals = createState<PendingExecApproval[]>([]); + const onAllowed = vi.fn(); + + await resolveExecApprovalViaStudio({ + client: { call }, + approvalId: "appr-1", + decision: "allow-once", + getAgents: () => [agent], + getLatestAgent: () => agent, + getPendingState: () => ({ + approvalsByAgentId: approvalsByAgentId.get(), + unscopedApprovals: unscopedApprovals.get(), + }), + setPendingExecApprovalsByAgentId: approvalsByAgentId.set, + setUnscopedPendingExecApprovals: unscopedApprovals.set, + requestHistoryRefresh: vi.fn(), + onAllowed, + isDisconnectLikeError: () => false, + }); + + expect(approvalsByAgentId.get()).toEqual({}); + expect(unscopedApprovals.get()).toEqual([]); + expect(onAllowed).not.toHaveBeenCalled(); + }); + + it("does not trigger onAllowed for deny decisions", async () => { + const call = vi.fn(async (method: string) => { + if (method === "exec.approval.resolve") { + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }); + + const approval: PendingExecApproval = { + id: "appr-1", + agentId: "a1", + sessionKey: "sess-1", + command: "echo hi", + cwd: null, + host: null, + security: null, + ask: null, + resolvedPath: null, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60_000, + resolving: false, + error: null, + }; + + const agent = { + agentId: "a1", + sessionKey: "sess-1", + sessionCreated: true, + status: "running", + runId: "run-1", + } as unknown as AgentState; + + const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({ + a1: [approval], + }); + const unscopedApprovals = createState<PendingExecApproval[]>([]); + const onAllowed = vi.fn(); + + await resolveExecApprovalViaStudio({ + client: { call }, + approvalId: "appr-1", + decision: "deny", + getAgents: () => [agent], + getLatestAgent: () => agent, + getPendingState: () => ({ + approvalsByAgentId: approvalsByAgentId.get(), + unscopedApprovals: unscopedApprovals.get(), + }), + setPendingExecApprovalsByAgentId: approvalsByAgentId.set, + setUnscopedPendingExecApprovals: unscopedApprovals.set, + requestHistoryRefresh: vi.fn(), + onAllowed, + isDisconnectLikeError: () => false, + }); + + expect(onAllowed).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/execApprovalRunControlOperation.test.ts b/tests/unit/execApprovalRunControlOperation.test.ts new file mode 100644 index 00000000..33c76620 --- /dev/null +++ b/tests/unit/execApprovalRunControlOperation.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS, + runExecApprovalAutoResumeOperation, + runGatewayEventIngressOperation, + runPauseRunForExecApprovalOperation, + runResolveExecApprovalOperation, +} from "@/features/agents/approvals/execApprovalRunControlOperation"; +import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 1, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + sessionExecAsk: "always", + ...overrides, +}); + +const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 10_000, + resolving: false, + error: null, + ...overrides, +}); + +const createPendingState = ( + overrides?: Partial<ExecApprovalPendingSnapshot> +): ExecApprovalPendingSnapshot => ({ + approvalsByAgentId: {}, + unscopedApprovals: [], + ...overrides, +}); + +describe("execApprovalRunControlOperation", () => { + it("pauses a run for pending approval after stale paused-run cleanup", async () => { + const call = vi.fn(async () => ({ ok: true })); + const pausedRunIdByAgentId = new Map<string, string>([ + ["stale-agent", "stale-run"], + ]); + + await runPauseRunForExecApprovalOperation({ + status: "connected", + client: { call }, + approval: createApproval("approval-1"), + preferredAgentId: "agent-1", + getAgents: () => [createAgent({ runId: "run-1" })], + pausedRunIdByAgentId, + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + }); + + expect(pausedRunIdByAgentId.has("stale-agent")).toBe(false); + expect(pausedRunIdByAgentId.get("agent-1")).toBe("run-1"); + expect(call).toHaveBeenCalledWith("chat.abort", { + sessionKey: "agent:agent-1:main", + }); + }); + + it("reverts paused-run map entry when pause abort call fails", async () => { + const call = vi.fn(async () => { + throw new Error("abort failed"); + }); + const logWarn = vi.fn(); + const pausedRunIdByAgentId = new Map<string, string>(); + + await runPauseRunForExecApprovalOperation({ + status: "connected", + client: { call }, + approval: createApproval("approval-1"), + preferredAgentId: "agent-1", + getAgents: () => [createAgent({ runId: "run-1" })], + pausedRunIdByAgentId, + isDisconnectLikeError: () => false, + logWarn, + }); + + expect(pausedRunIdByAgentId.has("agent-1")).toBe(false); + expect(logWarn).toHaveBeenCalledWith( + "Failed to pause run for pending exec approval.", + expect.any(Error) + ); + }); + + it("auto-resumes in order: dispatch running, wait paused run, then send follow-up", async () => { + const call = vi.fn(async (method: string) => { + if (method === "agent.wait") return { status: "ok" }; + throw new Error(`Unexpected method ${method}`); + }); + const dispatch = vi.fn(); + const sendChatMessage = vi.fn(async () => undefined); + const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]); + + await runExecApprovalAutoResumeOperation({ + client: { call }, + dispatch, + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + getAgents: () => [createAgent({ status: "running", runId: "run-1" })], + getPendingState: () => createPendingState(), + pausedRunIdByAgentId, + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + sendChatMessage, + now: () => 777, + }); + + expect(pausedRunIdByAgentId.has("agent-1")).toBe(false); + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { status: "running", runId: "run-1", lastActivityAt: 777 }, + }); + expect(call).toHaveBeenCalledWith("agent.wait", { + runId: "run-1", + timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS, + }); + expect(sendChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + echoUserMessage: false, + message: `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`, + }) + ); + + expect(dispatch.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]); + expect(call.mock.invocationCallOrder[0]).toBeLessThan( + sendChatMessage.mock.invocationCallOrder[0] + ); + }); + + it("skips follow-up send when post-wait auto-resume intent no longer holds", async () => { + const call = vi.fn(async () => ({ status: "ok" })); + const dispatch = vi.fn(); + const sendChatMessage = vi.fn(async () => undefined); + const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]); + + let readCount = 0; + const getAgents = () => { + readCount += 1; + if (readCount === 1) { + return [createAgent({ status: "running", runId: "run-1" })]; + } + return [createAgent({ status: "running", runId: "run-2" })]; + }; + + await runExecApprovalAutoResumeOperation({ + client: { call }, + dispatch, + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + getAgents, + getPendingState: () => createPendingState(), + pausedRunIdByAgentId, + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + sendChatMessage, + }); + + expect(call).toHaveBeenCalledWith("agent.wait", { + runId: "run-1", + timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS, + }); + expect(sendChatMessage).not.toHaveBeenCalled(); + }); + + it("resolves approvals through resolver and delegates allow flow to auto-resume operation", async () => { + const resolveExecApproval = vi.fn(async (params: { onAllowed?: (input: { + approval: PendingExecApproval; + targetAgentId: string; + }) => Promise<void> }) => { + await params.onAllowed?.({ + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + }); + }); + const runAutoResume = vi.fn(async () => undefined); + + await runResolveExecApprovalOperation({ + client: { call: vi.fn(async () => ({ ok: true })) }, + approvalId: "approval-1", + decision: "allow-once", + getAgents: () => [createAgent()], + getPendingState: () => createPendingState(), + setPendingExecApprovalsByAgentId: vi.fn(), + setUnscopedPendingExecApprovals: vi.fn(), + requestHistoryRefresh: vi.fn(), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + dispatch: vi.fn(), + isDisconnectLikeError: () => false, + resolveExecApproval: resolveExecApproval as never, + runAutoResume, + }); + + expect(resolveExecApproval).toHaveBeenCalledTimes(1); + expect(runAutoResume).toHaveBeenCalledWith( + expect.objectContaining({ + approval: expect.objectContaining({ id: "approval-1" }), + targetAgentId: "agent-1", + }) + ); + }); + + it("executes ingress commands from gateway events", () => { + const dispatch = vi.fn(); + const replacePendingState = vi.fn(); + const pauseRunForApproval = vi.fn(async () => undefined); + const recordCronDedupeKey = vi.fn(); + + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-1", + sessionId: "session-1", + runAtMs: 123, + status: "ok", + summary: "cron summary", + }, + }; + + const commands = runGatewayEventIngressOperation({ + event, + getAgents: () => [createAgent()], + getPendingState: () => createPendingState(), + pausedRunIdByAgentId: new Map(), + seenCronDedupeKeys: new Set(), + nowMs: 1_000, + replacePendingState, + pauseRunForApproval, + dispatch, + recordCronDedupeKey, + }); + + expect(commands).toHaveLength(2); + expect(recordCronDedupeKey).toHaveBeenCalledWith("cron:job-1:session-1"); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "appendOutput", + agentId: "agent-1", + }) + ); + expect(dispatch).toHaveBeenCalledWith({ + type: "markActivity", + agentId: "agent-1", + at: 123, + }); + expect(replacePendingState).not.toHaveBeenCalled(); + expect(pauseRunForApproval).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/execApprovalRunControlWorkflow.test.ts b/tests/unit/execApprovalRunControlWorkflow.test.ts new file mode 100644 index 00000000..aba803a6 --- /dev/null +++ b/tests/unit/execApprovalRunControlWorkflow.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; + +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + planApprovalIngressRunControl, + planAutoResumeRunControl, + planPauseRunControl, +} from "@/features/agents/approvals/execApprovalRunControlWorkflow"; +import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 1, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + sessionExecAsk: "always", + ...overrides, +}); + +const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 10_000, + resolving: false, + error: null, + ...overrides, +}); + +const createPendingState = ( + overrides?: Partial<ExecApprovalPendingSnapshot> +): ExecApprovalPendingSnapshot => ({ + approvalsByAgentId: {}, + unscopedApprovals: [], + ...overrides, +}); + +describe("execApprovalRunControlWorkflow", () => { + it("plans stale paused-run cleanup together with pause intent", () => { + const plan = planPauseRunControl({ + approval: createApproval("approval-1"), + preferredAgentId: "agent-1", + agents: [ + createAgent({ agentId: "agent-1", runId: "run-1" }), + createAgent({ + agentId: "agent-2", + sessionKey: "agent:agent-2:main", + runId: "run-2", + }), + ], + pausedRunIdByAgentId: new Map([ + ["agent-2", "stale-run"], + ]), + }); + + expect(plan.stalePausedAgentIds).toEqual(["agent-2"]); + expect(plan.pauseIntent).toEqual({ + kind: "pause", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + runId: "run-1", + }); + }); + + it("plans pre-wait and post-wait auto-resume intents", () => { + const plan = planAutoResumeRunControl({ + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + agents: [createAgent({ status: "running", runId: "run-1" })], + }); + + expect(plan.preWaitIntent).toEqual({ + kind: "resume", + targetAgentId: "agent-1", + pausedRunId: "run-1", + sessionKey: "agent:agent-1:main", + }); + expect(plan.postWaitIntent).toEqual({ + kind: "resume", + targetAgentId: "agent-1", + pausedRunId: "run-1", + sessionKey: "agent:agent-1:main", + }); + }); + + it("returns skip intents when pre-wait auto-resume is blocked", () => { + const plan = planAutoResumeRunControl({ + approval: createApproval("approval-1"), + targetAgentId: "agent-1", + pendingState: createPendingState({ + approvalsByAgentId: { + "agent-1": [createApproval("approval-1"), createApproval("approval-2")], + }, + }), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + agents: [createAgent({ status: "running", runId: "run-1" })], + }); + + expect(plan.preWaitIntent).toEqual({ + kind: "skip", + reason: "blocking-pending-approvals", + }); + expect(plan.postWaitIntent).toEqual({ + kind: "skip", + reason: "blocking-pending-approvals", + }); + }); + + it("plans ingress run-control commands from gateway events", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-1", + sessionId: "session-1", + runAtMs: 123, + status: "ok", + summary: "cron summary", + }, + }; + + const commands = planApprovalIngressRunControl({ + event, + agents: [createAgent()], + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map(), + seenCronDedupeKeys: new Set(), + nowMs: 1_000, + }); + + expect(commands).toEqual([ + { kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" }, + { + kind: "appendCronTranscript", + intent: { + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + dedupeKey: "cron:job-1:session-1", + line: "Cron finished (ok): job-1\n\ncron summary", + timestampMs: 123, + activityAtMs: 123, + }, + }, + ]); + }); +}); diff --git a/tests/unit/execApprovalRuntimeCoordinator.test.ts b/tests/unit/execApprovalRuntimeCoordinator.test.ts new file mode 100644 index 00000000..30e25bc9 --- /dev/null +++ b/tests/unit/execApprovalRuntimeCoordinator.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; + +import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + applyApprovalIngressEffects, + deriveAwaitingUserInputPatches, + derivePendingApprovalPruneDelayMs, + prunePendingApprovalState, + resolveApprovalAutoResumeDispatch, + resolveApprovalAutoResumePreflight, + type ApprovalPendingState, +} from "@/features/agents/approvals/execApprovalRuntimeCoordinator"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 1, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + sessionExecAsk: "always", + ...overrides, +}); + +const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({ + id, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 10_000, + resolving: false, + error: null, + ...overrides, +}); + +const createPendingState = (overrides?: Partial<ApprovalPendingState>): ApprovalPendingState => ({ + approvalsByAgentId: {}, + unscopedApprovals: [], + ...overrides, +}); + +describe("execApprovalRuntimeCoordinator", () => { + it("applies scoped/unscoped upserts and removals while deriving pause requests", () => { + const existingScoped = createApproval("existing-scoped"); + const existingUnscoped = createApproval("existing-unscoped", { agentId: null, sessionKey: "agent:other:main" }); + const scopedUpsert = createApproval("approval-scoped", { ask: "always" }); + const unscopedUpsert = createApproval("approval-unscoped", { + agentId: null, + sessionKey: "agent:other:main", + ask: "on-miss", + }); + + const pendingState = createPendingState({ + approvalsByAgentId: { "agent-1": [existingScoped] }, + unscopedApprovals: [existingUnscoped], + }); + + const effects: ExecApprovalEventEffects = { + scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }], + unscopedUpserts: [unscopedUpsert], + removals: ["existing-scoped", "existing-unscoped"], + markActivityAgentIds: ["agent-1"], + }; + + const result = applyApprovalIngressEffects({ + pendingState, + approvalEffects: effects, + agents: [createAgent(), createAgent({ agentId: "other", sessionKey: "agent:other:main", runId: "run-2", sessionExecAsk: "on-miss" })], + pausedRunIdByAgentId: new Map(), + }); + + expect(result.pendingState.approvalsByAgentId).toEqual({ + "agent-1": [scopedUpsert], + }); + expect(result.pendingState.unscopedApprovals).toEqual([unscopedUpsert]); + expect(result.markActivityAgentIds).toEqual(["agent-1"]); + expect(result.pauseRequests).toEqual([{ approval: scopedUpsert, preferredAgentId: "agent-1" }]); + }); + + it("does not emit pause request when run is already paused for the same run id", () => { + const scopedUpsert = createApproval("approval-scoped", { ask: "always" }); + const effects: ExecApprovalEventEffects = { + scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }], + unscopedUpserts: [], + removals: [], + markActivityAgentIds: [], + }; + + const result = applyApprovalIngressEffects({ + pendingState: createPendingState(), + approvalEffects: effects, + agents: [createAgent({ runId: "run-1" })], + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + }); + + expect(result.pauseRequests).toEqual([]); + }); + + it("blocks preflight auto-resume when sibling pending approvals exist", () => { + const pendingState = createPendingState({ + approvalsByAgentId: { + "agent-1": [createApproval("a-1"), createApproval("a-2")], + }, + unscopedApprovals: [], + }); + + const preflight = resolveApprovalAutoResumePreflight({ + approval: createApproval("a-1"), + targetAgentId: "agent-1", + pendingState, + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + }); + + expect(preflight).toEqual({ kind: "skip", reason: "blocking-pending-approvals" }); + }); + + it("allows preflight auto-resume when no blocking approvals remain", () => { + const preflight = resolveApprovalAutoResumePreflight({ + approval: createApproval("a-1"), + targetAgentId: "agent-1", + pendingState: createPendingState(), + pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]), + }); + + expect(preflight).toEqual({ + kind: "resume", + targetAgentId: "agent-1", + pausedRunId: "run-1", + }); + }); + + it("derives dispatch auto-resume intent only when run ownership is still valid", () => { + const replacedRun = resolveApprovalAutoResumeDispatch({ + targetAgentId: "agent-1", + pausedRunId: "run-1", + agents: [createAgent({ status: "running", runId: "run-2" })], + }); + expect(replacedRun).toEqual({ kind: "skip", reason: "run-replaced" }); + + const resume = resolveApprovalAutoResumeDispatch({ + targetAgentId: "agent-1", + pausedRunId: "run-1", + agents: [createAgent({ status: "running", runId: "run-1", sessionKey: "agent:agent-1:main" })], + }); + expect(resume).toEqual({ + kind: "resume", + targetAgentId: "agent-1", + pausedRunId: "run-1", + sessionKey: "agent:agent-1:main", + }); + }); + + it("derives awaiting-user-input patches from scoped pending approvals", () => { + const agents = [ + createAgent({ agentId: "agent-1", awaitingUserInput: false }), + createAgent({ agentId: "agent-2", awaitingUserInput: true, runId: "run-2", sessionKey: "agent:agent-2:main" }), + createAgent({ agentId: "agent-3", awaitingUserInput: false, runId: "run-3", sessionKey: "agent:agent-3:main" }), + ]; + + const patches = deriveAwaitingUserInputPatches({ + agents, + approvalsByAgentId: { + "agent-1": [createApproval("a-1")], + }, + }); + + expect(patches).toEqual([ + { agentId: "agent-1", awaitingUserInput: true }, + { agentId: "agent-2", awaitingUserInput: false }, + ]); + }); + + it("derives prune delay and pruned pending state", () => { + const pendingState = createPendingState({ + approvalsByAgentId: { + "agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })], + }, + unscopedApprovals: [createApproval("u-1", { agentId: null, expiresAtMs: 7_500 })], + }); + + const delay = derivePendingApprovalPruneDelayMs({ + pendingState, + nowMs: 5_000, + graceMs: 500, + }); + expect(delay).toBe(1_500); + + const pruned = prunePendingApprovalState({ + pendingState: { + approvalsByAgentId: { + "agent-1": [ + createApproval("expired", { expiresAtMs: 4_000 }), + createApproval("active", { expiresAtMs: 6_000 }), + ], + }, + unscopedApprovals: [ + createApproval("expired-u", { agentId: null, expiresAtMs: 4_100 }), + createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }), + ], + }, + nowMs: 5_000, + graceMs: 500, + }); + + expect(pruned.pendingState.approvalsByAgentId).toEqual({ + "agent-1": [createApproval("active", { expiresAtMs: 6_000 })], + }); + expect(pruned.pendingState.unscopedApprovals).toEqual([ + createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }), + ]); + }); +}); diff --git a/tests/unit/extractThinking.test.ts b/tests/unit/extractThinking.test.ts index 61db994d..e3cbe9c4 100644 --- a/tests/unit/extractThinking.test.ts +++ b/tests/unit/extractThinking.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import { extractThinking, + extractThinkingFromTaggedStream, + extractThinkingFromTaggedText, formatThinkingMarkdown, isTraceMarkdown, stripTraceMarkdown, -} from "@/lib/text/extractThinking"; +} from "@/lib/text/message-extract"; describe("extractThinking", () => { it("extracts thinking blocks from content arrays", () => { @@ -41,6 +43,35 @@ describe("extractThinking", () => { expect(extractThinking(message)).toBe("Plan A"); }); + it("extracts partial thinking from an open thinking tag", () => { + const message = { + role: "assistant", + content: "Hello <think>Plan A so far", + }; + + expect(extractThinking(message)).toBe("Plan A so far"); + }); + + it("extracts reasoning from runtime variant fields", () => { + const message = { + role: "assistant", + reasoningText: "Plan A", + }; + + expect(extractThinking(message)).toBe("Plan A"); + }); + + it("extracts reasoning from nested runtime deltas", () => { + const message = { + role: "assistant", + reasoning: { + delta: "still thinking", + }, + }; + + expect(extractThinking(message)).toBe("still thinking"); + }); + it("returns null when no thinking exists", () => { const message = { role: "assistant", @@ -65,6 +96,18 @@ describe("formatThinkingMarkdown", () => { const input = "Line 1\n\n Line 2 "; const formatted = formatThinkingMarkdown(input); expect(isTraceMarkdown(formatted)).toBe(true); - expect(stripTraceMarkdown(formatted)).toBe("_Line 1_\n_Line 2_"); + expect(stripTraceMarkdown(formatted)).toBe("_Line 1_\n\n_Line 2_"); + }); +}); + +describe("extractThinkingFromTaggedText", () => { + it("extracts from closed thinking tags", () => { + expect(extractThinkingFromTaggedText("<thinking>Plan A</thinking>\nOk")).toBe("Plan A"); + }); +}); + +describe("extractThinkingFromTaggedStream", () => { + it("extracts partial thinking from an open thinking tag", () => { + expect(extractThinkingFromTaggedStream("Hello <think>Plan A so far")).toBe("Plan A so far"); }); }); diff --git a/tests/unit/fleetLifecycleWorkflow.integration.test.ts b/tests/unit/fleetLifecycleWorkflow.integration.test.ts new file mode 100644 index 00000000..478b4412 --- /dev/null +++ b/tests/unit/fleetLifecycleWorkflow.integration.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; + +import { + buildLatestUpdatePatch, + resolveLatestUpdateIntent, +} from "@/features/agents/operations/latestUpdateWorkflow"; +import { + buildReconcileTerminalPatch, + resolveReconcileEligibility, + resolveReconcileWaitOutcome, + resolveSummarySnapshotIntent, +} from "@/features/agents/operations/fleetLifecycleWorkflow"; +import { buildSummarySnapshotPatches } from "@/features/agents/state/runtimeEventBridge"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (agentId: string, sessionKey: string, status: AgentState["status"]): AgentState => ({ + agentId, + name: agentId, + sessionKey, + status, + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: status === "running" ? "run-1" : null, + runStartedAt: status === "running" ? 1 : null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: agentId, + avatarUrl: null, +}); + +describe("fleetLifecycleWorkflow integration", () => { + it("page adapter applies latest-update reset/update intents without behavior drift", () => { + const resetIntent = resolveLatestUpdateIntent({ + message: "regular prompt", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + hasExistingOverride: true, + }); + expect(resetIntent).toEqual({ kind: "reset" }); + expect(buildLatestUpdatePatch("")).toEqual({ + latestOverride: null, + latestOverrideKind: null, + }); + + const heartbeatIntent = resolveLatestUpdateIntent({ + message: "heartbeat status please", + agentId: "", + sessionKey: "agent:agent-1:main", + hasExistingOverride: false, + }); + expect(heartbeatIntent).toEqual({ + kind: "fetch-heartbeat", + agentId: "agent-1", + sessionLimit: 48, + historyLimit: 200, + }); + expect(buildLatestUpdatePatch("Heartbeat is healthy.", "heartbeat")).toEqual({ + latestOverride: "Heartbeat is healthy.", + latestOverrideKind: "heartbeat", + }); + }); + + it("summary snapshot flow preserves status + preview patch application semantics", () => { + const agents = [ + createAgent("agent-1", "agent:agent-1:main", "idle"), + createAgent("agent-2", "agent:agent-2:main", "running"), + ]; + const summaryIntent = resolveSummarySnapshotIntent({ + agents, + maxKeys: 64, + }); + expect(summaryIntent).toEqual({ + kind: "fetch", + keys: ["agent:agent-1:main", "agent:agent-2:main"], + limit: 8, + maxChars: 240, + }); + + const patches = buildSummarySnapshotPatches({ + agents, + statusSummary: { + sessions: { + recent: [{ key: "agent:agent-1:main", updatedAt: 1234 }], + byAgent: [], + }, + }, + previewResult: { + ts: 1234, + previews: [ + { + key: "agent:agent-1:main", + status: "ok", + items: [ + { role: "user", text: "ping", timestamp: 1000 }, + { role: "assistant", text: "pong", timestamp: 1200 }, + ], + }, + ], + }, + }); + + expect(patches).toEqual([ + { + agentId: "agent-1", + patch: { + lastActivityAt: 1234, + lastAssistantMessageAt: 1200, + latestPreview: "pong", + lastUserMessage: "ping", + }, + }, + ]); + }); + + it("run reconciliation preserves terminal transition semantics and history reload trigger", () => { + const runReconcileAdapter = (params: { + status: AgentState["status"]; + sessionCreated: boolean; + runId: string | null; + waitStatus: unknown; + }) => { + const eligibility = resolveReconcileEligibility({ + status: params.status, + sessionCreated: params.sessionCreated, + runId: params.runId, + }); + if (!eligibility.shouldCheck) { + return { patch: null, shouldReloadHistory: false }; + } + const outcome = resolveReconcileWaitOutcome(params.waitStatus); + if (!outcome) { + return { patch: null, shouldReloadHistory: false }; + } + return { + patch: buildReconcileTerminalPatch({ outcome }), + shouldReloadHistory: true, + }; + }; + + expect( + runReconcileAdapter({ + status: "running", + sessionCreated: true, + runId: "run-1", + waitStatus: "ok", + }) + ).toEqual({ + patch: { + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }, + shouldReloadHistory: true, + }); + expect( + runReconcileAdapter({ + status: "running", + sessionCreated: true, + runId: "run-1", + waitStatus: "error", + }) + ).toEqual({ + patch: { + status: "error", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }, + shouldReloadHistory: true, + }); + }); +}); diff --git a/tests/unit/fleetLifecycleWorkflow.test.ts b/tests/unit/fleetLifecycleWorkflow.test.ts new file mode 100644 index 00000000..41d91817 --- /dev/null +++ b/tests/unit/fleetLifecycleWorkflow.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { + buildReconcileTerminalPatch, + resolveReconcileEligibility, + resolveReconcileWaitOutcome, + resolveSummarySnapshotIntent, +} from "@/features/agents/operations/fleetLifecycleWorkflow"; + +describe("fleetLifecycleWorkflow", () => { + it("returns summary snapshot skip when no valid session keys exist", () => { + expect( + resolveSummarySnapshotIntent({ + agents: [ + { sessionCreated: false, sessionKey: "agent:agent-1:main" }, + { sessionCreated: true, sessionKey: "" }, + { sessionCreated: true, sessionKey: " " }, + ], + maxKeys: 64, + }) + ).toEqual({ kind: "skip" }); + }); + + it("returns summary snapshot fetch intent when session keys are present", () => { + expect( + resolveSummarySnapshotIntent({ + agents: [ + { sessionCreated: true, sessionKey: "agent:agent-1:main" }, + { sessionCreated: true, sessionKey: "agent:agent-1:main" }, + { sessionCreated: true, sessionKey: "agent:agent-2:main" }, + { sessionCreated: true, sessionKey: "agent:agent-3:main" }, + ], + maxKeys: 2, + }) + ).toEqual({ + kind: "fetch", + keys: ["agent:agent-1:main", "agent:agent-2:main"], + limit: 8, + maxChars: 240, + }); + }); + + it("maps reconcile wait result ok/error to idle/error terminal patch", () => { + expect(resolveReconcileWaitOutcome("ok")).toBe("ok"); + expect(resolveReconcileWaitOutcome("error")).toBe("error"); + expect(resolveReconcileWaitOutcome("running")).toBeNull(); + expect(buildReconcileTerminalPatch({ outcome: "ok" })).toEqual({ + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }); + expect(buildReconcileTerminalPatch({ outcome: "error" })).toEqual({ + status: "error", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }); + }); + + it("rejects reconcile intent for non-running or missing-run agents", () => { + expect( + resolveReconcileEligibility({ + status: "idle", + sessionCreated: true, + runId: "run-1", + }) + ).toEqual({ + shouldCheck: false, + reason: "not-running", + }); + expect( + resolveReconcileEligibility({ + status: "running", + sessionCreated: false, + runId: "run-1", + }) + ).toEqual({ + shouldCheck: false, + reason: "not-session-created", + }); + expect( + resolveReconcileEligibility({ + status: "running", + sessionCreated: true, + runId: " ", + }) + ).toEqual({ + shouldCheck: false, + reason: "missing-run-id", + }); + expect( + resolveReconcileEligibility({ + status: "running", + sessionCreated: true, + runId: "run-1", + }) + ).toEqual({ + shouldCheck: true, + reason: "ok", + }); + }); +}); diff --git a/tests/unit/fleetSidebar-create.test.ts b/tests/unit/fleetSidebar-create.test.ts new file mode 100644 index 00000000..4dd13bb3 --- /dev/null +++ b/tests/unit/fleetSidebar-create.test.ts @@ -0,0 +1,147 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import type { AgentState } from "@/features/agents/state/store"; +import { FleetSidebar } from "@/features/agents/components/FleetSidebar"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("FleetSidebar new agent action", () => { + afterEach(() => { + cleanup(); + }); + + it("renders New agent button", () => { + render( + createElement(FleetSidebar, { + agents: [createAgent()], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent: vi.fn(), + }) + ); + + expect(screen.getByTestId("fleet-new-agent-button")).toBeInTheDocument(); + expect(screen.getByText("New agent")).toBeInTheDocument(); + }); + + it("calls onCreateAgent when clicked", () => { + const onCreateAgent = vi.fn(); + render( + createElement(FleetSidebar, { + agents: [createAgent()], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent, + }) + ); + + fireEvent.click(screen.getByTestId("fleet-new-agent-button")); + expect(onCreateAgent).toHaveBeenCalledTimes(1); + }); + + it("disables create button when createDisabled=true", () => { + render( + createElement(FleetSidebar, { + agents: [createAgent()], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent: vi.fn(), + createDisabled: true, + }) + ); + + expect(screen.getByTestId("fleet-new-agent-button")).toBeDisabled(); + }); + + it("shows approvals tab instead of idle tab", () => { + render( + createElement(FleetSidebar, { + agents: [createAgent()], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent: vi.fn(), + }) + ); + + expect(screen.getByTestId("fleet-filter-approvals")).toBeInTheDocument(); + expect(screen.queryByTestId("fleet-filter-idle")).toBeNull(); + }); + + it("shows needs approval badge for awaiting agents", () => { + render( + createElement(FleetSidebar, { + agents: [{ ...createAgent(), awaitingUserInput: true }], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent: vi.fn(), + }) + ); + + const approvalBadge = screen.getByText("Needs approval"); + expect(approvalBadge).toBeInTheDocument(); + expect(approvalBadge).toHaveClass("ui-badge-approval"); + expect(approvalBadge).toHaveAttribute("data-status", "approval"); + }); + + it("renders semantic class and status marker for agent status badge", () => { + render( + createElement(FleetSidebar, { + agents: [{ ...createAgent(), status: "running" }], + selectedAgentId: "agent-1", + filter: "all", + onFilterChange: vi.fn(), + onSelectAgent: vi.fn(), + onCreateAgent: vi.fn(), + }) + ); + + const row = screen.getByTestId("fleet-agent-row-agent-1"); + const statusBadge = within(row).getByText("Running"); + expect(statusBadge).toHaveAttribute("data-status", "running"); + expect(statusBadge).toHaveClass("ui-badge-status-running"); + }); +}); diff --git a/tests/unit/gatewayAgentFiles.test.ts b/tests/unit/gatewayAgentFiles.test.ts new file mode 100644 index 00000000..6a03f53f --- /dev/null +++ b/tests/unit/gatewayAgentFiles.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readGatewayAgentFile } from "@/lib/gateway/agentFiles"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +const createMockClient = (handler: (method: string, params: unknown) => unknown) => { + return { call: vi.fn(async (method: string, params: unknown) => handler(method, params)) } as unknown as GatewayClient; +}; + +describe("gateway agent files helpers", () => { + it("returns exists=false when gateway reports missing", async () => { + const client = createMockClient((method) => { + if (method === "agents.files.get") { + return { file: { missing: true } }; + } + return {}; + }); + + await expect( + readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" }) + ).resolves.toEqual({ exists: false, content: "" }); + }); + + it("returns exists=true and content when gateway returns content", async () => { + const client = createMockClient((method) => { + if (method === "agents.files.get") { + return { file: { missing: false, content: "hello" } }; + } + return {}; + }); + + await expect( + readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" }) + ).resolves.toEqual({ exists: true, content: "hello" }); + }); + + it("coerces non-string content to empty string", async () => { + const client = createMockClient((method) => { + if (method === "agents.files.get") { + return { file: { missing: false, content: { nope: true } } }; + } + return {}; + }); + + await expect( + readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" }) + ).resolves.toEqual({ exists: true, content: "" }); + }); + + it("throws when agentId is empty", async () => { + const client = createMockClient(() => ({})); + await expect( + readGatewayAgentFile({ client, agentId: " ", name: "AGENTS.md" }) + ).rejects.toThrow("agentId is required."); + }); +}); + diff --git a/tests/unit/gatewayAgentOverrides.test.ts b/tests/unit/gatewayAgentOverrides.test.ts new file mode 100644 index 00000000..1c928e4d --- /dev/null +++ b/tests/unit/gatewayAgentOverrides.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GatewayResponseError } from "@/lib/gateway/GatewayClient"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; + +describe("updateGatewayAgentOverrides", () => { + it("writes additive alsoAllow entries for per-agent tools", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-additive-1", + config: { + agents: { + list: [{ id: "agent-1", tools: { profile: "coding" } }], + }, + }, + }; + } + if (method === "config.set") { + const raw = (params as { raw?: string }).raw ?? ""; + const parsed = JSON.parse(raw) as { + agents?: { list?: Array<{ id?: string; tools?: { profile?: string; alsoAllow?: string[]; deny?: string[] } }> }; + }; + const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); + expect(entry?.tools).toEqual({ + profile: "coding", + alsoAllow: ["group:web", "group:runtime"], + deny: ["group:fs"], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + profile: "coding", + alsoAllow: ["group:web", "group:web", " group:runtime "], + deny: ["group:fs", "group:fs"], + }, + }, + }); + }); + + it("drops legacy allow when writing additive alsoAllow", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-additive-2", + config: { + agents: { + list: [{ id: "agent-1", tools: { profile: "coding", allow: ["group:web"] } }], + }, + }, + }; + } + if (method === "config.set") { + const raw = (params as { raw?: string }).raw ?? ""; + const parsed = JSON.parse(raw) as { + agents?: { + list?: Array<{ + id?: string; + tools?: { + profile?: string; + allow?: string[]; + alsoAllow?: string[]; + deny?: string[]; + }; + }>; + }; + }; + const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); + expect(entry?.tools).toEqual({ + profile: "coding", + alsoAllow: ["group:runtime"], + deny: ["group:fs"], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + alsoAllow: ["group:runtime"], + deny: ["group:fs"], + }, + }, + }); + }); + + it("preserves redacted non-agent fields when writing full config", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-redacted-1", + config: { + models: { + providers: { + xai: { + models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }], + }, + }, + }, + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.set") { + const raw = (params as { raw?: string }).raw ?? ""; + const parsed = JSON.parse(raw) as Record<string, unknown>; + expect(parsed.models).toEqual({ + providers: { + xai: { + models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }], + }, + }, + }); + expect(parsed.agents).toEqual({ + list: [ + { + id: "agent-1", + tools: { + profile: "coding", + alsoAllow: ["group:runtime"], + }, + }, + ], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + profile: "coding", + alsoAllow: ["group:runtime"], + }, + }, + }); + }); + + it("preserves concurrent config changes when config.set retries after stale hash", async () => { + let configGetCount = 0; + let configSetCount = 0; + const callOrder: string[] = []; + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + callOrder.push(method); + if (method === "config.get") { + configGetCount += 1; + if (configGetCount === 1) { + return { + exists: true, + hash: "cfg-retry-1", + config: { + gateway: { + reload: { + mode: "hybrid", + }, + }, + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + } + return { + exists: true, + hash: "cfg-retry-2", + config: { + gateway: { + reload: { + mode: "off", + }, + }, + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.set") { + configSetCount += 1; + const payload = params as { raw?: string; baseHash?: string }; + const parsed = JSON.parse(payload.raw ?? "") as { + gateway?: { reload?: { mode?: string } }; + agents?: { + list?: Array<{ + id?: string; + tools?: { + profile?: string; + alsoAllow?: string[]; + }; + }>; + }; + }; + if (configSetCount === 1) { + expect(payload.baseHash).toBe("cfg-retry-1"); + expect(parsed.gateway?.reload?.mode).toBe("hybrid"); + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "config changed since last load; re-run config.get and retry", + }); + } + expect(payload.baseHash).toBe("cfg-retry-2"); + expect(parsed.gateway?.reload?.mode).toBe("off"); + expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({ + profile: "coding", + alsoAllow: ["group:web"], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + profile: "coding", + alsoAllow: ["group:web"], + }, + }, + }); + + expect(configGetCount).toBe(2); + expect(configSetCount).toBe(2); + expect(callOrder).toEqual(["config.get", "config.set", "config.get", "config.set"]); + }); + + it("omits baseHash when config does not exist yet", async () => { + let configSetCount = 0; + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: false, + config: { + agents: { + list: [], + }, + }, + }; + } + if (method === "config.set") { + configSetCount += 1; + const payload = params as { raw?: string; baseHash?: string }; + const parsed = JSON.parse(payload.raw ?? "") as { + agents?: { + list?: Array<{ + id?: string; + tools?: { + alsoAllow?: string[]; + deny?: string[]; + }; + }>; + }; + }; + expect(payload.baseHash).toBeUndefined(); + expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({ + alsoAllow: ["group:runtime"], + deny: ["group:fs"], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + alsoAllow: ["group:runtime"], + deny: ["group:fs"], + }, + }, + }); + + expect(configSetCount).toBe(1); + }); + + it("fails after a single stale-hash retry attempt", async () => { + let configGetCount = 0; + let configSetCount = 0; + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + configGetCount += 1; + return { + exists: true, + hash: `cfg-stale-${configGetCount}`, + config: { + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.set") { + configSetCount += 1; + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "config changed since last load; re-run config.get and retry", + }); + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await expect( + updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + alsoAllow: ["group:runtime"], + }, + }, + }) + ).rejects.toBeInstanceOf(GatewayResponseError); + + expect(configGetCount).toBe(2); + expect(configSetCount).toBe(2); + }); + + it("fails fast when both allow and alsoAllow are provided", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect( + updateGatewayAgentOverrides({ + client, + agentId: "agent-1", + overrides: { + tools: { + allow: ["group:runtime"], + alsoAllow: ["group:web"], + }, + }, + }) + ).rejects.toThrow("Agent tools overrides cannot set both allow and alsoAllow."); + + expect(client.call).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/gatewayAgentSkillsAllowlist.test.ts b/tests/unit/gatewayAgentSkillsAllowlist.test.ts new file mode 100644 index 00000000..879b446e --- /dev/null +++ b/tests/unit/gatewayAgentSkillsAllowlist.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GatewayResponseError } from "@/lib/gateway/GatewayClient"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + readGatewayAgentSkillsAllowlist, + updateGatewayAgentSkillsAllowlist, +} from "@/lib/gateway/agentConfig"; + +describe("gateway agent skills allowlist", () => { + it("reads and normalizes existing skills allowlist", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-read-1", + config: { + agents: { + list: [{ id: "agent-1", skills: [" github ", "slack", "github"] }], + }, + }, + }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await expect( + readGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + }) + ).resolves.toEqual(["github", "slack"]); + }); + + it("writes mode all by removing the skills key", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-all-1", + config: { + agents: { + list: [{ id: "agent-1", skills: ["github", "slack"] }], + }, + }, + }; + } + if (method === "config.set") { + const payload = params as { raw?: string; baseHash?: string }; + const parsed = JSON.parse(payload.raw ?? "") as { + agents?: { list?: Array<{ id?: string; skills?: string[] }> }; + }; + expect(payload.baseHash).toBe("cfg-all-1"); + const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); + expect(entry).toEqual({ id: "agent-1" }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "all", + }); + }); + + it("writes mode none and mode allowlist with normalized names", async () => { + const calls: Array<{ method: string; params?: unknown }> = []; + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + calls.push({ method, params }); + if (method === "config.get") { + return { + exists: true, + hash: `cfg-${calls.length}`, + config: { + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.set") { + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "none", + }); + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "allowlist", + skillNames: [" slack ", "github", "slack"], + }); + + const nonePayload = calls.find( + (entry) => entry.method === "config.set" + )?.params as { raw?: string }; + const setCalls = calls.filter((entry) => entry.method === "config.set"); + const allowPayload = setCalls[1]?.params as { raw?: string }; + + expect( + (JSON.parse(nonePayload.raw ?? "") as { agents?: { list?: Array<{ id?: string; skills?: string[] }> } }) + .agents?.list?.find((entry) => entry.id === "agent-1") + ).toEqual({ id: "agent-1", skills: [] }); + expect( + (JSON.parse(allowPayload.raw ?? "") as { + agents?: { list?: Array<{ id?: string; skills?: string[] }> }; + }).agents?.list?.find((entry) => entry.id === "agent-1") + ).toEqual({ id: "agent-1", skills: ["github", "slack"] }); + }); + + it("retries once after stale hash and preserves concurrent config changes", async () => { + let getCount = 0; + let setCount = 0; + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + getCount += 1; + if (getCount === 1) { + return { + exists: true, + hash: "cfg-retry-1", + config: { + gateway: { reload: { mode: "hybrid" } }, + agents: { list: [{ id: "agent-1" }] }, + }, + }; + } + return { + exists: true, + hash: "cfg-retry-2", + config: { + gateway: { reload: { mode: "off" } }, + agents: { list: [{ id: "agent-1" }] }, + }, + }; + } + if (method === "config.set") { + setCount += 1; + const payload = params as { raw?: string; baseHash?: string }; + const parsed = JSON.parse(payload.raw ?? "") as { + gateway?: { reload?: { mode?: string } }; + agents?: { list?: Array<{ id?: string; skills?: string[] }> }; + }; + if (setCount === 1) { + expect(payload.baseHash).toBe("cfg-retry-1"); + expect(parsed.gateway?.reload?.mode).toBe("hybrid"); + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "config changed since last load; re-run config.get and retry", + }); + } + expect(payload.baseHash).toBe("cfg-retry-2"); + expect(parsed.gateway?.reload?.mode).toBe("off"); + expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")).toEqual({ + id: "agent-1", + skills: ["github"], + }); + return { ok: true }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "allowlist", + skillNames: ["github"], + }); + + expect(getCount).toBe(2); + expect(setCount).toBe(2); + }); + + it("fails fast when mode allowlist omits skill names", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect( + updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "allowlist", + }) + ).rejects.toThrow("Skills allowlist is required when mode is allowlist."); + expect(client.call).not.toHaveBeenCalled(); + }); + + it("skips config.set when mode all is already implied", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-skip-write", + config: { agents: { list: [{ id: "other-agent" }] } }, + }; + } + if (method === "config.set") { + throw new Error("config.set should not be called for no-op mode all"); + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "all", + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("config.get", {}); + }); + + it("skips config.set when mode all has no explicit skills on existing agent entry", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-skip-write-existing", + config: { agents: { list: [{ id: "agent-1", name: "Agent One" }] } }, + }; + } + if (method === "config.set") { + throw new Error("config.set should not be called for no-op mode all"); + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "all", + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("config.get", {}); + }); + + it("skips config.set when allowlist is unchanged after normalization", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "cfg-skip-write-allowlist", + config: { agents: { list: [{ id: "agent-1", skills: [" github ", "slack"] }] } }, + }; + } + if (method === "config.set") { + throw new Error("config.set should not be called for unchanged allowlist"); + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as GatewayClient; + + await updateGatewayAgentSkillsAllowlist({ + client, + agentId: "agent-1", + mode: "allowlist", + skillNames: ["slack", "github", "github"], + }); + + expect(client.call).toHaveBeenCalledTimes(1); + expect(client.call).toHaveBeenCalledWith("config.get", {}); + }); +}); diff --git a/tests/unit/gatewayBrowserClient.test.ts b/tests/unit/gatewayBrowserClient.test.ts new file mode 100644 index 00000000..90b265b2 --- /dev/null +++ b/tests/unit/gatewayBrowserClient.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GatewayBrowserClient } from "@/lib/gateway/openclaw/GatewayBrowserClient"; + +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class MockWebSocket { + static OPEN = 1; + static CLOSED = 3; + static instances: MockWebSocket[] = []; + static sent: string[] = []; + static closes: Array<{ code: number; reason: string }> = []; + + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + + constructor(public url: string) { + MockWebSocket.instances.push(this); + } + + send(data: string) { + MockWebSocket.sent.push(String(data)); + } + + close(code?: number, reason?: string) { + MockWebSocket.closes.push({ code: code ?? 1000, reason: reason ?? "" }); + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code: code ?? 1000, reason: reason ?? "" } as CloseEvent); + } +} + +describe("GatewayBrowserClient", () => { + const originalWebSocket = globalThis.WebSocket; + const originalSubtle = globalThis.crypto?.subtle; + + beforeEach(() => { + MockWebSocket.instances = []; + MockWebSocket.sent = []; + MockWebSocket.closes = []; + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + if (globalThis.crypto) { + Object.defineProperty(globalThis.crypto, "subtle", { + value: undefined, + configurable: true, + }); + } + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.WebSocket = originalWebSocket; + if (globalThis.crypto) { + Object.defineProperty(globalThis.crypto, "subtle", { + value: originalSubtle, + configurable: true, + }); + } + }); + + it("sends connect when connect.challenge arrives", async () => { + const client = new GatewayBrowserClient({ url: "ws://example.com" }); + client.start(); + + const ws = MockWebSocket.instances[0]; + if (!ws) { + throw new Error("WebSocket not created"); + } + + ws.onopen?.(); + + expect(MockWebSocket.sent).toHaveLength(0); + + ws.onmessage?.({ + data: JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "abc" }, + }), + } as MessageEvent); + + await vi.runAllTicks(); + + expect(MockWebSocket.sent).toHaveLength(1); + const frame = JSON.parse(MockWebSocket.sent[0] ?? "{}"); + expect(frame.type).toBe("req"); + expect(frame.method).toBe("connect"); + expect(typeof frame.id).toBe("string"); + expect(frame.id).toMatch(UUID_V4_RE); + expect(frame.params?.client?.id).toBe("openclaw-control-ui"); + }); + + it("truncates connect-failed close reason to websocket limit", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = new GatewayBrowserClient({ url: "ws://example.com", token: "secret" }); + client.start(); + + const ws = MockWebSocket.instances[0]; + if (!ws) { + throw new Error("WebSocket not created"); + } + + ws.onopen?.(); + vi.runAllTimers(); + + const connectFrame = JSON.parse(MockWebSocket.sent[0] ?? "{}"); + const connectId = String(connectFrame.id ?? ""); + expect(connectId).toMatch(UUID_V4_RE); + + ws.onmessage?.({ + data: JSON.stringify({ + type: "res", + id: connectId, + ok: false, + error: { + code: "INVALID_REQUEST", + message: `invalid config ${"x".repeat(260)}`, + }, + }), + } as MessageEvent); + + await vi.runAllTicks(); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + const lastClose = MockWebSocket.closes.at(-1); + expect(lastClose?.code).toBe(4008); + expect(lastClose?.reason.startsWith("connect failed: INVALID_REQUEST")).toBe(true); + expect(new TextEncoder().encode(lastClose?.reason ?? "").byteLength).toBeLessThanOrEqual(123); + warnSpy.mockRestore(); + }); +}); diff --git a/tests/unit/gatewayClient.connectErrors.test.ts b/tests/unit/gatewayClient.connectErrors.test.ts new file mode 100644 index 00000000..d3b19fbd --- /dev/null +++ b/tests/unit/gatewayClient.connectErrors.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GatewayResponseError } from "@/lib/gateway/errors"; +import { GatewayClient } from "@/lib/gateway/GatewayClient"; + +let lastOpts: Record<string, unknown> | null = null; + +vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { + class GatewayBrowserClient { + connected = false; + constructor(opts: Record<string, unknown>) { + lastOpts = opts; + } + start() {} + stop() {} + request() { + return Promise.resolve({}); + } + } + + return { GatewayBrowserClient }; +}); + +describe("GatewayClient connect failures", () => { + it("rejects connect with GatewayResponseError when close reason encodes connect failed", async () => { + const client = new GatewayClient(); + + const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" }); + + if (!lastOpts) { + throw new Error("Expected GatewayBrowserClient to be constructed"); + } + + const onClose = lastOpts.onClose as ((info: { code: number; reason: string }) => void) | undefined; + if (!onClose) { + throw new Error("Expected onClose callback"); + } + + onClose({ + code: 4008, + reason: + "connect failed: studio.gateway_token_missing Upstream gateway token is not configured on the Studio host.", + }); + + await expect(connectPromise).rejects.toBeInstanceOf(GatewayResponseError); + await expect(connectPromise).rejects.toMatchObject({ + name: "GatewayResponseError", + code: "studio.gateway_token_missing", + }); + }); +}); + diff --git a/tests/unit/gatewayClient.gap.test.ts b/tests/unit/gatewayClient.gap.test.ts new file mode 100644 index 00000000..2374546f --- /dev/null +++ b/tests/unit/gatewayClient.gap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GatewayClient } from "@/lib/gateway/GatewayClient"; + +let lastOpts: Record<string, unknown> | null = null; + +vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { + class GatewayBrowserClient { + connected = true; + constructor(opts: Record<string, unknown>) { + lastOpts = opts; + } + start() {} + stop() {} + request() { + return Promise.resolve({}); + } + } + return { GatewayBrowserClient }; +}); + +describe("GatewayClient onGap", () => { + it("forwards gateway seq gaps to subscribers", async () => { + const client = new GatewayClient(); + const onGap = vi.fn(); + client.onGap(onGap); + + const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" }); + if (!lastOpts) throw new Error("Expected GatewayBrowserClient to be constructed"); + + const onHello = lastOpts.onHello as ((hello: unknown) => void) | undefined; + if (!onHello) throw new Error("Expected onHello callback"); + onHello({} as never); + + await connectPromise; + + const gapCb = lastOpts.onGap as ((info: { expected: number; received: number }) => void) | undefined; + if (!gapCb) throw new Error("Expected onGap callback"); + gapCb({ expected: 10, received: 13 }); + + expect(onGap).toHaveBeenCalledTimes(1); + expect(onGap).toHaveBeenCalledWith({ expected: 10, received: 13 }); + }); +}); + diff --git a/tests/unit/gatewayClient.reconnect-recovery.test.ts b/tests/unit/gatewayClient.reconnect-recovery.test.ts new file mode 100644 index 00000000..1dc08f6d --- /dev/null +++ b/tests/unit/gatewayClient.reconnect-recovery.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GatewayClient } from "@/lib/gateway/GatewayClient"; + +type MockClientOptions = { + token?: unknown; + onHello?: (hello: unknown) => void; + onClose?: (info: { code: number; reason: string }) => void; +}; + +type MockInstance = { + opts: MockClientOptions; + stopped: boolean; +}; + +let instances: MockInstance[] = []; + +vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => { + class GatewayBrowserClient { + connected = false; + private index: number; + + constructor(opts: MockClientOptions) { + this.index = instances.length; + instances.push({ opts, stopped: false }); + } + + start() { + this.connected = true; + } + + stop() { + this.connected = false; + instances[this.index]!.stopped = true; + } + + request() { + return Promise.resolve({}); + } + } + + return { GatewayBrowserClient }; +}); + +afterEach(() => { + instances = []; +}); + +describe("GatewayClient reconnect recovery", () => { + it("allows a fresh connect after unexpected close", async () => { + const client = new GatewayClient(); + const statuses: string[] = []; + client.onStatus((status) => statuses.push(status)); + + const firstConnect = client.connect({ + gatewayUrl: "ws://example.invalid", + token: "old-token", + }); + const first = instances[0]; + if (!first) throw new Error("Expected first GatewayBrowserClient instance"); + + const onHelloFirst = first.opts.onHello; + const onCloseFirst = first.opts.onClose; + if (!onHelloFirst || !onCloseFirst) { + throw new Error("Expected first instance callbacks"); + } + + onHelloFirst({}); + await expect(firstConnect).resolves.toBeUndefined(); + + onCloseFirst({ code: 1012, reason: "upstream closed" }); + + expect(first.stopped).toBe(true); + expect(statuses.at(-1)).toBe("disconnected"); + + const secondConnect = client.connect({ + gatewayUrl: "ws://example.invalid", + token: "new-token", + }); + const second = instances[1]; + if (!second) throw new Error("Expected second GatewayBrowserClient instance"); + + expect(second.opts.token).toBe("new-token"); + + const onHelloSecond = second.opts.onHello; + if (!onHelloSecond) { + throw new Error("Expected second instance onHello callback"); + } + + onHelloSecond({}); + await expect(secondConnect).resolves.toBeUndefined(); + + expect(statuses.at(-1)).toBe("connected"); + }); + + it("ignores stale onClose callbacks from old instances", async () => { + const client = new GatewayClient(); + const statuses: string[] = []; + client.onStatus((status) => statuses.push(status)); + + const firstConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); + const first = instances[0]; + if (!first) throw new Error("Expected first GatewayBrowserClient instance"); + + const onHelloFirst = first.opts.onHello; + const onCloseFirst = first.opts.onClose; + if (!onHelloFirst || !onCloseFirst) { + throw new Error("Expected first instance callbacks"); + } + + onHelloFirst({}); + await firstConnect; + + onCloseFirst({ code: 1012, reason: "upstream closed" }); + + const secondConnect = client.connect({ gatewayUrl: "ws://example.invalid" }); + const second = instances[1]; + if (!second) throw new Error("Expected second GatewayBrowserClient instance"); + + const onHelloSecond = second.opts.onHello; + if (!onHelloSecond) { + throw new Error("Expected second instance onHello callback"); + } + + onHelloSecond({}); + await secondConnect; + + const statusCountBeforeStaleClose = statuses.length; + onCloseFirst({ code: 1012, reason: "late stale close" }); + + expect(statuses.length).toBe(statusCountBeforeStaleClose); + expect(statuses.at(-1)).toBe("connected"); + }); +}); diff --git a/tests/unit/gatewayConfigPatch.test.ts b/tests/unit/gatewayConfigPatch.test.ts new file mode 100644 index 00000000..8ce02a96 --- /dev/null +++ b/tests/unit/gatewayConfigPatch.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createGatewayAgent, + deleteGatewayAgent, + renameGatewayAgent, + resolveHeartbeatSettings, + removeGatewayHeartbeatOverride, + updateGatewayHeartbeat, +} from "@/lib/gateway/agentConfig"; +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; + +describe("gateway agent helpers", () => { + it("creates a new agent via agents.create and derives workspace from the config path", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-create-1", + path: "/Users/test/.openclaw/openclaw.json", + config: { agents: { list: [{ id: "agent-1", name: "Agent One" }] } }, + }; + } + if (method === "agents.create") { + expect(params).toEqual({ + name: "New Agent", + workspace: "/Users/test/.openclaw/workspace-new-agent", + }); + return { ok: true, agentId: "new-agent", name: "New Agent", workspace: "ignored" }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const entry = await createGatewayAgent({ client, name: "New Agent" }); + expect(entry.id).toBe("new-agent"); + expect(entry.name).toBe("New Agent"); + }); + + it("slugifies workspace names from agent names", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-create-slug-1", + path: "/Users/test/.openclaw/openclaw.json", + config: { agents: { list: [] } }, + }; + } + if (method === "agents.create") { + expect(params).toEqual({ + name: "My Project", + workspace: "/Users/test/.openclaw/workspace-my-project", + }); + return { ok: true, agentId: "my-project", name: "My Project", workspace: "ignored" }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const entry = await createGatewayAgent({ client, name: "My Project" }); + expect(entry.id).toBe("my-project"); + expect(entry.name).toBe("My Project"); + }); + + it("returns no-op on deleting a missing agent", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "agents.delete") { + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: 'agent "agent-2" not found', + }); + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const result = await deleteGatewayAgent({ + client, + agentId: "agent-2", + }); + + expect(result).toEqual({ removed: false, removedBindings: 0 }); + expect(client.call).toHaveBeenCalledTimes(1); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe("agents.delete"); + }); + + it("fails fast on empty create name", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect(createGatewayAgent({ client, name: " " })).rejects.toThrow( + "Agent name is required." + ); + expect(client.call).not.toHaveBeenCalled(); + }); + + it("fails when create name produces an empty id slug", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-create-empty-slug-1", + path: "/Users/test/.openclaw/openclaw.json", + config: { + agents: { list: [] }, + }, + }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + await expect(createGatewayAgent({ client, name: "!!!" })).rejects.toThrow( + "Name produced an empty folder name." + ); + expect(client.call).toHaveBeenCalledTimes(1); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe("config.get"); + }); + + it("returns current settings when no heartbeat override exists to remove", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-remove-1", + path: "/Users/test/.openclaw/openclaw.json", + config: { + agents: { + defaults: { + heartbeat: { + every: "10m", + target: "last", + includeReasoning: false, + ackMaxChars: 300, + }, + }, + list: [{ id: "agent-1", name: "Agent One" }], + }, + }, + }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const result = await removeGatewayHeartbeatOverride({ + client, + agentId: "agent-1", + }); + + expect(result).toEqual({ + heartbeat: { + every: "10m", + target: "last", + includeReasoning: false, + ackMaxChars: 300, + activeHours: null, + }, + hasOverride: false, + }); + expect(client.call).toHaveBeenCalledTimes(1); + }); + + it("renames an agent via agents.update", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "agents.update") { + expect(params).toEqual({ agentId: "agent-1", name: "New Name" }); + return { ok: true, agentId: "agent-1" }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + await renameGatewayAgent({ client, agentId: "agent-1", name: "New Name" }); + }); + + it("resolves heartbeat defaults and overrides", () => { + const config = { + agents: { + defaults: { + heartbeat: { + every: "2h", + target: "last", + includeReasoning: false, + ackMaxChars: 200, + }, + }, + list: [ + { + id: "agent-1", + heartbeat: { every: "30m", target: "none", includeReasoning: true }, + }, + ], + }, + }; + const result = resolveHeartbeatSettings(config, "agent-1"); + expect(result.heartbeat.every).toBe("30m"); + expect(result.heartbeat.target).toBe("none"); + expect(result.heartbeat.includeReasoning).toBe(true); + expect(result.hasOverride).toBe(true); + }); + + it("updates heartbeat overrides via config.patch", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { + exists: true, + hash: "hash-2", + path: "/Users/test/.openclaw/openclaw.json", + config: { + agents: { + defaults: { + heartbeat: { + every: "1h", + target: "last", + includeReasoning: false, + ackMaxChars: 300, + }, + }, + list: [{ id: "agent-1" }], + }, + }, + }; + } + if (method === "config.patch") { + const raw = (params as { raw?: string }).raw ?? ""; + const parsed = JSON.parse(raw) as { + agents?: { list?: Array<{ id?: string; heartbeat?: unknown }> }; + }; + const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); + expect(entry && typeof entry === "object").toBe(true); + return { ok: true }; + } + throw new Error("unexpected method"); + }), + } as unknown as GatewayClient; + + const result = await updateGatewayHeartbeat({ + client, + agentId: "agent-1", + payload: { + override: true, + heartbeat: { + every: "15m", + target: "none", + includeReasoning: true, + ackMaxChars: 120, + activeHours: { start: "08:00", end: "18:00" }, + }, + }, + }); + + expect(result.heartbeat.every).toBe("15m"); + expect(result.heartbeat.target).toBe("none"); + expect(result.heartbeat.includeReasoning).toBe(true); + expect(result.hasOverride).toBe(true); + }); +}); diff --git a/tests/unit/gatewayConfigSyncWorkflow.test.ts b/tests/unit/gatewayConfigSyncWorkflow.test.ts new file mode 100644 index 00000000..6e9a2e74 --- /dev/null +++ b/tests/unit/gatewayConfigSyncWorkflow.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveGatewayConfigRecord, + resolveGatewayModelsSyncIntent, + resolveSandboxRepairAgentIds, + resolveSandboxRepairIntent, + shouldRefreshGatewayConfigForSettingsRoute, +} from "@/features/agents/operations/gatewayConfigSyncWorkflow"; +import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; + +describe("gatewayConfigSyncWorkflow", () => { + it("resolves config record only when snapshot config is an object", () => { + expect(resolveGatewayConfigRecord(null)).toBeNull(); + expect(resolveGatewayConfigRecord({ config: [] } as unknown as GatewayModelPolicySnapshot)).toBeNull(); + + const snapshot: GatewayModelPolicySnapshot = { + config: { + agents: { + list: [{ id: "agent-1" }], + }, + }, + }; + + expect(resolveGatewayConfigRecord(snapshot)).toEqual(snapshot.config); + }); + + it("finds sandbox repair candidates with sandbox all mode and empty sandbox allowlist", () => { + const snapshot = { + config: { + agents: { + list: [ + { + id: "agent-broken", + sandbox: { mode: "all" }, + tools: { sandbox: { tools: { allow: [] } } }, + }, + { + id: "agent-ok-mode", + sandbox: { mode: "off" }, + tools: { sandbox: { tools: { allow: [] } } }, + }, + { + id: "agent-ok-allow", + sandbox: { mode: "all" }, + tools: { sandbox: { tools: { allow: ["*"] } } }, + }, + ], + }, + }, + } as unknown as GatewayModelPolicySnapshot; + + expect(resolveSandboxRepairAgentIds(snapshot)).toEqual(["agent-broken"]); + }); + + it("builds sandbox repair intent from status, attempt guard, and candidate list", () => { + const snapshot = { + config: { + agents: { + list: [ + { + id: "agent-broken", + sandbox: { mode: "all" }, + tools: { sandbox: { tools: { allow: [] } } }, + }, + ], + }, + }, + } as unknown as GatewayModelPolicySnapshot; + + expect( + resolveSandboxRepairIntent({ + status: "disconnected", + attempted: false, + snapshot, + }) + ).toEqual({ kind: "skip", reason: "not-connected" }); + + expect( + resolveSandboxRepairIntent({ + status: "connected", + attempted: true, + snapshot, + }) + ).toEqual({ kind: "skip", reason: "already-attempted" }); + + expect( + resolveSandboxRepairIntent({ + status: "connected", + attempted: false, + snapshot, + }) + ).toEqual({ kind: "repair", agentIds: ["agent-broken"] }); + }); + + it("gates settings-route refresh on route flag, inspect agent id, and connected status", () => { + expect( + shouldRefreshGatewayConfigForSettingsRoute({ + status: "connected", + settingsRouteActive: false, + inspectSidebarAgentId: "agent-1", + }) + ).toBe(false); + + expect( + shouldRefreshGatewayConfigForSettingsRoute({ + status: "connected", + settingsRouteActive: true, + inspectSidebarAgentId: null, + }) + ).toBe(false); + + expect( + shouldRefreshGatewayConfigForSettingsRoute({ + status: "connecting", + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + }) + ).toBe(false); + + expect( + shouldRefreshGatewayConfigForSettingsRoute({ + status: "connected", + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + }) + ).toBe(true); + }); + + it("returns model sync load intent only when connected", () => { + expect(resolveGatewayModelsSyncIntent({ status: "connected" })).toEqual({ kind: "load" }); + expect(resolveGatewayModelsSyncIntent({ status: "connecting" })).toEqual({ kind: "clear" }); + expect(resolveGatewayModelsSyncIntent({ status: "disconnected" })).toEqual({ kind: "clear" }); + }); +}); diff --git a/tests/unit/gatewayConnectRetryPolicy.test.ts b/tests/unit/gatewayConnectRetryPolicy.test.ts new file mode 100644 index 00000000..8d0917f0 --- /dev/null +++ b/tests/unit/gatewayConnectRetryPolicy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { resolveGatewayAutoRetryDelayMs } from "@/lib/gateway/GatewayClient"; + +describe("resolveGatewayAutoRetryDelayMs", () => { + it("does not retry when upstream gateway url is missing on Studio host", () => { + const delay = resolveGatewayAutoRetryDelayMs({ + status: "disconnected", + didAutoConnect: true, + wasManualDisconnect: false, + gatewayUrl: "wss://remote.example", + errorMessage: "Gateway error (studio.gateway_url_missing): Upstream gateway URL is missing.", + connectErrorCode: "studio.gateway_url_missing", + attempt: 0, + }); + + expect(delay).toBeNull(); + }); + + it("retries for non-auth connect failures", () => { + const delay = resolveGatewayAutoRetryDelayMs({ + status: "disconnected", + didAutoConnect: true, + wasManualDisconnect: false, + gatewayUrl: "wss://remote.example", + errorMessage: + "Gateway error (studio.upstream_error): Failed to connect to upstream gateway WebSocket.", + connectErrorCode: "studio.upstream_error", + attempt: 0, + }); + + expect(delay).toBeTypeOf("number"); + expect(delay).toBeGreaterThan(0); + }); +}); + diff --git a/tests/unit/gatewayEventIngressWorkflow.test.ts b/tests/unit/gatewayEventIngressWorkflow.test.ts new file mode 100644 index 00000000..cd2b5b4e --- /dev/null +++ b/tests/unit/gatewayEventIngressWorkflow.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import { resolveExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import { resolveGatewayEventIngressDecision } from "@/features/agents/state/gatewayEventIngressWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + ...(overrides ?? {}), +}); + +describe("gatewayEventIngressWorkflow", () => { + it("returns no cron decision for non-cron events", () => { + const event: EventFrame = { type: "event", event: "heartbeat", payload: {} }; + + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent()], + seenCronDedupeKeys: new Set<string>(), + nowMs: 1000, + }); + + expect(decision.cronDedupeKeyToRecord).toBeNull(); + expect(decision.cronTranscriptIntent).toBeNull(); + expect(decision.approvalEffects).toBeNull(); + }); + + it("ignores malformed cron payload variants", () => { + const malformedEvents: EventFrame[] = [ + { type: "event", event: "cron", payload: null }, + { type: "event", event: "cron", payload: "bad" }, + { type: "event", event: "cron", payload: { action: "started" } }, + { type: "event", event: "cron", payload: { action: "finished", sessionKey: "" } }, + { + type: "event", + event: "cron", + payload: { action: "finished", sessionKey: "invalid", jobId: "job-1" }, + }, + { + type: "event", + event: "cron", + payload: { action: "finished", sessionKey: "agent:agent-1:main", jobId: "" }, + }, + ]; + + for (const event of malformedEvents) { + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent()], + seenCronDedupeKeys: new Set<string>(), + nowMs: 1000, + }); + expect(decision.cronDedupeKeyToRecord).toBeNull(); + expect(decision.cronTranscriptIntent).toBeNull(); + } + }); + + it("returns dedupe and transcript intent for valid finished cron event", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-1", + sessionId: "session-1", + runAtMs: 123, + status: "ok", + summary: "cron summary", + }, + }; + + const seen = new Set<string>(); + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent({ sessionKey: "agent:agent-1:studio:test-session" })], + seenCronDedupeKeys: seen, + nowMs: 999, + }); + + expect(seen.size).toBe(0); + expect(decision.cronDedupeKeyToRecord).toBe("cron:job-1:session-1"); + expect(decision.cronTranscriptIntent).toEqual({ + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:test-session", + dedupeKey: "cron:job-1:session-1", + line: "Cron finished (ok): job-1\n\ncron summary", + timestampMs: 123, + activityAtMs: 123, + }); + }); + + it("returns dedupe-only decision for unknown-agent finished cron", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:missing:main", + jobId: "job-2", + runAtMs: 456, + }, + }; + + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent()], + seenCronDedupeKeys: new Set<string>(), + nowMs: 1000, + }); + + expect(decision.cronDedupeKeyToRecord).toBe("cron:job-2:456"); + expect(decision.cronTranscriptIntent).toBeNull(); + }); + + it("suppresses cron decision for duplicate dedupe key", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-3", + runAtMs: 777, + }, + }; + + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent()], + seenCronDedupeKeys: new Set(["cron:job-3:777"]), + nowMs: 1000, + }); + + expect(decision.cronDedupeKeyToRecord).toBeNull(); + expect(decision.cronTranscriptIntent).toBeNull(); + }); + + it("falls back to nowMs and no-output body when runAtMs/summary/error are missing", () => { + const event: EventFrame = { + type: "event", + event: "cron", + payload: { + action: "finished", + sessionKey: "agent:agent-1:main", + jobId: "job-4", + }, + }; + + const decision = resolveGatewayEventIngressDecision({ + event, + agents: [createAgent()], + seenCronDedupeKeys: new Set<string>(), + nowMs: 4321, + }); + + expect(decision.cronDedupeKeyToRecord).toBe("cron:job-4:none"); + expect(decision.cronTranscriptIntent).toEqual({ + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:test-session", + dedupeKey: "cron:job-4:none", + line: "Cron finished (unknown): job-4\n\n(no output)", + timestampMs: 4321, + activityAtMs: null, + }); + }); + + it("delegates approval event effects unchanged", () => { + const agents = [createAgent()]; + const requestedEvent: EventFrame = { + type: "event", + event: "exec.approval.requested", + payload: { + id: "approval-1", + request: { + command: "npm test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + agentId: "agent-1", + resolvedPath: "/usr/bin/npm", + sessionKey: "agent:agent-1:main", + }, + createdAtMs: 100, + expiresAtMs: 200, + }, + }; + + const expectedRequested = resolveExecApprovalEventEffects({ + event: requestedEvent, + agents, + }); + const requestedDecision = resolveGatewayEventIngressDecision({ + event: requestedEvent, + agents, + seenCronDedupeKeys: new Set<string>(), + nowMs: 1000, + }); + + expect(requestedDecision.approvalEffects).toEqual(expectedRequested); + expect(requestedDecision.approvalEffects?.markActivityAgentIds).toEqual(["agent-1"]); + + const resolvedEvent: EventFrame = { + type: "event", + event: "exec.approval.resolved", + payload: { + id: "approval-1", + decision: "allow-once", + resolvedBy: "studio", + ts: 999, + }, + }; + + const expectedResolved = resolveExecApprovalEventEffects({ event: resolvedEvent, agents }); + const resolvedDecision = resolveGatewayEventIngressDecision({ + event: resolvedEvent, + agents, + seenCronDedupeKeys: new Set<string>(), + nowMs: 1000, + }); + + expect(resolvedDecision.approvalEffects).toEqual(expectedResolved); + expect(resolvedDecision.approvalEffects?.removals).toEqual(["approval-1"]); + }); +}); diff --git a/tests/unit/gatewayExecApprovals.test.ts b/tests/unit/gatewayExecApprovals.test.ts new file mode 100644 index 00000000..e26f52fa --- /dev/null +++ b/tests/unit/gatewayExecApprovals.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; +import { upsertGatewayAgentExecApprovals } from "@/lib/gateway/execApprovals"; + +describe("upsertGatewayAgentExecApprovals", () => { + it("writes per-agent policy with base hash", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "exec.approvals.get") { + return { + exists: true, + hash: "hash-1", + file: { + version: 1, + agents: { + main: { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/main" }], + }, + }, + }, + }; + } + if (method === "exec.approvals.set") { + const payload = params as { + baseHash?: string; + file?: { + agents?: Record<string, { security?: string; ask?: string; allowlist?: Array<{ pattern: string }> }>; + }; + }; + expect(payload.baseHash).toBe("hash-1"); + expect(payload.file?.agents?.["agent-2"]).toEqual({ + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/usr/bin/git" }], + }); + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await upsertGatewayAgentExecApprovals({ + client, + agentId: "agent-2", + policy: { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/usr/bin/git" }], + }, + }); + }); + + it("removes per-agent policy when policy is null", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "exec.approvals.get") { + return { + exists: true, + hash: "hash-2", + file: { + version: 1, + agents: { + "agent-1": { + security: "allowlist", + ask: "always", + allowlist: [{ pattern: "/bin/echo" }], + }, + }, + }, + }; + } + if (method === "exec.approvals.set") { + const payload = params as { + file?: { agents?: Record<string, unknown> }; + }; + expect(payload.file?.agents?.["agent-1"]).toBeUndefined(); + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await upsertGatewayAgentExecApprovals({ + client, + agentId: "agent-1", + policy: null, + }); + }); + + it("retries once when gateway reports stale base hash", async () => { + let setAttempts = 0; + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "exec.approvals.get") { + return { + exists: true, + hash: setAttempts === 0 ? "hash-old" : "hash-new", + file: { + version: 1, + agents: {}, + }, + }; + } + if (method === "exec.approvals.set") { + setAttempts += 1; + const payload = params as { baseHash?: string }; + if (setAttempts === 1) { + expect(payload.baseHash).toBe("hash-old"); + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "exec approvals changed since last load; re-run exec.approvals.get and retry", + }); + } + expect(payload.baseHash).toBe("hash-new"); + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await upsertGatewayAgentExecApprovals({ + client, + agentId: "agent-3", + policy: { + security: "full", + ask: "off", + allowlist: [], + }, + }); + + expect(setAttempts).toBe(2); + }); +}); diff --git a/tests/unit/gatewayFrames.test.ts b/tests/unit/gatewayFrames.test.ts new file mode 100644 index 00000000..83c01605 --- /dev/null +++ b/tests/unit/gatewayFrames.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { parseGatewayFrame } from "@/lib/gateway/GatewayClient"; + +describe("gateway frames", () => { + it("parses event stateVersion objects", () => { + const raw = JSON.stringify({ + type: "event", + event: "presence", + payload: { presence: [] }, + stateVersion: { presence: 2, health: 5 }, + }); + + const frame = parseGatewayFrame(raw); + + expect(frame?.type).toBe("event"); + if (frame?.type !== "event") { + throw new Error("Expected event frame"); + } + expect(frame.stateVersion?.presence).toBe(2); + expect(frame.stateVersion?.health).toBe(5); + }); +}); diff --git a/tests/unit/gatewayMediaRoute.test.ts b/tests/unit/gatewayMediaRoute.test.ts new file mode 100644 index 00000000..76d96be8 --- /dev/null +++ b/tests/unit/gatewayMediaRoute.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ORIGINAL_ENV = { ...process.env }; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual<typeof import("node:child_process")>( + "node:child_process" + ); + return { + default: actual, + ...actual, + spawnSync: vi.fn(), + }; +}); + +const mockedSpawnSync = vi.mocked(spawnSync); + +let GET: typeof import("@/app/api/gateway/media/route")["GET"]; + +const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + +const writeStudioSettings = (stateDir: string, gatewayUrl: string) => { + const settingsDir = path.join(stateDir, "openclaw-studio"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: gatewayUrl, token: "token-123" }, + focused: {}, + }, + null, + 2 + ), + "utf8" + ); +}; + +beforeAll(async () => { + ({ GET } = await import("@/app/api/gateway/media/route")); +}); + +describe("/api/gateway/media route", () => { + let tempDir: string | null = null; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.OPENCLAW_GATEWAY_SSH_TARGET; + delete process.env.OPENCLAW_GATEWAY_SSH_USER; + delete process.env.OPENCLAW_STATE_DIR; + mockedSpawnSync.mockReset(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("returns binary image data when reading remote media over ssh", async () => { + tempDir = makeTempDir("gateway-media-route-remote"); + process.env.OPENCLAW_STATE_DIR = tempDir; + process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test"; + writeStudioSettings(tempDir, "ws://example.test:18789"); + + const payloadBytes = Buffer.from("fake", "utf8"); + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + ok: true, + mime: "image/png", + size: payloadBytes.length, + data: payloadBytes.toString("base64"), + }), + stderr: "", + error: undefined, + } as never); + + const remotePath = "/home/ubuntu/.openclaw/images/pic.png"; + const response = await GET( + new Request( + `http://localhost/api/gateway/media?path=${encodeURIComponent(remotePath)}` + ) + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/png"); + expect(response.headers.get("Content-Length")).toBe(String(payloadBytes.length)); + + const buf = Buffer.from(await response.arrayBuffer()); + expect(buf.equals(payloadBytes)).toBe(true); + + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + const [cmd, args, options] = mockedSpawnSync.mock.calls[0] as [ + string, + string[], + { encoding?: string; input?: string; maxBuffer?: number }, + ]; + expect(cmd).toBe("ssh"); + expect(args).toEqual( + expect.arrayContaining([ + "-o", + "BatchMode=yes", + "me@host.test", + "bash", + "-s", + "--", + remotePath, + ]) + ); + expect(options.encoding).toBe("utf8"); + expect(options.input).toContain("python3 - \"$1\""); + expect(typeof options.maxBuffer).toBe("number"); + expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length); + }); +}); + diff --git a/tests/unit/gatewayModelsPolicy.test.ts b/tests/unit/gatewayModelsPolicy.test.ts new file mode 100644 index 00000000..dd854114 --- /dev/null +++ b/tests/unit/gatewayModelsPolicy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAllowedModelKeys, + buildGatewayModelChoices, + resolveConfiguredModelKey, + type GatewayModelPolicySnapshot, +} from "@/lib/gateway/models"; + +describe("gateway model policy helpers", () => { + it("resolves configured aliases and shorthand ids", () => { + const modelAliases = { + "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "openai/gpt-4o": { alias: "omni" }, + }; + + expect(resolveConfiguredModelKey("sonnet", modelAliases)).toBe( + "anthropic/claude-sonnet-4-5" + ); + expect(resolveConfiguredModelKey("omni", modelAliases)).toBe("openai/gpt-4o"); + expect(resolveConfiguredModelKey("claude-opus-4", modelAliases)).toBe( + "anthropic/claude-opus-4" + ); + expect(resolveConfiguredModelKey("openai/o3", modelAliases)).toBe("openai/o3"); + expect(resolveConfiguredModelKey(" ", modelAliases)).toBeNull(); + }); + + it("builds deduped allowlist keys from defaults and aliases", () => { + const snapshot: GatewayModelPolicySnapshot = { + config: { + agents: { + defaults: { + model: { + primary: "sonnet", + fallbacks: ["omni", "anthropic/claude-sonnet-4-5", ""], + }, + models: { + "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "openai/gpt-4o": { alias: "omni" }, + "openai/o3": {}, + }, + }, + }, + }, + }; + + expect(buildAllowedModelKeys(snapshot)).toEqual([ + "anthropic/claude-sonnet-4-5", + "openai/gpt-4o", + "openai/o3", + ]); + }); + + it("filters model catalog and appends configured extras", () => { + const snapshot: GatewayModelPolicySnapshot = { + config: { + agents: { + defaults: { + model: "sonnet", + models: { + "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "openai/o3": {}, + }, + }, + }, + }, + }; + const catalog = [ + { + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + { + provider: "openai", + id: "gpt-4o", + name: "GPT-4o", + }, + ]; + + expect(buildGatewayModelChoices(catalog, snapshot)).toEqual([ + { + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + { + provider: "openai", + id: "o3", + name: "openai/o3", + }, + ]); + }); + + it("returns the catalog unchanged when no config allowlist is present", () => { + const catalog = [ + { + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + ]; + + expect(buildGatewayModelChoices(catalog, null)).toEqual(catalog); + }); +}); diff --git a/tests/unit/gatewayProxy.test.ts b/tests/unit/gatewayProxy.test.ts new file mode 100644 index 00000000..53455ef9 --- /dev/null +++ b/tests/unit/gatewayProxy.test.ts @@ -0,0 +1,552 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; + +const waitForEvent = <T = unknown>( + target: { once: (event: string, cb: (...args: unknown[]) => void) => void }, + event: string +) => + new Promise<T>((resolve) => { + target.once(event, (...args: unknown[]) => resolve(args as unknown as T)); + }); + +const closeHttpServer = (server: import("node:http").Server) => + new Promise<void>((resolve) => server.close(() => resolve())); + +const closeWebSocketServer = (server: WebSocketServer) => + new Promise<void>((resolve) => server.close(() => resolve())); + +const closeWebSocket = (ws: WebSocket) => + new Promise<void>((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.once("close", () => resolve()); + ws.close(); + }); + +describe("createGatewayProxy", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("injects gateway token into connect request", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenToken: string | null = null; + let seenOrigin: string | undefined; + upstream.on("connection", (ws, req) => { + seenOrigin = req.headers.origin; + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenToken = parsed?.params?.auth?.token ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "token-123" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + + browser.send( + JSON.stringify({ + type: "req", + id: "connect-1", + method: "connect", + params: { auth: {} }, + }) + ); + + await waitForEvent(browser, "message"); + + expect(seenToken).toBe("token-123"); + expect(seenOrigin).toBe(`http://localhost:${address.port}`); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("allows browser auth token passthrough when host token is missing", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenToken: string | null = null; + upstream.on("connection", (ws) => { + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenToken = parsed?.params?.auth?.token ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-pass-token", + method: "connect", + params: { auth: { token: "browser-token-123" } }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ type: "res", id: "connect-pass-token", ok: true }); + expect(seenToken).toBe("browser-token-123"); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("preserves browser auth token when both browser and host tokens are present", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenToken: string | null = null; + upstream.on("connection", (ws) => { + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenToken = parsed?.params?.auth?.token ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-browser-precedence", + method: "connect", + params: { auth: { token: "browser-token-789" } }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ type: "res", id: "connect-browser-precedence", ok: true }); + expect(seenToken).toBe("browser-token-789"); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("allows browser device signature passthrough when host token is missing", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenToken: string | null = null; + let seenDeviceSignature: string | null = null; + let seenDeviceId: string | null = null; + let seenDevicePublicKey: string | null = null; + let seenDeviceNonce: string | null = null; + let seenDeviceSignedAt: number | null = null; + upstream.on("connection", (ws) => { + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenToken = parsed?.params?.auth?.token ?? null; + seenDeviceSignature = parsed?.params?.device?.signature ?? null; + seenDeviceId = parsed?.params?.device?.id ?? null; + seenDevicePublicKey = parsed?.params?.device?.publicKey ?? null; + seenDeviceNonce = parsed?.params?.device?.nonce ?? null; + seenDeviceSignedAt = parsed?.params?.device?.signedAt ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-pass-device", + method: "connect", + params: { + device: { + id: "device-id-123", + publicKey: "device-public-key-123", + signature: "device-signature-123", + signedAt: Date.now(), + nonce: "device-nonce-123", + }, + }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ type: "res", id: "connect-pass-device", ok: true }); + expect(seenDeviceSignature).toBe("device-signature-123"); + expect(seenDeviceId).toBe("device-id-123"); + expect(seenDevicePublicKey).toBe("device-public-key-123"); + expect(seenDeviceNonce).toBe("device-nonce-123"); + expect(typeof seenDeviceSignedAt).toBe("number"); + expect(seenToken).toBeNull(); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("allows browser password passthrough when host token is missing", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenPassword: string | null = null; + let seenToken: string | null = null; + upstream.on("connection", (ws) => { + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenPassword = parsed?.params?.auth?.password ?? null; + seenToken = parsed?.params?.auth?.token ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-pass-password", + method: "connect", + params: { auth: { password: "browser-password-123" } }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ type: "res", id: "connect-pass-password", ok: true }); + expect(seenPassword).toBe("browser-password-123"); + expect(seenToken).toBeNull(); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("allows browser deviceToken passthrough when host token is missing", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let seenDeviceToken: string | null = null; + let seenToken: string | null = null; + upstream.on("connection", (ws) => { + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (parsed?.method === "connect") { + seenDeviceToken = parsed?.params?.auth?.deviceToken ?? null; + seenToken = parsed?.params?.auth?.token ?? null; + ws.send( + JSON.stringify({ + type: "res", + id: parsed.id, + ok: true, + payload: { type: "hello-ok", protocol: 3, auth: {} }, + }) + ); + } + }); + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-pass-device-token", + method: "connect", + params: { auth: { deviceToken: "browser-device-token-123" } }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ type: "res", id: "connect-pass-device-token", ok: true }); + expect(seenDeviceToken).toBe("browser-device-token-123"); + expect(seenToken).toBeNull(); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); + + it("returns studio.gateway_token_missing when browser auth and host token are both missing", async () => { + const upstream = new WebSocketServer({ port: 0 }); + const address = upstream.address(); + if (!address || typeof address === "string") { + throw new Error("expected upstream server to have a port"); + } + const upstreamUrl = `ws://127.0.0.1:${address.port}`; + + let upstreamConnectionCount = 0; + upstream.on("connection", () => { + upstreamConnectionCount += 1; + }); + + const { createGatewayProxy } = await import("../../server/gateway-proxy"); + + const proxyHttp = await import("node:http").then((m) => m.createServer()); + const proxy = createGatewayProxy({ + loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), + allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", + logError: () => {}, + }); + proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); + + await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); + const proxyAddr = proxyHttp.address(); + if (!proxyAddr || typeof proxyAddr === "string") { + throw new Error("expected proxy server to have a port"); + } + + const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); + try { + await waitForEvent(browser, "open"); + const closePromise = waitForEvent<[number, Buffer]>(browser, "close"); + browser.send( + JSON.stringify({ + type: "req", + id: "connect-missing-token", + method: "connect", + params: { auth: {} }, + }) + ); + + const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); + const response = JSON.parse(String(rawMessage ?? "")); + expect(response).toMatchObject({ + type: "res", + id: "connect-missing-token", + ok: false, + error: { code: "studio.gateway_token_missing" }, + }); + + const [closeCode] = await closePromise; + expect(closeCode).toBe(1011); + expect(upstreamConnectionCount).toBe(0); + } finally { + for (const client of upstream.clients) { + client.close(); + } + await Promise.all([ + closeWebSocket(browser), + closeWebSocketServer(upstream), + closeHttpServer(proxyHttp), + ]); + } + }); +}); diff --git a/tests/unit/gatewayReloadMode.test.ts b/tests/unit/gatewayReloadMode.test.ts new file mode 100644 index 00000000..7e212128 --- /dev/null +++ b/tests/unit/gatewayReloadMode.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ensureGatewayReloadModeHotForLocalStudio, + shouldAwaitDisconnectRestartForRemoteMutation, +} from "@/lib/gateway/gatewayReloadMode"; +import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient"; + +describe("ensureGatewayReloadModeHotForLocalStudio", () => { + it("skips non-local upstream gateways", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + await ensureGatewayReloadModeHotForLocalStudio({ + client, + upstreamGatewayUrl: "ws://10.0.0.5:18789", + }); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("sets gateway.reload.mode=hot when missing", async () => { + const client = { + call: vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { exists: true, hash: "hash-1", config: {} }; + } + if (method === "config.set") { + const payload = params as { raw?: string; baseHash?: string }; + expect(payload.baseHash).toBe("hash-1"); + const parsed = JSON.parse(payload.raw ?? "{}") as { + gateway?: { reload?: { mode?: string } }; + }; + expect(parsed.gateway?.reload?.mode).toBe("hot"); + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await ensureGatewayReloadModeHotForLocalStudio({ + client, + upstreamGatewayUrl: "ws://127.0.0.1:18789", + }); + }); + + it("does nothing when mode is already hot", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + return { exists: true, hash: "hash-1", config: { gateway: { reload: { mode: "hot" } } } }; + } + if (method === "config.set") { + throw new Error("config.set should not be called"); + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await ensureGatewayReloadModeHotForLocalStudio({ + client, + upstreamGatewayUrl: "ws://localhost:18789", + }); + }); + + it("retries once on base-hash mismatch", async () => { + let getCount = 0; + const client = { + call: vi.fn(async (method: string) => { + if (method === "config.get") { + getCount += 1; + return { exists: true, hash: getCount === 1 ? "hash-1" : "hash-2", config: {} }; + } + if (method === "config.set") { + if (getCount === 1) { + throw new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "config changed since last load; re-run config.get and retry", + }); + } + return { ok: true }; + } + throw new Error(`unexpected method: ${method}`); + }), + } as unknown as GatewayClient; + + await ensureGatewayReloadModeHotForLocalStudio({ + client, + upstreamGatewayUrl: "ws://127.0.0.1:18789", + }); + + const setCalls = (client.call as ReturnType<typeof vi.fn>).mock.calls.filter( + ([method]) => method === "config.set", + ); + expect(setCalls.length).toBe(2); + expect(getCount).toBe(2); + }); +}); + +describe("shouldAwaitDisconnectRestartForRemoteMutation", () => { + it("returns false for cached hot mode", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: { config: { gateway: { reload: { mode: "hot" } } } }, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("returns false for cached off mode", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: { config: { gateway: { reload: { mode: "off" } } } }, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("returns false for cached hybrid mode", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: { config: { gateway: { reload: { mode: "hybrid" } } } }, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("treats missing cached reload mode as hybrid", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: { config: {} }, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("returns true when reload mode is unknown", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: { config: { gateway: { reload: { mode: "restart" } } } }, + }); + expect(shouldAwait).toBe(true); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0); + }); + + it("loads config when cache is missing and returns false for hot mode", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method !== "config.get") { + throw new Error(`unexpected method: ${method}`); + } + return { config: { gateway: { reload: { mode: "hot" } } } }; + }), + } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: null, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls).toEqual([["config.get", {}]]); + }); + + it("loads config when cache is missing and treats missing reload mode as hybrid", async () => { + const client = { + call: vi.fn(async (method: string) => { + if (method !== "config.get") { + throw new Error(`unexpected method: ${method}`); + } + return { config: {} }; + }), + } as unknown as GatewayClient; + const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({ + client, + cachedConfigSnapshot: null, + }); + expect(shouldAwait).toBe(false); + expect((client.call as ReturnType<typeof vi.fn>).mock.calls).toEqual([["config.get", {}]]); + }); +}); diff --git a/tests/unit/gatewayRestartPolicy.test.ts b/tests/unit/gatewayRestartPolicy.test.ts new file mode 100644 index 00000000..f43895a2 --- /dev/null +++ b/tests/unit/gatewayRestartPolicy.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { observeGatewayRestart } from "@/features/agents/operations/gatewayRestartPolicy"; + +describe("observeGatewayRestart", () => { + it("marks_saw_disconnect_on_non_connected_status_and_completes_on_reconnect", () => { + const start = { sawDisconnect: false }; + + const connected = observeGatewayRestart(start, "connected"); + expect(connected).toEqual({ next: { sawDisconnect: false }, restartComplete: false }); + + const connecting = observeGatewayRestart(connected.next, "connecting"); + expect(connecting).toEqual({ next: { sawDisconnect: true }, restartComplete: false }); + + const reconnected = observeGatewayRestart(connecting.next, "connected"); + expect(reconnected).toEqual({ next: { sawDisconnect: true }, restartComplete: true }); + }); +}); + diff --git a/tests/unit/gatewayRuntimeEventHandler.agent.test.ts b/tests/unit/gatewayRuntimeEventHandler.agent.test.ts new file mode 100644 index 00000000..db210d57 --- /dev/null +++ b/tests/unit/gatewayRuntimeEventHandler.agent.test.ts @@ -0,0 +1,755 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + const merged = { ...base, ...(overrides ?? {}) }; + + return { + ...merged, + historyFetchLimit: merged.historyFetchLimit ?? null, + historyFetchedCount: merged.historyFetchedCount ?? null, + historyMaybeTruncated: merged.historyMaybeTruncated ?? false, + }; +}; + +describe("gateway runtime event handler (agent)", () => { + it("updates reasoning stream thinking trace via queueLivePatch", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + stream: "reasoning", + data: { text: "first" }, + }, + } as EventFrame); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + stream: "reasoning", + data: { text: "first second" }, + }, + } as EventFrame); + + expect(queueLivePatch).toHaveBeenCalled(); + expect(queueLivePatch).toHaveBeenLastCalledWith( + "agent-1", + expect.objectContaining({ + status: "running", + runId: "run-1", + thinkingTrace: "first second", + }) + ); + }); + + it("suppresses assistant stream publish when chat stream already owns it", () => { + const agents = [ + createAgent({ + status: "running", + runId: "run-2", + runStartedAt: 900, + streamText: "already streaming", + }), + ]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-2", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "user", content: "hi" }, + }, + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-2", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { delta: "hello" }, + }, + } as EventFrame); + + const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as + | [string, Partial<AgentState>] + | undefined; + if (!lastCall) throw new Error("Expected queueLivePatch to be called"); + const patch = lastCall[1]; + expect(patch.status).toBe("running"); + expect(patch.runId).toBe("run-2"); + expect("streamText" in patch).toBe(false); + }); + + it("does not publish streamText for assistant open thinking chunk", () => { + const agents = [createAgent({ status: "running", runId: "run-open-think", runStartedAt: 900 })]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-open-think", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { text: "<thinking>planning" }, + }, + } as EventFrame); + + const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as + | [string, Partial<AgentState>] + | undefined; + if (!lastCall) throw new Error("Expected queueLivePatch to be called"); + const patch = lastCall[1]; + expect(patch.status).toBe("running"); + expect(patch.runId).toBe("run-open-think"); + expect(patch.thinkingTrace).toBe("planning"); + expect("streamText" in patch).toBe(false); + }); + + it("publishes streamText when assistant thinking block is closed and visible text is present", () => { + const agents = [ + createAgent({ status: "running", runId: "run-closed-think", runStartedAt: 900 }), + ]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-closed-think", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { text: "<thinking>same</thinking>same" }, + }, + } as EventFrame); + + const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as + | [string, Partial<AgentState>] + | undefined; + if (!lastCall) throw new Error("Expected queueLivePatch to be called"); + const patch = lastCall[1]; + expect(patch.status).toBe("running"); + expect(patch.runId).toBe("run-closed-think"); + expect(patch.thinkingTrace).toBe("same"); + expect(patch.streamText).toBe("same"); + }); + + it("allows assistant stream extension when chat stream stalls", () => { + const agents = [ + createAgent({ + status: "running", + runId: "run-2", + runStartedAt: 900, + streamText: "hello", + }), + ]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-2", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "user", content: "hi" }, + }, + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-2", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { delta: "hello" }, + }, + } as EventFrame); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-2", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { delta: " world" }, + }, + } as EventFrame); + + const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as + | [string, Partial<AgentState>] + | undefined; + if (!lastCall) throw new Error("Expected queueLivePatch to be called"); + const patch = lastCall[1]; + expect(patch.status).toBe("running"); + expect(patch.runId).toBe("run-2"); + expect(patch.streamText).toBe("hello world"); + }); + + it("formats and dedupes tool call lines per run", () => { + const agents = [createAgent({ status: "running", runId: "run-3", runStartedAt: 900 })]; + const actions: Array<{ type: string; line?: string }> = []; + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + actions.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + const toolEvent: EventFrame = { + type: "event", + event: "agent", + payload: { + runId: "run-3", + sessionKey: agents[0]!.sessionKey, + stream: "tool", + data: { + phase: "call", + name: "myTool", + toolCallId: "id-1", + arguments: { a: 1 }, + }, + }, + }; + + handler.handleEvent(toolEvent); + handler.handleEvent(toolEvent); + + const toolLines = actions + .filter((a) => a.type === "appendOutput") + .map((a) => a.line ?? "") + .filter((line) => line.startsWith("[[tool]]")); + expect(toolLines.length).toBe(1); + expect(toolLines[0]).toContain("myTool"); + }); + + it("requests history refresh once per run after first tool result when thinking traces enabled", () => { + const agents = [createAgent({ status: "running", runId: "run-5", runStartedAt: 900 })]; + const requestHistoryRefresh = vi.fn(async () => {}); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh, + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn) => { + fn(); + return 1; + }, + clearTimeout: vi.fn(), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + const toolResultEvent: EventFrame = { + type: "event", + event: "agent", + payload: { + runId: "run-5", + sessionKey: agents[0]!.sessionKey, + stream: "tool", + data: { + phase: "result", + name: "exec", + toolCallId: "tool-1", + result: { content: [{ type: "text", text: "ok" }] }, + }, + }, + }; + + handler.handleEvent(toolResultEvent); + handler.handleEvent({ + ...toolResultEvent, + payload: { + ...(toolResultEvent.payload as Record<string, unknown>), + data: { + phase: "result", + name: "exec", + toolCallId: "tool-2", + result: { content: [{ type: "text", text: "ok again" }] }, + }, + }, + }); + + expect(requestHistoryRefresh).toHaveBeenCalledTimes(1); + expect(requestHistoryRefresh).toHaveBeenCalledWith({ + agentId: "agent-1", + reason: "chat-final-no-trace", + }); + }); + + it("ignores stale assistant stream events for non-active runIds", () => { + const agents = [createAgent({ status: "running", runId: "run-2", runStartedAt: 900 })]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + stream: "assistant", + data: { text: "stale text" }, + }, + } as EventFrame); + + expect(queueLivePatch).not.toHaveBeenCalled(); + }); + + it("applies lifecycle transitions and appends final stream text when no chat events", () => { + vi.useFakeTimers(); + try { + const agents = [createAgent({ streamText: "final text", runId: "run-4" })]; + const actions: Array<{ type: string; agentId: string; line?: string; patch?: unknown }> = []; + const clearPendingLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + actions.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch, + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-4", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "start" }, + }, + } as EventFrame); + + expect( + actions.some((a) => { + if (a.type !== "updateAgent") return false; + const patch = a.patch as Record<string, unknown>; + return patch.status === "running" && patch.runId === "run-4"; + }) + ).toBe(true); + + actions.length = 0; + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-4", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "end" }, + }, + } as EventFrame); + + expect( + actions.some((a) => { + if (a.type !== "updateAgent") return false; + const patch = a.patch as Record<string, unknown>; + return patch.status === "idle" && patch.runId === null; + }) + ).toBe(false); + + vi.runAllTimers(); + + expect(actions.some((a) => a.type === "appendOutput" && a.line === "final text")).toBe(true); + expect( + actions.some((a) => { + if (a.type !== "updateAgent") return false; + const patch = a.patch as Record<string, unknown>; + return patch.lastResult === "final text" && patch.lastAssistantMessageAt === 1000; + }) + ).toBe(true); + expect( + actions.some((a) => { + if (a.type !== "updateAgent") return false; + const patch = a.patch as Record<string, unknown>; + return patch.status === "idle" && patch.runId === null; + }) + ).toBe(true); + expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not schedule lifecycle fallback final text for error transitions", () => { + vi.useFakeTimers(); + try { + const agents = [createAgent({ streamText: "partial text", runId: "run-err" })]; + const actions: Array<{ type: string; line?: string; patch?: unknown }> = []; + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + actions.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-err", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "error" }, + }, + } as EventFrame); + + vi.runAllTimers(); + + expect(actions.some((entry) => entry.type === "appendOutput")).toBe(false); + expect( + actions.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.status === "error" && patch.runId === null; + }) + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("prefers canonical chat final over lifecycle fallback when final arrives immediately", () => { + vi.useFakeTimers(); + try { + const agents = [ + createAgent({ + status: "running", + runId: "run-7", + runStartedAt: 900, + streamText: "fallback final", + }), + ]; + const actions: Array<{ + type: string; + line?: string; + transcript?: { kind?: string; role?: string }; + }> = []; + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + actions.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-7", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "end" }, + }, + } as EventFrame); + + expect( + actions.filter( + (entry) => entry.type === "appendOutput" && entry.transcript?.kind === "assistant" + ) + ).toHaveLength(0); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-7", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "canonical final" }, + }, + } as EventFrame); + + vi.runAllTimers(); + + const assistantLines = actions + .filter((entry) => entry.type === "appendOutput" && entry.transcript?.kind === "assistant") + .map((entry) => entry.line); + const assistantMetaLines = actions.filter( + (entry) => + entry.type === "appendOutput" && + entry.transcript?.kind === "meta" && + entry.transcript?.role === "assistant" + ); + + expect(assistantLines).toEqual(["canonical final"]); + expect(assistantMetaLines).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("normalizes markdown-rich lifecycle fallback assistant text before append and lastResult update", () => { + vi.useFakeTimers(); + try { + const normalizedAssistantText = ["- item one", "- item two", "", "```ts", "const n = 1;", "```"].join( + "\n" + ); + const agents = [ + createAgent({ + streamText: "\n- item one \n- item two\t \n\n\n```ts \nconst n = 1;\t\n```\n\n", + runId: "run-6", + }), + ]; + const actions: Array<{ type: string; line?: string; patch?: unknown }> = []; + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + actions.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-6", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "end" }, + }, + } as EventFrame); + + vi.runAllTimers(); + + expect( + actions.some((entry) => entry.type === "appendOutput" && entry.line === normalizedAssistantText) + ).toBe(true); + expect( + actions.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.lastResult === normalizedAssistantText; + }) + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/gatewayRuntimeEventHandler.chat.test.ts b/tests/unit/gatewayRuntimeEventHandler.chat.test.ts new file mode 100644 index 00000000..48d76196 --- /dev/null +++ b/tests/unit/gatewayRuntimeEventHandler.chat.test.ts @@ -0,0 +1,963 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler"; +import { + agentStoreReducer, + initialAgentStoreState, + type AgentState, + type AgentStoreSeed, +} from "@/features/agents/state/store"; +import * as transcriptState from "@/features/agents/state/transcript"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + const merged = { ...base, ...(overrides ?? {}) }; + + return { + ...merged, + historyFetchLimit: merged.historyFetchLimit ?? null, + historyFetchedCount: merged.historyFetchedCount ?? null, + historyMaybeTruncated: merged.historyMaybeTruncated ?? false, + }; +}; + +describe("gateway runtime event handler (chat)", () => { + it("applies delta assistant chat stream via queueLivePatch", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatch = vi.fn(); + const queueLivePatch = vi.fn(); + + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + const event: EventFrame = { + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "assistant", content: "Hello" }, + }, + }; + + handler.handleEvent(event); + + expect(queueLivePatch).toHaveBeenCalledTimes(1); + expect(queueLivePatch).toHaveBeenCalledWith( + "agent-1", + expect.objectContaining({ + streamText: "Hello", + status: "running", + }) + ); + }); + + it("ignores user/system roles for streaming output", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const queueLivePatch = vi.fn(); + const dispatch = vi.fn(); + + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "user", content: "Hello" }, + }, + }); + + expect(queueLivePatch).not.toHaveBeenCalled(); + }); + + it("ignores stale delta chat events for non-active runIds", () => { + const agents = [ + createAgent({ + status: "running", + runId: "run-2", + runStartedAt: 900, + }), + ]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "assistant", content: "stale text" }, + }, + }); + + expect(queueLivePatch).not.toHaveBeenCalled(); + }); + + it("applies final assistant chat by appending output and clearing stream fields", async () => { + const agents = [ + createAgent({ + lastUserMessage: "hello", + latestOverride: null, + status: "running", + runId: "run-1", + runStartedAt: 900, + }), + ]; + const dispatched: Array<{ type: string; agentId: string; line?: string; patch?: unknown }> = []; + const dispatch = vi.fn((action) => { + dispatched.push(action as never); + }); + const updateSpecialLatestUpdate = vi.fn(); + const clearPendingLivePatch = vi.fn(); + + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch, + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate, + }); + + const ts = "2024-01-01T00:00:00.000Z"; + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "Done", timestamp: ts, thinking: "t" }, + }, + }); + + expect(dispatched.some((entry) => entry.type === "appendOutput" && entry.line === "Done")).toBe( + true + ); + expect( + dispatched.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.streamText === null && patch.thinkingTrace === null; + }) + ).toBe(true); + expect( + dispatched.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.status === "idle" && patch.runId === null; + }) + ).toBe(true); + expect( + dispatched.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.lastAssistantMessageAt === Date.parse(ts); + }) + ).toBe(true); + + expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1); + expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello"); + expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1"); + }); + + it("uses the current chat agent snapshot for latest-update effects", () => { + const agents = [ + createAgent({ + lastUserMessage: "hello", + latestOverride: null, + status: "running", + runId: "run-1", + runStartedAt: 900, + }), + ]; + let getAgentsCalls = 0; + const getAgents = () => { + getAgentsCalls += 1; + return getAgentsCalls === 1 ? agents : []; + }; + const updateSpecialLatestUpdate = vi.fn(); + + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents, + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate, + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "Done", timestamp: "2024-01-01T00:00:00.000Z" }, + }, + }); + + expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1); + expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello"); + }); + + it("normalizes markdown-rich final assistant chat text before append and lastResult update", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatched: Array<{ type: string; line?: string; patch?: unknown }> = []; + const normalizedAssistantText = ["- item one", "- item two", "", "```ts", "const n = 1;", "```"].join( + "\n" + ); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn((action) => { + dispatched.push(action as never); + }), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { + role: "assistant", + content: "\n- item one \r\n- item two\t \r\n\r\n\r\n```ts \r\nconst n = 1;\t\r\n```\r\n\r\n", + }, + }, + }); + + expect( + dispatched.some( + (entry) => entry.type === "appendOutput" && entry.line === normalizedAssistantText + ) + ).toBe(true); + expect( + dispatched.some((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.lastResult === normalizedAssistantText; + }) + ).toBe(true); + }); + + it("requests history refresh through boundary command only when final assistant arrives without trace lines", () => { + vi.useFakeTimers(); + try { + const agents = [createAgent({ outputLines: [] })]; + const requestHistoryRefresh = vi.fn(async () => {}); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh, + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "Done" }, + }, + }); + + expect(requestHistoryRefresh).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(requestHistoryRefresh).toHaveBeenCalledTimes(1); + expect(requestHistoryRefresh).toHaveBeenCalledWith({ + agentId: "agent-1", + reason: "chat-final-no-trace", + }); + } finally { + vi.useRealTimers(); + } + }); + + it("replaces committed lifecycle fallback with canonical chat final in reducer state", () => { + vi.useFakeTimers(); + const metricSpy = vi + .spyOn(transcriptState, "logTranscriptDebugMetric") + .mockImplementation(() => {}); + try { + const agents = [ + createAgent({ + status: "running", + runId: "run-1", + runStartedAt: 900, + streamText: "fallback final", + }), + ]; + const dispatched: Array<Record<string, unknown>> = []; + const dispatch = vi.fn((action) => { + dispatched.push(action as never); + }); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + stream: "lifecycle", + data: { phase: "end" }, + }, + } as EventFrame); + vi.advanceTimersByTime(400); + + expect( + dispatched.some( + (entry) => + entry.type === "appendOutput" && + entry.line === "fallback final" && + typeof entry.transcript === "object" + ) + ).toBe(true); + + const event: EventFrame = { + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "canonical final" }, + }, + }; + handler.handleEvent(event); + + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: agents[0]!.sessionKey, + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + status: "running", + runId: "run-1", + runStartedAt: 900, + streamText: "fallback final", + }, + }); + for (const action of dispatched) { + if (!action || typeof action !== "object") continue; + if (typeof (action as { type?: unknown }).type !== "string") continue; + state = agentStoreReducer(state, action as never); + } + const agentState = state.agents.find((entry) => entry.agentId === "agent-1"); + const transcriptEntries = agentState?.transcriptEntries ?? []; + const assistantEntries = transcriptEntries.filter((entry) => entry.kind === "assistant"); + const assistantMetaEntries = transcriptEntries.filter( + (entry) => entry.kind === "meta" && entry.role === "assistant" + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.text).toBe("canonical final"); + expect(assistantMetaEntries).toHaveLength(1); + expect(metricSpy).toHaveBeenCalledWith( + "lifecycle_fallback_replaced_by_chat_final", + expect.objectContaining({ runId: "run-1" }) + ); + } finally { + metricSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it("ignores terminal chat events with same-or-lower payload sequence for a run", () => { + const metricSpy = vi + .spyOn(transcriptState, "logTranscriptDebugMetric") + .mockImplementation(() => {}); + try { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatched: Array<Record<string, unknown>> = []; + const dispatch = vi.fn((action) => { + dispatched.push(action as never); + }); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + seq: 4, + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "final seq 4" }, + }, + }); + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + seq: 4, + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "final seq 4 replay" }, + }, + }); + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + seq: 3, + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "final seq 3 stale" }, + }, + }); + + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: agents[0]!.sessionKey, + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + status: "running", + runId: "run-1", + runStartedAt: 900, + }, + }); + for (const action of dispatched) { + if (!action || typeof action !== "object") continue; + if (typeof (action as { type?: unknown }).type !== "string") continue; + state = agentStoreReducer(state, action as never); + } + const agentState = state.agents.find((entry) => entry.agentId === "agent-1"); + const assistantEntries = (agentState?.transcriptEntries ?? []).filter( + (entry) => entry.kind === "assistant" + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.text).toBe("final seq 4"); + const staleCalls = metricSpy.mock.calls.filter( + (call) => call[0] === "stale_terminal_chat_event_ignored" + ); + expect(staleCalls).toHaveLength(2); + expect(staleCalls[0]?.[1]).toEqual( + expect.objectContaining({ + runId: "run-1", + seq: 4, + lastTerminalSeq: 4, + commitSource: "chat-final", + }) + ); + expect(staleCalls[1]?.[1]).toEqual( + expect.objectContaining({ + runId: "run-1", + seq: 3, + lastTerminalSeq: 4, + commitSource: "chat-final", + }) + ); + } finally { + metricSpy.mockRestore(); + } + }); + + it("accepts higher-sequence terminal chat events and keeps newest final text", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatched: Array<Record<string, unknown>> = []; + const dispatch = vi.fn((action) => { + dispatched.push(action as never); + }); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + seq: 2, + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "final seq 2" }, + }, + }); + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + seq: 3, + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "final seq 3" }, + }, + }); + + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: agents[0]!.sessionKey, + }; + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + status: "running", + runId: "run-1", + runStartedAt: 900, + }, + }); + for (const action of dispatched) { + if (!action || typeof action !== "object") continue; + if (typeof (action as { type?: unknown }).type !== "string") continue; + state = agentStoreReducer(state, action as never); + } + const agentState = state.agents.find((entry) => entry.agentId === "agent-1"); + const assistantEntries = (agentState?.transcriptEntries ?? []).filter( + (entry) => entry.kind === "assistant" + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.text).toBe("final seq 3"); + }); + + it("ignores terminal chat events for non-active runIds", () => { + const agents = [ + createAgent({ + status: "running", + runId: "run-2", + runStartedAt: 900, + streamText: "still streaming", + thinkingTrace: "t", + }), + ]; + const dispatched: Array<{ type: string; agentId: string; patch?: unknown }> = []; + const dispatch = vi.fn((action) => { + if (action && typeof action === "object") { + dispatched.push(action as never); + } + }); + const requestHistoryRefresh = vi.fn(async () => {}); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh, + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "old done" }, + }, + }); + + const terminalClears = dispatched.filter((entry) => { + if (entry.type !== "updateAgent") return false; + const patch = entry.patch as Record<string, unknown>; + return patch.streamText === null || patch.thinkingTrace === null || patch.runStartedAt === null; + }); + expect(terminalClears.length).toBe(0); + expect(requestHistoryRefresh).not.toHaveBeenCalled(); + }); + + it("handles aborted/error by appending output and clearing stream fields", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "aborted", + message: { role: "assistant", content: "" }, + }, + }); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." }) + ); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateAgent", + agentId: "agent-1", + patch: expect.objectContaining({ status: "idle" }), + }) + ); + + const errorDispatch = vi.fn(); + const errorHandler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: errorDispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + errorHandler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "error", + errorMessage: "bad", + message: { role: "assistant", content: "" }, + }, + }); + + expect(errorDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Error: bad" }) + ); + expect(errorDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateAgent", + agentId: "agent-1", + patch: expect.objectContaining({ status: "error" }), + }) + ); + }); + + it("suppresses aborted status line when abort is an approval pause", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const dispatch = vi.fn(); + const shouldSuppressRunAbortedLine = vi.fn(({ runId, stopReason }) => { + return runId === "run-1" && stopReason === "rpc"; + }); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch, + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + shouldSuppressRunAbortedLine, + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "aborted", + stopReason: "rpc", + message: { role: "assistant", content: "" }, + }, + }); + + expect(shouldSuppressRunAbortedLine).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + runId: "run-1", + stopReason: "rpc", + }) + ); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." }) + ); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateAgent", + agentId: "agent-1", + patch: expect.objectContaining({ status: "idle" }), + }) + ); + }); + + it("ignores late delta chat events after a run has already finalized", () => { + const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })]; + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => agents, + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "final", + message: { role: "assistant", content: "done" }, + }, + }); + + queueLivePatch.mockClear(); + + handler.handleEvent({ + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: agents[0]!.sessionKey, + state: "delta", + message: { role: "assistant", content: "late text" }, + }, + }); + + expect(queueLivePatch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/gatewayRuntimeEventHandler.policyDelegation.test.ts b/tests/unit/gatewayRuntimeEventHandler.policyDelegation.test.ts new file mode 100644 index 00000000..6cd0f38a --- /dev/null +++ b/tests/unit/gatewayRuntimeEventHandler.policyDelegation.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const policyMocks = vi.hoisted(() => ({ + decideRuntimeChatEvent: vi.fn(), + decideRuntimeAgentEvent: vi.fn(), + decideSummaryRefreshEvent: vi.fn(), +})); + +vi.mock("@/features/agents/state/runtimeEventPolicy", () => policyMocks); + +import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 900, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + ...(overrides ?? {}), +}); + +describe("gateway runtime event handler policy delegation", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("uses chat policy intents to drive delta live patching", () => { + policyMocks.decideRuntimeChatEvent.mockReturnValue([ + { + kind: "queueLivePatch", + agentId: "agent-1", + patch: { streamText: "from-policy", status: "running" }, + }, + ]); + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => [createAgent()], + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + const event: EventFrame = { + type: "event", + event: "chat", + payload: { + runId: "run-1", + sessionKey: "agent:agent-1:studio:test-session", + state: "delta", + message: { role: "assistant", content: "raw" }, + }, + }; + handler.handleEvent(event); + + expect(policyMocks.decideRuntimeChatEvent).toHaveBeenCalledTimes(1); + expect(queueLivePatch).toHaveBeenCalledWith("agent-1", { + streamText: "from-policy", + status: "running", + }); + }); + + it("uses agent policy intents to short-circuit processing", () => { + policyMocks.decideRuntimeAgentEvent.mockReturnValue([{ kind: "ignore", reason: "forced" }]); + const queueLivePatch = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => [createAgent()], + dispatch: vi.fn(), + queueLivePatch, + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot: vi.fn(async () => {}), + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ + type: "event", + event: "agent", + payload: { + runId: "run-1", + sessionKey: "agent:agent-1:studio:test-session", + stream: "assistant", + data: { delta: "raw" }, + }, + } as EventFrame); + + expect(policyMocks.decideRuntimeAgentEvent).toHaveBeenCalledTimes(1); + expect(queueLivePatch).not.toHaveBeenCalled(); + }); + + it("uses summary policy intents for heartbeat refresh behavior", async () => { + vi.useFakeTimers(); + policyMocks.decideSummaryRefreshEvent.mockReturnValue([ + { + kind: "scheduleSummaryRefresh", + delayMs: 10, + includeHeartbeatRefresh: true, + }, + ]); + const loadSummarySnapshot = vi.fn(async () => {}); + const bumpHeartbeatTick = vi.fn(); + const refreshHeartbeatLatestUpdate = vi.fn(); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => [createAgent()], + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot, + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate, + bumpHeartbeatTick, + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ type: "event", event: "presence", payload: {} }); + await vi.advanceTimersByTimeAsync(10); + + expect(policyMocks.decideSummaryRefreshEvent).toHaveBeenCalledTimes(1); + expect(bumpHeartbeatTick).toHaveBeenCalledTimes(1); + expect(refreshHeartbeatLatestUpdate).toHaveBeenCalledTimes(1); + expect(loadSummarySnapshot).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/tests/unit/gatewayRuntimeEventHandler.summaryRefresh.test.ts b/tests/unit/gatewayRuntimeEventHandler.summaryRefresh.test.ts new file mode 100644 index 00000000..be793b5f --- /dev/null +++ b/tests/unit/gatewayRuntimeEventHandler.summaryRefresh.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler"; +import type { AgentState } from "@/features/agents/state/store"; +import type { EventFrame } from "@/lib/gateway/GatewayClient"; + +const createAgent = (): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, +}); + +describe("gateway runtime event handler (summary refresh)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("debounces summary refresh events and loads summary once", async () => { + vi.useFakeTimers(); + const loadSummarySnapshot = vi.fn(async () => {}); + const bumpHeartbeatTick = vi.fn(); + const refreshHeartbeatLatestUpdate = vi.fn(); + + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "connected", + getAgents: () => [createAgent()], + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot, + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate, + bumpHeartbeatTick, + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + const presence: EventFrame = { type: "event", event: "presence", payload: {} }; + handler.handleEvent(presence); + handler.handleEvent(presence); + handler.handleEvent({ type: "event", event: "heartbeat", payload: {} }); + + expect(bumpHeartbeatTick).toHaveBeenCalledTimes(1); + expect(refreshHeartbeatLatestUpdate).toHaveBeenCalledTimes(1); + expect(loadSummarySnapshot).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(749); + expect(loadSummarySnapshot).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(1); + expect(loadSummarySnapshot).toHaveBeenCalledTimes(1); + + handler.dispose(); + }); + + it("ignores summary refresh when not connected", async () => { + vi.useFakeTimers(); + const loadSummarySnapshot = vi.fn(async () => {}); + const handler = createGatewayRuntimeEventHandler({ + getStatus: () => "disconnected", + getAgents: () => [createAgent()], + dispatch: vi.fn(), + queueLivePatch: vi.fn(), + clearPendingLivePatch: vi.fn(), + now: () => 1000, + loadSummarySnapshot, + requestHistoryRefresh: vi.fn(async () => {}), + refreshHeartbeatLatestUpdate: vi.fn(), + bumpHeartbeatTick: vi.fn(), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout), + isDisconnectLikeError: () => false, + logWarn: vi.fn(), + updateSpecialLatestUpdate: vi.fn(), + }); + + handler.handleEvent({ type: "event", event: "presence", payload: {} }); + await vi.runAllTimersAsync(); + + expect(loadSummarySnapshot).toHaveBeenCalledTimes(0); + handler.dispose(); + }); +}); diff --git a/tests/unit/gatewaySshTarget.test.ts b/tests/unit/gatewaySshTarget.test.ts new file mode 100644 index 00000000..132dc97e --- /dev/null +++ b/tests/unit/gatewaySshTarget.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveConfiguredSshTarget, + resolveGatewaySshTargetFromGatewayUrl, +} from "@/lib/ssh/gateway-host"; + +describe("gateway ssh target resolution", () => { + it("uses_configured_target_with_at_sign", () => { + expect( + resolveConfiguredSshTarget({ + OPENCLAW_GATEWAY_SSH_TARGET: "me@example.test", + } as unknown as NodeJS.ProcessEnv) + ).toBe("me@example.test"); + }); + + it("combines_user_and_target_when_target_missing_at_sign", () => { + expect( + resolveConfiguredSshTarget({ + OPENCLAW_GATEWAY_SSH_TARGET: "example.test", + OPENCLAW_GATEWAY_SSH_USER: "me", + } as unknown as NodeJS.ProcessEnv) + ).toBe("me@example.test"); + }); + + it("derives_target_from_gateway_url_with_default_user_ubuntu", () => { + expect( + resolveGatewaySshTargetFromGatewayUrl( + "ws://example.test:18789", + {} as unknown as NodeJS.ProcessEnv + ) + ).toBe("ubuntu@example.test"); + }); + + it("throws_on_missing_gateway_url_when_no_env_override", () => { + expect(() => + resolveGatewaySshTargetFromGatewayUrl("", {} as unknown as NodeJS.ProcessEnv) + ).toThrow("Gateway URL is missing."); + }); + + it("throws_on_invalid_gateway_url", () => { + expect(() => + resolveGatewaySshTargetFromGatewayUrl("not a url", {} as unknown as NodeJS.ProcessEnv) + ).toThrow("Invalid gateway URL:"); + }); +}); diff --git a/tests/unit/headerBar-brain-toggle.test.ts b/tests/unit/headerBar-brain-toggle.test.ts new file mode 100644 index 00000000..42e81c7c --- /dev/null +++ b/tests/unit/headerBar-brain-toggle.test.ts @@ -0,0 +1,54 @@ +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { HeaderBar } from "@/features/agents/components/HeaderBar"; + +describe("HeaderBar controls", () => { + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("does_not_render_brain_toggle_in_header", () => { + render( + createElement(HeaderBar, { + status: "disconnected", + onConnectionSettings: vi.fn(), + }) + ); + + expect(screen.queryByTestId("brain-files-toggle")).not.toBeInTheDocument(); + }); + + it("opens_menu_and_calls_connection_settings_handler", () => { + const onConnectionSettings = vi.fn(); + + render( + createElement(HeaderBar, { + status: "disconnected", + onConnectionSettings, + }) + ); + + fireEvent.click(screen.getByTestId("studio-menu-toggle")); + fireEvent.click(screen.getByTestId("gateway-settings-toggle")); + + expect(onConnectionSettings).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/heartbeatAgentConfig.test.ts b/tests/unit/heartbeatAgentConfig.test.ts new file mode 100644 index 00000000..1d03bdea --- /dev/null +++ b/tests/unit/heartbeatAgentConfig.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; + +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { + listHeartbeatsForAgent, + readConfigAgentList, + resolveHeartbeatSettings, + upsertConfigAgentEntry, + writeConfigAgentList, + type ConfigAgentEntry, +} from "@/lib/gateway/agentConfig"; + +const makeFakeClient = (responses: { + config: Record<string, unknown>; + status: Record<string, unknown>; +}) => { + return { + call: async (method: string) => { + if (method === "config.get") { + return { config: responses.config, hash: "hash", exists: true }; + } + if (method === "status") { + return responses.status; + } + if (method === "wake") { + return { ok: true }; + } + throw new Error(`Unexpected method: ${method}`); + }, + } as unknown as GatewayClient; +}; + +describe("heartbeat gateway helpers", () => { + it("resolveHeartbeatSettings merges defaults and per-agent overrides", () => { + const config: Record<string, unknown> = { + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + includeReasoning: false, + ackMaxChars: 111, + activeHours: { start: "09:00", end: "17:00" }, + }, + }, + list: [ + { + id: "alpha", + heartbeat: { + every: "5m", + target: "last", + includeReasoning: true, + }, + }, + ], + }, + }; + + const resolved = resolveHeartbeatSettings(config, "alpha"); + expect(resolved.hasOverride).toBe(true); + expect(resolved.heartbeat.every).toBe("5m"); + expect(resolved.heartbeat.includeReasoning).toBe(true); + expect(resolved.heartbeat.ackMaxChars).toBe(111); + expect(resolved.heartbeat.activeHours).toEqual({ start: "09:00", end: "17:00" }); + + const fallback = resolveHeartbeatSettings(config, "beta"); + expect(fallback.hasOverride).toBe(false); + expect(fallback.heartbeat.every).toBe("30m"); + expect(fallback.heartbeat.includeReasoning).toBe(false); + expect(fallback.heartbeat.ackMaxChars).toBe(111); + expect(fallback.heartbeat.activeHours).toEqual({ start: "09:00", end: "17:00" }); + }); + + it("listHeartbeatsForAgent returns [] when disabled and no override exists", async () => { + const config: Record<string, unknown> = { + agents: { + defaults: { + heartbeat: { every: "30m", target: "last", includeReasoning: false }, + }, + list: [], + }, + }; + const status: Record<string, unknown> = { + heartbeat: { agents: [{ agentId: "alpha", enabled: false }] }, + }; + const client = makeFakeClient({ config, status }); + + const result = await listHeartbeatsForAgent(client, "alpha"); + expect(result.heartbeats).toEqual([]); + }); + + it('listHeartbeatsForAgent returns one entry with source "default" when enabled and no override exists', async () => { + const config: Record<string, unknown> = { + agents: { + defaults: { + heartbeat: { every: "30m", target: "last", includeReasoning: false }, + }, + list: [], + }, + }; + const status: Record<string, unknown> = { + heartbeat: { agents: [{ agentId: "alpha", enabled: true }] }, + }; + const client = makeFakeClient({ config, status }); + + const result = await listHeartbeatsForAgent(client, "alpha"); + expect(result.heartbeats).toHaveLength(1); + expect(result.heartbeats[0]?.source).toBe("default"); + expect(result.heartbeats[0]?.enabled).toBe(true); + expect(result.heartbeats[0]?.heartbeat.every).toBe("30m"); + }); + + it('listHeartbeatsForAgent returns one entry with source "override" when an override exists', async () => { + const config: Record<string, unknown> = { + agents: { + defaults: { + heartbeat: { every: "30m", target: "last", includeReasoning: false }, + }, + list: [ + { + id: "alpha", + heartbeat: { every: "5m", target: "last", includeReasoning: true }, + }, + ], + }, + }; + const status: Record<string, unknown> = { + heartbeat: { agents: [{ agentId: "alpha", enabled: false }] }, + }; + const client = makeFakeClient({ config, status }); + + const result = await listHeartbeatsForAgent(client, "alpha"); + expect(result.heartbeats).toHaveLength(1); + expect(result.heartbeats[0]?.source).toBe("override"); + expect(result.heartbeats[0]?.enabled).toBe(false); + }); + + it("listHeartbeatsForAgent prefers status every over config-derived every", async () => { + const config: Record<string, unknown> = { + agents: { + defaults: { + heartbeat: { every: "30m", target: "last", includeReasoning: false }, + }, + list: [], + }, + }; + const status: Record<string, unknown> = { + heartbeat: { agents: [{ agentId: "alpha", enabled: true, every: "7m" }] }, + }; + const client = makeFakeClient({ config, status }); + + const result = await listHeartbeatsForAgent(client, "alpha"); + expect(result.heartbeats).toHaveLength(1); + expect(result.heartbeats[0]?.heartbeat.every).toBe("7m"); + }); +}); + +describe("gateway config list helpers", () => { + it("reads an empty list when agents.list is missing", () => { + expect(readConfigAgentList({})).toEqual([]); + }); + + it("filters invalid list entries and keeps id-based entries", () => { + const config = { + agents: { + list: [ + null, + { id: "agent-1", name: "One" }, + { id: "" }, + { name: "missing-id" }, + { id: "agent-2", heartbeat: { every: "30m" } }, + ], + }, + }; + expect(readConfigAgentList(config)).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", heartbeat: { every: "30m" } }, + ]); + }); + + it("writes agents.list immutably", () => { + const initial: Record<string, unknown> = { + agents: { defaults: { heartbeat: { every: "1h" } } }, + bindings: [{ agentId: "agent-1" }], + }; + const list: ConfigAgentEntry[] = [{ id: "agent-1", name: "One" }]; + const next = writeConfigAgentList(initial, list); + + expect(next).not.toBe(initial); + expect(next.agents).not.toBe(initial.agents); + expect((next.agents as Record<string, unknown>).list).toEqual(list); + expect((next.agents as Record<string, unknown>).defaults).toEqual({ + heartbeat: { every: "1h" }, + }); + expect(next.bindings).toEqual([{ agentId: "agent-1" }]); + }); + + it("upserts agent entries", () => { + const list: ConfigAgentEntry[] = [ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two" }, + ]; + + const updated = upsertConfigAgentEntry(list, "agent-2", (entry) => ({ + ...entry, + name: "Two Updated", + })); + expect(updated.list).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two Updated" }, + ]); + expect(updated.entry).toEqual({ id: "agent-2", name: "Two Updated" }); + + const inserted = upsertConfigAgentEntry(updated.list, "agent-3", (entry) => ({ + ...entry, + name: "Three", + })); + expect(inserted.list).toEqual([ + { id: "agent-1", name: "One" }, + { id: "agent-2", name: "Two Updated" }, + { id: "agent-3", name: "Three" }, + ]); + expect(inserted.entry).toEqual({ id: "agent-3", name: "Three" }); + }); +}); diff --git a/tests/unit/heartbeatGatewayClient.test.ts b/tests/unit/heartbeatGatewayClient.test.ts new file mode 100644 index 00000000..9e143fe7 --- /dev/null +++ b/tests/unit/heartbeatGatewayClient.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + listHeartbeatsForAgent, + triggerHeartbeatNow, +} from "@/lib/gateway/agentConfig"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +describe("heartbeat gateway client", () => { + it("returns_empty_list_when_agent_has_no_heartbeat", async () => { + const client = { + call: vi + .fn() + .mockResolvedValueOnce({ config: { agents: { list: [{ id: "agent-1" }] } } }) + .mockResolvedValueOnce({ + heartbeat: { agents: [{ agentId: "agent-1", enabled: false, every: "disabled" }] }, + }), + } as unknown as GatewayClient; + + const result = await listHeartbeatsForAgent(client, "agent-1"); + + expect(result.heartbeats).toEqual([]); + expect(client.call).toHaveBeenCalledTimes(2); + expect(client.call).toHaveBeenNthCalledWith(1, "config.get", {}); + expect(client.call).toHaveBeenNthCalledWith(2, "status", {}); + }); + + it("returns_override_heartbeat_for_agent", async () => { + const client = { + call: vi + .fn() + .mockResolvedValueOnce({ + config: { + agents: { + defaults: { heartbeat: { every: "30m", target: "last", includeReasoning: false } }, + list: [ + { + id: "agent-1", + heartbeat: { every: "15m", target: "none", includeReasoning: true }, + }, + ], + }, + }, + }) + .mockResolvedValueOnce({ + heartbeat: { agents: [{ agentId: "agent-1", enabled: true, every: "15m" }] }, + }), + } as unknown as GatewayClient; + + const result = await listHeartbeatsForAgent(client, "agent-1"); + + expect(result.heartbeats).toEqual([ + { + id: "agent-1", + agentId: "agent-1", + source: "override", + enabled: true, + heartbeat: { + every: "15m", + target: "none", + includeReasoning: true, + ackMaxChars: 300, + activeHours: null, + }, + }, + ]); + }); + + it("triggers_wake_now_for_heartbeat", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await triggerHeartbeatNow(client, "agent-1"); + + expect(client.call).toHaveBeenCalledWith("wake", { + mode: "now", + text: "OpenClaw Studio heartbeat trigger (agent-1).", + }); + }); +}); diff --git a/tests/unit/historyLifecycleWorkflow.integration.test.ts b/tests/unit/historyLifecycleWorkflow.integration.test.ts new file mode 100644 index 00000000..4ec176c2 --- /dev/null +++ b/tests/unit/historyLifecycleWorkflow.integration.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { + buildHistoryMetadataPatch, + resolveHistoryRequestIntent, + resolveHistoryResponseDisposition, +} from "@/features/agents/operations/historyLifecycleWorkflow"; +import { buildHistorySyncPatch, type ChatHistoryMessage } from "@/features/agents/state/runtimeEventBridge"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + return { ...base, ...(overrides ?? {}) }; +}; + +const runPageHistoryAdapter = (params: { + requestAgent: AgentState; + latestAgent: AgentState | null; + messages: ChatHistoryMessage[]; + requestedLimit?: number; +}) => { + const requestIntent = resolveHistoryRequestIntent({ + agent: params.requestAgent, + requestedLimit: params.requestedLimit, + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-1", + loadedAt: 1_234, + }); + if (requestIntent.kind === "skip") { + return { disposition: "skip" as const, patch: null, next: params.requestAgent }; + } + + const latest = params.latestAgent; + const disposition = resolveHistoryResponseDisposition({ + latestAgent: latest, + expectedSessionKey: requestIntent.sessionKey, + requestEpoch: requestIntent.requestEpoch, + requestRevision: requestIntent.requestRevision, + }); + const metadataPatch = buildHistoryMetadataPatch({ + loadedAt: requestIntent.loadedAt, + fetchedCount: params.messages.length, + limit: requestIntent.limit, + requestId: requestIntent.requestId, + }); + + if (!latest) { + return { disposition: "drop" as const, patch: null, next: params.requestAgent }; + } + + if (disposition.kind === "drop") { + return { disposition: "drop" as const, patch: null, next: latest }; + } + + const applyPatch = buildHistorySyncPatch({ + messages: params.messages, + currentLines: latest.outputLines, + loadedAt: requestIntent.loadedAt, + status: latest.status, + runId: latest.runId, + }); + const patch = { ...applyPatch, ...metadataPatch }; + return { + disposition: "apply" as const, + patch, + next: { ...latest, ...patch }, + }; +}; + +describe("historyLifecycleWorkflow integration", () => { + it("page adapter applies transcript patch even when running run is still active", () => { + const latest = createAgent({ + status: "running", + runId: "run-1", + outputLines: ["> user", "assistant draft"], + transcriptRevision: 2, + }); + + const result = runPageHistoryAdapter({ + requestAgent: latest, + latestAgent: latest, + messages: [{ role: "assistant", content: "final" }], + }); + + expect(result.disposition).toBe("apply"); + expect(result.next.outputLines).toEqual(["> user", "assistant draft", "final"]); + expect(result.patch).toEqual({ + outputLines: ["> user", "assistant draft", "final"], + lastResult: "final", + latestPreview: "final", + historyLoadedAt: 1_234, + historyFetchLimit: 200, + historyFetchedCount: 1, + historyMaybeTruncated: false, + lastAppliedHistoryRequestId: "req-1", + }); + }); + + it("page adapter drops responses when session epoch changed and preserves existing transcript", () => { + const requestAgent = createAgent({ + outputLines: ["> user", "assistant current"], + transcriptRevision: 7, + }); + const latest = createAgent({ + outputLines: ["> user", "assistant current"], + transcriptRevision: 8, + sessionEpoch: 1, + }); + + const result = runPageHistoryAdapter({ + requestAgent, + latestAgent: latest, + messages: [{ role: "assistant", content: "assistant stale" }], + }); + + expect(result.disposition).toBe("drop"); + expect(result.next.outputLines).toEqual(["> user", "assistant current"]); + expect(result.patch).toBeNull(); + }); + + it("page adapter applies transcript merge patch when workflow disposition is apply", () => { + const latest = createAgent({ + outputLines: ["> local question"], + transcriptRevision: 1, + }); + + const result = runPageHistoryAdapter({ + requestAgent: latest, + latestAgent: latest, + messages: [{ role: "assistant", content: "Merged answer" }], + }); + + expect(result.disposition).toBe("apply"); + expect(result.next.outputLines).toContain("> local question"); + expect(result.next.outputLines).toContain("Merged answer"); + expect(result.next.lastResult).toBe("Merged answer"); + expect(result.next.lastAppliedHistoryRequestId).toBe("req-1"); + }); + + it("page adapter collapses duplicate terminal assistant lines after reconcile-driven history apply", () => { + const requestAgent = createAgent({ + status: "running", + runId: "run-1", + outputLines: ["> question", "final answer", "final answer"], + transcriptRevision: 5, + }); + const latest = createAgent({ + status: "idle", + runId: null, + outputLines: ["> question", "final answer", "final answer"], + transcriptRevision: 5, + }); + + const result = runPageHistoryAdapter({ + requestAgent, + latestAgent: latest, + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: "final answer" }, + ], + }); + + expect(result.disposition).toBe("apply"); + expect(result.next.outputLines.filter((line) => line === "final answer")).toHaveLength(1); + }); +}); diff --git a/tests/unit/historyLifecycleWorkflow.test.ts b/tests/unit/historyLifecycleWorkflow.test.ts new file mode 100644 index 00000000..d7ad50df --- /dev/null +++ b/tests/unit/historyLifecycleWorkflow.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; + +import { + buildHistoryMetadataPatch, + resolveHistoryRequestIntent, + resolveHistoryResponseDisposition, +} from "@/features/agents/operations/historyLifecycleWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + return { ...base, ...(overrides ?? {}) }; +}; + +describe("historyLifecycleWorkflow", () => { + it("returns skip intent when session is missing or not created", () => { + expect( + resolveHistoryRequestIntent({ + agent: null, + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-1", + loadedAt: 1000, + }) + ).toEqual({ kind: "skip", reason: "missing-agent" }); + + expect( + resolveHistoryRequestIntent({ + agent: createAgent({ sessionCreated: false }), + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-1", + loadedAt: 1000, + }) + ).toEqual({ kind: "skip", reason: "session-not-created" }); + + expect( + resolveHistoryRequestIntent({ + agent: createAgent({ sessionKey: " " }), + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-1", + loadedAt: 1000, + }) + ).toEqual({ kind: "skip", reason: "missing-session-key" }); + }); + + it("plans history request with bounded limit and request identifiers", () => { + expect( + resolveHistoryRequestIntent({ + agent: createAgent({ transcriptRevision: 14, outputLines: ["one", "two"] }), + requestedLimit: 9000, + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-42", + loadedAt: 777, + }) + ).toEqual({ + kind: "fetch", + sessionKey: "agent:agent-1:main", + limit: 5000, + requestRevision: 14, + requestEpoch: 0, + requestId: "req-42", + loadedAt: 777, + }); + + expect( + resolveHistoryRequestIntent({ + agent: createAgent(), + defaultLimit: 200, + maxLimit: 5000, + inFlightSessionKeys: new Set<string>(), + requestId: "req-2", + loadedAt: 2000, + }) + ).toEqual({ + kind: "fetch", + sessionKey: "agent:agent-1:main", + limit: 200, + requestRevision: 0, + requestEpoch: 0, + requestId: "req-2", + loadedAt: 2000, + }); + }); + + it("drops stale responses when session key, epoch, or revision changed", () => { + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ sessionKey: "agent:agent-1:other" }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 0, + }) + ).toEqual({ + kind: "drop", + reason: "session-key-changed", + }); + + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ sessionEpoch: 4 }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 3, + requestRevision: 0, + }) + ).toEqual({ + kind: "drop", + reason: "session-epoch-changed", + }); + + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ transcriptRevision: 12 }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 11, + }) + ).toEqual({ + kind: "drop", + reason: "transcript-revision-changed", + }); + + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ outputLines: ["one", "two"] }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 1, + }) + ).toEqual({ + kind: "drop", + reason: "transcript-revision-changed", + }); + }); + + it("applies history even while run is still active", () => { + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ + status: "running", + runId: "run-1", + transcriptRevision: 9, + }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 9, + }) + ).toEqual({ + kind: "apply", + }); + + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ + status: "idle", + runId: null, + transcriptRevision: 9, + }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 9, + }) + ).toEqual({ + kind: "apply", + }); + + expect( + resolveHistoryResponseDisposition({ + latestAgent: createAgent({ + status: "idle", + runId: null, + outputLines: ["> q1", "a1"], + }), + expectedSessionKey: "agent:agent-1:main", + requestEpoch: 0, + requestRevision: 2, + }) + ).toEqual({ + kind: "apply", + }); + }); + + it("builds metadata patch with truncation semantics", () => { + expect( + buildHistoryMetadataPatch({ + loadedAt: 123, + fetchedCount: 8, + limit: 8, + requestId: "req-77", + }) + ).toEqual({ + historyLoadedAt: 123, + historyFetchLimit: 8, + historyFetchedCount: 8, + historyMaybeTruncated: true, + lastAppliedHistoryRequestId: "req-77", + }); + }); +}); diff --git a/tests/unit/historySyncOperation.integration.test.ts b/tests/unit/historySyncOperation.integration.test.ts new file mode 100644 index 00000000..d049c9de --- /dev/null +++ b/tests/unit/historySyncOperation.integration.test.ts @@ -0,0 +1,553 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + executeHistorySyncCommands, + runHistorySyncOperation, + type HistorySyncCommand, +} from "@/features/agents/operations/historySyncOperation"; +import type { AgentState } from "@/features/agents/state/store"; +import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript"; + +describe("historySyncOperation integration", () => { + it("executes dispatch and metric commands and suppresses disconnect-like errors", () => { + const dispatch = vi.fn(); + const logMetric = vi.fn(); + const logError = vi.fn(); + const commands: HistorySyncCommand[] = [ + { + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { historyLoadedAt: 1234 } as Partial<AgentState>, + }, + { + kind: "logMetric", + metric: "history_sync_test_metric", + meta: { agentId: "agent-1", requestId: "req-1", runId: "run-1" }, + }, + { + kind: "logError", + message: "Disconnected", + error: new Error("socket disconnected"), + }, + { + kind: "logError", + message: "Unexpected failure", + error: new Error("boom"), + }, + { kind: "noop", reason: "missing-agent" }, + ]; + + executeHistorySyncCommands({ + commands, + dispatch, + logMetric, + isDisconnectLikeError: (error) => + error instanceof Error && error.message.toLowerCase().includes("disconnected"), + logError, + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { historyLoadedAt: 1234 }, + }); + expect(logMetric).toHaveBeenCalledTimes(1); + expect(logMetric).toHaveBeenCalledWith("history_sync_test_metric", { + agentId: "agent-1", + requestId: "req-1", + runId: "run-1", + }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith("Unexpected failure", expect.any(Error)); + }); + + it("collapses duplicate non-active run assistant terminals during gap recovery history sync", async () => { + const sessionKey = "agent:agent-1:main"; + const duplicateOne = createTranscriptEntryFromLine({ + line: "final answer", + sessionKey, + source: "runtime-agent", + sequenceKey: 10, + runId: "run-1", + role: "assistant", + kind: "assistant", + entryId: "runtime-agent:run-1:final-1", + confirmed: false, + }); + const duplicateTwo = createTranscriptEntryFromLine({ + line: "final answer", + sessionKey, + source: "runtime-chat", + sequenceKey: 11, + runId: "run-1", + role: "assistant", + kind: "assistant", + entryId: "runtime-chat:run-1:final-2", + confirmed: true, + }); + if (!duplicateOne || !duplicateTwo) { + throw new Error("Expected transcript entries."); + } + + const requestAgent: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: ["> question", "final answer", "final answer"], + lastResult: "final answer", + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: "final answer", + lastUserMessage: "question", + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + transcriptEntries: [duplicateOne, duplicateTwo], + transcriptRevision: 2, + transcriptSequenceCounter: 12, + }; + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey, + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: "final answer" }, + ], + }) as T, + }, + agentId: "agent-1", + getAgent: () => requestAgent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-gap-1", + loadedAt: 10_000, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") { + throw new Error("Expected final dispatch update."); + } + const lines = finalUpdate.patch.outputLines ?? []; + expect(lines.filter((line) => line === "final answer")).toHaveLength(1); + }); + + it("preserves assistant duplicates for the active running run", async () => { + const sessionKey = "agent:agent-1:main"; + const duplicateOne = createTranscriptEntryFromLine({ + line: "stream line", + sessionKey, + source: "runtime-agent", + sequenceKey: 20, + runId: "run-active", + role: "assistant", + kind: "assistant", + entryId: "runtime-agent:run-active:1", + confirmed: false, + }); + const duplicateTwo = createTranscriptEntryFromLine({ + line: "stream line", + sessionKey, + source: "runtime-chat", + sequenceKey: 21, + runId: "run-active", + role: "assistant", + kind: "assistant", + entryId: "runtime-chat:run-active:2", + confirmed: true, + }); + if (!duplicateOne || !duplicateTwo) { + throw new Error("Expected transcript entries."); + } + + const runningAgent: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey, + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: ["> question", "stream line", "stream line"], + lastResult: null, + lastDiff: null, + runId: "run-active", + runStartedAt: 1_000, + streamText: "stream line", + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: "stream line", + lastUserMessage: "question", + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + transcriptEntries: [duplicateOne, duplicateTwo], + transcriptRevision: 4, + transcriptSequenceCounter: 22, + }; + + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey, + messages: [{ role: "assistant", content: "stream line" }], + }) as T, + }, + agentId: "agent-1", + getAgent: () => runningAgent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-gap-2", + loadedAt: 11_000, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") { + throw new Error("Expected final dispatch update."); + } + const lines = finalUpdate.patch.outputLines ?? []; + expect(lines.filter((line) => line === "stream line")).toHaveLength(2); + }); + + it("keeps repeated canonical history entries when content and timestamp are identical", async () => { + const sessionKey = "agent:agent-1:main"; + const agent: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + transcriptEntries: [], + transcriptRevision: 0, + transcriptSequenceCounter: 0, + }; + + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey, + messages: [ + { + role: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + content: "same line", + }, + { + role: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + content: "same line", + }, + ], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-dup-history", + loadedAt: 12_000, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") { + throw new Error("Expected final dispatch update."); + } + const lines = finalUpdate.patch.outputLines ?? []; + expect(lines.filter((line) => line === "same line")).toHaveLength(2); + const transcriptEntries = finalUpdate.patch.transcriptEntries ?? []; + expect( + transcriptEntries.filter((entry) => entry.kind === "assistant" && entry.text === "same line") + ).toHaveLength(2); + }); + + it("does not replay prior confirmed assistant turn during running history refresh", async () => { + const sessionKey = "agent:agent-1:main"; + const priorUser = createTranscriptEntryFromLine({ + line: "> what should we work on today?", + sessionKey, + source: "history", + sequenceKey: 1, + runId: null, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:user:prior", + }); + const priorAssistant = createTranscriptEntryFromLine({ + line: "win + progress + cleanup", + sessionKey, + source: "runtime-chat", + sequenceKey: 2, + runId: "run-prior", + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "run:run-prior:assistant:final", + }); + const nextUser = createTranscriptEntryFromLine({ + line: "> naw - sounds boring", + sessionKey, + source: "local-send", + sequenceKey: 3, + runId: "run-active", + role: "user", + kind: "user", + confirmed: false, + entryId: "local:user:next", + }); + if (!priorUser || !priorAssistant || !nextUser) { + throw new Error("Expected transcript entries."); + } + + const runningAgent: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey, + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [ + "> what should we work on today?", + "win + progress + cleanup", + "> naw - sounds boring", + ], + lastResult: "win + progress + cleanup", + lastDiff: null, + runId: "run-active", + runStartedAt: 10_000, + streamText: "", + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: 9_000, + lastActivityAt: 10_000, + latestPreview: "win + progress + cleanup", + lastUserMessage: "naw - sounds boring", + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + transcriptEntries: [priorUser, priorAssistant, nextUser], + transcriptRevision: 7, + transcriptSequenceCounter: 4, + }; + + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey, + messages: [ + { role: "user", content: "what should we work on today?" }, + { role: "assistant", content: "win + progress + cleanup" }, + ], + }) as T, + }, + agentId: "agent-1", + getAgent: () => runningAgent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-replay-1", + loadedAt: 15_000, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") { + throw new Error("Expected final dispatch update."); + } + const lines = finalUpdate.patch.outputLines ?? runningAgent.outputLines; + expect(lines.filter((line) => line === "win + progress + cleanup")).toHaveLength(1); + const transcriptEntries = + finalUpdate.patch.transcriptEntries ?? runningAgent.transcriptEntries ?? []; + expect( + transcriptEntries.filter( + (entry) => entry.kind === "assistant" && entry.text === "win + progress + cleanup" + ) + ).toHaveLength(1); + }); + + it("drops stale history response when transcript revision changes after request", async () => { + const requestAgent: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: ["> local question", "assistant current"], + lastResult: "assistant current", + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: "assistant current", + lastUserMessage: "local question", + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + transcriptEntries: [], + transcriptRevision: 7, + transcriptSequenceCounter: 0, + sessionEpoch: 0, + }; + const latestAgent: AgentState = { + ...requestAgent, + transcriptRevision: 8, + }; + let readCount = 0; + const inFlightSessionKeys = new Set<string>(); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: requestAgent.sessionKey, + messages: [{ role: "assistant", content: "stale remote answer" }], + }) as T, + }, + agentId: requestAgent.agentId, + getAgent: () => { + readCount += 1; + return readCount <= 1 ? requestAgent : latestAgent; + }, + inFlightSessionKeys, + requestId: "req-revision-drop-1", + loadedAt: 16_000, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent"); + expect(updates).toHaveLength(1); + expect(updates[0]).toEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { lastHistoryRequestRevision: 7 }, + }); + + const staleDropMetrics = commands.filter( + (entry) => entry.kind === "logMetric" && entry.metric === "history_response_dropped_stale" + ); + expect(staleDropMetrics).toEqual([ + { + kind: "logMetric", + metric: "history_response_dropped_stale", + meta: { + reason: "transcript_revision_changed", + agentId: "agent-1", + requestId: "req-revision-drop-1", + }, + }, + ]); + + expect( + updates.some((entry) => { + const patch = entry.patch; + return ( + Object.prototype.hasOwnProperty.call(patch, "outputLines") || + Object.prototype.hasOwnProperty.call(patch, "lastAppliedHistoryRequestId") + ); + }) + ).toBe(false); + expect(inFlightSessionKeys.size).toBe(0); + }); +}); diff --git a/tests/unit/historySyncOperation.test.ts b/tests/unit/historySyncOperation.test.ts new file mode 100644 index 00000000..f2d4d49e --- /dev/null +++ b/tests/unit/historySyncOperation.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it } from "vitest"; + +import { + runHistorySyncOperation, + type HistorySyncCommand, +} from "@/features/agents/operations/historySyncOperation"; +import type { AgentState } from "@/features/agents/state/store"; + +type ChatHistoryMessage = Record<string, unknown>; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + return { ...base, ...(overrides ?? {}) }; +}; + +const getCommandsByKind = <TKind extends HistorySyncCommand["kind"]>( + commands: HistorySyncCommand[], + kind: TKind +): Array<Extract<HistorySyncCommand, { kind: TKind }>> => + commands.filter((command) => command.kind === kind) as Array< + Extract<HistorySyncCommand, { kind: TKind }> + >; + +describe("historySyncOperation", () => { + it("returns noop when request intent resolves to skip", async () => { + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => ({ messages: [] as ChatHistoryMessage[] }) as T, + }, + agentId: "agent-1", + getAgent: () => null, + inFlightSessionKeys: new Set<string>(), + requestId: "req-1", + loadedAt: 1_234, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + expect(commands).toEqual([{ kind: "noop", reason: "missing-agent" }]); + }); + + it("applies history updates even when latest agent is running with active run", async () => { + const agent = createAgent({ + status: "running", + runId: "run-1", + transcriptRevision: 3, + outputLines: ["> local question", "assistant draft"], + }); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages: [{ role: "assistant", content: "remote answer" }], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-2", + loadedAt: 2_345, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + const metrics = getCommandsByKind(commands, "logMetric"); + expect(metrics).toEqual([]); + + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(patch.outputLines).toContain("> local question"); + expect(patch.outputLines).toContain("assistant draft"); + expect(patch.outputLines).toContain("remote answer"); + expect(patch.lastResult).toBe("remote answer"); + expect(patch.latestPreview).toBe("remote answer"); + expect(patch.lastAppliedHistoryRequestId).toBe("req-2"); + }); + + it("returns transcript merge update commands when disposition is apply and transcript v2 is enabled", async () => { + const agent = createAgent({ + transcriptRevision: 1, + outputLines: ["> local question"], + }); + const markdownAssistant = [ + "- first bullet", + "- second bullet", + "", + "```ts", + "console.log('merged answer');", + "```", + ].join("\n"); + const messages: ChatHistoryMessage[] = [{ role: "assistant", content: markdownAssistant }]; + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages, + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-3", + loadedAt: 3_456, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + expect(updates.length).toBeGreaterThanOrEqual(2); + expect(updates).toContainEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { lastHistoryRequestRevision: 1 }, + }); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(Array.isArray(patch.outputLines)).toBe(true); + expect(patch.outputLines).toContain("> local question"); + expect(patch.outputLines).toContain(markdownAssistant); + expect(patch.lastResult).toBe(markdownAssistant); + expect(patch.latestPreview).toBe(markdownAssistant); + expect(patch.lastAppliedHistoryRequestId).toBe("req-3"); + }); + + it("normalizes assistant text in transcript-v2 history sync patches", async () => { + const agent = createAgent({ + transcriptRevision: 1, + outputLines: ["> local question"], + }); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages: [{ role: "assistant", content: "\n- alpha \n\n\n- beta\t \n\n" }], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-3b", + loadedAt: 3_789, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(Array.isArray(patch.outputLines)).toBe(true); + expect(patch.outputLines).toContain("> local question"); + expect(patch.outputLines).toContain("- alpha\n\n- beta"); + expect(patch.lastResult).toBe("- alpha\n\n- beta"); + expect(patch.latestPreview).toBe("- alpha\n\n- beta"); + expect(patch.lastAppliedHistoryRequestId).toBe("req-3b"); + }); + + it("infers running state from recent user-terminal history in transcript-v2 mode", async () => { + const agent = createAgent({ + status: "idle", + runId: null, + runStartedAt: null, + transcriptRevision: 1, + outputLines: [], + }); + const loadedAt = Date.parse("2024-01-01T00:30:00.000Z"); + const lastUserAt = Date.parse("2024-01-01T00:29:40.000Z"); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages: [ + { + role: "user", + timestamp: new Date(lastUserAt).toISOString(), + content: "still working?", + }, + ], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-3c", + loadedAt, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(patch.status).toBe("running"); + expect(patch.runId).toBeNull(); + expect(patch.runStartedAt).toBe(lastUserAt); + expect(patch.streamText).toBeNull(); + expect(patch.thinkingTrace).toBeNull(); + expect(patch.lastUserMessage).toBe("still working?"); + expect(patch.lastAppliedHistoryRequestId).toBe("req-3c"); + }); + + it("does not infer running state when terminal history includes an aborted assistant message", async () => { + const agent = createAgent({ + status: "idle", + runId: null, + runStartedAt: null, + transcriptRevision: 1, + outputLines: [], + }); + const loadedAt = Date.parse("2024-01-01T00:30:00.000Z"); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages: [ + { + role: "user", + timestamp: "2024-01-01T00:29:40.000Z", + content: "still working?", + }, + { + role: "assistant", + timestamp: "2024-01-01T00:29:41.000Z", + content: [], + stopReason: "aborted", + errorMessage: "Request was aborted", + }, + ], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-3d", + loadedAt, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(patch.status).toBeUndefined(); + expect(patch.runId).toBeUndefined(); + expect(Array.isArray(patch.outputLines)).toBe(true); + expect(patch.outputLines).toContain("Run aborted."); + expect(patch.lastResult).toBe("Run aborted."); + expect(patch.latestPreview).toBe("Run aborted."); + expect(patch.lastAppliedHistoryRequestId).toBe("req-3d"); + }); + + it("returns legacy history sync patch command when transcript v2 is disabled", async () => { + const agent = createAgent({ + transcriptRevision: 0, + outputLines: ["> local question"], + }); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: agent.sessionKey, + messages: [{ role: "assistant", content: "Legacy answer" }], + }) as T, + }, + agentId: "agent-1", + getAgent: () => agent, + inFlightSessionKeys: new Set<string>(), + requestId: "req-4", + loadedAt: 4_567, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: false, + }); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(patch.outputLines).toContain("> local question"); + expect(patch.outputLines).toContain("Legacy answer"); + expect(patch.lastResult).toBe("Legacy answer"); + expect(patch.lastAppliedHistoryRequestId).toBe("req-4"); + }); + + it("drops stale history when transcript revision changes during fetch", async () => { + const requestAgent = createAgent({ + transcriptRevision: 7, + outputLines: ["> local question", "assistant current"], + }); + const latestAgent = createAgent({ + transcriptRevision: 8, + outputLines: ["> local question", "assistant current"], + }); + let readCount = 0; + const inFlight = new Set<string>(); + const commands = await runHistorySyncOperation({ + client: { + call: async <T>() => + ({ + sessionKey: requestAgent.sessionKey, + messages: [{ role: "assistant", content: "stale remote answer" }], + }) as T, + }, + agentId: "agent-1", + getAgent: () => { + readCount += 1; + return readCount <= 1 ? requestAgent : latestAgent; + }, + inFlightSessionKeys: inFlight, + requestId: "req-5", + loadedAt: 5_678, + defaultLimit: 200, + maxLimit: 5000, + transcriptV2Enabled: true, + }); + + const metrics = getCommandsByKind(commands, "logMetric"); + expect(metrics).toEqual([ + { + kind: "logMetric", + metric: "history_response_dropped_stale", + meta: { + reason: "transcript_revision_changed", + agentId: "agent-1", + requestId: "req-5", + }, + }, + ]); + + const updates = getCommandsByKind(commands, "dispatchUpdateAgent"); + expect(updates).toContainEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { lastHistoryRequestRevision: 7 }, + }); + const finalUpdate = updates[updates.length - 1]; + if (!finalUpdate) throw new Error("Expected final update command."); + const patch = finalUpdate.patch; + expect(patch).not.toHaveProperty("outputLines"); + expect(patch).not.toHaveProperty("lastAppliedHistoryRequestId"); + expect(patch).not.toHaveProperty("lastResult"); + expect(patch).not.toHaveProperty("latestPreview"); + expect(inFlight.size).toBe(0); + }); +}); diff --git a/tests/unit/latestUpdateWorkflow.test.ts b/tests/unit/latestUpdateWorkflow.test.ts new file mode 100644 index 00000000..fed17d93 --- /dev/null +++ b/tests/unit/latestUpdateWorkflow.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { + buildLatestUpdatePatch, + resolveLatestUpdateIntent, + resolveLatestUpdateKind, +} from "@/features/agents/operations/latestUpdateWorkflow"; + +describe("latestUpdateWorkflow", () => { + it("resolves latest-update kind as heartbeat, cron, or none from message content", () => { + expect(resolveLatestUpdateKind("")).toBeNull(); + expect(resolveLatestUpdateKind("check heartbeat status")).toBe("heartbeat"); + expect(resolveLatestUpdateKind("cron report pending")).toBe("cron"); + expect(resolveLatestUpdateKind("heartbeat then cron")).toBe("cron"); + expect(resolveLatestUpdateKind("cron then heartbeat")).toBe("heartbeat"); + }); + + it("returns reset intent when no latest-update kind is present and existing override is set", () => { + expect( + resolveLatestUpdateIntent({ + message: "plain user prompt", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + hasExistingOverride: true, + }) + ).toEqual({ kind: "reset" }); + expect( + resolveLatestUpdateIntent({ + message: "plain user prompt", + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + hasExistingOverride: false, + }) + ).toEqual({ kind: "noop" }); + }); + + it("returns heartbeat fetch intent with fallback session strategy", () => { + expect( + resolveLatestUpdateIntent({ + message: "heartbeat please", + agentId: "", + sessionKey: "agent:fallback-agent:main", + hasExistingOverride: false, + }) + ).toEqual({ + kind: "fetch-heartbeat", + agentId: "fallback-agent", + sessionLimit: 48, + historyLimit: 200, + }); + expect( + resolveLatestUpdateIntent({ + message: "heartbeat please", + agentId: "", + sessionKey: "invalid", + hasExistingOverride: false, + }) + ).toEqual({ kind: "reset" }); + }); + + it("maps fetched content into latest override patch semantics", () => { + expect(buildLatestUpdatePatch("", "heartbeat")).toEqual({ + latestOverride: null, + latestOverrideKind: null, + }); + expect(buildLatestUpdatePatch("Heartbeat is healthy.", "heartbeat")).toEqual({ + latestOverride: "Heartbeat is healthy.", + latestOverrideKind: "heartbeat", + }); + }); +}); diff --git a/tests/unit/lifecycleControllerWorkflow.integration.test.ts b/tests/unit/lifecycleControllerWorkflow.integration.test.ts new file mode 100644 index 00000000..7a663166 --- /dev/null +++ b/tests/unit/lifecycleControllerWorkflow.integration.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { resolveExecApprovalFollowUpIntent } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; +import type { AgentState } from "@/features/agents/state/store"; + +const createAgent = (agentId: string, sessionKey: string): AgentState => ({ + agentId, + name: agentId, + sessionKey, + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: agentId, + avatarUrl: null, +}); + +const createApproval = (): PendingExecApproval => ({ + id: "approval-1", + agentId: null, + sessionKey: "agent:agent-1:main", + command: "npm run test", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/usr/bin/npm", + createdAtMs: 1, + expiresAtMs: 2, + resolving: false, + error: null, +}); + +describe("lifecycleControllerWorkflow integration", () => { + it("allow-once and allow-always still trigger follow-up message send once", () => { + const approval = createApproval(); + const agents = [createAgent("agent-1", "agent:agent-1:main")]; + let sendCount = 0; + + for (const decision of ["allow-once", "allow-always"] as const) { + const intent = resolveExecApprovalFollowUpIntent({ + decision, + approval, + agents, + followUpMessage: "An exec approval was granted.", + }); + if (intent.shouldSend) { + sendCount += 1; + } + } + + expect(sendCount).toBe(2); + }); + + it("deny decision does not trigger follow-up message send", () => { + const intent = resolveExecApprovalFollowUpIntent({ + decision: "deny", + approval: createApproval(), + agents: [createAgent("agent-1", "agent:agent-1:main")], + followUpMessage: "An exec approval was granted.", + }); + + expect(intent).toEqual({ + shouldSend: false, + agentId: null, + sessionKey: null, + message: null, + }); + }); +}); diff --git a/tests/unit/livePatchQueue.test.ts b/tests/unit/livePatchQueue.test.ts new file mode 100644 index 00000000..3bb2a7a3 --- /dev/null +++ b/tests/unit/livePatchQueue.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue"; + +describe("mergePendingLivePatch", () => { + it("replaces pending patch when incoming runId differs", () => { + const merged = mergePendingLivePatch( + { + runId: "run-old", + streamText: "old text", + thinkingTrace: "old trace", + status: "running", + }, + { + runId: "run-new", + thinkingTrace: "new trace", + status: "running", + } + ); + + expect(merged).toEqual({ + runId: "run-new", + thinkingTrace: "new trace", + status: "running", + }); + }); + + it("drops stale live text when incoming patch introduces runId", () => { + const merged = mergePendingLivePatch( + { + streamText: "old text", + thinkingTrace: "old trace", + runStartedAt: 100, + }, + { + runId: "run-2", + thinkingTrace: "new trace", + status: "running", + } + ); + + expect(merged).toEqual({ + runStartedAt: 100, + runId: "run-2", + thinkingTrace: "new trace", + status: "running", + }); + }); + + it("merges same-run patches normally", () => { + const merged = mergePendingLivePatch( + { + runId: "run-1", + thinkingTrace: "thinking", + }, + { + runId: "run-1", + streamText: "answer", + } + ); + + expect(merged).toEqual({ + runId: "run-1", + thinkingTrace: "thinking", + streamText: "answer", + }); + }); +}); diff --git a/tests/unit/mediaMarkdown.test.ts b/tests/unit/mediaMarkdown.test.ts new file mode 100644 index 00000000..fb64590e --- /dev/null +++ b/tests/unit/mediaMarkdown.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown"; + +describe("media-markdown", () => { + it("rewrites MEDIA: lines pointing to images into markdown images", () => { + const input = "Hello\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\nDone"; + const out = rewriteMediaLinesToMarkdown(input); + + expect(out).toContain("![](/api/gateway/media?path="); + expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png"); + expect(out).toContain("Hello"); + expect(out).toContain("Done"); + }); + + it("rewrites MEDIA: with the image path on the next line", () => { + const input = "Hello\nMEDIA:\n/home/ubuntu/.openclaw/workspace-agent/foo.png\nDone"; + const out = rewriteMediaLinesToMarkdown(input); + + expect(out).toContain("![](/api/gateway/media?path="); + expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png"); + expect(out).toContain("Hello"); + expect(out).toContain("Done"); + }); + + it("does not rewrite inside fenced code blocks", () => { + const input = "```\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\n```"; + const out = rewriteMediaLinesToMarkdown(input); + expect(out).toBe(input); + }); +}); diff --git a/tests/unit/messageExtract.test.ts b/tests/unit/messageExtract.test.ts new file mode 100644 index 00000000..d1531b7a --- /dev/null +++ b/tests/unit/messageExtract.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeAssistantDisplayText } from "@/lib/text/assistantText"; +import { + buildAgentInstruction, + EXEC_APPROVAL_AUTO_RESUME_MARKER, + extractText, + extractTextCached, + extractThinking, + extractThinkingCached, + extractToolLines, + isUiMetadataPrefix, + stripUiMetadata, +} from "@/lib/text/message-extract"; + +describe("message-extract", () => { + it("strips envelope headers from user messages", () => { + const message = { + role: "user", + content: + "[Discord Guild #openclaw-studio channel id:123 +0s 2026-02-01 00:00 UTC] hello there", + }; + + expect(extractText(message)).toBe("hello there"); + }); + + it("removes <thinking>/<analysis> blocks from assistant-visible text", () => { + const message = { + role: "assistant", + content: "<thinking>Plan A</thinking>\n<analysis>Details</analysis>\nOk.", + }; + + expect(extractText(message)).toBe("Ok."); + }); + + it("strips assistant control prefixes in single- and double-bracket forms", () => { + expect(extractText({ role: "assistant", content: "[reply_to_current] hello" })).toBe("hello"); + expect(extractText({ role: "assistant", content: "[[reply_to_current]] hello" })).toBe("hello"); + }); + + it("extractTextCached matches extractText and is consistent", () => { + const message = { role: "user", content: "plain text" }; + + expect(extractTextCached(message)).toBe(extractText(message)); + expect(extractTextCached(message)).toBe("plain text"); + expect(extractTextCached(message)).toBe("plain text"); + }); + + it("extractThinkingCached matches extractThinking and is consistent", () => { + const message = { + role: "assistant", + content: [{ type: "thinking", thinking: "Plan A" }], + }; + + expect(extractThinkingCached(message)).toBe(extractThinking(message)); + expect(extractThinkingCached(message)).toBe("Plan A"); + expect(extractThinkingCached(message)).toBe("Plan A"); + }); + + it("formats tool call + tool result lines", () => { + const callMessage = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-1", + name: "functions.exec", + arguments: { command: "echo hi" }, + }, + ], + }; + + const resultMessage = { + role: "toolResult", + toolCallId: "call-1", + toolName: "functions.exec", + details: { status: "ok", exitCode: 0 }, + content: "hi\n", + }; + + const callLines = extractToolLines(callMessage).join("\n"); + expect(callLines).toContain("[[tool]] functions.exec (call-1)"); + expect(callLines).toContain("\"command\": \"echo hi\""); + + const resultLines = extractToolLines(resultMessage).join("\n"); + expect(resultLines).toContain("[[tool-result]] functions.exec (call-1)"); + expect(resultLines).toContain("ok"); + expect(resultLines).toContain("hi"); + }); + + it("does not treat normal messages as UI metadata", () => { + const built = buildAgentInstruction({ + message: "hello", + }); + + expect(isUiMetadataPrefix(built)).toBe(false); + expect(stripUiMetadata(built)).toContain("hello"); + expect(stripUiMetadata(built)).not.toContain("Execution approval policy:"); + }); + + it("strips leading system event blocks from queued session updates", () => { + const raw = `System: [2026-02-12 01:09:16 UTC] Exec failed (mild-she, signal SIGKILL) + +[Thu 2026-02-12 01:14 UTC] nope none of those are it. keep looking +[message_id: e050a641-aa32-4950-8083-c3bb7efdfc6d]`; + + expect(stripUiMetadata(raw)).toBe("nope none of those are it. keep looking"); + }); + + it("hides internal exec approval auto-resume messages from transcript text", () => { + const raw = `[Tue 2026-02-17 12:52 PST] ${EXEC_APPROVAL_AUTO_RESUME_MARKER} +Continue where you left off and finish the task.`; + expect(stripUiMetadata(raw)).toBe(""); + }); + + it("hides legacy auto-resume messages without internal marker", () => { + const raw = `printf "\\n== Root (/) ==\\n" ls -la / +[Tue 2026-02-17 12:52 PST] The exec approval was granted. Continue where you left off and finish the task.`; + expect(stripUiMetadata(raw)).toBe(""); + }); + + it("normalizes assistant helper text shape", () => { + expect(normalizeAssistantDisplayText("first\r\n\r\n\r\nsecond")).toBe("first\n\nsecond"); + expect(normalizeAssistantDisplayText("line one \nline two\t \n")).toBe("line one\nline two"); + expect(normalizeAssistantDisplayText("\n\nalpha\n\n\nbeta\n\n")).toBe("alpha\n\nbeta"); + }); +}); diff --git a/tests/unit/messageHelpers.test.ts b/tests/unit/messageHelpers.test.ts new file mode 100644 index 00000000..25cd6c6a --- /dev/null +++ b/tests/unit/messageHelpers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentInstruction } from "@/lib/text/message-extract"; + +describe("buildAgentInstruction", () => { + it("returns trimmed message for normal prompts", () => { + const message = buildAgentInstruction({ + message: " Ship it ", + }); + expect(message).toBe("Ship it"); + }); + + it("returns command messages untouched", () => { + const message = buildAgentInstruction({ + message: "/help", + }); + expect(message).toBe("/help"); + }); +}); diff --git a/tests/unit/mutationLifecycleWorkflow.create.test.ts b/tests/unit/mutationLifecycleWorkflow.create.test.ts new file mode 100644 index 00000000..a3d62762 --- /dev/null +++ b/tests/unit/mutationLifecycleWorkflow.create.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; +import type { CreateAgentMutationLifecycleDeps } from "@/features/agents/operations/mutationLifecycleWorkflow"; +import { + isCreateBlockTimedOut, + runCreateAgentMutationLifecycle, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; + +const createPayload = ( + overrides: Partial<AgentCreateModalSubmitPayload> = {} +): AgentCreateModalSubmitPayload => ({ + name: "Agent One", + avatarSeed: "seed-1", + ...overrides, +}); + +const createDeps = ( + overrides: Partial<CreateAgentMutationLifecycleDeps> = {} +): CreateAgentMutationLifecycleDeps => ({ + enqueueConfigMutation: async ({ run }) => { + await run(); + }, + createAgent: async () => ({ id: "agent-1" }), + setQueuedBlock: () => undefined, + setCreatingBlock: () => undefined, + onCompletion: async () => undefined, + setCreateAgentModalError: () => undefined, + setCreateAgentBusy: () => undefined, + clearCreateBlock: () => undefined, + onError: () => undefined, + ...overrides, +}); + +describe("mutationLifecycleWorkflow create lifecycle", () => { + it("blocks create and sets modal error when disconnected", async () => { + const setCreateAgentModalError = vi.fn(); + const enqueueConfigMutation = vi.fn(async () => undefined); + + const result = await runCreateAgentMutationLifecycle( + { + payload: createPayload(), + status: "disconnected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + createAgentBusy: false, + }, + createDeps({ + setCreateAgentModalError, + enqueueConfigMutation, + }) + ); + + expect(result).toBe(false); + expect(setCreateAgentModalError).toHaveBeenCalledWith("Connect to gateway before creating an agent."); + expect(enqueueConfigMutation).not.toHaveBeenCalled(); + }); + + it("fails fast when the submitted name is empty", async () => { + const setCreateAgentModalError = vi.fn(); + const enqueueConfigMutation = vi.fn(async () => undefined); + + const result = await runCreateAgentMutationLifecycle( + { + payload: createPayload({ name: " " }), + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + createAgentBusy: false, + }, + createDeps({ + setCreateAgentModalError, + enqueueConfigMutation, + }) + ); + + expect(result).toBe(false); + expect(setCreateAgentModalError).toHaveBeenCalledWith("Agent name is required."); + expect(enqueueConfigMutation).not.toHaveBeenCalled(); + }); + + it("runs create-only lifecycle and completion callback", async () => { + const order: string[] = []; + const onCompletion = vi.fn(async (completion: { agentId: string; agentName: string }) => { + order.push(`completion:${completion.agentId}:${completion.agentName}`); + }); + + const result = await runCreateAgentMutationLifecycle( + { + payload: createPayload(), + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + createAgentBusy: false, + }, + createDeps({ + setCreateAgentBusy: (busy) => { + order.push(`busy:${busy ? "on" : "off"}`); + }, + setCreateAgentModalError: (message) => { + order.push(`modalError:${message === null ? "clear" : "set"}`); + }, + setQueuedBlock: () => { + order.push("queued"); + }, + enqueueConfigMutation: async ({ run }) => { + order.push("enqueue"); + await run(); + }, + setCreatingBlock: () => { + order.push("creating"); + }, + createAgent: async () => { + order.push("createAgent"); + return { id: "agent-1" }; + }, + onCompletion, + }) + ); + + expect(result).toBe(true); + expect(order).toEqual([ + "busy:on", + "modalError:clear", + "queued", + "enqueue", + "creating", + "createAgent", + "completion:agent-1:Agent One", + "busy:off", + ]); + expect(onCompletion).toHaveBeenCalledTimes(1); + }); + + it("surfaces create errors and clears create block", async () => { + const clearCreateBlock = vi.fn(); + const setCreateAgentModalError = vi.fn(); + const onError = vi.fn(); + + const result = await runCreateAgentMutationLifecycle( + { + payload: createPayload(), + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + createAgentBusy: false, + }, + createDeps({ + createAgent: async () => { + throw new Error("create exploded"); + }, + clearCreateBlock, + setCreateAgentModalError, + onError, + }) + ); + + expect(result).toBe(false); + expect(clearCreateBlock).toHaveBeenCalledTimes(1); + expect(setCreateAgentModalError).toHaveBeenCalledWith("create exploded"); + expect(onError).toHaveBeenCalledWith("create exploded"); + }); + + it("maps create block timeout through shared mutation timeout policy", () => { + expect( + isCreateBlockTimedOut({ + block: null, + nowMs: 100_000, + maxWaitMs: 90_000, + }) + ).toBe(false); + + expect( + isCreateBlockTimedOut({ + block: { + agentName: "Agent One", + phase: "queued", + startedAt: 0, + }, + nowMs: 100_000, + maxWaitMs: 90_000, + }) + ).toBe(false); + + expect( + isCreateBlockTimedOut({ + block: { + agentName: "Agent One", + phase: "creating", + startedAt: 0, + }, + nowMs: 100_000, + maxWaitMs: 90_000, + }) + ).toBe(true); + }); +}); diff --git a/tests/unit/mutationLifecycleWorkflow.integration.test.ts b/tests/unit/mutationLifecycleWorkflow.integration.test.ts new file mode 100644 index 00000000..63acb86d --- /dev/null +++ b/tests/unit/mutationLifecycleWorkflow.integration.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildConfigMutationFailureMessage, + buildMutationSideEffectCommands, + buildQueuedMutationBlock, + resolveConfigMutationPostRunEffects, + resolveConfigMutationStatusLine, + resolveMutationStartGuard, + runConfigMutationWorkflow, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; +import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy"; + +describe("mutationLifecycleWorkflow integration", () => { + it("page create handler uses shared start guard and queued block shape", () => { + const denied = resolveMutationStartGuard({ + status: "disconnected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + }); + expect(denied).toEqual({ kind: "deny", reason: "not-connected" }); + + const allowed = resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + }); + expect(allowed).toEqual({ kind: "allow" }); + + const queued = buildQueuedMutationBlock({ + kind: "create-agent", + agentId: "", + agentName: "Agent One", + startedAt: 42, + }); + expect(queued).toEqual({ + kind: "create-agent", + agentId: "", + agentName: "Agent One", + phase: "queued", + startedAt: 42, + sawDisconnect: false, + }); + }); + + it("delete workflow maps awaiting-restart outcome to awaiting-restart block phase", async () => { + const result = await runConfigMutationWorkflow( + { kind: "delete-agent", isLocalGateway: false }, + { + executeMutation: async () => undefined, + shouldAwaitRemoteRestart: async () => true, + } + ); + + const effects = resolveConfigMutationPostRunEffects(result); + expect(effects).toEqual({ + shouldReloadAgents: false, + shouldClearBlock: false, + awaitingRestartPatch: { + phase: "awaiting-restart", + sawDisconnect: false, + }, + }); + }); + + it("rename workflow maps completed outcome to load-and-clear flow", async () => { + const result = await runConfigMutationWorkflow( + { kind: "rename-agent", isLocalGateway: false }, + { + executeMutation: async () => undefined, + shouldAwaitRemoteRestart: async () => false, + } + ); + + const effects = resolveConfigMutationPostRunEffects(result); + let didLoadAgents = false; + let block: { phase: string; sawDisconnect: boolean } | null = { + phase: "mutating", + sawDisconnect: false, + }; + if (effects.shouldReloadAgents) { + didLoadAgents = true; + } + if (effects.shouldClearBlock) { + block = null; + } + + expect(didLoadAgents).toBe(true); + expect(block).toBeNull(); + expect(effects.awaitingRestartPatch).toBeNull(); + }); + + it("page rename and delete handlers share lifecycle guard plus post-run transitions", async () => { + const blocked = resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: true, + hasRenameBlock: false, + hasDeleteBlock: false, + }); + expect(blocked).toEqual({ + kind: "deny", + reason: "create-block-active", + }); + + const allowed = resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + }); + expect(allowed).toEqual({ kind: "allow" }); + + const executeMutation = vi.fn(async () => undefined); + + const renameCompleted = await runConfigMutationWorkflow( + { + kind: "rename-agent", + isLocalGateway: false, + }, + { + executeMutation, + shouldAwaitRemoteRestart: async () => false, + } + ); + expect(buildMutationSideEffectCommands({ disposition: renameCompleted.disposition })).toEqual([ + { kind: "reload-agents" }, + { kind: "clear-mutation-block" }, + { kind: "set-mobile-pane", pane: "chat" }, + ]); + + const deleteAwaitingRestart = await runConfigMutationWorkflow( + { + kind: "delete-agent", + isLocalGateway: false, + }, + { + executeMutation, + shouldAwaitRemoteRestart: async () => true, + } + ); + expect(buildMutationSideEffectCommands({ disposition: deleteAwaitingRestart.disposition })).toEqual( + [{ kind: "patch-mutation-block", patch: { phase: "awaiting-restart", sawDisconnect: false } }] + ); + expect(executeMutation).toHaveBeenCalledTimes(2); + }); + + it("uses typed mutation commands for lifecycle side effects instead of inline branching", async () => { + const commandLog: string[] = []; + const runCommands = async ( + disposition: "completed" | "awaiting-restart" + ): Promise<{ phase: string; sawDisconnect: boolean } | null> => { + let block: { phase: string; sawDisconnect: boolean } | null = { + phase: "mutating", + sawDisconnect: false, + }; + for (const command of buildMutationSideEffectCommands({ disposition })) { + if (command.kind === "reload-agents") { + commandLog.push("reload"); + continue; + } + if (command.kind === "clear-mutation-block") { + commandLog.push("clear"); + block = null; + continue; + } + if (command.kind === "set-mobile-pane") { + commandLog.push(`pane:${command.pane}`); + continue; + } + commandLog.push(`patch:${command.patch.phase}`); + block = { + phase: command.patch.phase, + sawDisconnect: command.patch.sawDisconnect, + }; + } + return block; + }; + + const completedBlock = await runCommands("completed"); + const awaitingBlock = await runCommands("awaiting-restart"); + + expect(completedBlock).toBeNull(); + expect(awaitingBlock).toEqual({ + phase: "awaiting-restart", + sawDisconnect: false, + }); + expect(commandLog).toEqual(["reload", "clear", "pane:chat", "patch:awaiting-restart"]); + }); + + it("workflow errors clear block and set page error message", async () => { + let block: { phase: string; sawDisconnect: boolean } | null = { + phase: "mutating", + sawDisconnect: false, + }; + let errorMessage: string | null = null; + + try { + await runConfigMutationWorkflow( + { kind: "rename-agent", isLocalGateway: false }, + { + executeMutation: async () => { + throw new Error("rename exploded"); + }, + shouldAwaitRemoteRestart: async () => false, + } + ); + } catch (error) { + block = null; + errorMessage = buildConfigMutationFailureMessage({ + kind: "rename-agent", + error, + }); + } + + expect(block).toBeNull(); + expect(errorMessage).toBe("rename exploded"); + }); + + it("preserves queue gating when restart block is active", () => { + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: true, + queuedCount: 1, + }) + ).toBe(false); + + expect( + shouldStartNextConfigMutation({ + status: "connected", + hasRunningAgents: false, + nextMutationRequiresIdleAgents: false, + hasActiveMutation: false, + hasRestartBlockInProgress: false, + queuedCount: 1, + }) + ).toBe(true); + }); + + it("preserves lock-status text behavior across queued, mutating, and awaiting-restart phases", () => { + expect( + resolveConfigMutationStatusLine({ + block: { phase: "queued", sawDisconnect: false }, + status: "connected", + }) + ).toBe("Waiting for active runs to finish"); + + expect( + resolveConfigMutationStatusLine({ + block: { phase: "mutating", sawDisconnect: false }, + status: "connected", + }) + ).toBe("Submitting config change"); + + expect( + resolveConfigMutationStatusLine({ + block: { phase: "awaiting-restart", sawDisconnect: false }, + status: "connected", + }) + ).toBe("Waiting for gateway to restart"); + + expect( + resolveConfigMutationStatusLine({ + block: { phase: "awaiting-restart", sawDisconnect: true }, + status: "disconnected", + }) + ).toBe("Gateway restart in progress"); + + expect( + resolveConfigMutationStatusLine({ + block: { phase: "awaiting-restart", sawDisconnect: true }, + status: "connected", + }) + ).toBe("Gateway is back online, syncing agents"); + }); +}); diff --git a/tests/unit/mutationLifecycleWorkflow.lifecycle.test.ts b/tests/unit/mutationLifecycleWorkflow.lifecycle.test.ts new file mode 100644 index 00000000..fbcabdfe --- /dev/null +++ b/tests/unit/mutationLifecycleWorkflow.lifecycle.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow"; + +describe("mutationLifecycleWorkflow lifecycle runner", () => { + it("runs completed rename lifecycle commands in order", async () => { + const order: string[] = []; + const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => { + order.push("enqueue"); + await run(); + }); + const setQueuedBlock = vi.fn(() => { + order.push("queued"); + }); + const setMutatingBlock = vi.fn(() => { + order.push("mutating"); + }); + const executeMutation = vi.fn(async () => { + order.push("execute"); + }); + const shouldAwaitRemoteRestart = vi.fn(async () => { + order.push("await-check"); + return false; + }); + const reloadAgents = vi.fn(async () => { + order.push("reload"); + }); + const clearBlock = vi.fn(() => { + order.push("clear"); + }); + const setMobilePaneChat = vi.fn(() => { + order.push("pane"); + }); + const patchBlockAwaitingRestart = vi.fn(() => { + order.push("patch"); + }); + const onError = vi.fn(); + + const result = await runAgentConfigMutationLifecycle({ + kind: "rename-agent", + label: "Rename Agent One", + isLocalGateway: false, + deps: { + enqueueConfigMutation, + setQueuedBlock, + setMutatingBlock, + patchBlockAwaitingRestart, + clearBlock, + executeMutation, + shouldAwaitRemoteRestart, + reloadAgents, + setMobilePaneChat, + onError, + }, + }); + + expect(result).toBe(true); + expect(order).toEqual([ + "queued", + "enqueue", + "mutating", + "execute", + "await-check", + "reload", + "clear", + "pane", + ]); + expect(patchBlockAwaitingRestart).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(enqueueConfigMutation).toHaveBeenCalledWith({ + kind: "rename-agent", + label: "Rename Agent One", + run: expect.any(Function), + }); + }); + + it("applies awaiting-restart patch for remote delete", async () => { + const clearBlock = vi.fn(); + const patchBlockAwaitingRestart = vi.fn(); + + const result = await runAgentConfigMutationLifecycle({ + kind: "delete-agent", + label: "Delete Agent One", + isLocalGateway: false, + deps: { + enqueueConfigMutation: async ({ run }) => { + await run(); + }, + setQueuedBlock: () => undefined, + setMutatingBlock: () => undefined, + patchBlockAwaitingRestart, + clearBlock, + executeMutation: async () => undefined, + shouldAwaitRemoteRestart: async () => true, + reloadAgents: async () => undefined, + setMobilePaneChat: () => undefined, + onError: () => undefined, + }, + }); + + expect(result).toBe(true); + expect(patchBlockAwaitingRestart).toHaveBeenCalledWith({ + phase: "awaiting-restart", + sawDisconnect: false, + }); + expect(clearBlock).not.toHaveBeenCalled(); + }); + + it("does not call restart-check on local gateway", async () => { + const shouldAwaitRemoteRestart = vi.fn(async () => true); + + const result = await runAgentConfigMutationLifecycle({ + kind: "rename-agent", + label: "Rename Agent One", + isLocalGateway: true, + deps: { + enqueueConfigMutation: async ({ run }) => { + await run(); + }, + setQueuedBlock: () => undefined, + setMutatingBlock: () => undefined, + patchBlockAwaitingRestart: () => undefined, + clearBlock: () => undefined, + executeMutation: async () => undefined, + shouldAwaitRemoteRestart, + reloadAgents: async () => undefined, + setMobilePaneChat: () => undefined, + onError: () => undefined, + }, + }); + + expect(result).toBe(true); + expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled(); + }); + + it("clears block and reports mapped error on mutation failure", async () => { + const clearBlock = vi.fn(); + const onError = vi.fn(); + + const result = await runAgentConfigMutationLifecycle({ + kind: "rename-agent", + label: "Rename Agent One", + isLocalGateway: false, + deps: { + enqueueConfigMutation: async ({ run }) => { + await run(); + }, + setQueuedBlock: () => undefined, + setMutatingBlock: () => undefined, + patchBlockAwaitingRestart: () => undefined, + clearBlock, + executeMutation: async () => { + throw new Error("rename exploded"); + }, + shouldAwaitRemoteRestart: async () => false, + reloadAgents: async () => undefined, + setMobilePaneChat: () => undefined, + onError, + }, + }); + + expect(result).toBe(false); + expect(clearBlock).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith("rename exploded"); + }); +}); diff --git a/tests/unit/mutationLifecycleWorkflow.test.ts b/tests/unit/mutationLifecycleWorkflow.test.ts new file mode 100644 index 00000000..21f87ef6 --- /dev/null +++ b/tests/unit/mutationLifecycleWorkflow.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildConfigMutationFailureMessage, + buildMutationSideEffectCommands, + buildMutatingMutationBlock, + buildQueuedMutationBlock, + resolveMutationPostRunIntent, + resolveMutationStartGuard, + resolveMutationTimeoutIntent, + runConfigMutationWorkflow, + type MutationWorkflowKind, +} from "@/features/agents/operations/mutationLifecycleWorkflow"; + +describe("mutationLifecycleWorkflow", () => { + it("returns completed for local gateway mutations without restart wait", async () => { + const executeMutation = vi.fn(async () => undefined); + const shouldAwaitRemoteRestart = vi.fn(async () => true); + + const result = await runConfigMutationWorkflow( + { kind: "delete-agent", isLocalGateway: true }, + { executeMutation, shouldAwaitRemoteRestart } + ); + + expect(result).toEqual({ disposition: "completed" }); + expect(executeMutation).toHaveBeenCalledTimes(1); + expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled(); + }); + + it("returns completed for remote mutation when restart wait is not required", async () => { + const executeMutation = vi.fn(async () => undefined); + const shouldAwaitRemoteRestart = vi.fn(async () => false); + + const result = await runConfigMutationWorkflow( + { kind: "rename-agent", isLocalGateway: false }, + { executeMutation, shouldAwaitRemoteRestart } + ); + + expect(result).toEqual({ disposition: "completed" }); + expect(executeMutation).toHaveBeenCalledTimes(1); + expect(shouldAwaitRemoteRestart).toHaveBeenCalledTimes(1); + }); + + it("returns awaiting-restart for remote mutation when restart wait is required", async () => { + const executeMutation = vi.fn(async () => undefined); + const shouldAwaitRemoteRestart = vi.fn(async () => true); + + const result = await runConfigMutationWorkflow( + { kind: "delete-agent", isLocalGateway: false }, + { executeMutation, shouldAwaitRemoteRestart } + ); + + expect(result).toEqual({ disposition: "awaiting-restart" }); + expect(executeMutation).toHaveBeenCalledTimes(1); + expect(shouldAwaitRemoteRestart).toHaveBeenCalledTimes(1); + }); + + it("maps mutation failures to user-facing errors", () => { + const fallbackByKind: Record<MutationWorkflowKind, string> = { + "rename-agent": "Failed to rename agent.", + "delete-agent": "Failed to delete agent.", + }; + for (const [kind, fallback] of Object.entries(fallbackByKind) as Array< + [MutationWorkflowKind, string] + >) { + expect(buildConfigMutationFailureMessage({ kind, error: new Error("boom") })).toBe("boom"); + expect(buildConfigMutationFailureMessage({ kind, error: 123 })).toBe(fallback); + } + }); + + it("rejects invalid mutation input before side effects", async () => { + const executeMutation = vi.fn(async () => undefined); + const shouldAwaitRemoteRestart = vi.fn(async () => true); + + await expect( + runConfigMutationWorkflow( + // @ts-expect-error intentional invalid kind check + { kind: "unknown-kind", isLocalGateway: false }, + { executeMutation, shouldAwaitRemoteRestart } + ) + ).rejects.toThrow("Unknown config mutation kind: unknown-kind"); + + expect(executeMutation).not.toHaveBeenCalled(); + expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled(); + }); + + it("blocks mutation starts when another mutation block is active", () => { + expect( + resolveMutationStartGuard({ + status: "disconnected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + }) + ).toEqual({ kind: "deny", reason: "not-connected" }); + + expect( + resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: true, + hasRenameBlock: false, + hasDeleteBlock: false, + }) + ).toEqual({ kind: "deny", reason: "create-block-active" }); + + expect( + resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: true, + hasDeleteBlock: false, + }) + ).toEqual({ kind: "deny", reason: "rename-block-active" }); + + expect( + resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: true, + }) + ).toEqual({ kind: "deny", reason: "delete-block-active" }); + + expect( + resolveMutationStartGuard({ + status: "connected", + hasCreateBlock: false, + hasRenameBlock: false, + hasDeleteBlock: false, + }) + ).toEqual({ kind: "allow" }); + }); + + it("builds deterministic queued and mutating block transitions", () => { + const queued = buildQueuedMutationBlock({ + kind: "rename-agent", + agentId: "agent-1", + agentName: "Agent One", + startedAt: 123, + }); + + expect(queued).toEqual({ + kind: "rename-agent", + agentId: "agent-1", + agentName: "Agent One", + phase: "queued", + startedAt: 123, + sawDisconnect: false, + }); + + expect(buildMutatingMutationBlock(queued)).toEqual({ + ...queued, + phase: "mutating", + }); + }); + + it("resolves post-mutation block outcomes for completed vs awaiting-restart", () => { + expect(resolveMutationPostRunIntent({ disposition: "completed" })).toEqual({ + kind: "clear", + }); + + expect(resolveMutationPostRunIntent({ disposition: "awaiting-restart" })).toEqual({ + kind: "awaiting-restart", + patch: { + phase: "awaiting-restart", + sawDisconnect: false, + }, + }); + }); + + it("builds typed side-effect commands for completed and awaiting-restart dispositions", () => { + expect(buildMutationSideEffectCommands({ disposition: "completed" })).toEqual([ + { kind: "reload-agents" }, + { kind: "clear-mutation-block" }, + { kind: "set-mobile-pane", pane: "chat" }, + ]); + + expect(buildMutationSideEffectCommands({ disposition: "awaiting-restart" })).toEqual([ + { + kind: "patch-mutation-block", + patch: { phase: "awaiting-restart", sawDisconnect: false }, + }, + ]); + }); + + it("returns timeout intent when mutation block exceeds max wait", () => { + expect( + resolveMutationTimeoutIntent({ + block: null, + nowMs: 10_000, + maxWaitMs: 90_000, + }) + ).toEqual({ kind: "none" }); + + const createBlock = buildQueuedMutationBlock({ + kind: "create-agent", + agentId: "agent-1", + agentName: "A", + startedAt: 1_000, + }); + const renameBlock = buildQueuedMutationBlock({ + kind: "rename-agent", + agentId: "agent-1", + agentName: "A", + startedAt: 1_000, + }); + const deleteBlock = buildQueuedMutationBlock({ + kind: "delete-agent", + agentId: "agent-1", + agentName: "A", + startedAt: 1_000, + }); + + expect( + resolveMutationTimeoutIntent({ + block: createBlock, + nowMs: 91_000, + maxWaitMs: 90_000, + }) + ).toEqual({ kind: "timeout", reason: "create-timeout" }); + + expect( + resolveMutationTimeoutIntent({ + block: renameBlock, + nowMs: 91_000, + maxWaitMs: 90_000, + }) + ).toEqual({ kind: "timeout", reason: "rename-timeout" }); + + expect( + resolveMutationTimeoutIntent({ + block: deleteBlock, + nowMs: 91_000, + maxWaitMs: 90_000, + }) + ).toEqual({ kind: "timeout", reason: "delete-timeout" }); + + expect( + resolveMutationTimeoutIntent({ + block: createBlock, + nowMs: 50_000, + maxWaitMs: 90_000, + }) + ).toEqual({ kind: "none" }); + }); +}); diff --git a/tests/unit/packageManifest.test.ts b/tests/unit/packageManifest.test.ts new file mode 100644 index 00000000..620cab71 --- /dev/null +++ b/tests/unit/packageManifest.test.ts @@ -0,0 +1,19 @@ +// @vitest-environment node + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("package manifest", () => { + it("does not export local openclaw-studio bin", () => { + const packageJsonPath = path.join(process.cwd(), "package.json"); + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + bin?: Record<string, unknown>; + }; + const hasOpenclawStudioBin = Object.prototype.hasOwnProperty.call( + parsed.bin ?? {}, + "openclaw-studio" + ); + expect(hasOpenclawStudioBin).toBe(false); + }); +}); diff --git a/tests/unit/pathSuggestionsRoute.test.ts b/tests/unit/pathSuggestionsRoute.test.ts new file mode 100644 index 00000000..07bbea7e --- /dev/null +++ b/tests/unit/pathSuggestionsRoute.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +let tempHome: string | null = null; + +const setupHome = () => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + + fs.mkdirSync(path.join(tempHome, "Documents"), { recursive: true }); + fs.mkdirSync(path.join(tempHome, "Downloads"), { recursive: true }); + fs.writeFileSync(path.join(tempHome, "Doc.txt"), "doc", "utf8"); + fs.writeFileSync(path.join(tempHome, "Notes.txt"), "notes", "utf8"); + fs.writeFileSync(path.join(tempHome, ".secret"), "hidden", "utf8"); +}; + +const cleanupHome = () => { + const home = tempHome; + tempHome = null; + vi.restoreAllMocks(); + if (!home) return; + fs.rmSync(home, { recursive: true, force: true }); +}; + +let GET: typeof import("@/app/api/path-suggestions/route")["GET"]; + +beforeAll(async () => { + ({ GET } = await import("@/app/api/path-suggestions/route")); +}); + +beforeEach(setupHome); +afterEach(cleanupHome); + +let consoleErrorSpy: ReturnType<typeof vi.spyOn>; + +describe("/api/path-suggestions route", () => { + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("returns non-hidden entries for home by default", async () => { + const response = await GET(new Request("http://localhost/api/path-suggestions")); + const body = (await response.json()) as { entries: Array<{ displayPath: string }> }; + + expect(response.status).toBe(200); + expect(body.entries.map((entry) => entry.displayPath)).toEqual([ + "~/Documents/", + "~/Downloads/", + "~/Doc.txt", + "~/Notes.txt", + ]); + }); + + it("filters by prefix within the current directory", async () => { + const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/Doc")); + const body = (await response.json()) as { entries: Array<{ displayPath: string }> }; + + expect(response.status).toBe(200); + expect(body.entries.map((entry) => entry.displayPath)).toEqual([ + "~/Documents/", + "~/Doc.txt", + ]); + }); + + it("rejects paths outside the home directory", async () => { + const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/../")); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(400); + expect(body.error).toMatch(/home/i); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it("returns 404 for missing directories", async () => { + const response = await GET( + new Request("http://localhost/api/path-suggestions?q=~/Missing/") + ); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(404); + expect(body.error).toMatch(/does not exist/i); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/pendingExecApprovalsStore.test.ts b/tests/unit/pendingExecApprovalsStore.test.ts new file mode 100644 index 00000000..1111b872 --- /dev/null +++ b/tests/unit/pendingExecApprovalsStore.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import type { PendingExecApproval } from "@/features/agents/approvals/types"; +import { + mergePendingApprovalsForFocusedAgent, + removePendingApprovalEverywhere, + nextPendingApprovalPruneDelayMs, + pruneExpiredPendingApprovals, + pruneExpiredPendingApprovalsMap, + removePendingApprovalById, + removePendingApprovalByIdMap, + upsertPendingApproval, + updatePendingApprovalById, +} from "@/features/agents/approvals/pendingStore"; + +const createApproval = (id: string, expiresAtMs: number): PendingExecApproval => ({ + id, + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + command: "pwd", + cwd: "/repo", + host: "gateway", + security: "allowlist", + ask: "always", + resolvedPath: "/bin/pwd", + createdAtMs: expiresAtMs - 1000, + expiresAtMs, + resolving: false, + error: null, +}); + +describe("pending approval store", () => { + it("removes an approval id from scoped and unscoped collections", () => { + const approvalA = createApproval("a", 10_000); + const approvalB = createApproval("b", 20_000); + const approvalC = createApproval("c", 30_000); + const scoped = { + "agent-1": [approvalA, approvalB], + "agent-2": [approvalC], + }; + const unscoped = [approvalB, approvalC]; + + const removed = removePendingApprovalEverywhere({ + approvalsByAgentId: scoped, + unscopedApprovals: unscoped, + approvalId: "b", + }); + + expect(removed.approvalsByAgentId).toEqual({ + "agent-1": [approvalA], + "agent-2": [approvalC], + }); + expect(removed.unscopedApprovals).toEqual([approvalC]); + }); + + it("is idempotent when approval id is missing", () => { + const scoped = { + "agent-1": [createApproval("a", 10_000)], + }; + const unscoped = [createApproval("b", 20_000)]; + + const removed = removePendingApprovalEverywhere({ + approvalsByAgentId: scoped, + unscopedApprovals: unscoped, + approvalId: "missing", + }); + + expect(removed.approvalsByAgentId).toBe(scoped); + expect(removed.unscopedApprovals).toBe(unscoped); + }); + + it("drops empty scoped buckets after removal", () => { + const scoped = { + "agent-1": [createApproval("a", 10_000)], + "agent-2": [createApproval("b", 20_000)], + }; + + const removed = removePendingApprovalEverywhere({ + approvalsByAgentId: scoped, + unscopedApprovals: [], + approvalId: "a", + }); + + expect(removed.approvalsByAgentId).toEqual({ + "agent-2": [createApproval("b", 20_000)], + }); + }); + + it("upserts approvals and keeps most recent at the top", () => { + const a = createApproval("a", 10_000); + const b = createApproval("b", 20_000); + const updatedA = { ...a, command: "ls" }; + + const added = upsertPendingApproval([], a); + expect(added).toEqual([a]); + + const withB = upsertPendingApproval(added, b); + expect(withB).toEqual([b, a]); + + const upsertedA = upsertPendingApproval(withB, updatedA); + expect(upsertedA).toEqual([b, updatedA]); + }); + + it("updates and removes approvals by id", () => { + const approvals = [createApproval("a", 10_000), createApproval("b", 20_000)]; + const updated = updatePendingApprovalById(approvals, "a", (approval) => ({ + ...approval, + resolving: true, + })); + expect(updated[0]?.resolving).toBe(true); + + const removed = removePendingApprovalById(updated, "a"); + expect(removed).toHaveLength(1); + expect(removed[0]?.id).toBe("b"); + }); + + it("removes approvals by id across agent map and drops empty keys", () => { + const map = { + "agent-1": [createApproval("a", 10_000)], + "agent-2": [createApproval("b", 20_000)], + }; + const removed = removePendingApprovalByIdMap(map, "a"); + expect(removed).toEqual({ + "agent-2": [createApproval("b", 20_000)], + }); + }); + + it("prunes expired approvals with grace window", () => { + const nowMs = 10_000; + const graceMs = 500; + const expired = createApproval("a", nowMs - 600); + const graceBoundary = createApproval("b", nowMs - 500); + const active = createApproval("c", nowMs + 200); + + const pruned = pruneExpiredPendingApprovals([expired, graceBoundary, active], { + nowMs, + graceMs, + }); + + expect(pruned.map((entry) => entry.id)).toEqual(["b", "c"]); + + const mapPruned = pruneExpiredPendingApprovalsMap( + { + "agent-1": [expired, active], + "agent-2": [graceBoundary], + }, + { nowMs, graceMs } + ); + + expect(mapPruned).toEqual({ + "agent-1": [active], + "agent-2": [graceBoundary], + }); + }); + + it("computes next prune delay from the earliest expiry", () => { + const nowMs = 5_000; + const delay = nextPendingApprovalPruneDelayMs({ + approvalsByAgentId: { + "agent-1": [createApproval("a", 9_000)], + "agent-2": [createApproval("b", 6_000)], + }, + unscopedApprovals: [createApproval("c", 7_000)], + nowMs, + graceMs: 500, + }); + expect(delay).toBe(1_500); + + const none = nextPendingApprovalPruneDelayMs({ + approvalsByAgentId: {}, + unscopedApprovals: [], + nowMs, + graceMs: 500, + }); + expect(none).toBeNull(); + }); + + it("merges focused approvals without rendering duplicate ids", () => { + const unscopedA = createApproval("same", 10_000); + const unscopedB = createApproval("unscoped-only", 11_000); + const scopedSame = { ...createApproval("same", 12_000), agentId: "agent-2" }; + const scopedC = { ...createApproval("scoped-only", 13_000), agentId: "agent-2" }; + + const merged = mergePendingApprovalsForFocusedAgent({ + scopedApprovals: [scopedSame, scopedC], + unscopedApprovals: [unscopedA, unscopedB], + }); + + expect(merged.map((entry) => entry.id)).toEqual(["same", "unscoped-only", "scoped-only"]); + expect(merged[0]).toEqual(scopedSame); + }); +}); diff --git a/tests/unit/personalityBuilder.test.ts b/tests/unit/personalityBuilder.test.ts new file mode 100644 index 00000000..f2ebe611 --- /dev/null +++ b/tests/unit/personalityBuilder.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { createAgentFilesState } from "@/lib/agents/agentFiles"; +import { + parsePersonalityFiles, + serializePersonalityFiles, + type PersonalityBuilderDraft, +} from "@/lib/agents/personalityBuilder"; + +const createFiles = () => createAgentFilesState(); + +describe("personalityBuilder", () => { + it("parseIdentityMarkdown_extracts_fields_from_template_style_list", () => { + const files = createFiles(); + files["IDENTITY.md"] = { + exists: true, + content: `# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** 🦊\n- **Avatar:** avatars/nova.png\n`, + }; + + const draft = parsePersonalityFiles(files); + + expect(draft.identity).toEqual({ + name: "Nova", + creature: "fox spirit", + vibe: "calm + direct", + emoji: "🦊", + avatar: "avatars/nova.png", + }); + }); + + it("parseUserMarkdown_extracts_context_block_and_profile_fields", () => { + const files = createFiles(); + files["USER.md"] = { + exists: true, + content: `# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building OpenClaw Studio.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n`, + }; + + const draft = parsePersonalityFiles(files); + + expect(draft.user).toEqual({ + name: "George", + callThem: "GP", + pronouns: "he/him", + timezone: "America/Chicago", + notes: "Building OpenClaw Studio.", + context: "Wants concise technical answers.\nPrefers implementation over discussion.", + }); + }); + + it("parseSoulMarkdown_extracts_core_sections", () => { + const files = createFiles(); + files["SOUL.md"] = { + exists: true, + content: `# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n`, + }; + + const draft = parsePersonalityFiles(files); + + expect(draft.soul).toEqual({ + coreTruths: "Be direct.\nAvoid filler.", + boundaries: "- Keep user data private.", + vibe: "Pragmatic and calm.", + continuity: "Update files when behavior changes.", + }); + }); + + it("ignores_template_placeholders_for_identity_and_user", () => { + const files = createFiles(); + files["IDENTITY.md"] = { + exists: true, + content: + "# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature — pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n", + }; + files["USER.md"] = { + exists: true, + content: + "# USER.md - About Your Human\n\n- **Name:**\n- **What to call them:**\n- **Pronouns:** _(optional)_\n- **Timezone:**\n- **Notes:**\n\n## Context\n\n_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_\n", + }; + + const draft = parsePersonalityFiles(files); + + expect(draft.identity).toEqual({ + name: "", + creature: "", + vibe: "", + emoji: "", + avatar: "", + }); + expect(draft.user).toEqual({ + name: "", + callThem: "", + pronouns: "", + timezone: "", + notes: "", + context: "", + }); + }); + + it("serializePersonalityFiles_emits_stable_markdown_for_identity_user_soul", () => { + const draft: PersonalityBuilderDraft = { + identity: { + name: "Nova", + creature: "fox spirit", + vibe: "calm + direct", + emoji: "🦊", + avatar: "avatars/nova.png", + }, + user: { + name: "George", + callThem: "GP", + pronouns: "he/him", + timezone: "America/Chicago", + notes: "Building OpenClaw Studio.", + context: "Wants concise technical answers.\nPrefers implementation over discussion.", + }, + soul: { + coreTruths: "Be direct.\nAvoid filler.", + boundaries: "- Keep user data private.", + vibe: "Pragmatic and calm.", + continuity: "Update files when behavior changes.", + }, + agents: "Top-level operating rules.", + tools: "Tool conventions.", + heartbeat: "Heartbeat notes.", + memory: "Durable memory.", + }; + + const files = serializePersonalityFiles(draft); + + expect(files["IDENTITY.md"]).toBe( + [ + "# IDENTITY.md - Who Am I?", + "", + "- Name: Nova", + "- Creature: fox spirit", + "- Vibe: calm + direct", + "- Emoji: 🦊", + "- Avatar: avatars/nova.png", + "", + ].join("\n") + ); + + expect(files["USER.md"]).toBe( + [ + "# USER.md - About Your Human", + "", + "- Name: George", + "- What to call them: GP", + "- Pronouns: he/him", + "- Timezone: America/Chicago", + "- Notes: Building OpenClaw Studio.", + "", + "## Context", + "", + "Wants concise technical answers.", + "Prefers implementation over discussion.", + "", + ].join("\n") + ); + + expect(files["SOUL.md"]).toBe( + [ + "# SOUL.md - Who You Are", + "", + "## Core Truths", + "", + "Be direct.", + "Avoid filler.", + "", + "## Boundaries", + "", + "- Keep user data private.", + "", + "## Vibe", + "", + "Pragmatic and calm.", + "", + "## Continuity", + "", + "Update files when behavior changes.", + "", + ].join("\n") + ); + + expect(files["AGENTS.md"]).toBe("Top-level operating rules."); + expect(files["TOOLS.md"]).toBe("Tool conventions."); + expect(files["HEARTBEAT.md"]).toBe("Heartbeat notes."); + expect(files["MEMORY.md"]).toBe("Durable memory."); + }); +}); diff --git a/tests/unit/projectFs.test.ts b/tests/unit/projectFs.test.ts deleted file mode 100644 index 33a8547f..00000000 --- a/tests/unit/projectFs.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - deleteDirIfExists, - resolveClawdbotStateDir, - resolveHomePath, -} from "@/lib/projects/fs.server"; - -let tempDir: string | null = null; - -const cleanup = () => { - if (!tempDir) return; - fs.rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; -}; - -afterEach(cleanup); - -describe("projectFs", () => { - it("resolvesHomePathVariants", () => { - expect(resolveHomePath("~")).toBe(os.homedir()); - expect(resolveHomePath("~/foo")).toBe(path.join(os.homedir(), "foo")); - expect(resolveHomePath("/tmp/x")).toBe("/tmp/x"); - }); - - it("resolvesClawdbotStateDirFromEnv", () => { - const prev = process.env.CLAWDBOT_STATE_DIR; - process.env.CLAWDBOT_STATE_DIR = "~/state-test"; - try { - expect(resolveClawdbotStateDir()).toBe(path.join(os.homedir(), "state-test")); - } finally { - if (prev === undefined) { - delete process.env.CLAWDBOT_STATE_DIR; - } else { - process.env.CLAWDBOT_STATE_DIR = prev; - } - } - }); - - it("deleteDirIfExistsRemovesDirectory", () => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-projectfs-")); - const warnings: string[] = []; - deleteDirIfExists(tempDir, "Temp dir", warnings); - expect(fs.existsSync(tempDir)).toBe(false); - expect(warnings).toEqual([]); - }); -}); diff --git a/tests/unit/rafBatcher.test.ts b/tests/unit/rafBatcher.test.ts new file mode 100644 index 00000000..1d035a5c --- /dev/null +++ b/tests/unit/rafBatcher.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createRafBatcher } from "@/lib/dom"; + +describe("createRafBatcher", () => { + const originalRaf = globalThis.requestAnimationFrame; + const originalCaf = globalThis.cancelAnimationFrame; + + afterEach(() => { + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + }); + + it("flushes at most once per animation frame", () => { + const flush = vi.fn(); + let queued: unknown = null; + globalThis.requestAnimationFrame = vi.fn((cb: (time: number) => void) => { + queued = cb; + return 1; + }); + globalThis.cancelAnimationFrame = vi.fn(); + + const batcher = createRafBatcher(flush); + batcher.schedule(); + batcher.schedule(); + batcher.schedule(); + + expect(flush).not.toHaveBeenCalled(); + if (typeof queued !== "function") { + throw new Error("requestAnimationFrame was not scheduled."); + } + (queued as (time: number) => void)(0); + expect(flush).toHaveBeenCalledTimes(1); + }); + + it("cancels a scheduled flush", () => { + const flush = vi.fn(); + globalThis.requestAnimationFrame = vi.fn(() => 123); + globalThis.cancelAnimationFrame = vi.fn(); + + const batcher = createRafBatcher(flush); + batcher.schedule(); + batcher.cancel(); + + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(123); + }); +}); diff --git a/tests/unit/runSshJson.test.ts b/tests/unit/runSshJson.test.ts new file mode 100644 index 00000000..cb34dd32 --- /dev/null +++ b/tests/unit/runSshJson.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; + +import { spawnSync } from "node:child_process"; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual<typeof import("node:child_process")>( + "node:child_process" + ); + return { + default: actual, + ...actual, + spawnSync: vi.fn(), + }; +}); + +import { runSshJson } from "@/lib/ssh/gateway-host"; + +const mockedSpawnSync = vi.mocked(spawnSync); + +describe("runSshJson", () => { + it("forwards maxBuffer to spawnSync when provided", () => { + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ ok: true }), + stderr: "", + error: undefined, + } as never); + + runSshJson({ + sshTarget: "me@example.test", + argv: ["bash", "-lc", "echo ok"], + label: "ssh-json-test", + input: "echo hello", + maxBuffer: 12345, + } as unknown as Parameters<typeof runSshJson>[0]); + + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + const [, , options] = mockedSpawnSync.mock.calls[0] as [ + string, + string[], + { encoding?: string; input?: string; maxBuffer?: number }, + ]; + expect(options.maxBuffer).toBe(12345); + }); +}); diff --git a/tests/unit/runtimeAgentEventWorkflow.test.ts b/tests/unit/runtimeAgentEventWorkflow.test.ts new file mode 100644 index 00000000..fdb82180 --- /dev/null +++ b/tests/unit/runtimeAgentEventWorkflow.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { + planRuntimeAgentEvent, + type RuntimeAgentWorkflowCommand, + type RuntimeAgentWorkflowInput, +} from "@/features/agents/state/runtimeAgentEventWorkflow"; +import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy"; +import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge"; +import { + createRuntimeTerminalState, + markClosedRun, + type RuntimeTerminalCommand, + type RuntimeTerminalState, +} from "@/features/agents/state/runtimeTerminalWorkflow"; + +type InputOverrides = Partial<Omit<RuntimeAgentWorkflowInput, "payload" | "agent">> & { + payload?: AgentEventPayload; + agent?: AgentState; +}; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + ...(overrides ?? {}), +}); + +const createPayload = (overrides?: Partial<AgentEventPayload>): AgentEventPayload => ({ + runId: "run-1", + sessionKey: "agent:agent-1:studio:test-session", + stream: "assistant", + data: { delta: "hello" }, + ...(overrides ?? {}), +}); + +const createInput = (overrides?: InputOverrides): RuntimeAgentWorkflowInput => ({ + payload: overrides?.payload ?? createPayload(), + agent: overrides?.agent ?? createAgent(), + activeRunId: "run-1", + nowMs: 1000, + runtimeTerminalState: + overrides?.runtimeTerminalState ?? (createRuntimeTerminalState() as RuntimeTerminalState), + hasChatEvents: false, + hasPendingFallbackTimer: false, + previousThinkingRaw: null, + previousAssistantRaw: null, + thinkingStartedAtMs: null, + historyRefreshRequested: false, + lifecycleFallbackDelayMs: 0, + ...(overrides ?? {}), +}); + +const findCommand = <TKind extends RuntimeAgentWorkflowCommand["kind"]>( + commands: RuntimeAgentWorkflowCommand[], + kind: TKind +): Extract<RuntimeAgentWorkflowCommand, { kind: TKind }> | undefined => + commands.find((command) => command.kind === kind) as + | Extract<RuntimeAgentWorkflowCommand, { kind: TKind }> + | undefined; + +const findIntent = <TKind extends RuntimePolicyIntent["kind"]>( + intents: RuntimePolicyIntent[], + kind: TKind +): Extract<RuntimePolicyIntent, { kind: TKind }> | undefined => + intents.find((intent) => intent.kind === kind) as + | Extract<RuntimePolicyIntent, { kind: TKind }> + | undefined; + +const hasTerminalCommand = ( + commands: RuntimeTerminalCommand[], + kind: RuntimeTerminalCommand["kind"] +): boolean => commands.some((command) => command.kind === kind); + +describe("runtime agent event workflow", () => { + it("returns preflight cleanup intents when incoming run is stale", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ runId: "run-stale", stream: "assistant", data: { delta: "x" } }), + activeRunId: "run-active", + }) + ); + + expect(result.commands).toEqual([ + { + kind: "applyPolicyIntents", + intents: [{ kind: "clearRunTracking", runId: "run-stale" }], + }, + ]); + }); + + it("logs late-event metric for closed-run preflight ignore", () => { + const closedState = markClosedRun(createRuntimeTerminalState(), { + runId: "run-1", + now: 500, + ttlMs: 10_000, + }); + const result = planRuntimeAgentEvent( + createInput({ + runtimeTerminalState: closedState, + }) + ); + + expect(result.commands).toEqual([ + { + kind: "logMetric", + metric: "late_event_ignored_closed_run", + meta: { + stream: "assistant", + runId: "run-1", + }, + }, + ]); + }); + + it("plans reasoning stream cache update and thinking live patch", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ stream: "reasoning", data: { text: "thinking out loud" } }), + agent: createAgent({ runStartedAt: null }), + }) + ); + + expect(findCommand(result.commands, "markActivity")).toEqual({ + kind: "markActivity", + at: 1000, + }); + expect(findCommand(result.commands, "setThinkingStreamRaw")).toEqual({ + kind: "setThinkingStreamRaw", + runId: "run-1", + raw: "thinking out loud", + }); + expect(findCommand(result.commands, "markThinkingStarted")).toEqual({ + kind: "markThinkingStarted", + runId: "run-1", + at: 1000, + }); + expect(findCommand(result.commands, "queueAgentPatch")).toEqual({ + kind: "queueAgentPatch", + patch: { + status: "running", + runId: "run-1", + runStartedAt: 1000, + sessionCreated: true, + lastActivityAt: 1000, + thinkingTrace: "thinking out loud", + }, + }); + }); + + it("suppresses assistant streamText patch when chat stream owns transcript", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ stream: "assistant", data: { delta: "hello" } }), + agent: createAgent({ streamText: "already streaming" }), + hasChatEvents: true, + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.status).toBe("running"); + expect(queue?.patch.runId).toBe("run-1"); + expect("streamText" in (queue?.patch ?? {})).toBe(false); + }); + + it("extends assistant streamText when incoming stream advances current text", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ stream: "assistant", data: { delta: "hello world" } }), + agent: createAgent({ streamText: "hello" }), + hasChatEvents: true, + previousAssistantRaw: "hello", + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.streamText).toBe("hello world"); + }); + + it("does not publish assistant streamText for open thinking chunk", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "assistant", + data: { text: "<thinking>planning" }, + }), + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.thinkingTrace).toBe("planning"); + expect("streamText" in (queue?.patch ?? {})).toBe(false); + }); + + it("publishes assistant streamText once answer appears after closing thinking tag", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "assistant", + data: { delta: "</thinking>Answer" }, + }), + previousAssistantRaw: "<thinking>planning", + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.thinkingTrace).toBe("planning"); + expect(queue?.patch.streamText).toBe("Answer"); + }); + + it("does not leak open thinking chunk into streamText when thinking traces are hidden", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "assistant", + data: { text: "<thinking>planning" }, + }), + agent: createAgent({ showThinkingTraces: false }), + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect("streamText" in (queue?.patch ?? {})).toBe(false); + }); + + it("publishes visible assistant text when thinking block is closed even if text matches", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "assistant", + data: { text: "<thinking>same</thinking>same" }, + }), + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.thinkingTrace).toBe("same"); + expect(queue?.patch.streamText).toBe("same"); + }); + + it("does not publish assistant streamText for reasoning-prefixed content", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "assistant", + data: { text: "reasoning: planning" }, + }), + }) + ); + + const queue = findCommand(result.commands, "queueAgentPatch"); + expect(queue).toBeDefined(); + expect(queue?.patch.thinkingTrace).toBe("planning"); + expect("streamText" in (queue?.patch ?? {})).toBe(false); + }); + + it("plans tool call line append", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "tool", + data: { + phase: "call", + name: "myTool", + toolCallId: "tool-1", + arguments: { a: 1 }, + }, + }), + }) + ); + + const append = findCommand(result.commands, "appendToolLines"); + expect(append).toBeDefined(); + expect(append?.lines).toHaveLength(1); + expect(append?.lines[0]).toContain("[[tool]] myTool (tool-1)"); + }); + + it("plans tool result append and one-time history refresh", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "tool", + data: { + phase: "result", + name: "exec", + toolCallId: "tool-2", + result: { content: [{ type: "text", text: "ok" }] }, + }, + }), + historyRefreshRequested: false, + }) + ); + + const append = findCommand(result.commands, "appendToolLines"); + expect(append).toBeDefined(); + expect(append?.lines.some((line) => line.startsWith("[[tool-result]]"))).toBe(true); + expect(findCommand(result.commands, "markHistoryRefreshRequested")).toEqual({ + kind: "markHistoryRefreshRequested", + runId: "run-1", + }); + expect(findCommand(result.commands, "scheduleHistoryRefresh")).toEqual({ + kind: "scheduleHistoryRefresh", + delayMs: 750, + reason: "chat-final-no-trace", + }); + }); + + it("plans lifecycle decision with deferred transition patch when fallback is scheduled", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "lifecycle", + data: { phase: "end" }, + }), + agent: createAgent({ streamText: "final text", runId: "run-1" }), + }) + ); + + const lifecycle = findCommand(result.commands, "applyLifecycleDecision"); + expect(lifecycle).toBeDefined(); + expect(lifecycle?.shouldClearPendingLivePatch).toBe(true); + expect(lifecycle?.decision.deferTransitionPatch).toBe(true); + expect(hasTerminalCommand(lifecycle?.decision.commands ?? [], "cancelLifecycleFallback")).toBe( + true + ); + expect( + hasTerminalCommand( + lifecycle?.decision.commands ?? [], + "scheduleLifecycleFallback" + ) + ).toBe(true); + }); + + it("does not request history refresh when tool result already requested once", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "tool", + data: { + phase: "result", + name: "exec", + toolCallId: "tool-3", + result: { content: [{ type: "text", text: "ok" }] }, + }, + }), + historyRefreshRequested: true, + }) + ); + + expect(findCommand(result.commands, "markHistoryRefreshRequested")).toBeUndefined(); + expect(findCommand(result.commands, "scheduleHistoryRefresh")).toBeUndefined(); + }); + + it("keeps preflight intents empty for active lifecycle start and emits activity command", () => { + const result = planRuntimeAgentEvent( + createInput({ + payload: createPayload({ + stream: "lifecycle", + data: { phase: "start" }, + }), + }) + ); + + expect(findCommand(result.commands, "markActivity")).toEqual({ + kind: "markActivity", + at: 1000, + }); + + const lifecycle = findCommand(result.commands, "applyLifecycleDecision"); + expect(lifecycle).toBeDefined(); + expect(findIntent([], "clearRunTracking")).toBeUndefined(); + }); +}); diff --git a/tests/unit/runtimeChatEventWorkflow.test.ts b/tests/unit/runtimeChatEventWorkflow.test.ts new file mode 100644 index 00000000..e1ed3f8c --- /dev/null +++ b/tests/unit/runtimeChatEventWorkflow.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { + planRuntimeChatEvent, + type RuntimeChatWorkflowCommand, + type RuntimeChatWorkflowInput, +} from "@/features/agents/state/runtimeChatEventWorkflow"; +import type { RuntimePolicyIntent } from "@/features/agents/state/runtimeEventPolicy"; +import type { ChatEventPayload } from "@/features/agents/state/runtimeEventBridge"; +import { + applyTerminalCommit, + createRuntimeTerminalState, + type RuntimeTerminalState, +} from "@/features/agents/state/runtimeTerminalWorkflow"; + +type InputOverrides = Partial<Omit<RuntimeChatWorkflowInput, "payload" | "agent">> & { + payload?: ChatEventPayload; + agent?: AgentState | undefined; +}; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + return { + ...base, + ...(overrides ?? {}), + }; +}; + +const createPayload = (overrides?: Partial<ChatEventPayload>): ChatEventPayload => ({ + runId: "run-1", + sessionKey: "agent:agent-1:studio:test-session", + state: "delta", + message: { role: "assistant", content: "Hello" }, + ...(overrides ?? {}), +}); + +const createInput = (overrides?: InputOverrides): RuntimeChatWorkflowInput => ({ + payload: overrides?.payload ?? createPayload(), + agentId: "agent-1", + agent: overrides?.agent ?? createAgent(), + activeRunId: "run-1", + runtimeTerminalState: + overrides?.runtimeTerminalState ?? (createRuntimeTerminalState() as RuntimeTerminalState), + role: "assistant", + nowMs: 1000, + nextTextRaw: "Hello", + nextText: "Hello", + nextThinking: null, + toolLines: [], + isToolRole: false, + assistantCompletionAt: null, + finalAssistantText: null, + hasThinkingStarted: false, + hasTraceInOutput: false, + isThinkingDebugSessionSeen: false, + thinkingStartedAtMs: null, + ...(overrides ?? {}), +}); + +const findCommand = <TKind extends RuntimeChatWorkflowCommand["kind"]>( + commands: RuntimeChatWorkflowCommand[], + kind: TKind +): Extract<RuntimeChatWorkflowCommand, { kind: TKind }> | undefined => + commands.find((command) => command.kind === kind) as + | Extract<RuntimeChatWorkflowCommand, { kind: TKind }> + | undefined; + +const findIntent = <TKind extends RuntimePolicyIntent["kind"]>( + intents: RuntimePolicyIntent[], + kind: TKind +): Extract<RuntimePolicyIntent, { kind: TKind }> | undefined => + intents.find((intent) => intent.kind === kind) as + | Extract<RuntimePolicyIntent, { kind: TKind }> + | undefined; + +describe("runtime chat event workflow", () => { + it("ignores delta events that begin with UI metadata", () => { + const result = planRuntimeChatEvent( + createInput({ + nextTextRaw: "Project path: /tmp/work", + nextText: "Project path: /tmp/work", + }) + ); + + expect(result.commands).toEqual([]); + }); + + it("plans delta intents and tool append commands", () => { + const result = planRuntimeChatEvent( + createInput({ + nextThinking: "think", + toolLines: ["[[tool]] call"], + }) + ); + + const policy = findCommand(result.commands, "applyPolicyIntents"); + expect(policy).toBeDefined(); + expect(findIntent(policy?.intents ?? [], "markThinkingStarted")).toEqual({ + kind: "markThinkingStarted", + runId: "run-1", + at: 1000, + }); + expect(findIntent(policy?.intents ?? [], "queueLivePatch")).toEqual({ + kind: "queueLivePatch", + agentId: "agent-1", + patch: { + thinkingTrace: "think", + streamText: "Hello", + status: "running", + runId: "run-1", + runStartedAt: 1000, + }, + }); + + expect(findCommand(result.commands, "appendToolLines")).toEqual({ + kind: "appendToolLines", + lines: ["[[tool]] call"], + timestampMs: 1000, + }); + }); + + it("plans final assistant completion with fallback replacement metrics", () => { + const runtimeTerminalState = applyTerminalCommit(createRuntimeTerminalState(), { + runId: "run-1", + source: "lifecycle-fallback", + seq: null, + }); + + const result = planRuntimeChatEvent( + createInput({ + payload: createPayload({ state: "final", seq: 7 }), + runtimeTerminalState, + nowMs: 2200, + nextTextRaw: "Done", + nextText: "Done", + nextThinking: "first\nsecond", + assistantCompletionAt: 2100, + finalAssistantText: "Done", + hasThinkingStarted: true, + thinkingStartedAtMs: 2000, + }) + ); + + const terminalDecision = findCommand(result.commands, "applyChatTerminalDecision"); + expect(terminalDecision?.decision.fallbackCommittedBeforeFinal).toBe(true); + + expect( + result.commands.some( + (command) => + command.kind === "logMetric" && + command.metric === "lifecycle_fallback_replaced_by_chat_final" + ) + ).toBe(true); + + expect( + result.commands.some( + (command) => + command.kind === "appendOutput" && + command.transcript.kind === "meta" && + command.transcript.timestampMs === 2100 && + command.line.startsWith("[[meta]]") + ) + ).toBe(true); + + expect( + result.commands.some( + (command) => + command.kind === "appendOutput" && + command.transcript.kind === "thinking" && + command.line.startsWith("[[trace]]") + ) + ).toBe(true); + + expect( + result.commands.some( + (command) => + command.kind === "appendOutput" && + command.transcript.kind === "assistant" && + command.line === "Done" + ) + ).toBe(true); + + expect(findCommand(result.commands, "applyTerminalCommit")).toEqual({ + kind: "applyTerminalCommit", + runId: "run-1", + seq: 7, + }); + + const policy = findCommand(result.commands, "applyPolicyIntents"); + expect(policy).toBeDefined(); + expect(findIntent(policy?.intents ?? [], "clearPendingLivePatch")).toEqual({ + kind: "clearPendingLivePatch", + agentId: "agent-1", + }); + expect(findIntent(policy?.intents ?? [], "markRunClosed")).toEqual({ + kind: "markRunClosed", + runId: "run-1", + }); + }); + + it("returns only stale-terminal diagnostics for stale final events", () => { + const runtimeTerminalState = applyTerminalCommit(createRuntimeTerminalState(), { + runId: "run-1", + source: "chat-final", + seq: 4, + }); + + const result = planRuntimeChatEvent( + createInput({ + payload: createPayload({ state: "final", seq: 4 }), + runtimeTerminalState, + nextTextRaw: "Done", + nextText: "Done", + assistantCompletionAt: 2000, + finalAssistantText: "Done", + }) + ); + + expect(result.commands).toHaveLength(2); + expect(result.commands[0]).toMatchObject({ kind: "applyChatTerminalDecision" }); + expect(result.commands[1]).toMatchObject({ + kind: "logMetric", + metric: "stale_terminal_chat_event_ignored", + }); + }); + + it("plans missing-thinking diagnostics and history refresh for assistant final", () => { + const result = planRuntimeChatEvent( + createInput({ + payload: createPayload({ state: "final", seq: 1 }), + nextTextRaw: "Done", + nextText: "Done", + nextThinking: null, + assistantCompletionAt: 2000, + finalAssistantText: "Done", + }) + ); + + expect(findCommand(result.commands, "markThinkingDebugSession")).toEqual({ + kind: "markThinkingDebugSession", + sessionKey: "agent:agent-1:studio:test-session", + }); + + const warn = findCommand(result.commands, "logWarn"); + expect(warn).toBeDefined(); + expect(warn?.message).toBe("No thinking trace extracted from chat event."); + + const policy = findCommand(result.commands, "applyPolicyIntents"); + expect(policy).toBeDefined(); + expect(findIntent(policy?.intents ?? [], "requestHistoryRefresh")).toEqual({ + kind: "requestHistoryRefresh", + agentId: "agent-1", + reason: "chat-final-no-trace", + }); + }); + + it("plans aborted output command and policy intents", () => { + const result = planRuntimeChatEvent( + createInput({ + payload: createPayload({ state: "aborted" }), + }) + ); + + expect(result.commands).toEqual([ + { kind: "appendAbortedIfNotSuppressed", timestampMs: 1000 }, + expect.objectContaining({ kind: "applyPolicyIntents" }), + ]); + }); + + it("plans error output with error-state policy intents", () => { + const result = planRuntimeChatEvent( + createInput({ + payload: createPayload({ state: "error", errorMessage: "boom" }), + }) + ); + + expect(result.commands[0]).toEqual( + expect.objectContaining({ + kind: "appendOutput", + line: "Error: boom", + }) + ); + const policy = findCommand(result.commands, "applyPolicyIntents"); + expect(policy).toBeDefined(); + expect( + (policy?.intents ?? []).some( + (intent) => + intent.kind === "dispatchUpdateAgent" && + intent.patch.status === "error" && + intent.patch.runId === null + ) + ).toBe(true); + }); +}); diff --git a/tests/unit/runtimeEventBridge.test.ts b/tests/unit/runtimeEventBridge.test.ts new file mode 100644 index 00000000..5c977f44 --- /dev/null +++ b/tests/unit/runtimeEventBridge.test.ts @@ -0,0 +1,640 @@ +import { describe, expect, it } from "vitest"; + +import { + buildHistoryLines, + buildHistorySyncPatch, + buildSummarySnapshotPatches, + classifyGatewayEventKind, + dedupeRunLines, + getAgentSummaryPatch, + getChatSummaryPatch, + isReasoningRuntimeAgentStream, + mergeHistoryWithPending, + mergeRuntimeStream, + resolveAssistantCompletionTimestamp, + resolveLifecyclePatch, + shouldPublishAssistantStream, +} from "@/features/agents/state/runtimeEventBridge"; +import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract"; + +describe("runtime event bridge helpers", () => { + it("classifies gateway events by routing category", () => { + expect(classifyGatewayEventKind("presence")).toBe("summary-refresh"); + expect(classifyGatewayEventKind("heartbeat")).toBe("summary-refresh"); + expect(classifyGatewayEventKind("chat")).toBe("runtime-chat"); + expect(classifyGatewayEventKind("agent")).toBe("runtime-agent"); + expect(classifyGatewayEventKind("unknown")).toBe("ignore"); + }); + + it("detects reasoning-like runtime agent streams", () => { + expect(isReasoningRuntimeAgentStream("reasoning")).toBe(true); + expect(isReasoningRuntimeAgentStream("assistant.reasoning")).toBe(true); + expect(isReasoningRuntimeAgentStream("thinking_stream")).toBe(true); + expect(isReasoningRuntimeAgentStream("trace")).toBe(true); + expect(isReasoningRuntimeAgentStream("analysis")).toBe(true); + expect(isReasoningRuntimeAgentStream("assistant")).toBe(false); + expect(isReasoningRuntimeAgentStream("tool")).toBe(false); + expect(isReasoningRuntimeAgentStream("lifecycle")).toBe(false); + }); + + it("merges assistant stream text deterministically", () => { + expect(mergeRuntimeStream("", "delta")).toBe("delta"); + expect(mergeRuntimeStream("hello", "hello world")).toBe("hello world"); + expect(mergeRuntimeStream("hello", " world")).toBe("hello world"); + expect(mergeRuntimeStream("hello", "hello")).toBe("hello"); + }); + + it("dedupes tool lines per run", () => { + const first = dedupeRunLines(new Set<string>(), ["a", "b", "a"]); + expect(first.appended).toEqual(["a", "b"]); + const second = dedupeRunLines(first.nextSeen, ["b", "c"]); + expect(second.appended).toEqual(["c"]); + }); + + it("resolves lifecycle transitions with run guards", () => { + const started = resolveLifecyclePatch({ + phase: "start", + incomingRunId: "run-1", + currentRunId: null, + lastActivityAt: 123, + }); + expect(started.kind).toBe("start"); + if (started.kind !== "start") throw new Error("Expected start transition"); + expect(started.patch.status).toBe("running"); + expect(started.patch.runId).toBe("run-1"); + + const ignored = resolveLifecyclePatch({ + phase: "end", + incomingRunId: "run-2", + currentRunId: "run-1", + lastActivityAt: 456, + }); + expect(ignored.kind).toBe("ignore"); + + const ended = resolveLifecyclePatch({ + phase: "end", + incomingRunId: "run-1", + currentRunId: "run-1", + lastActivityAt: 789, + }); + expect(ended.kind).toBe("terminal"); + if (ended.kind !== "terminal") throw new Error("Expected terminal transition"); + expect(ended.patch.status).toBe("idle"); + expect(ended.patch.runId).toBeNull(); + expect(ended.clearRunTracking).toBe(true); + }); + + it("suppresses assistant stream publish when chat stream already owns it", () => { + expect( + shouldPublishAssistantStream({ + nextText: "hello", + rawText: "", + hasChatEvents: true, + currentStreamText: "already streaming", + }) + ).toBe(false); + expect( + shouldPublishAssistantStream({ + nextText: "hello", + rawText: "", + hasChatEvents: false, + currentStreamText: "already streaming", + }) + ).toBe(true); + expect( + shouldPublishAssistantStream({ + nextText: "", + rawText: "", + hasChatEvents: false, + currentStreamText: null, + }) + ).toBe(false); + expect( + shouldPublishAssistantStream({ + nextText: "already streaming plus more", + rawText: "", + hasChatEvents: true, + currentStreamText: "already streaming", + }) + ).toBe(true); + }); + + it("updates preview and activity from assistant chat", () => { + const patch = getChatSummaryPatch( + { + runId: "run-1", + sessionKey: "agent:main:studio:agent-1", + state: "final", + message: { role: "assistant", content: "Hello" }, + }, + 123 + ); + + expect(patch?.latestPreview).toBe("Hello"); + expect(patch?.lastActivityAt).toBe(123); + }); + + it("updates status from agent lifecycle events", () => { + const patch = getAgentSummaryPatch( + { + runId: "run-2", + stream: "lifecycle", + data: { phase: "start" }, + }, + 456 + ); + + expect(patch?.status).toBe("running"); + expect(patch?.lastActivityAt).toBe(456); + }); + + it("resolves assistant completion timestamp only for final assistant messages", () => { + expect( + resolveAssistantCompletionTimestamp({ + role: "assistant", + state: "delta", + message: { timestamp: "2024-01-01T00:00:00.000Z" }, + }) + ).toBeNull(); + expect( + resolveAssistantCompletionTimestamp({ + role: "user", + state: "final", + message: { timestamp: "2024-01-01T00:00:00.000Z" }, + }) + ).toBeNull(); + expect( + resolveAssistantCompletionTimestamp({ + role: "assistant", + state: "final", + message: { timestamp: "2024-01-01T00:00:00.000Z" }, + }) + ).toBe(Date.parse("2024-01-01T00:00:00.000Z")); + expect( + resolveAssistantCompletionTimestamp({ + role: "assistant", + state: "final", + message: {}, + now: 1234, + }) + ).toBe(1234); + }); + + it("builds summary patches from status and preview snapshots", () => { + const patches = buildSummarySnapshotPatches({ + agents: [ + { agentId: "agent-1", sessionKey: "agent:agent-1:studio:session-a" }, + { agentId: "agent-2", sessionKey: "agent:agent-2:studio:session-a" }, + ], + statusSummary: { + sessions: { + recent: [{ key: "agent:agent-1:studio:session-a", updatedAt: 111 }], + byAgent: [ + { + agentId: "agent-2", + recent: [{ key: "agent:agent-2:studio:session-a", updatedAt: 222 }], + }, + ], + }, + }, + previewResult: { + ts: 0, + previews: [ + { + key: "agent:agent-1:studio:session-a", + status: "ok", + items: [ + { role: "user", text: "Project path: /tmp\n\nhello there" }, + { role: "assistant", text: "assistant latest", timestamp: "not-a-date" }, + ], + }, + ], + }, + }); + + expect(patches).toEqual([ + { + agentId: "agent-1", + patch: { + lastActivityAt: 111, + lastAssistantMessageAt: 111, + latestPreview: "assistant latest", + lastUserMessage: "hello there", + }, + }, + { + agentId: "agent-2", + patch: { + lastActivityAt: 222, + }, + }, + ]); + }); + + it("returns no entries when snapshots produce no patch fields", () => { + const patches = buildSummarySnapshotPatches({ + agents: [{ agentId: "agent-1", sessionKey: "agent:agent-1:studio:session-a" }], + statusSummary: { sessions: { recent: [] } }, + previewResult: { ts: 0, previews: [] }, + }); + + expect(patches).toEqual([]); + }); + + it("does not update assistant sort timestamp from summary while agent is running", () => { + const patches = buildSummarySnapshotPatches({ + agents: [ + { + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:session-a", + status: "running", + }, + ], + statusSummary: { + sessions: { + recent: [{ key: "agent:agent-1:studio:session-a", updatedAt: 111 }], + }, + }, + previewResult: { + ts: 0, + previews: [ + { + key: "agent:agent-1:studio:session-a", + status: "ok", + items: [{ role: "assistant", text: "assistant latest", timestamp: 999 }], + }, + ], + }, + }); + + expect(patches).toEqual([ + { + agentId: "agent-1", + patch: { + lastActivityAt: 111, + latestPreview: "assistant latest", + }, + }, + ]); + }); + + it("extracts history lines with heartbeat filtering and preserves canonical repeats", () => { + const history = buildHistoryLines([ + { role: "user", content: "Read HEARTBEAT.md if it exists\nHeartbeat file path: /tmp/HEARTBEAT.md" }, + { role: "user", content: "Project path: /tmp/project\n\nhello there" }, + { + role: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + content: [ + { type: "thinking", thinking: "step one" }, + { type: "text", text: "assistant final" }, + ], + }, + { + role: "assistant", + timestamp: "2024-01-01T00:00:01.000Z", + content: "assistant final", + }, + { + role: "toolResult", + toolName: "shell", + toolCallId: "call-1", + details: { status: "ok" }, + text: "done", + }, + ]); + + expect(history.lines).toEqual([ + "> hello there", + '[[meta]]{"role":"assistant","timestamp":1704067200000}', + "[[trace]]\n_step one_", + "assistant final", + '[[meta]]{"role":"assistant","timestamp":1704067201000}', + "assistant final", + "[[tool-result]] shell (call-1)\nok\n```text\ndone\n```", + ]); + expect(history.lastAssistant).toBe("assistant final"); + expect(history.lastAssistantAt).toBe(Date.parse("2024-01-01T00:00:01.000Z")); + expect(history.lastRole).toBe("assistant"); + expect(history.lastUser).toBe("hello there"); + }); + + it("records aborted assistant terminal messages even when assistant content is empty", () => { + const abortedAt = Date.parse("2024-01-01T00:00:01.000Z"); + const history = buildHistoryLines([ + { + role: "user", + timestamp: "2024-01-01T00:00:00.000Z", + content: "hi", + }, + { + role: "assistant", + timestamp: new Date(abortedAt).toISOString(), + content: [], + stopReason: "aborted", + errorMessage: "Request was aborted", + }, + ]); + + expect(history.lines).toEqual([ + '[[meta]]{"role":"user","timestamp":1704067200000}', + "> hi", + '[[meta]]{"role":"assistant","timestamp":1704067201000}', + "Run aborted.", + ]); + expect(history.lastAssistant).toBe("Run aborted."); + expect(history.lastAssistantAt).toBe(abortedAt); + expect(history.lastRole).toBe("assistant"); + expect(history.lastUser).toBe("hi"); + }); + + it("does not render internal auto-resume user messages in reconstructed history", () => { + const history = buildHistoryLines([ + { + role: "user", + content: `[Tue 2026-02-17 12:52 PST] ${EXEC_APPROVAL_AUTO_RESUME_MARKER} +Continue where you left off and finish the task.`, + }, + { + role: "assistant", + content: "resumed output", + }, + ]); + + expect(history.lines).toEqual(["resumed output"]); + expect(history.lastUser).toBeNull(); + }); + + it("preserves markdown-rich assistant lines and explicit tool boundaries", () => { + const assistantMarkdown = [ + "- item one", + "- item two", + "", + "```json", + '{"ok":true}', + "```", + ].join("\n"); + const history = buildHistoryLines([ + { + role: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + content: assistantMarkdown, + }, + { + role: "assistant", + timestamp: "2024-01-01T00:00:01.000Z", + content: assistantMarkdown, + }, + { + role: "toolResult", + toolName: "shell", + toolCallId: "call-2", + details: { status: "ok" }, + text: "done", + }, + ]); + + expect(history.lines).toEqual([ + '[[meta]]{"role":"assistant","timestamp":1704067200000}', + assistantMarkdown, + '[[meta]]{"role":"assistant","timestamp":1704067201000}', + assistantMarkdown, + "[[tool-result]] shell (call-2)\nok\n```text\ndone\n```", + ]); + expect(history.lastAssistant).toBe(assistantMarkdown); + expect(history.lastAssistantAt).toBe(Date.parse("2024-01-01T00:00:01.000Z")); + expect(history.lastRole).toBe("assistant"); + }); + + it("normalizes assistant text in history reconstruction", () => { + const history = buildHistoryLines([ + { + role: "assistant", + content: "\n- item one \n\n\n- item two\t \n\n", + }, + ]); + + expect(history.lines).toEqual(["- item one\n\n- item two"]); + expect(history.lastAssistant).toBe("- item one\n\n- item two"); + expect(history.lastRole).toBe("assistant"); + }); + + it("merges history lines with pending output order and preserves empty-history behavior", () => { + expect(mergeHistoryWithPending(["a", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"]); + expect(mergeHistoryWithPending([], ["a", "b"])).toEqual([]); + expect(mergeHistoryWithPending(["a", "b"], [])).toEqual(["a", "b"]); + }); + + it("collapses duplicate plain assistant lines only when history and pending both contain them", () => { + expect(mergeHistoryWithPending(["> q", "final"], ["> q", "final", "final"])).toEqual([ + "> q", + "final", + ]); + expect(mergeHistoryWithPending(["> q", "final", "final"], ["> q"])).toEqual([ + "> q", + "final", + "final", + ]); + }); + + it("caps overlapping assistant duplicate counts to canonical history counts", () => { + expect(mergeHistoryWithPending(["a", "b", "a"], ["a", "a", "b", "a"])).toEqual([ + "a", + "b", + "a", + ]); + }); + + it("preserves repeated tool and meta lines during history merge", () => { + const tool = "[[tool]] shell (call-1)\n```json\n{}\n```"; + const meta = '[[meta]]{"role":"assistant","timestamp":1704067200000}'; + expect(mergeHistoryWithPending([tool], [tool, tool])).toEqual([tool, tool]); + expect(mergeHistoryWithPending([meta], [meta, meta])).toEqual([meta, meta]); + }); + + it("builds history sync patches for empty, unchanged, and merged cases", () => { + expect( + buildHistorySyncPatch({ + messages: [], + currentLines: ["> hello"], + loadedAt: 100, + status: "idle", + runId: null, + }) + ).toEqual({ historyLoadedAt: 100 }); + + const unchanged = buildHistorySyncPatch({ + messages: [ + { + role: "assistant", + timestamp: "2024-01-01T00:00:00.000Z", + content: "done", + }, + ], + currentLines: ["done"], + loadedAt: 200, + status: "running", + runId: null, + }); + expect(unchanged).toEqual({ + outputLines: ['[[meta]]{"role":"assistant","timestamp":1704067200000}', "done"], + lastResult: "done", + latestPreview: "done", + lastAssistantMessageAt: Date.parse("2024-01-01T00:00:00.000Z"), + historyLoadedAt: 200, + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }); + + const merged = buildHistorySyncPatch({ + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + timestamp: "2024-01-01T00:00:02.000Z", + content: "assistant final", + }, + ], + currentLines: ["> hello", "pending line"], + loadedAt: 300, + status: "running", + runId: null, + }); + expect(merged).toEqual({ + outputLines: ["> hello", "pending line", '[[meta]]{"role":"assistant","timestamp":1704067202000}', "assistant final"], + lastResult: "assistant final", + latestPreview: "assistant final", + lastAssistantMessageAt: Date.parse("2024-01-01T00:00:02.000Z"), + lastUserMessage: "hello", + historyLoadedAt: 300, + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }); + }); + + it("clears stale running state when history ends with aborted assistant terminal message", () => { + const loadedAt = Date.parse("2024-01-01T00:30:00.000Z"); + const patch = buildHistorySyncPatch({ + messages: [ + { + role: "user", + timestamp: "2024-01-01T00:29:00.000Z", + content: "hi", + }, + { + role: "assistant", + timestamp: "2024-01-01T00:29:01.000Z", + content: [], + stopReason: "aborted", + errorMessage: "Request was aborted", + }, + ], + currentLines: [], + loadedAt, + status: "running", + runId: null, + }); + + expect(patch).toEqual({ + outputLines: [ + '[[meta]]{"role":"user","timestamp":1704068940000}', + "> hi", + '[[meta]]{"role":"assistant","timestamp":1704068941000}', + "Run aborted.", + ], + lastResult: "Run aborted.", + latestPreview: "Run aborted.", + lastAssistantMessageAt: Date.parse("2024-01-01T00:29:01.000Z"), + lastUserMessage: "hi", + historyLoadedAt: loadedAt, + status: "idle", + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + }); + }); + + it("infers a running state from recent user-terminal history after reload", () => { + const loadedAt = Date.parse("2024-01-01T00:30:00.000Z"); + const lastUserAt = Date.parse("2024-01-01T00:29:30.000Z"); + const patch = buildHistorySyncPatch({ + messages: [ + { + role: "user", + timestamp: new Date(lastUserAt).toISOString(), + content: "keep going", + }, + ], + currentLines: [], + loadedAt, + status: "idle", + runId: null, + }); + + expect(patch).toEqual({ + outputLines: ['[[meta]]{"role":"user","timestamp":1704068970000}', "> keep going"], + lastResult: null, + lastUserMessage: "keep going", + historyLoadedAt: loadedAt, + status: "running", + runId: null, + runStartedAt: lastUserAt, + streamText: null, + thinkingTrace: null, + }); + }); + + it("prefers canonical history when optimistic user content differs only by whitespace", () => { + const patch = buildHistorySyncPatch({ + messages: [ + { + role: "user", + timestamp: "2024-01-01T00:00:03.000Z", + content: "line one line two", + }, + ], + currentLines: ["> line one\n\nline two"], + loadedAt: 400, + status: "idle", + runId: null, + }); + + expect(patch).toEqual({ + outputLines: ['[[meta]]{"role":"user","timestamp":1704067203000}', "> line one line two"], + lastResult: null, + lastUserMessage: "line one line two", + historyLoadedAt: 400, + }); + }); + + it("collapses optimistic user lines when history carries a post-system timestamp envelope", () => { + const patch = buildHistorySyncPatch({ + messages: [ + { + role: "user", + timestamp: "2026-02-17T23:39:00.000Z", + content: + "System: [2026-02-17 23:38 UTC] queued\n\n[Tue 2026-02-17 23:39 UTC] Ask me some questions", + }, + ], + currentLines: ["> Ask me some questions"], + loadedAt: 500, + status: "idle", + runId: null, + }); + + expect(patch).toEqual({ + outputLines: ['[[meta]]{"role":"user","timestamp":1771371540000}', "> Ask me some questions"], + lastResult: null, + lastUserMessage: "Ask me some questions", + historyLoadedAt: 500, + }); + }); +}); diff --git a/tests/unit/runtimeEventCoordinatorWorkflow.test.ts b/tests/unit/runtimeEventCoordinatorWorkflow.test.ts new file mode 100644 index 00000000..e0c13dc4 --- /dev/null +++ b/tests/unit/runtimeEventCoordinatorWorkflow.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentState } from "@/features/agents/state/store"; +import { + createRuntimeEventCoordinatorState, + markChatRunSeen, + reduceClearRunTracking, + reduceLifecycleFallbackFired, + reduceMarkActivityThrottled, + reduceRuntimeAgentWorkflowCommands, + reduceRuntimePolicyIntents, +} from "@/features/agents/state/runtimeEventCoordinatorWorkflow"; +import { + applyTerminalCommit, + createRuntimeTerminalState, + deriveLifecycleTerminalDecision, +} from "@/features/agents/state/runtimeTerminalWorkflow"; +import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge"; + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "running", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: 100, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + ...(overrides ?? {}), +}); + +const createAgentPayload = (overrides?: Partial<AgentEventPayload>): AgentEventPayload => ({ + runId: "run-1", + sessionKey: "agent:agent-1:studio:test-session", + stream: "assistant", + data: { delta: "hello" }, + ...(overrides ?? {}), +}); + +describe("runtimeEventCoordinatorWorkflow", () => { + it("reduces runtime policy intents into effects and run cleanup", () => { + let state = createRuntimeEventCoordinatorState(); + state = markChatRunSeen(state, "run-1"); + state.thinkingStartedAtByRun.set("run-1", 900); + + const reduced = reduceRuntimePolicyIntents({ + state, + nowMs: 1000, + intents: [ + { kind: "queueLivePatch", agentId: "agent-1", patch: { streamText: "stream" } }, + { + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { status: "running", runId: "run-1" }, + }, + { + kind: "requestHistoryRefresh", + agentId: "agent-1", + reason: "chat-final-no-trace", + }, + { + kind: "scheduleSummaryRefresh", + delayMs: 750, + includeHeartbeatRefresh: true, + }, + { kind: "clearRunTracking", runId: "run-1" }, + ], + }); + + expect(reduced.effects).toEqual( + expect.arrayContaining([ + { + kind: "queueLivePatch", + agentId: "agent-1", + patch: { streamText: "stream" }, + }, + { + kind: "dispatch", + action: { + type: "updateAgent", + agentId: "agent-1", + patch: { status: "running", runId: "run-1" }, + }, + }, + { + kind: "requestHistoryRefresh", + agentId: "agent-1", + reason: "chat-final-no-trace", + deferMs: 0, + }, + { + kind: "scheduleSummaryRefresh", + delayMs: 750, + includeHeartbeatRefresh: true, + }, + { + kind: "cancelLifecycleFallback", + runId: "run-1", + }, + ]) + ); + expect(reduced.state.chatRunSeen.has("run-1")).toBe(false); + expect(reduced.state.thinkingStartedAtByRun.has("run-1")).toBe(false); + }); + + it("reduces lifecycle decision into fallback scheduling/cancellation effects", () => { + const initial = createRuntimeEventCoordinatorState(); + const scheduleDecision = deriveLifecycleTerminalDecision({ + mode: "event", + state: initial.runtimeTerminalState, + runId: "run-1", + phase: "end", + hasPendingFallbackTimer: false, + fallbackDelayMs: 250, + fallbackFinalText: "fallback final", + transitionClearsRunTracking: true, + }); + + const scheduleReduced = reduceRuntimeAgentWorkflowCommands({ + state: initial, + payload: createAgentPayload({ + stream: "lifecycle", + data: { phase: "end" }, + }), + agentId: "agent-1", + agent: createAgent(), + nowMs: 1000, + commands: [ + { + kind: "applyLifecycleDecision", + decision: scheduleDecision, + transitionPatch: { status: "idle", runId: null }, + shouldClearPendingLivePatch: true, + }, + ], + }); + + expect(scheduleReduced.effects).toEqual( + expect.arrayContaining([ + { kind: "clearPendingLivePatch", agentId: "agent-1" }, + { kind: "cancelLifecycleFallback", runId: "run-1" }, + { + kind: "scheduleLifecycleFallback", + runId: "run-1", + delayMs: 250, + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:test-session", + finalText: "fallback final", + transitionPatch: { status: "idle", runId: null }, + }, + ]) + ); + expect( + scheduleReduced.effects.some( + (effect) => + effect.kind === "dispatch" && + effect.action.type === "updateAgent" && + effect.action.patch.status === "idle" + ) + ).toBe(false); + + const cancelDecision = deriveLifecycleTerminalDecision({ + mode: "event", + state: initial.runtimeTerminalState, + runId: "run-2", + phase: "start", + hasPendingFallbackTimer: true, + fallbackDelayMs: 250, + fallbackFinalText: null, + transitionClearsRunTracking: false, + }); + const cancelReduced = reduceRuntimeAgentWorkflowCommands({ + state: initial, + payload: createAgentPayload({ runId: "run-2", stream: "lifecycle", data: { phase: "start" } }), + agentId: "agent-1", + agent: createAgent({ runId: "run-2" }), + nowMs: 1000, + commands: [ + { + kind: "applyLifecycleDecision", + decision: cancelDecision, + transitionPatch: { status: "running", runId: "run-2" }, + shouldClearPendingLivePatch: false, + }, + ], + }); + + expect( + cancelReduced.effects.some( + (effect) => effect.kind === "cancelLifecycleFallback" && effect.runId === "run-2" + ) + ).toBe(true); + }); + + it("applies fallback-fired commits only when chat final has not already committed", () => { + const baseDecision = deriveLifecycleTerminalDecision({ + mode: "event", + state: createRuntimeTerminalState(), + runId: "run-1", + phase: "end", + hasPendingFallbackTimer: false, + fallbackDelayMs: 0, + fallbackFinalText: "fallback final", + transitionClearsRunTracking: true, + }); + + const state = { + ...createRuntimeEventCoordinatorState(), + runtimeTerminalState: baseDecision.state, + thinkingStartedAtByRun: new Map<string, number>([["run-1", 1000]]), + }; + + const committed = reduceLifecycleFallbackFired({ + state, + runId: "run-1", + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:test-session", + finalText: "fallback final", + transitionPatch: { status: "idle", runId: null }, + nowMs: 1300, + }); + + expect( + committed.effects.some( + (effect) => + effect.kind === "dispatch" && + effect.action.type === "appendOutput" && + effect.action.transcript?.kind === "meta" + ) + ).toBe(true); + expect( + committed.effects.some( + (effect) => + effect.kind === "dispatch" && + effect.action.type === "appendOutput" && + effect.action.line === "fallback final" + ) + ).toBe(true); + expect( + committed.effects.some( + (effect) => + effect.kind === "dispatch" && + effect.action.type === "updateAgent" && + effect.action.patch.lastResult === "fallback final" + ) + ).toBe(true); + + const chatFinalCommittedState = { + ...state, + runtimeTerminalState: applyTerminalCommit(state.runtimeTerminalState, { + runId: "run-1", + source: "chat-final", + seq: 1, + }), + }; + + const skipped = reduceLifecycleFallbackFired({ + state: chatFinalCommittedState, + runId: "run-1", + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:test-session", + finalText: "fallback final", + transitionPatch: { status: "idle", runId: null }, + nowMs: 1400, + }); + + expect(skipped.effects).toEqual([]); + }); + + it("tracks history refresh state per run and clears it with run cleanup", () => { + const reduced = reduceRuntimeAgentWorkflowCommands({ + state: createRuntimeEventCoordinatorState(), + payload: createAgentPayload({ + stream: "tool", + data: { phase: "result" }, + }), + agentId: "agent-1", + agent: createAgent(), + nowMs: 2000, + commands: [ + { kind: "markHistoryRefreshRequested", runId: "run-1" }, + { + kind: "scheduleHistoryRefresh", + delayMs: 750, + reason: "chat-final-no-trace", + }, + ], + }); + + expect(reduced.state.historyRefreshRequestedByRun.has("run-1")).toBe(true); + expect(reduced.effects).toContainEqual({ + kind: "requestHistoryRefresh", + agentId: "agent-1", + reason: "chat-final-no-trace", + deferMs: 750, + }); + + const cleared = reduceClearRunTracking({ state: reduced.state, runId: "run-1" }); + expect(cleared.state.historyRefreshRequestedByRun.has("run-1")).toBe(false); + expect(cleared.effects).toContainEqual({ + kind: "cancelLifecycleFallback", + runId: "run-1", + }); + }); + + it("throttles mark-activity effects by agent", () => { + const first = reduceMarkActivityThrottled({ + state: createRuntimeEventCoordinatorState(), + agentId: "agent-1", + at: 1000, + }); + expect(first.effects).toContainEqual({ + kind: "dispatch", + action: { + type: "markActivity", + agentId: "agent-1", + at: 1000, + }, + }); + + const second = reduceMarkActivityThrottled({ + state: first.state, + agentId: "agent-1", + at: 1100, + }); + expect(second.effects).toEqual([]); + + const third = reduceMarkActivityThrottled({ + state: second.state, + agentId: "agent-1", + at: 1301, + }); + expect(third.effects).toContainEqual({ + kind: "dispatch", + action: { + type: "markActivity", + agentId: "agent-1", + at: 1301, + }, + }); + }); +}); diff --git a/tests/unit/runtimeEventPolicy.test.ts b/tests/unit/runtimeEventPolicy.test.ts new file mode 100644 index 00000000..3509b918 --- /dev/null +++ b/tests/unit/runtimeEventPolicy.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import { + decideRuntimeAgentEvent, + decideRuntimeChatEvent, + decideSummaryRefreshEvent, + type RuntimePolicyIntent, +} from "@/features/agents/state/runtimeEventPolicy"; + +const findIntent = <TKind extends RuntimePolicyIntent["kind"]>( + intents: RuntimePolicyIntent[], + kind: TKind +): Extract<RuntimePolicyIntent, { kind: TKind }> | undefined => + intents.find((intent) => intent.kind === kind) as + | Extract<RuntimePolicyIntent, { kind: TKind }> + | undefined; + +describe("runtime event policy", () => { + it("returns_noop_for_stale_chat_delta_run", () => { + const intents = decideRuntimeChatEvent({ + agentId: "agent-1", + state: "delta", + runId: "run-stale", + role: "assistant", + activeRunId: "run-active", + agentStatus: "running", + now: 1000, + agentRunStartedAt: 900, + nextThinking: null, + nextText: "hello", + hasThinkingStarted: false, + isClosedRun: false, + isStaleTerminal: false, + shouldRequestHistoryRefresh: false, + shouldUpdateLastResult: false, + shouldSetRunIdle: false, + shouldSetRunError: false, + lastResultText: null, + assistantCompletionAt: null, + shouldQueueLatestUpdate: false, + latestUpdateMessage: null, + }); + + expect(intents).toEqual([{ kind: "clearRunTracking", runId: "run-stale" }]); + }); + + it("returns_live_patch_intent_for_assistant_delta", () => { + const intents = decideRuntimeChatEvent({ + agentId: "agent-1", + state: "delta", + runId: "run-1", + role: "assistant", + activeRunId: "run-1", + agentStatus: "running", + now: 1000, + agentRunStartedAt: null, + nextThinking: "thinking", + nextText: "answer", + hasThinkingStarted: false, + isClosedRun: false, + isStaleTerminal: false, + shouldRequestHistoryRefresh: false, + shouldUpdateLastResult: false, + shouldSetRunIdle: false, + shouldSetRunError: false, + lastResultText: null, + assistantCompletionAt: null, + shouldQueueLatestUpdate: false, + latestUpdateMessage: null, + }); + + expect(findIntent(intents, "markThinkingStarted")).toEqual({ + kind: "markThinkingStarted", + runId: "run-1", + at: 1000, + }); + expect(findIntent(intents, "queueLivePatch")).toEqual({ + kind: "queueLivePatch", + agentId: "agent-1", + patch: { + thinkingTrace: "thinking", + streamText: "answer", + runId: "run-1", + status: "running", + runStartedAt: 1000, + }, + }); + }); + + it("returns_terminal_intents_for_chat_final_assistant", () => { + const intents = decideRuntimeChatEvent({ + agentId: "agent-1", + state: "final", + runId: "run-1", + role: "assistant", + activeRunId: "run-1", + agentStatus: "running", + now: 2000, + agentRunStartedAt: 900, + nextThinking: null, + nextText: "Done", + hasThinkingStarted: true, + isClosedRun: false, + isStaleTerminal: false, + shouldRequestHistoryRefresh: true, + shouldUpdateLastResult: true, + shouldSetRunIdle: true, + shouldSetRunError: false, + lastResultText: "Done", + assistantCompletionAt: 1900, + shouldQueueLatestUpdate: true, + latestUpdateMessage: "hello", + }); + + expect(findIntent(intents, "clearPendingLivePatch")).toEqual({ + kind: "clearPendingLivePatch", + agentId: "agent-1", + }); + expect(findIntent(intents, "markRunClosed")).toEqual({ + kind: "markRunClosed", + runId: "run-1", + }); + expect(findIntent(intents, "requestHistoryRefresh")).toEqual({ + kind: "requestHistoryRefresh", + agentId: "agent-1", + reason: "chat-final-no-trace", + }); + const updates = intents.filter( + (intent): intent is Extract<RuntimePolicyIntent, { kind: "dispatchUpdateAgent" }> => + intent.kind === "dispatchUpdateAgent" + ); + expect(updates).toContainEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { lastResult: "Done" }, + }); + expect(updates).toContainEqual({ + kind: "dispatchUpdateAgent", + agentId: "agent-1", + patch: { + streamText: null, + thinkingTrace: null, + runStartedAt: null, + lastAssistantMessageAt: 1900, + status: "idle", + runId: null, + }, + }); + }); + + it("returns_ignore_for_stale_terminal_chat_event", () => { + const intents = decideRuntimeChatEvent({ + agentId: "agent-1", + state: "final", + runId: "run-1", + role: "assistant", + activeRunId: "run-1", + agentStatus: "running", + now: 2000, + agentRunStartedAt: 900, + nextThinking: null, + nextText: "Done", + hasThinkingStarted: true, + isClosedRun: false, + isStaleTerminal: true, + shouldRequestHistoryRefresh: false, + shouldUpdateLastResult: false, + shouldSetRunIdle: true, + shouldSetRunError: false, + lastResultText: null, + assistantCompletionAt: 1900, + shouldQueueLatestUpdate: false, + latestUpdateMessage: null, + }); + + expect(intents).toEqual([{ kind: "ignore", reason: "stale-terminal-event" }]); + }); + + it("returns_agent_preflight_intents_for_closed_or_stale_runs", () => { + const closed = decideRuntimeAgentEvent({ + runId: "run-1", + stream: "assistant", + phase: "", + activeRunId: "run-1", + agentStatus: "running", + isClosedRun: true, + }); + const stale = decideRuntimeAgentEvent({ + runId: "run-1", + stream: "assistant", + phase: "", + activeRunId: "run-2", + agentStatus: "running", + isClosedRun: false, + }); + + expect(closed).toEqual([{ kind: "ignore", reason: "closed-run-event" }]); + expect(stale).toEqual([{ kind: "clearRunTracking", runId: "run-1" }]); + }); + + it("returns_summary_refresh_intent_for_presence_and_heartbeat", () => { + const presence = decideSummaryRefreshEvent({ + event: "presence", + status: "connected", + }); + const heartbeat = decideSummaryRefreshEvent({ + event: "heartbeat", + status: "connected", + }); + const disconnected = decideSummaryRefreshEvent({ + event: "presence", + status: "disconnected", + }); + + expect(presence).toEqual([ + { + kind: "scheduleSummaryRefresh", + delayMs: 750, + includeHeartbeatRefresh: false, + }, + ]); + expect(heartbeat).toEqual([ + { + kind: "scheduleSummaryRefresh", + delayMs: 750, + includeHeartbeatRefresh: true, + }, + ]); + expect(disconnected).toEqual([]); + }); +}); diff --git a/tests/unit/runtimeSyncControlWorkflow.test.ts b/tests/unit/runtimeSyncControlWorkflow.test.ts new file mode 100644 index 00000000..14b1506d --- /dev/null +++ b/tests/unit/runtimeSyncControlWorkflow.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { + RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS, + RUNTIME_SYNC_RECONCILE_INTERVAL_MS, + resolveRuntimeSyncBootstrapHistoryAgentIds, + resolveRuntimeSyncFocusedHistoryPollingIntent, + resolveRuntimeSyncGapRecoveryIntent, + resolveRuntimeSyncLoadMoreHistoryLimit, + resolveRuntimeSyncReconcilePollingIntent, + shouldRuntimeSyncContinueFocusedHistoryPolling, +} from "@/features/agents/operations/runtimeSyncControlWorkflow"; + +describe("runtimeSyncControlWorkflow", () => { + it("plans reconcile polling only when connected", () => { + expect( + resolveRuntimeSyncReconcilePollingIntent({ + status: "disconnected", + }) + ).toEqual({ + kind: "stop", + reason: "not-connected", + }); + + expect( + resolveRuntimeSyncReconcilePollingIntent({ + status: "connected", + }) + ).toEqual({ + kind: "start", + intervalMs: RUNTIME_SYNC_RECONCILE_INTERVAL_MS, + runImmediately: true, + }); + }); + + it("plans history bootstrap for connected unloaded sessions", () => { + expect( + resolveRuntimeSyncBootstrapHistoryAgentIds({ + status: "connected", + agents: [ + { agentId: "agent-1", sessionCreated: true, historyLoadedAt: null }, + { agentId: "agent-2", sessionCreated: true, historyLoadedAt: 1234 }, + { agentId: "agent-3", sessionCreated: false, historyLoadedAt: null }, + ], + }) + ).toEqual(["agent-1"]); + + expect( + resolveRuntimeSyncBootstrapHistoryAgentIds({ + status: "connecting", + agents: [{ agentId: "agent-1", sessionCreated: true, historyLoadedAt: null }], + }) + ).toEqual([]); + }); + + it("plans focused history polling with explicit stop reasons", () => { + expect( + resolveRuntimeSyncFocusedHistoryPollingIntent({ + status: "connected", + focusedAgentId: "agent-1", + focusedAgentRunning: true, + }) + ).toEqual({ + kind: "start", + agentId: "agent-1", + intervalMs: RUNTIME_SYNC_FOCUSED_HISTORY_INTERVAL_MS, + runImmediately: true, + }); + + expect( + resolveRuntimeSyncFocusedHistoryPollingIntent({ + status: "connected", + focusedAgentId: null, + focusedAgentRunning: true, + }) + ).toEqual({ + kind: "stop", + reason: "missing-focused-agent", + }); + + expect( + resolveRuntimeSyncFocusedHistoryPollingIntent({ + status: "connected", + focusedAgentId: "agent-1", + focusedAgentRunning: false, + }) + ).toEqual({ + kind: "stop", + reason: "focused-not-running", + }); + }); + + it("checks focused polling continuation against latest running state", () => { + expect( + shouldRuntimeSyncContinueFocusedHistoryPolling({ + agentId: "agent-1", + agents: [{ agentId: "agent-1", status: "running" }], + }) + ).toBe(true); + + expect( + shouldRuntimeSyncContinueFocusedHistoryPolling({ + agentId: "agent-1", + agents: [{ agentId: "agent-1", status: "idle" }], + }) + ).toBe(false); + + expect( + shouldRuntimeSyncContinueFocusedHistoryPolling({ + agentId: "agent-1", + agents: [], + }) + ).toBe(false); + }); + + it("resolves load-more limits with floor and max bounds", () => { + expect( + resolveRuntimeSyncLoadMoreHistoryLimit({ + currentLimit: 200, + defaultLimit: 200, + maxLimit: 5000, + }) + ).toBe(400); + + expect( + resolveRuntimeSyncLoadMoreHistoryLimit({ + currentLimit: 3000, + defaultLimit: 200, + maxLimit: 5000, + }) + ).toBe(5000); + + expect( + resolveRuntimeSyncLoadMoreHistoryLimit({ + currentLimit: null, + defaultLimit: 200, + maxLimit: 5000, + }) + ).toBe(400); + }); + + it("always plans summary refresh plus reconcile for gap recovery", () => { + expect(resolveRuntimeSyncGapRecoveryIntent()).toEqual({ + refreshSummarySnapshot: true, + reconcileRunningAgents: true, + }); + }); +}); diff --git a/tests/unit/runtimeTerminalWorkflow.test.ts b/tests/unit/runtimeTerminalWorkflow.test.ts new file mode 100644 index 00000000..33131676 --- /dev/null +++ b/tests/unit/runtimeTerminalWorkflow.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { + applyTerminalCommit, + createRuntimeTerminalState, + deriveChatTerminalDecision, + deriveLifecycleTerminalDecision, + isClosedRun, + markClosedRun, + pruneClosedRuns, +} from "@/features/agents/state/runtimeTerminalWorkflow"; + +describe("runtime terminal workflow", () => { + it("marks same-or-lower chat final sequence as stale, but accepts higher sequence", () => { + let state = createRuntimeTerminalState(); + state = applyTerminalCommit(state, { + runId: "run-1", + source: "chat-final", + seq: 4, + }); + + const sameSeq = deriveChatTerminalDecision({ + state, + runId: "run-1", + isFinal: true, + seq: 4, + }); + expect(sameSeq.isStaleTerminal).toBe(true); + expect(sameSeq.lastTerminalSeqBeforeFinal).toBe(4); + expect(sameSeq.commitSourceBeforeFinal).toBe("chat-final"); + + const lowerSeq = deriveChatTerminalDecision({ + state, + runId: "run-1", + isFinal: true, + seq: 3, + }); + expect(lowerSeq.isStaleTerminal).toBe(true); + + const higherSeq = deriveChatTerminalDecision({ + state, + runId: "run-1", + isFinal: true, + seq: 5, + }); + expect(higherSeq.isStaleTerminal).toBe(false); + }); + + it("schedules lifecycle fallback only when lifecycle end arrives before chat final", () => { + const freshState = createRuntimeTerminalState(); + const pendingFallback = deriveLifecycleTerminalDecision({ + mode: "event", + state: freshState, + runId: "run-2", + phase: "end", + hasPendingFallbackTimer: false, + fallbackDelayMs: 250, + fallbackFinalText: "fallback final", + transitionClearsRunTracking: true, + }); + expect(pendingFallback.deferTransitionPatch).toBe(true); + expect(pendingFallback.commands).toEqual( + expect.arrayContaining([ + { kind: "cancelLifecycleFallback", runId: "run-2" }, + { + kind: "scheduleLifecycleFallback", + runId: "run-2", + delayMs: 250, + finalText: "fallback final", + }, + ]) + ); + + let chatSeenState = createRuntimeTerminalState(); + chatSeenState = applyTerminalCommit(chatSeenState, { + runId: "run-2", + source: "chat-final", + seq: 1, + }); + const noFallback = deriveLifecycleTerminalDecision({ + mode: "event", + state: chatSeenState, + runId: "run-2", + phase: "end", + hasPendingFallbackTimer: false, + fallbackDelayMs: 250, + fallbackFinalText: "fallback final", + transitionClearsRunTracking: true, + }); + expect(noFallback.deferTransitionPatch).toBe(false); + expect( + noFallback.commands.find((command) => command.kind === "scheduleLifecycleFallback") + ).toBeUndefined(); + expect(noFallback.commands).toEqual( + expect.arrayContaining([ + { kind: "markRunClosed", runId: "run-2" }, + { kind: "clearRunTracking", runId: "run-2" }, + ]) + ); + }); + + it("supports closed-run mark, lookup, and prune semantics", () => { + let state = createRuntimeTerminalState(); + state = applyTerminalCommit(state, { + runId: "run-closed", + source: "lifecycle-fallback", + seq: null, + }); + state = markClosedRun(state, { + runId: "run-closed", + now: 1000, + ttlMs: 30, + }); + + expect(isClosedRun(state, "run-closed")).toBe(true); + const beforeExpiry = pruneClosedRuns(state, { at: 1029 }); + expect(beforeExpiry.expiredRunIds).toEqual([]); + expect(isClosedRun(beforeExpiry.state, "run-closed")).toBe(true); + + const afterExpiry = pruneClosedRuns(beforeExpiry.state, { at: 1030 }); + expect(afterExpiry.expiredRunIds).toEqual(["run-closed"]); + expect(isClosedRun(afterExpiry.state, "run-closed")).toBe(false); + }); + + it("transitions commit source from lifecycle fallback to chat final", () => { + let state = createRuntimeTerminalState(); + state = applyTerminalCommit(state, { + runId: "run-3", + source: "lifecycle-fallback", + seq: null, + }); + + const missingSeqAfterFallback = deriveChatTerminalDecision({ + state, + runId: "run-3", + isFinal: true, + seq: null, + }); + expect(missingSeqAfterFallback.isStaleTerminal).toBe(false); + expect(missingSeqAfterFallback.commitSourceBeforeFinal).toBe("lifecycle-fallback"); + + state = applyTerminalCommit(state, { + runId: "run-3", + source: "chat-final", + seq: 2, + }); + const missingSeqAfterChatFinal = deriveChatTerminalDecision({ + state, + runId: "run-3", + isFinal: true, + seq: null, + }); + expect(missingSeqAfterChatFinal.isStaleTerminal).toBe(true); + expect(missingSeqAfterChatFinal.commitSourceBeforeFinal).toBe("chat-final"); + }); + + it("generates fallback schedule intent with explicit delay and no timer handles", () => { + const decision = deriveLifecycleTerminalDecision({ + mode: "event", + state: createRuntimeTerminalState(), + runId: "run-4", + phase: "end", + hasPendingFallbackTimer: false, + fallbackDelayMs: 777, + fallbackFinalText: "fallback", + transitionClearsRunTracking: true, + }); + + const schedule = decision.commands.find( + (command) => command.kind === "scheduleLifecycleFallback" + ); + expect(schedule).toEqual({ + kind: "scheduleLifecycleFallback", + runId: "run-4", + delayMs: 777, + finalText: "fallback", + }); + }); +}); diff --git a/tests/unit/scrollNearBottom.test.ts b/tests/unit/scrollNearBottom.test.ts new file mode 100644 index 00000000..6de13801 --- /dev/null +++ b/tests/unit/scrollNearBottom.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isNearBottom } from "@/lib/dom"; + +describe("isNearBottom", () => { + it("returns true when within the threshold of the bottom", () => { + expect( + isNearBottom({ scrollTop: 560, clientHeight: 400, scrollHeight: 1000 }, 40) + ).toBe(true); + }); + + it("returns false when above the threshold", () => { + expect( + isNearBottom({ scrollTop: 500, clientHeight: 400, scrollHeight: 1000 }, 40) + ).toBe(false); + }); + + it("treats negative remaining distance as near bottom", () => { + expect( + isNearBottom({ scrollTop: 700, clientHeight: 400, scrollHeight: 1000 }, 40) + ).toBe(true); + }); +}); diff --git a/tests/unit/serverNetworkPolicy.test.ts b/tests/unit/serverNetworkPolicy.test.ts new file mode 100644 index 00000000..6351fe8e --- /dev/null +++ b/tests/unit/serverNetworkPolicy.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +describe("server network policy", () => { + it("defaults to dual loopback hosts", async () => { + const { resolveHosts, resolveHost } = await import("../../server/network-policy"); + expect(resolveHosts({} as unknown as NodeJS.ProcessEnv)).toEqual(["127.0.0.1", "::1"]); + expect(resolveHost({} as unknown as NodeJS.ProcessEnv)).toBe("127.0.0.1"); + }); + + it("ignores HOSTNAME and uses only HOST for bind resolution", async () => { + const { resolveHosts, resolveHost } = await import("../../server/network-policy"); + expect(resolveHosts({ HOSTNAME: "example-host" } as unknown as NodeJS.ProcessEnv)).toEqual([ + "127.0.0.1", + "::1", + ]); + expect(resolveHost({ HOSTNAME: "example-host" } as unknown as NodeJS.ProcessEnv)).toBe("127.0.0.1"); + expect( + resolveHosts({ HOST: "0.0.0.0", HOSTNAME: "example-host" } as unknown as NodeJS.ProcessEnv) + ).toEqual(["0.0.0.0"]); + expect( + resolveHost({ HOST: "0.0.0.0", HOSTNAME: "example-host" } as unknown as NodeJS.ProcessEnv) + ).toBe("0.0.0.0"); + }); + + it("classifies wildcard and non-loopback hosts as public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("0.0.0.0")).toBe(true); + expect(isPublicHost("::")).toBe(true); + expect(isPublicHost("studio.example.com")).toBe(true); + }); + + it("classifies loopback hosts as non-public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("127.0.0.1")).toBe(false); + expect(isPublicHost("::1")).toBe(false); + expect(isPublicHost("0:0:0:0:0:0:0:1")).toBe(false); + expect(isPublicHost("::ffff:127.0.0.1")).toBe(false); + expect(isPublicHost("[::1]:3000")).toBe(false); + expect(isPublicHost("localhost")).toBe(false); + }); + + it("classifies non-loopback IPv6 addresses as public", async () => { + const { isPublicHost } = await import("../../server/network-policy"); + expect(isPublicHost("::ffff:192.168.1.10")).toBe(true); + }); + + it("rejects public bind without non-empty studio access token", async () => { + const { assertPublicHostAllowed } = await import("../../server/network-policy"); + expect(() => assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: "" })).toThrow( + /Refusing to bind Studio to public host/ + ); + expect(() => assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: " " })).toThrow( + /Refusing to bind Studio to public host/ + ); + expect(() => + assertPublicHostAllowed({ host: "0.0.0.0", studioAccessToken: "abc" }) + ).not.toThrow(); + }); +}); diff --git a/tests/unit/sessionKey.test.ts b/tests/unit/sessionKey.test.ts new file mode 100644 index 00000000..2a8c9e83 --- /dev/null +++ b/tests/unit/sessionKey.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentMainSessionKey, + isSameSessionKey, + parseAgentIdFromSessionKey, +} from "@/lib/gateway/GatewayClient"; + +describe("sessionKey helpers", () => { + it("buildAgentMainSessionKey formats agent session key", () => { + expect(buildAgentMainSessionKey("agent-1", "main")).toBe("agent:agent-1:main"); + }); + + it("parseAgentIdFromSessionKey extracts agent id", () => { + expect(parseAgentIdFromSessionKey("agent:agent-1:main")).toBe("agent-1"); + }); + + it("parseAgentIdFromSessionKey returns null when missing", () => { + expect(parseAgentIdFromSessionKey("")).toBeNull(); + }); + + it("isSameSessionKey requires exact session key match", () => { + expect(isSameSessionKey("agent:main:studio:one", "agent:main:studio:one")).toBe(true); + expect(isSameSessionKey("agent:main:studio:one", "agent:main:discord:one")).toBe(false); + }); + + it("isSameSessionKey trims whitespace", () => { + expect(isSameSessionKey(" agent:main:studio:one ", "agent:main:studio:one")).toBe(true); + }); +}); diff --git a/tests/unit/sessionSettings.test.ts b/tests/unit/sessionSettings.test.ts new file mode 100644 index 00000000..7b8980db --- /dev/null +++ b/tests/unit/sessionSettings.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + isWebchatSessionMutationBlockedError, + syncGatewaySessionSettings, +} from "@/lib/gateway/GatewayClient"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { GatewayResponseError } from "@/lib/gateway/errors"; + +describe("session settings sync helper", () => { + it("detects webchat session mutation blocked gateway errors", () => { + const blocked = new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates", + }); + expect(isWebchatSessionMutationBlockedError(blocked)).toBe(true); + }); + + it("does not misclassify unrelated invalid request errors", () => { + const invalid = new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "invalid model ref", + }); + expect(isWebchatSessionMutationBlockedError(invalid)).toBe(false); + }); + + it("does not misclassify non-gateway errors", () => { + expect( + isWebchatSessionMutationBlockedError( + new Error("webchat clients cannot patch sessions; use chat.send for session-scoped updates") + ) + ).toBe(false); + }); + + it("throws when session key is missing", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + await expect( + syncGatewaySessionSettings({ + client, + sessionKey: "", + model: "openai/gpt-5", + }) + ).rejects.toThrow("Session key is required."); + }); + + it("throws when no settings are provided", async () => { + const client = { call: vi.fn() } as unknown as GatewayClient; + await expect( + syncGatewaySessionSettings({ + client, + sessionKey: "agent:1:studio:abc", + }) + ).rejects.toThrow("At least one session setting must be provided."); + }); + + it("patches model and thinking level together", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await syncGatewaySessionSettings({ + client, + sessionKey: "agent:1:studio:abc", + model: "openai/gpt-5", + thinkingLevel: "medium", + }); + + expect(client.call).toHaveBeenCalledWith("sessions.patch", { + key: "agent:1:studio:abc", + model: "openai/gpt-5", + thinkingLevel: "medium", + }); + }); + + it("patches only model when thinking is omitted", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await syncGatewaySessionSettings({ + client, + sessionKey: "agent:1:studio:abc", + model: null, + }); + + expect(client.call).toHaveBeenCalledWith("sessions.patch", { + key: "agent:1:studio:abc", + model: null, + }); + }); + + it("patches exec session overrides without model settings", async () => { + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await syncGatewaySessionSettings({ + client, + sessionKey: "agent:1:studio:abc", + execHost: "gateway", + execSecurity: "allowlist", + execAsk: "always", + }); + + expect(client.call).toHaveBeenCalledWith("sessions.patch", { + key: "agent:1:studio:abc", + execHost: "gateway", + execSecurity: "allowlist", + execAsk: "always", + }); + }); +}); diff --git a/tests/unit/sessionSettingsMutations.test.ts b/tests/unit/sessionSettingsMutations.test.ts new file mode 100644 index 00000000..05b91a32 --- /dev/null +++ b/tests/unit/sessionSettingsMutations.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest"; + +import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { GatewayResponseError } from "@/lib/gateway/errors"; + +const createWebchatBlockedPatchError = () => + new GatewayResponseError({ + code: "INVALID_REQUEST", + message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates", + }); + +describe("session settings mutations helper", () => { + it("applies optimistic update before remote sync", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: "openai/gpt-5", + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { model: "openai/gpt-5", sessionSettingsSynced: false }, + }); + }); + + it("syncs even when session has not been created", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: false }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: "openai/gpt-5", + }); + + expect(client.call).toHaveBeenCalledWith("sessions.patch", { + key: "agent:1:studio:abc", + model: "openai/gpt-5", + }); + }); + + it("marks session settings synced after successful remote sync", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => ({ ok: true })), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "thinkingLevel", + value: "medium", + }); + + expect(client.call).toHaveBeenCalledWith("sessions.patch", { + key: "agent:1:studio:abc", + thinkingLevel: "medium", + }); + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { sessionSettingsSynced: true, sessionCreated: true }, + }); + }); + + it("reconciles model to the resolved gateway model when clearing override", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => ({ + ok: true, + key: "agent:1:studio:abc", + resolved: { modelProvider: "openai", model: "gpt-5-mini" }, + })), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: null, + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { model: "openai/gpt-5-mini", sessionSettingsSynced: true, sessionCreated: true }, + }); + }); + + it("appends actionable error output when sync fails", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => { + throw new Error("network timeout"); + }), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: "openai/gpt-5", + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: "agent-1", + line: "Model update failed: network timeout", + }); + }); + + it("restores sync state and appends capability notice when webchat patch is blocked", async () => { + const dispatch = vi.fn(); + const client = { + call: vi.fn(async () => { + throw createWebchatBlockedPatchError(); + }), + } as unknown as GatewayClient; + + await applySessionSettingMutation({ + agents: [{ agentId: "agent-1", sessionCreated: true, model: "openai/gpt-5-mini" }], + dispatch, + client, + agentId: "agent-1", + sessionKey: "agent:1:studio:abc", + field: "model", + value: "openai/gpt-5", + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { + model: "openai/gpt-5-mini", + sessionSettingsSynced: true, + sessionCreated: true, + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: "agent-1", + line: + "Model update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works.", + }); + + const failureLines = dispatch.mock.calls + .map((entry) => entry[0]) + .filter( + ( + action + ): action is { + type: "appendOutput"; + line: string; + } => + action && + typeof action === "object" && + "type" in action && + action.type === "appendOutput" && + "line" in action && + typeof action.line === "string" && + action.line.startsWith("Model update failed:") + ); + expect(failureLines).toHaveLength(0); + }); +}); diff --git a/tests/unit/settingsRouteWorkflow.test.ts b/tests/unit/settingsRouteWorkflow.test.ts new file mode 100644 index 00000000..e6eb0f61 --- /dev/null +++ b/tests/unit/settingsRouteWorkflow.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import { + SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM, + buildSettingsRouteHref, + parseSettingsRouteAgentIdFromQueryParam, + parseSettingsRouteAgentIdFromPathname, + planBackToChatCommands, + planFleetSelectCommands, + planNonRouteSelectionSyncCommands, + planOpenSettingsRouteCommands, + planSettingsRouteSyncCommands, + planSettingsTabChangeCommands, +} from "@/features/agents/operations/settingsRouteWorkflow"; + +describe("settingsRouteWorkflow", () => { + it("parses a valid settings route agent id", () => { + expect(parseSettingsRouteAgentIdFromPathname("/agents/agent%201/settings")).toBe("agent 1"); + }); + + it("returns null for non-settings routes", () => { + expect(parseSettingsRouteAgentIdFromPathname("/agents/main/chat")).toBeNull(); + expect(parseSettingsRouteAgentIdFromPathname("/")).toBeNull(); + }); + + it("falls back to raw path segment when decoding throws", () => { + expect(parseSettingsRouteAgentIdFromPathname("/agents/%E0%A4%A/settings")).toBe( + "%E0%A4%A" + ); + }); + + it("builds encoded settings route href", () => { + expect(buildSettingsRouteHref("agent one/2")).toBe( + `/?${SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM}=agent%20one%2F2` + ); + }); + + it("parses settings route agent id from query param", () => { + expect(parseSettingsRouteAgentIdFromQueryParam("agent-1")).toBe("agent-1"); + expect(parseSettingsRouteAgentIdFromQueryParam("agent%201")).toBe("agent 1"); + expect(parseSettingsRouteAgentIdFromQueryParam("%E0%A4%A")).toBe("%E0%A4%A"); + expect(parseSettingsRouteAgentIdFromQueryParam(" ")).toBeNull(); + expect(parseSettingsRouteAgentIdFromQueryParam(null)).toBeNull(); + }); + + it("throws when building settings route href with empty agent id", () => { + expect(() => buildSettingsRouteHref(" ")).toThrow( + "Cannot build settings route href: agent id is empty." + ); + }); + + it("requires discard confirmation for back-to-chat when personality is dirty", () => { + expect( + planBackToChatCommands({ + settingsRouteActive: true, + activeTab: "personality", + personalityHasUnsavedChanges: true, + discardConfirmed: false, + }) + ).toEqual([]); + + expect( + planBackToChatCommands({ + settingsRouteActive: true, + activeTab: "personality", + personalityHasUnsavedChanges: true, + discardConfirmed: true, + }) + ).toEqual([ + { kind: "set-personality-dirty", value: false }, + { kind: "push", href: "/" }, + ]); + }); + + it("plans settings tab change and clears personality dirty state after confirmed discard", () => { + expect( + planSettingsTabChangeCommands({ + nextTab: "capabilities", + currentInspectSidebar: { agentId: "agent-1", tab: "personality" }, + settingsRouteAgentId: "agent-1", + settingsRouteActive: true, + personalityHasUnsavedChanges: true, + discardConfirmed: false, + }) + ).toEqual([]); + + expect( + planSettingsTabChangeCommands({ + nextTab: "capabilities", + currentInspectSidebar: { agentId: "agent-1", tab: "personality" }, + settingsRouteAgentId: "agent-1", + settingsRouteActive: true, + personalityHasUnsavedChanges: true, + discardConfirmed: true, + }) + ).toEqual([ + { kind: "set-personality-dirty", value: false }, + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-1", tab: "capabilities" }, + }, + ]); + }); + + it("changes from capabilities to skills without discard confirmation", () => { + expect( + planSettingsTabChangeCommands({ + nextTab: "skills", + currentInspectSidebar: { agentId: "agent-1", tab: "capabilities" }, + settingsRouteAgentId: "agent-1", + settingsRouteActive: true, + personalityHasUnsavedChanges: true, + discardConfirmed: false, + }) + ).toEqual([ + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-1", tab: "skills" }, + }, + ]); + }); + + it("changes from skills to system without discard confirmation", () => { + expect( + planSettingsTabChangeCommands({ + nextTab: "system", + currentInspectSidebar: { agentId: "agent-1", tab: "skills" }, + settingsRouteAgentId: "agent-1", + settingsRouteActive: true, + personalityHasUnsavedChanges: true, + discardConfirmed: false, + }) + ).toEqual([ + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-1", tab: "system" }, + }, + ]); + }); + + it("plans route-agent synchronization commands", () => { + expect( + planSettingsRouteSyncCommands({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-2", + status: "connected", + agentsLoadedOnce: true, + selectedAgentId: "agent-1", + hasRouteAgent: true, + currentInspectSidebar: null, + }) + ).toEqual([ + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-2", tab: "personality" }, + }, + { kind: "select-agent", agentId: "agent-2" }, + ]); + }); + + it("plans redirect when settings route agent is missing after load", () => { + expect( + planSettingsRouteSyncCommands({ + settingsRouteActive: true, + settingsRouteAgentId: "missing", + status: "connected", + agentsLoadedOnce: true, + selectedAgentId: null, + hasRouteAgent: false, + currentInspectSidebar: null, + }) + ).toEqual([{ kind: "replace", href: "/" }]); + }); + + it("plans non-route selection reconciliation", () => { + expect( + planNonRouteSelectionSyncCommands({ + settingsRouteActive: false, + selectedAgentId: "agent-2", + focusedAgentId: "agent-3", + hasSelectedAgentInAgents: false, + currentInspectSidebar: { agentId: "agent-1", tab: "automations" }, + hasInspectSidebarAgent: false, + }) + ).toEqual([ + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-2", tab: "automations" }, + }, + { kind: "set-inspect-sidebar", value: null }, + { kind: "select-agent", agentId: null }, + { kind: "select-agent", agentId: "agent-3" }, + ]); + }); + + it("plans settings-route open and fleet-select commands with draft flush", () => { + expect( + planOpenSettingsRouteCommands({ + agentId: "agent 2", + currentInspectSidebar: { agentId: "agent-1", tab: "advanced" }, + focusedAgentId: "agent-1", + }) + ).toEqual([ + { kind: "flush-pending-draft", agentId: "agent-1" }, + { kind: "select-agent", agentId: "agent 2" }, + { + kind: "set-inspect-sidebar", + value: { agentId: "agent 2", tab: "advanced" }, + }, + { kind: "set-mobile-pane-chat" }, + { kind: "push", href: "/?settingsAgentId=agent%202" }, + ]); + + expect( + planFleetSelectCommands({ + agentId: "agent-9", + currentInspectSidebar: { agentId: "agent-1", tab: "capabilities" }, + focusedAgentId: "agent-3", + }) + ).toEqual([ + { kind: "flush-pending-draft", agentId: "agent-3" }, + { kind: "select-agent", agentId: "agent-9" }, + { + kind: "set-inspect-sidebar", + value: { agentId: "agent-9", tab: "capabilities" }, + }, + { kind: "set-mobile-pane-chat" }, + ]); + }); +}); diff --git a/tests/unit/skillsGatewayClient.test.ts b/tests/unit/skillsGatewayClient.test.ts new file mode 100644 index 00000000..86ccb575 --- /dev/null +++ b/tests/unit/skillsGatewayClient.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; +import { installSkill, loadAgentSkillStatus, updateSkill } from "@/lib/skills/types"; + +describe("skills gateway client", () => { + it("loads skills status for the selected agent", async () => { + const report = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + const client = { + call: vi.fn(async () => report), + } as unknown as GatewayClient; + + const result = await loadAgentSkillStatus(client, " agent-1 "); + + expect(client.call).toHaveBeenCalledWith("skills.status", { agentId: "agent-1" }); + expect(result).toBe(report); + }); + + it("fails fast when agent id is empty", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect(loadAgentSkillStatus(client, " ")).rejects.toThrow( + "Agent id is required to load skill status." + ); + expect(client.call).not.toHaveBeenCalled(); + }); + + it("installs skill dependencies with normalized params", async () => { + const response = { + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + }; + const client = { + call: vi.fn(async () => response), + } as unknown as GatewayClient; + + const result = await installSkill(client, { + name: " browser ", + installId: " install-browser ", + timeoutMs: 120_000, + }); + + expect(client.call).toHaveBeenCalledWith("skills.install", { + name: "browser", + installId: "install-browser", + timeoutMs: 120_000, + }); + expect(result).toBe(response); + }); + + it("fails fast when install inputs are empty", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect(installSkill(client, { name: " ", installId: "id" })).rejects.toThrow( + "Skill name is required to install dependencies." + ); + await expect(installSkill(client, { name: "browser", installId: " " })).rejects.toThrow( + "Install option id is required to install dependencies." + ); + expect(client.call).not.toHaveBeenCalled(); + }); + + it("updates skill setup with normalized skill key", async () => { + const response = { + ok: true, + skillKey: "browser", + config: {}, + }; + const client = { + call: vi.fn(async () => response), + } as unknown as GatewayClient; + + const result = await updateSkill(client, { + skillKey: " browser ", + apiKey: "secret-token", + }); + + expect(client.call).toHaveBeenCalledWith("skills.update", { + skillKey: "browser", + apiKey: "secret-token", + }); + expect(result).toBe(response); + }); + + it("updates global enabled state through skills.update", async () => { + const response = { + ok: true, + skillKey: "browser", + config: {}, + }; + const client = { + call: vi.fn(async () => response), + } as unknown as GatewayClient; + + await updateSkill(client, { + skillKey: " browser ", + enabled: false, + }); + + expect(client.call).toHaveBeenCalledWith("skills.update", { + skillKey: "browser", + enabled: false, + }); + }); + + it("fails fast when skill key is empty for updates", async () => { + const client = { + call: vi.fn(), + } as unknown as GatewayClient; + + await expect(updateSkill(client, { skillKey: " ", apiKey: "token" })).rejects.toThrow( + "Skill key is required to update skill setup." + ); + expect(client.call).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/skillsPresentation.test.ts b/tests/unit/skillsPresentation.test.ts new file mode 100644 index 00000000..a7e6b7e0 --- /dev/null +++ b/tests/unit/skillsPresentation.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import type { SkillStatusEntry } from "@/lib/skills/types"; +import { + deriveAgentSkillDisplayState, + buildAgentSkillsAllowlistSet, + buildSkillMissingDetails, + buildSkillReasons, + canRemoveSkill, + deriveAgentSkillsAccessMode, + deriveSkillReadinessState, + filterOsCompatibleSkills, + groupSkillsBySource, + hasInstallableMissingBinary, + isBundledBlockedSkill, + isSkillOsIncompatible, + normalizeAgentSkillsAllowlist, + resolvePreferredInstallOption, +} from "@/lib/skills/presentation"; + +const createSkill = (overrides: Partial<SkillStatusEntry>): SkillStatusEntry => ({ + name: "skill", + description: "", + source: "openclaw-workspace", + bundled: false, + filePath: "/tmp/workspace/skill/SKILL.md", + baseDir: "/tmp/workspace/skill", + skillKey: "skill", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + ...overrides, +}); + +describe("skills presentation helpers", () => { + it("groups skills by source with stable ordering", () => { + const groups = groupSkillsBySource([ + createSkill({ name: "other", source: "custom-source" }), + createSkill({ name: "installed", source: "openclaw-managed" }), + createSkill({ name: "workspace", source: "openclaw-workspace" }), + createSkill({ name: "bundled", source: "openclaw-bundled", bundled: true }), + createSkill({ name: "extra", source: "openclaw-extra" }), + ]); + + expect(groups.map((group) => group.id)).toEqual([ + "workspace", + "built-in", + "installed", + "extra", + "other", + ]); + expect(groups[0]?.skills.map((skill) => skill.name)).toEqual(["workspace"]); + expect(groups[1]?.skills.map((skill) => skill.name)).toEqual(["bundled"]); + }); + + it("builds explicit missing detail lines", () => { + const details = buildSkillMissingDetails( + createSkill({ + eligible: false, + missing: { + bins: ["playwright"], + anyBins: ["chromium", "chrome"], + env: ["GITHUB_TOKEN"], + config: ["browser.enabled"], + os: ["linux"], + }, + }) + ); + + expect(details).toEqual([ + "Missing tools: playwright", + "Missing one-of tools (install any): chromium | chrome", + "Missing env vars (set in gateway env): GITHUB_TOKEN", + "Missing config values (set in openclaw.json): browser.enabled", + "Requires OS: Linux", + ]); + }); + + it("builds reasons from policy and missing requirements", () => { + const reasons = buildSkillReasons( + createSkill({ + eligible: false, + disabled: true, + blockedByAllowlist: true, + missing: { + bins: ["playwright"], + anyBins: [], + env: [], + config: [], + os: [], + }, + }) + ); + + expect(reasons).toEqual(["disabled", "blocked by allowlist", "missing tools"]); + }); + + it("detects_os_incompatibility_from_missing_os_requirements", () => { + expect( + isSkillOsIncompatible( + createSkill({ + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: ["darwin"], + }, + }) + ) + ).toBe(true); + expect( + isSkillOsIncompatible( + createSkill({ + missing: { + bins: [], + anyBins: [], + env: [], + config: [], + os: [" "], + }, + }) + ) + ).toBe(false); + }); + + it("filters_out_os_incompatible_skills_while_preserving_order", () => { + const filtered = filterOsCompatibleSkills([ + createSkill({ name: "github", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }), + createSkill({ + name: "apple-notes", + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + }), + createSkill({ name: "slack", missing: { bins: [], anyBins: [], env: [], config: [], os: [] } }), + ]); + + expect(filtered.map((skill) => skill.name)).toEqual(["github", "slack"]); + }); + + it("detects bundled blocked skills", () => { + expect( + isBundledBlockedSkill( + createSkill({ + source: "openclaw-bundled", + bundled: true, + eligible: false, + }) + ) + ).toBe(true); + expect(isBundledBlockedSkill(createSkill({ bundled: true, eligible: true }))).toBe(false); + }); + + it("detects installable missing binaries including anyBins overlap", () => { + const skill = createSkill({ + eligible: false, + missing: { + bins: [], + anyBins: ["chromium", "chrome"], + env: [], + config: [], + os: [], + }, + install: [ + { + id: "install-chromium", + kind: "download", + label: "Install chromium", + bins: ["chromium"], + }, + ], + }); + + expect(hasInstallableMissingBinary(skill)).toBe(true); + expect(resolvePreferredInstallOption(skill)?.id).toBe("install-chromium"); + }); + + it("selects_install_option_that_matches_missing_bins", () => { + const skill = createSkill({ + eligible: false, + missing: { + bins: ["gh"], + anyBins: [], + env: [], + config: [], + os: [], + }, + install: [ + { + id: "install-other", + kind: "download", + label: "Install other tool", + bins: ["other"], + }, + { + id: "install-gh", + kind: "brew", + label: "Install gh", + bins: ["gh"], + }, + ], + }); + + expect(resolvePreferredInstallOption(skill)?.id).toBe("install-gh"); + }); + + it("marks only gateway-managed and workspace skill sources as removable", () => { + expect(canRemoveSkill(createSkill({ source: "openclaw-managed" }))).toBe(true); + expect(canRemoveSkill(createSkill({ source: "openclaw-workspace" }))).toBe(true); + expect(canRemoveSkill(createSkill({ source: "agents-skills-personal" }))).toBe(false); + expect(canRemoveSkill(createSkill({ source: "agents-skills-project" }))).toBe(false); + expect(canRemoveSkill(createSkill({ source: "openclaw-bundled", bundled: true }))).toBe( + false + ); + expect(canRemoveSkill(createSkill({ source: "openclaw-extra" }))).toBe(false); + }); + + it("derives agent access mode from allowlist shape", () => { + expect(deriveAgentSkillsAccessMode(undefined)).toBe("all"); + expect(deriveAgentSkillsAccessMode([])).toBe("none"); + expect(deriveAgentSkillsAccessMode([" ", "github"])).toBe("selected"); + }); + + it("normalizes allowlist values and creates a lookup set", () => { + expect(normalizeAgentSkillsAllowlist([" github ", "github", "slack", " "])).toEqual([ + "github", + "slack", + ]); + expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("github")).toBe(true); + expect(buildAgentSkillsAllowlistSet([" github ", "slack"]).has("browser")).toBe(false); + }); + + it("classifies readiness with disabled and unavailable precedence", () => { + expect(deriveSkillReadinessState(createSkill({ disabled: true, eligible: false }))).toBe( + "disabled-globally" + ); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + }) + ) + ).toBe("unavailable"); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + blockedByAllowlist: true, + }) + ) + ).toBe("unavailable"); + expect( + deriveSkillReadinessState( + createSkill({ + eligible: false, + missing: { bins: ["gh"], anyBins: [], env: [], config: [], os: [] }, + }) + ) + ).toBe("needs-setup"); + expect(deriveSkillReadinessState(createSkill({ eligible: true }))).toBe("ready"); + }); + + it("maps readiness into agent display states", () => { + expect(deriveAgentSkillDisplayState("ready")).toBe("ready"); + expect(deriveAgentSkillDisplayState("needs-setup")).toBe("setup-required"); + expect(deriveAgentSkillDisplayState("disabled-globally")).toBe("setup-required"); + expect(deriveAgentSkillDisplayState("unavailable")).toBe("not-supported"); + }); +}); diff --git a/tests/unit/skillsRemoveClient.test.ts b/tests/unit/skillsRemoveClient.test.ts new file mode 100644 index 00000000..c56f8a32 --- /dev/null +++ b/tests/unit/skillsRemoveClient.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { removeSkillFromGateway } from "@/lib/skills/remove"; + +describe("skills remove client", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("posts skill removal payload to the Studio API route", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + text: async () => + JSON.stringify({ + result: { + removed: true, + removedPath: "/tmp/workspace/skills/github", + source: "openclaw-workspace", + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await removeSkillFromGateway({ + skillKey: " github ", + source: "openclaw-workspace", + baseDir: " /tmp/workspace/skills/github ", + workspaceDir: " /tmp/workspace ", + managedSkillsDir: " /tmp/managed ", + }); + + expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: "/tmp/workspace/skills/github", + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/managed", + }), + }); + expect(result).toEqual({ + removed: true, + removedPath: "/tmp/workspace/skills/github", + source: "openclaw-workspace", + }); + }); + + it("fails fast when required payload fields are missing", async () => { + await expect( + removeSkillFromGateway({ + skillKey: " ", + source: "openclaw-workspace", + baseDir: "/tmp/workspace/skills/github", + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/managed", + }) + ).rejects.toThrow("skillKey is required."); + + await expect( + removeSkillFromGateway({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: " ", + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/managed", + }) + ).rejects.toThrow("baseDir is required."); + }); +}); diff --git a/tests/unit/skillsRemoveExecutor.test.ts b/tests/unit/skillsRemoveExecutor.test.ts new file mode 100644 index 00000000..f047bc01 --- /dev/null +++ b/tests/unit/skillsRemoveExecutor.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { runSshJson } from "@/lib/ssh/gateway-host"; +import { removeSkillOverSsh } from "@/lib/ssh/skills-remove"; + +vi.mock("@/lib/ssh/gateway-host", () => ({ + runSshJson: vi.fn(), +})); + +describe("skills remove ssh executor", () => { + const mockedRunSshJson = vi.mocked(runSshJson); + + beforeEach(() => { + mockedRunSshJson.mockReset(); + }); + + it("removes skill files via ssh", () => { + mockedRunSshJson.mockReturnValueOnce({ + removed: true, + removedPath: "/home/ubuntu/.openclaw/skills/github", + source: "openclaw-managed", + }); + + const result = removeSkillOverSsh({ + sshTarget: "me@host", + request: { + skillKey: "github", + source: "openclaw-managed", + baseDir: "/home/ubuntu/.openclaw/skills/github", + workspaceDir: "/home/ubuntu/.openclaw/workspace-main", + managedSkillsDir: "/home/ubuntu/.openclaw/skills", + }, + }); + + expect(result).toEqual({ + removed: true, + removedPath: "/home/ubuntu/.openclaw/skills/github", + source: "openclaw-managed", + }); + expect(runSshJson).toHaveBeenCalledWith( + expect.objectContaining({ + sshTarget: "me@host", + argv: [ + "bash", + "-s", + "--", + "github", + "openclaw-managed", + "/home/ubuntu/.openclaw/skills/github", + "/home/ubuntu/.openclaw/workspace-main", + "/home/ubuntu/.openclaw/skills", + ], + label: "remove skill (github)", + input: expect.stringContaining('python3 - "$1" "$2" "$3" "$4" "$5"'), + }) + ); + }); +}); diff --git a/tests/unit/skillsRemoveLocal.test.ts b/tests/unit/skillsRemoveLocal.test.ts new file mode 100644 index 00000000..414387e6 --- /dev/null +++ b/tests/unit/skillsRemoveLocal.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { removeSkillLocally } from "@/lib/skills/remove-local"; + +const mkTmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-skill-remove-")); + +describe("skills remove local", () => { + it("removes a workspace skill directory", () => { + const workspaceDir = mkTmpDir(); + const managedSkillsDir = mkTmpDir(); + const skillDir = path.join(workspaceDir, "skills", "github"); + + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "# skill", "utf8"); + + const result = removeSkillLocally({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: skillDir, + workspaceDir, + managedSkillsDir, + }); + + expect(result).toEqual({ + removed: true, + removedPath: skillDir, + source: "openclaw-workspace", + }); + expect(fs.existsSync(skillDir)).toBe(false); + }); + + it("rejects removal outside the source root", () => { + const workspaceDir = mkTmpDir(); + const managedSkillsDir = mkTmpDir(); + const outsideDir = mkTmpDir(); + + expect(() => + removeSkillLocally({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: outsideDir, + workspaceDir, + managedSkillsDir, + }) + ).toThrow("Refusing to remove skill outside allowed root"); + }); + + it("refuses removing the root skills directory itself", () => { + const workspaceDir = mkTmpDir(); + const managedSkillsDir = mkTmpDir(); + const workspaceSkillsRoot = path.join(workspaceDir, "skills"); + fs.mkdirSync(workspaceSkillsRoot, { recursive: true }); + + expect(() => + removeSkillLocally({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: workspaceSkillsRoot, + workspaceDir, + managedSkillsDir, + }) + ).toThrow("Refusing to remove the skills root directory"); + }); + + it("refuses removing directories that are not skills", () => { + const workspaceDir = mkTmpDir(); + const managedSkillsDir = mkTmpDir(); + const nonSkillDir = path.join(workspaceDir, "skills", "tmp"); + fs.mkdirSync(nonSkillDir, { recursive: true }); + + expect(() => + removeSkillLocally({ + skillKey: "tmp", + source: "openclaw-workspace", + baseDir: nonSkillDir, + workspaceDir, + managedSkillsDir, + }) + ).toThrow("Refusing to remove non-skill directory"); + }); +}); diff --git a/tests/unit/skillsRemoveRoute.test.ts b/tests/unit/skillsRemoveRoute.test.ts new file mode 100644 index 00000000..800a79d1 --- /dev/null +++ b/tests/unit/skillsRemoveRoute.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { POST } from "@/app/api/gateway/skills/remove/route"; + +const ORIGINAL_ENV = { ...process.env }; + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual<typeof import("node:child_process")>( + "node:child_process" + ); + return { + default: actual, + ...actual, + spawnSync: vi.fn(), + }; +}); + +const mockedSpawnSync = vi.mocked(spawnSync); + +const writeStudioSettings = (gatewayUrl: string) => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-state-")); + process.env.OPENCLAW_STATE_DIR = stateDir; + + const settingsDir = path.join(stateDir, "openclaw-studio"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify( + { + version: 1, + gateway: { url: gatewayUrl, token: "token-123" }, + focused: {}, + }, + null, + 2 + ), + "utf8" + ); +}; + +describe("skills remove route", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.OPENCLAW_GATEWAY_SSH_TARGET; + delete process.env.OPENCLAW_GATEWAY_SSH_USER; + delete process.env.OPENCLAW_STATE_DIR; + mockedSpawnSync.mockReset(); + }); + + it("rejects invalid payload", async () => { + const response = await POST( + new Request("http://localhost/api/gateway/skills/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + ); + + expect(response.status).toBe(400); + }); + + it("removes skills via ssh for remote gateways", async () => { + writeStudioSettings("ws://example.test:18789"); + + mockedSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + removed: true, + removedPath: "/home/ubuntu/.openclaw/skills/github", + source: "openclaw-managed", + }), + stderr: "", + error: undefined, + } as never); + + const response = await POST( + new Request("http://localhost/api/gateway/skills/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + skillKey: "github", + source: "openclaw-managed", + baseDir: "/home/ubuntu/.openclaw/skills/github", + workspaceDir: "/home/ubuntu/.openclaw/workspace-main", + managedSkillsDir: "/home/ubuntu/.openclaw/skills", + }), + }) + ); + + expect(response.status).toBe(200); + expect(mockedSpawnSync).toHaveBeenCalledTimes(1); + + const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]]; + expect(cmd).toBe("ssh"); + expect(args).toEqual( + expect.arrayContaining([ + "-o", + "BatchMode=yes", + "ubuntu@example.test", + "bash", + "-s", + "--", + "github", + "openclaw-managed", + ]) + ); + }); + + it("removes local workspace skills without ssh", async () => { + writeStudioSettings("ws://localhost:18789"); + + const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "workspace-")); + const managedSkillsDir = fs.mkdtempSync(path.join(os.tmpdir(), "managed-")); + const skillDir = path.join(workspaceDir, "skills", "github"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "# skill", "utf8"); + + const response = await POST( + new Request("http://localhost/api/gateway/skills/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + skillKey: "github", + source: "openclaw-workspace", + baseDir: skillDir, + workspaceDir, + managedSkillsDir, + }), + }) + ); + + expect(response.status).toBe(200); + expect(mockedSpawnSync).not.toHaveBeenCalled(); + + const body = (await response.json()) as { + result: { removed: boolean; removedPath: string; source: string }; + }; + expect(body.result).toEqual({ + removed: true, + removedPath: skillDir, + source: "openclaw-workspace", + }); + expect(fs.existsSync(skillDir)).toBe(false); + }); +}); diff --git a/tests/unit/slugifyProjectName.test.ts b/tests/unit/slugifyProjectName.test.ts deleted file mode 100644 index 880bbecc..00000000 --- a/tests/unit/slugifyProjectName.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { slugifyProjectName } from "@/lib/ids/slugify"; - -describe("slugifyProjectName", () => { - it("slugifies project names", () => { - expect(slugifyProjectName("My Project")).toBe("my-project"); - }); - - it("throws on empty slugs", () => { - expect(() => slugifyProjectName("!!!")).toThrow( - "Workspace name produced an empty folder name." - ); - }); -}); diff --git a/tests/unit/specialLatestUpdateOperation.test.ts b/tests/unit/specialLatestUpdateOperation.test.ts new file mode 100644 index 00000000..35b240c8 --- /dev/null +++ b/tests/unit/specialLatestUpdateOperation.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildLatestUpdatePatch } from "@/features/agents/operations/latestUpdateWorkflow"; +import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation"; +import { resolveLatestCronJobForAgent, type CronJobSummary } from "@/lib/cron/types"; +import type { AgentState } from "@/features/agents/state/store"; + +const makeAgent = (overrides?: Partial<Pick<AgentState, "agentId" | "sessionKey" | "latestOverride" | "latestOverrideKind">>) => { + return { + agentId: "agent-1", + sessionKey: "agent:agent-1:main", + latestOverride: null, + latestOverrideKind: null, + ...overrides, + } as unknown as AgentState; +}; + +describe("specialLatestUpdateOperation", () => { + it("dispatches reset patch when intent resolves to reset", async () => { + const agent = makeAgent({ latestOverrideKind: "cron" }); + + const dispatchUpdateAgent = vi.fn(); + const operation = createSpecialLatestUpdateOperation({ + callGateway: async () => { + throw new Error("callGateway should not be invoked for reset intent"); + }, + listCronJobs: async () => ({ jobs: [] }), + resolveCronJobForAgent: () => null, + formatCronJobDisplay: () => "", + dispatchUpdateAgent, + isDisconnectLikeError: () => false, + logError: () => {}, + }); + + await operation.update(agent.agentId, agent, "plain user prompt"); + + expect(dispatchUpdateAgent).toHaveBeenCalledTimes(1); + expect(dispatchUpdateAgent).toHaveBeenCalledWith(agent.agentId, buildLatestUpdatePatch("")); + }); + + it("selects heartbeat session, reads history, and stores last assistant response after a heartbeat prompt", async () => { + const agent = makeAgent(); + + const callGateway = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + sessions: [ + { key: "agent:agent-1:main", updatedAt: 200, origin: { label: "main" } }, + { key: "agent:agent-1:hb", updatedAt: 100, origin: { label: "Heartbeat" } }, + ], + }; + } + if (method === "chat.history") { + return { + messages: [ + { role: "user", content: "Read HEARTBEAT.md if it exists" }, + { role: "assistant", content: "First response" }, + { role: "assistant", content: "Second response" }, + ], + }; + } + throw new Error(`Unhandled gateway method: ${method}`); + }); + + const dispatchUpdateAgent = vi.fn(); + const operation = createSpecialLatestUpdateOperation({ + callGateway, + listCronJobs: async () => ({ jobs: [] }), + resolveCronJobForAgent: () => null, + formatCronJobDisplay: () => "", + dispatchUpdateAgent, + isDisconnectLikeError: () => false, + logError: () => {}, + }); + + await operation.update(agent.agentId, agent, "heartbeat please"); + + expect(callGateway).toHaveBeenCalledWith("sessions.list", expect.anything()); + expect(callGateway).toHaveBeenCalledWith("chat.history", expect.anything()); + expect(dispatchUpdateAgent).toHaveBeenCalledWith( + agent.agentId, + buildLatestUpdatePatch("Second response", "heartbeat") + ); + }); + + it("fetches cron jobs, selects latest cron for agentId, and stores formatted cron display", async () => { + const agent = makeAgent(); + + const jobs: CronJobSummary[] = [ + { + id: "job-1", + name: "Older", + agentId: "agent-1", + enabled: true, + updatedAtMs: 1, + schedule: { kind: "every", everyMs: 60000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "one" }, + state: {}, + }, + { + id: "job-2", + name: "Newer", + agentId: "agent-1", + enabled: true, + updatedAtMs: 2, + schedule: { kind: "every", everyMs: 60000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "two" }, + state: {}, + }, + ]; + + const dispatchUpdateAgent = vi.fn(); + const operation = createSpecialLatestUpdateOperation({ + callGateway: async () => { + throw new Error("callGateway should not be invoked for cron intent"); + }, + listCronJobs: async () => ({ jobs }), + resolveCronJobForAgent: resolveLatestCronJobForAgent, + formatCronJobDisplay: (job) => `formatted:${job.id}`, + dispatchUpdateAgent, + isDisconnectLikeError: () => false, + logError: () => {}, + }); + + await operation.update(agent.agentId, agent, "cron report pending"); + + expect(dispatchUpdateAgent).toHaveBeenCalledWith( + agent.agentId, + buildLatestUpdatePatch("formatted:job-2", "cron") + ); + }); + + it("dedupes concurrent updates for same agentId while first is in flight", async () => { + const agent = makeAgent(); + + let resolveSessions!: (value: unknown) => void; + const sessionsPromise = new Promise<unknown>((resolve) => { + resolveSessions = resolve; + }); + + const callGateway = vi.fn((method: string) => { + if (method === "sessions.list") { + return sessionsPromise; + } + if (method === "chat.history") { + return Promise.resolve({ + messages: [ + { role: "user", content: "Read HEARTBEAT.md if it exists" }, + { role: "assistant", content: "ok" }, + ], + }); + } + return Promise.reject(new Error(`Unhandled gateway method: ${method}`)); + }); + + const dispatchUpdateAgent = vi.fn(); + const operation = createSpecialLatestUpdateOperation({ + callGateway, + listCronJobs: async () => ({ jobs: [] }), + resolveCronJobForAgent: () => null, + formatCronJobDisplay: () => "", + dispatchUpdateAgent, + isDisconnectLikeError: () => false, + logError: () => {}, + }); + + const first = operation.update(agent.agentId, agent, "heartbeat please"); + const second = operation.update(agent.agentId, agent, "heartbeat please"); + await second; + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith("sessions.list", expect.anything()); + + resolveSessions({ + sessions: [{ key: "agent:agent-1:hb", updatedAt: 1, origin: { label: "heartbeat" } }], + }); + await first; + + expect(callGateway).toHaveBeenCalledTimes(2); + expect(callGateway).toHaveBeenCalledWith("chat.history", expect.anything()); + expect(dispatchUpdateAgent).toHaveBeenCalledWith( + agent.agentId, + buildLatestUpdatePatch("ok", "heartbeat") + ); + }); +}); diff --git a/tests/unit/store.transcript-upsert.test.ts b/tests/unit/store.transcript-upsert.test.ts new file mode 100644 index 00000000..b98fabc9 --- /dev/null +++ b/tests/unit/store.transcript-upsert.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { + agentStoreReducer, + initialAgentStoreState, + type AgentStoreSeed, +} from "@/features/agents/state/store"; +import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript"; + +describe("agent store transcript upsert", () => { + it("replaces appendOutput entries that share transcript entryId", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + }; + + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + + state = agentStoreReducer(state, { + type: "appendOutput", + agentId: "agent-1", + line: "fallback final", + transcript: { + source: "runtime-agent", + runId: "run-1", + sessionKey: seed.sessionKey, + timestampMs: 1000, + role: "assistant", + kind: "assistant", + entryId: "run:run-1:assistant:final", + confirmed: false, + }, + }); + + state = agentStoreReducer(state, { + type: "appendOutput", + agentId: "agent-1", + line: "canonical final", + transcript: { + source: "runtime-chat", + runId: "run-1", + sessionKey: seed.sessionKey, + timestampMs: 1100, + role: "assistant", + kind: "assistant", + entryId: "run:run-1:assistant:final", + confirmed: true, + }, + }); + + const agent = state.agents.find((entry) => entry.agentId === "agent-1"); + const assistantEntries = (agent?.transcriptEntries ?? []).filter( + (entry) => entry.kind === "assistant" + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.text).toBe("canonical final"); + expect(assistantEntries[0]?.confirmed).toBe(true); + expect(agent?.outputLines).toEqual(["canonical final"]); + }); + + it("collapses duplicate transcript entryIds when applying an upsert", () => { + const seed: AgentStoreSeed = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + }; + + let state = agentStoreReducer(initialAgentStoreState, { + type: "hydrateAgents", + agents: [seed], + }); + + const first = createTranscriptEntryFromLine({ + line: "first duplicate", + sessionKey: seed.sessionKey, + source: "runtime-agent", + sequenceKey: 1, + runId: "run-1", + role: "assistant", + kind: "assistant", + entryId: "run:run-1:assistant:final", + confirmed: false, + }); + const second = createTranscriptEntryFromLine({ + line: "second duplicate", + sessionKey: seed.sessionKey, + source: "runtime-chat", + sequenceKey: 2, + runId: "run-1", + role: "assistant", + kind: "assistant", + entryId: "run:run-1:assistant:final", + confirmed: false, + }); + if (!first || !second) { + throw new Error("expected transcript entries"); + } + + state = agentStoreReducer(state, { + type: "updateAgent", + agentId: "agent-1", + patch: { + transcriptEntries: [first, second], + outputLines: [first.text, second.text], + }, + }); + + state = agentStoreReducer(state, { + type: "appendOutput", + agentId: "agent-1", + line: "canonical final", + transcript: { + source: "runtime-chat", + runId: "run-1", + sessionKey: seed.sessionKey, + timestampMs: 1100, + role: "assistant", + kind: "assistant", + entryId: "run:run-1:assistant:final", + confirmed: true, + }, + }); + + const agent = state.agents.find((entry) => entry.agentId === "agent-1"); + const assistantEntries = (agent?.transcriptEntries ?? []).filter( + (entry) => entry.kind === "assistant" + ); + + expect(assistantEntries).toHaveLength(1); + expect(assistantEntries[0]?.text).toBe("canonical final"); + expect(agent?.outputLines).toEqual(["canonical final"]); + }); +}); diff --git a/tests/unit/studioBootstrapOperation.test.ts b/tests/unit/studioBootstrapOperation.test.ts new file mode 100644 index 00000000..998b1e81 --- /dev/null +++ b/tests/unit/studioBootstrapOperation.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AgentStoreSeed } from "@/features/agents/state/store"; +import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import type { StudioSettingsPatch } from "@/lib/studio/settings"; + +vi.mock("@/features/agents/operations/agentFleetHydration", () => ({ + hydrateAgentFleetFromGateway: vi.fn(), +})); + +import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration"; +import { + executeStudioBootstrapLoadCommands, + executeStudioFocusedPatchCommands, + executeStudioFocusedPreferenceLoadCommands, + runStudioBootstrapLoadOperation, + runStudioFocusFilterPersistenceOperation, + runStudioFocusedPreferenceLoadOperation, + runStudioFocusedSelectionPersistenceOperation, + type StudioBootstrapLoadCommand, +} from "@/features/agents/operations/studioBootstrapOperation"; + +const hydrateAgentFleetFromGatewayMock = vi.mocked(hydrateAgentFleetFromGateway); + +describe("studioBootstrapOperation", () => { + beforeEach(() => { + hydrateAgentFleetFromGatewayMock.mockReset(); + }); + + it("builds bootstrap commands from hydrated fleet result", async () => { + const seeds: AgentStoreSeed[] = [ + { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + }, + { + agentId: "agent-2", + name: "Agent Two", + sessionKey: "agent:agent-2:main", + }, + ]; + const snapshot = { config: {} } as GatewayModelPolicySnapshot; + hydrateAgentFleetFromGatewayMock.mockResolvedValue({ + seeds, + sessionCreatedAgentIds: ["agent-1"], + sessionSettingsSyncedAgentIds: ["agent-1"], + summaryPatches: [{ agentId: "agent-2", patch: { latestPreview: "hello" } }], + suggestedSelectedAgentId: "agent-2", + configSnapshot: snapshot, + }); + + const commands = await runStudioBootstrapLoadOperation({ + client: { call: async () => null }, + gatewayUrl: "https://gateway.test", + cachedConfigSnapshot: null, + loadStudioSettings: async () => null, + isDisconnectLikeError: () => false, + preferredSelectedAgentId: "agent-1", + hasCurrentSelection: false, + }); + + expect(commands).toEqual([ + { kind: "set-gateway-config-snapshot", snapshot }, + { + kind: "hydrate-agents", + seeds, + initialSelectedAgentId: "agent-1", + }, + { + kind: "mark-session-created", + agentId: "agent-1", + sessionSettingsSynced: true, + }, + { + kind: "apply-summary-patch", + agentId: "agent-2", + patch: { latestPreview: "hello" }, + }, + ]); + }); + + it("returns set-error command when fleet hydration fails", async () => { + hydrateAgentFleetFromGatewayMock.mockRejectedValue(new Error("load failed")); + + const commands = await runStudioBootstrapLoadOperation({ + client: { call: async () => null }, + gatewayUrl: "https://gateway.test", + cachedConfigSnapshot: null, + loadStudioSettings: async () => null, + isDisconnectLikeError: () => false, + preferredSelectedAgentId: null, + hasCurrentSelection: false, + }); + + expect(commands).toEqual([{ kind: "set-error", message: "load failed" }]); + }); + + it("executes bootstrap commands with injected callbacks", () => { + const commands: StudioBootstrapLoadCommand[] = [ + { + kind: "set-gateway-config-snapshot", + snapshot: { config: {} } as GatewayModelPolicySnapshot, + }, + { + kind: "hydrate-agents", + seeds: [{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }], + initialSelectedAgentId: "agent-1", + }, + { + kind: "mark-session-created", + agentId: "agent-1", + sessionSettingsSynced: true, + }, + { + kind: "apply-summary-patch", + agentId: "agent-1", + patch: { latestPreview: "preview" }, + }, + { + kind: "set-error", + message: "failed", + }, + ]; + + const setGatewayConfigSnapshot = vi.fn(); + const hydrateAgents = vi.fn(); + const dispatchUpdateAgent = vi.fn(); + const setError = vi.fn(); + + executeStudioBootstrapLoadCommands({ + commands, + setGatewayConfigSnapshot, + hydrateAgents, + dispatchUpdateAgent, + setError, + }); + + expect(setGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + expect(hydrateAgents).toHaveBeenCalledWith( + [{ agentId: "agent-1", name: "Agent One", sessionKey: "s1" }], + "agent-1" + ); + expect(dispatchUpdateAgent).toHaveBeenCalledWith("agent-1", { + sessionCreated: true, + sessionSettingsSynced: true, + }); + expect(dispatchUpdateAgent).toHaveBeenCalledWith("agent-1", { latestPreview: "preview" }); + expect(setError).toHaveBeenCalledWith("failed"); + }); + + it("loads focused preference and emits restore commands", async () => { + const commands = await runStudioFocusedPreferenceLoadOperation({ + gatewayUrl: "https://gateway.test", + loadStudioSettings: async () => ({ + version: 1, + gateway: null, + focused: { + "https://gateway.test": { + mode: "focused", + selectedAgentId: "agent-9", + filter: "running", + }, + }, + avatars: {}, + }), + isFocusFilterTouched: () => false, + }); + + expect(commands).toEqual([ + { + kind: "set-preferred-selected-agent-id", + agentId: "agent-9", + }, + { + kind: "set-focus-filter", + filter: "all", + }, + { + kind: "set-focused-preferences-loaded", + value: true, + }, + ]); + }); + + it("skips focused preference restore when user touched filter during load", async () => { + const commands = await runStudioFocusedPreferenceLoadOperation({ + gatewayUrl: "https://gateway.test", + loadStudioSettings: async () => ({ + version: 1, + gateway: null, + focused: { + "https://gateway.test": { + mode: "focused", + selectedAgentId: "agent-9", + filter: "running", + }, + }, + avatars: {}, + }), + isFocusFilterTouched: () => true, + }); + + expect(commands).toEqual([ + { + kind: "set-focused-preferences-loaded", + value: true, + }, + ]); + }); + + it("returns focused preference load error command on failure", async () => { + const commands = await runStudioFocusedPreferenceLoadOperation({ + gatewayUrl: "https://gateway.test", + loadStudioSettings: async () => { + throw new Error("settings failed"); + }, + isFocusFilterTouched: () => false, + }); + + expect(commands[0]).toMatchObject({ + kind: "log-error", + message: "Failed to load focused preference.", + }); + expect(commands[1]).toEqual({ + kind: "set-focused-preferences-loaded", + value: true, + }); + }); + + it("executes focused preference load commands", () => { + const setFocusedPreferencesLoaded = vi.fn(); + const setPreferredSelectedAgentId = vi.fn(); + const setFocusFilter = vi.fn(); + const logError = vi.fn(); + + executeStudioFocusedPreferenceLoadCommands({ + commands: [ + { kind: "set-focused-preferences-loaded", value: false }, + { kind: "set-preferred-selected-agent-id", agentId: "agent-1" }, + { kind: "set-focus-filter", filter: "approvals" }, + { kind: "log-error", message: "failed", error: new Error("boom") }, + ], + setFocusedPreferencesLoaded, + setPreferredSelectedAgentId, + setFocusFilter, + logError, + }); + + expect(setFocusedPreferencesLoaded).toHaveBeenCalledWith(false); + expect(setPreferredSelectedAgentId).toHaveBeenCalledWith("agent-1"); + expect(setFocusFilter).toHaveBeenCalledWith("approvals"); + expect(logError).toHaveBeenCalledTimes(1); + }); + + it("plans focused persistence patch commands and executes scheduler", () => { + const filterCommands = runStudioFocusFilterPersistenceOperation({ + gatewayUrl: "https://gateway.test", + focusFilterTouched: true, + focusFilter: "running", + }); + const selectionCommands = runStudioFocusedSelectionPersistenceOperation({ + gatewayUrl: "https://gateway.test", + status: "connected", + focusedPreferencesLoaded: true, + agentsLoadedOnce: true, + selectedAgentId: "agent-2", + }); + + const schedulePatch = vi.fn(); + executeStudioFocusedPatchCommands({ + commands: [...filterCommands, ...selectionCommands], + schedulePatch, + }); + + expect(schedulePatch).toHaveBeenCalledTimes(2); + const firstCall = schedulePatch.mock.calls[0] as [StudioSettingsPatch, number]; + const secondCall = schedulePatch.mock.calls[1] as [StudioSettingsPatch, number]; + expect(firstCall[1]).toBe(300); + expect(secondCall[1]).toBe(300); + }); +}); diff --git a/tests/unit/studioBootstrapWorkflow.test.ts b/tests/unit/studioBootstrapWorkflow.test.ts new file mode 100644 index 00000000..1acef70a --- /dev/null +++ b/tests/unit/studioBootstrapWorkflow.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import { + planBootstrapSelection, + planFocusedFilterPatch, + planFocusedPreferenceRestore, + planFocusedSelectionPatch, +} from "@/features/agents/operations/studioBootstrapWorkflow"; +import type { StudioSettings } from "@/lib/studio/settings"; + +describe("studioBootstrapWorkflow", () => { + it("keeps existing selection when one is already active", () => { + const intent = planBootstrapSelection({ + hasCurrentSelection: true, + preferredSelectedAgentId: "agent-1", + availableAgentIds: ["agent-1", "agent-2"], + suggestedSelectedAgentId: "agent-2", + }); + + expect(intent).toEqual({ initialSelectedAgentId: undefined }); + }); + + it("prefers saved selected agent when present in seeds", () => { + const intent = planBootstrapSelection({ + hasCurrentSelection: false, + preferredSelectedAgentId: "agent-2", + availableAgentIds: ["agent-1", "agent-2"], + suggestedSelectedAgentId: "agent-1", + }); + + expect(intent).toEqual({ initialSelectedAgentId: "agent-2" }); + }); + + it("falls back to suggested selected agent when saved preference is unavailable", () => { + const intent = planBootstrapSelection({ + hasCurrentSelection: false, + preferredSelectedAgentId: "agent-9", + availableAgentIds: ["agent-1", "agent-2"], + suggestedSelectedAgentId: "agent-1", + }); + + expect(intent).toEqual({ initialSelectedAgentId: "agent-1" }); + }); + + it("builds focused filter patch only when gateway key and touch state allow it", () => { + expect( + planFocusedFilterPatch({ + gatewayKey: "", + focusFilterTouched: true, + focusFilter: "running", + }) + ).toEqual({ kind: "skip", reason: "missing-gateway-key" }); + + expect( + planFocusedFilterPatch({ + gatewayKey: "https://gateway.test", + focusFilterTouched: false, + focusFilter: "running", + }) + ).toEqual({ kind: "skip", reason: "focus-filter-not-touched" }); + + expect( + planFocusedFilterPatch({ + gatewayKey: "https://gateway.test", + focusFilterTouched: true, + focusFilter: "running", + }) + ).toEqual({ + kind: "patch", + patch: { + focused: { + "https://gateway.test": { + mode: "focused", + filter: "running", + }, + }, + }, + debounceMs: 300, + }); + }); + + it("builds focused selected-agent patch only when connection and load gates pass", () => { + expect( + planFocusedSelectionPatch({ + gatewayKey: "", + status: "connected", + focusedPreferencesLoaded: true, + agentsLoadedOnce: true, + selectedAgentId: "agent-1", + }) + ).toEqual({ kind: "skip", reason: "missing-gateway-key" }); + + expect( + planFocusedSelectionPatch({ + gatewayKey: "https://gateway.test", + status: "connecting", + focusedPreferencesLoaded: true, + agentsLoadedOnce: true, + selectedAgentId: "agent-1", + }) + ).toEqual({ kind: "skip", reason: "not-connected" }); + + expect( + planFocusedSelectionPatch({ + gatewayKey: "https://gateway.test", + status: "connected", + focusedPreferencesLoaded: false, + agentsLoadedOnce: true, + selectedAgentId: "agent-1", + }) + ).toEqual({ kind: "skip", reason: "focused-preferences-not-loaded" }); + + expect( + planFocusedSelectionPatch({ + gatewayKey: "https://gateway.test", + status: "connected", + focusedPreferencesLoaded: true, + agentsLoadedOnce: false, + selectedAgentId: "agent-1", + }) + ).toEqual({ kind: "skip", reason: "agents-not-loaded" }); + + expect( + planFocusedSelectionPatch({ + gatewayKey: "https://gateway.test", + status: "connected", + focusedPreferencesLoaded: true, + agentsLoadedOnce: true, + selectedAgentId: "agent-2", + }) + ).toEqual({ + kind: "patch", + patch: { + focused: { + "https://gateway.test": { + mode: "focused", + selectedAgentId: "agent-2", + }, + }, + }, + debounceMs: 300, + }); + }); + + it("resolves focused preference restore values from settings", () => { + const settings: StudioSettings = { + version: 1, + gateway: null, + focused: { + "https://gateway.test": { + mode: "focused", + selectedAgentId: "agent-3", + filter: "approvals", + }, + }, + avatars: {}, + }; + + expect( + planFocusedPreferenceRestore({ + settings, + gatewayKey: "https://gateway.test", + focusFilterTouched: false, + }) + ).toEqual({ + preferredSelectedAgentId: "agent-3", + focusFilter: "approvals", + }); + + expect( + planFocusedPreferenceRestore({ + settings, + gatewayKey: "https://gateway.unknown", + focusFilterTouched: false, + }) + ).toEqual({ + preferredSelectedAgentId: null, + focusFilter: "all", + }); + }); + + it("restores running filter as all", () => { + const settings: StudioSettings = { + version: 1, + gateway: null, + focused: { + "https://gateway.test": { + mode: "focused", + selectedAgentId: "agent-7", + filter: "running", + }, + }, + avatars: {}, + }; + + expect( + planFocusedPreferenceRestore({ + settings, + gatewayKey: "https://gateway.test", + focusFilterTouched: false, + }) + ).toEqual({ + preferredSelectedAgentId: "agent-7", + focusFilter: "all", + }); + }); +}); diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts new file mode 100644 index 00000000..a49c5f35 --- /dev/null +++ b/tests/unit/studioSettings.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { + mergeStudioSettings, + normalizeStudioSettings, +} from "@/lib/studio/settings"; + +describe("studio settings normalization", () => { + it("returns defaults for empty input", () => { + const normalized = normalizeStudioSettings(null); + expect(normalized.version).toBe(1); + expect(normalized.gateway).toBeNull(); + expect(normalized.focused).toEqual({}); + expect(normalized.avatars).toEqual({}); + }); + + it("normalizes gateway entries", () => { + const normalized = normalizeStudioSettings({ + gateway: { url: " ws://localhost:18789 ", token: " token " }, + }); + + expect(normalized.gateway?.url).toBe("ws://localhost:18789"); + expect(normalized.gateway?.token).toBe("token"); + }); + + it("normalizes loopback ip gateway urls to localhost", () => { + const normalized = normalizeStudioSettings({ + gateway: { url: "ws://127.0.0.1:18789", token: "token" }, + }); + + expect(normalized.gateway?.url).toBe("ws://localhost:18789"); + }); + + it("normalizes_dual_mode_preferences", () => { + const normalized = normalizeStudioSettings({ + focused: { + " ws://localhost:18789 ": { + mode: "focused", + selectedAgentId: " agent-2 ", + filter: "running", + }, + bad: { + mode: "nope", + selectedAgentId: 12, + filter: "bad-filter", + }, + }, + }); + + expect(normalized.focused["ws://localhost:18789"]).toEqual({ + mode: "focused", + selectedAgentId: "agent-2", + filter: "running", + }); + expect(normalized.focused.bad).toEqual({ + mode: "focused", + selectedAgentId: null, + filter: "all", + }); + }); + + it("normalizes_legacy_idle_filter_to_approvals", () => { + const normalized = normalizeStudioSettings({ + focused: { + "ws://localhost:18789": { + mode: "focused", + selectedAgentId: "agent-1", + filter: "idle", + }, + }, + }); + + expect(normalized.focused["ws://localhost:18789"]).toEqual({ + mode: "focused", + selectedAgentId: "agent-1", + filter: "approvals", + }); + }); + + it("merges_dual_mode_preferences", () => { + const current = normalizeStudioSettings({ + focused: { + "ws://localhost:18789": { + mode: "focused", + selectedAgentId: "main", + filter: "all", + }, + }, + }); + + const merged = mergeStudioSettings(current, { + focused: { + "ws://localhost:18789": { + filter: "approvals", + }, + }, + }); + + expect(merged.focused["ws://localhost:18789"]).toEqual({ + mode: "focused", + selectedAgentId: "main", + filter: "approvals", + }); + }); + + it("normalizes avatar seeds per gateway", () => { + const normalized = normalizeStudioSettings({ + avatars: { + " ws://localhost:18789 ": { + " agent-1 ": " seed-1 ", + " agent-2 ": " ", + }, + bad: "nope", + }, + }); + + expect(normalized.avatars["ws://localhost:18789"]).toEqual({ + "agent-1": "seed-1", + }); + }); + + it("merges avatar patches", () => { + const current = normalizeStudioSettings({ + avatars: { + "ws://localhost:18789": { + "agent-1": "seed-1", + }, + }, + }); + + const merged = mergeStudioSettings(current, { + avatars: { + "ws://localhost:18789": { + "agent-1": "seed-2", + "agent-2": "seed-3", + }, + }, + }); + + expect(merged.avatars["ws://localhost:18789"]).toEqual({ + "agent-1": "seed-2", + "agent-2": "seed-3", + }); + }); +}); diff --git a/tests/unit/studioSettingsCoordinator.test.ts b/tests/unit/studioSettingsCoordinator.test.ts new file mode 100644 index 00000000..874103df --- /dev/null +++ b/tests/unit/studioSettingsCoordinator.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultStudioSettings } from "@/lib/studio/settings"; +import { StudioSettingsCoordinator } from "@/lib/studio/coordinator"; + +describe("StudioSettingsCoordinator", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("coalesces multiple scheduled patches into one update", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 300); + + coordinator.schedulePatch({ + gateway: { url: "ws://localhost:18789", token: "abc" }, + }); + coordinator.schedulePatch({ + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "running", + selectedAgentId: null, + }, + }, + }); + + await vi.advanceTimersByTimeAsync(300); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + gateway: { url: "ws://localhost:18789", token: "abc" }, + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "running", + selectedAgentId: null, + }, + }, + }); + + coordinator.dispose(); + }); + + it("flushPending persists queued patch immediately", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 1000); + + coordinator.schedulePatch({ + gateway: { url: "ws://localhost:18789", token: "session-a" }, + }); + + await coordinator.flushPending(); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(updateSettings).toHaveBeenCalledWith({ + gateway: { url: "ws://localhost:18789", token: "session-a" }, + }); + + await vi.advanceTimersByTimeAsync(2000); + expect(updateSettings).toHaveBeenCalledTimes(1); + + coordinator.dispose(); + }); + + it("dispose clears pending timer without writing", async () => { + const fetchSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const updateSettings = vi.fn(async () => ({ settings: defaultStudioSettings() })); + const coordinator = new StudioSettingsCoordinator({ fetchSettings, updateSettings }, 200); + + coordinator.schedulePatch({ + focused: { + "ws://localhost:18789": { + mode: "focused", + filter: "approvals", + selectedAgentId: null, + }, + }, + }); + coordinator.dispose(); + + await vi.advanceTimersByTimeAsync(500); + + expect(updateSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/studioSettingsRoute.test.ts b/tests/unit/studioSettingsRoute.test.ts new file mode 100644 index 00000000..a3ea62bf --- /dev/null +++ b/tests/unit/studioSettingsRoute.test.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { GET, PUT } from "@/app/api/studio/route"; + +const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + +describe("studio settings route", () => { + const priorStateDir = process.env.OPENCLAW_STATE_DIR; + let tempDir: string | null = null; + + afterEach(() => { + process.env.OPENCLAW_STATE_DIR = priorStateDir; + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("GET returns default settings when missing", async () => { + tempDir = makeTempDir("studio-settings-get-default"); + process.env.OPENCLAW_STATE_DIR = tempDir; + + const response = await GET(); + const body = (await response.json()) as { + settings?: Record<string, unknown>; + localGatewayDefaults?: unknown; + }; + + expect(response.status).toBe(200); + expect(body.settings?.gateway).toBe(null); + expect(body.localGatewayDefaults ?? null).toBeNull(); + expect(body.settings?.version).toBe(1); + }); + + it("GET returns local gateway defaults from openclaw.json", async () => { + tempDir = makeTempDir("studio-settings-get-local-defaults"); + process.env.OPENCLAW_STATE_DIR = tempDir; + fs.writeFileSync( + path.join(tempDir, "openclaw.json"), + JSON.stringify({ gateway: { port: 18791, auth: { token: "local-token" } } }, null, 2), + "utf8" + ); + + const response = await GET(); + const body = (await response.json()) as { + settings?: { gateway?: { url?: string; token?: string } | null }; + localGatewayDefaults?: { url?: string; token?: string } | null; + }; + + expect(response.status).toBe(200); + expect(body.localGatewayDefaults).toEqual({ + url: "ws://localhost:18791", + token: "local-token", + }); + expect(body.settings?.gateway).toEqual({ + url: "ws://localhost:18791", + token: "local-token", + }); + }); + + it("PUT returns 400 for non-object JSON payload", async () => { + tempDir = makeTempDir("studio-settings-put-invalid"); + process.env.OPENCLAW_STATE_DIR = tempDir; + + const response = await PUT({ + json: async () => "nope", + } as unknown as Request); + const body = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(typeof body.error).toBe("string"); + expect(body.error?.length).toBeGreaterThan(0); + }); + + it("PUT persists a patch and GET returns merged settings", async () => { + tempDir = makeTempDir("studio-settings-put-persist"); + process.env.OPENCLAW_STATE_DIR = tempDir; + + const patch = { + gateway: { url: "ws://example.test:1234", token: "t" }, + }; + + const putResponse = await PUT({ + json: async () => patch, + } as unknown as Request); + expect(putResponse.status).toBe(200); + + const getResponse = await GET(); + const body = (await getResponse.json()) as { + settings?: { gateway?: { url?: string; token?: string } | null }; + }; + + expect(getResponse.status).toBe(200); + expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "t" }); + + const settingsPath = path.join(tempDir, "openclaw-studio", "settings.json"); + expect(fs.existsSync(settingsPath)).toBe(true); + const raw = fs.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null }; + expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" }); + }); +}); diff --git a/tests/unit/studioSetupPaths.test.ts b/tests/unit/studioSetupPaths.test.ts new file mode 100644 index 00000000..b59f6574 --- /dev/null +++ b/tests/unit/studioSetupPaths.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment node + +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("studio setup paths", () => { + it("resolves settings path under OPENCLAW_STATE_DIR when set", async () => { + const { resolveStudioSettingsPath } = await import("../../server/studio-settings"); + const settingsPath = resolveStudioSettingsPath({ + OPENCLAW_STATE_DIR: "/tmp/openclaw-state", + } as unknown as NodeJS.ProcessEnv); + expect(settingsPath).toBe("/tmp/openclaw-state/openclaw-studio/settings.json"); + }); + + it("resolves settings path under ~/.openclaw by default", async () => { + const { resolveStudioSettingsPath } = await import("../../server/studio-settings"); + const settingsPath = resolveStudioSettingsPath({} as NodeJS.ProcessEnv); + expect(settingsPath).toBe( + path.join(os.homedir(), ".openclaw", "openclaw-studio", "settings.json") + ); + }); +}); diff --git a/tests/unit/studioUpstreamGatewaySettings.test.ts b/tests/unit/studioUpstreamGatewaySettings.test.ts new file mode 100644 index 00000000..3f5d9410 --- /dev/null +++ b/tests/unit/studioUpstreamGatewaySettings.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); + +describe("server studio upstream gateway settings", () => { + const priorStateDir = process.env.OPENCLAW_STATE_DIR; + let tempDir: string | null = null; + + afterEach(() => { + process.env.OPENCLAW_STATE_DIR = priorStateDir; + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + it("falls back to openclaw.json token/port when studio settings are missing", async () => { + tempDir = makeTempDir("studio-upstream-openclaw-defaults"); + process.env.OPENCLAW_STATE_DIR = tempDir; + + fs.writeFileSync( + path.join(tempDir, "openclaw.json"), + JSON.stringify({ gateway: { port: 18790, auth: { token: "tok" } } }, null, 2), + "utf8" + ); + + const { loadUpstreamGatewaySettings } = await import("../../server/studio-settings"); + const settings = loadUpstreamGatewaySettings(process.env); + expect(settings.url).toBe("ws://localhost:18790"); + expect(settings.token).toBe("tok"); + }); + + it("keeps a configured url and fills token from openclaw.json when missing", async () => { + tempDir = makeTempDir("studio-upstream-url-keep"); + process.env.OPENCLAW_STATE_DIR = tempDir; + + fs.mkdirSync(path.join(tempDir, "openclaw-studio"), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, "openclaw-studio", "settings.json"), + JSON.stringify({ gateway: { url: "ws://gateway.example:18789", token: "" } }, null, 2), + "utf8" + ); + fs.writeFileSync( + path.join(tempDir, "openclaw.json"), + JSON.stringify({ gateway: { port: 18789, auth: { token: "tok-local" } } }, null, 2), + "utf8" + ); + + const { loadUpstreamGatewaySettings } = await import("../../server/studio-settings"); + const settings = loadUpstreamGatewaySettings(process.env); + expect(settings.url).toBe("ws://gateway.example:18789"); + expect(settings.token).toBe("tok-local"); + }); +}); diff --git a/tests/unit/themeToggle.test.ts b/tests/unit/themeToggle.test.ts new file mode 100644 index 00000000..313ec40d --- /dev/null +++ b/tests/unit/themeToggle.test.ts @@ -0,0 +1,53 @@ +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { ThemeToggle } from "@/components/theme-toggle"; + +const buildMatchMedia = (matches: boolean) => + vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + +describe("ThemeToggle", () => { + beforeEach(() => { + document.documentElement.classList.remove("dark"); + window.localStorage.clear(); + vi.stubGlobal("matchMedia", buildMatchMedia(false)); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("applies and persists theme when toggled", () => { + render(createElement(ThemeToggle)); + + fireEvent.click(screen.getByRole("button", { name: "Switch to dark mode" })); + expect(document.documentElement).toHaveClass("dark"); + expect(window.localStorage.getItem("theme")).toBe("dark"); + + fireEvent.click(screen.getByRole("button", { name: "Switch to light mode" })); + expect(document.documentElement).not.toHaveClass("dark"); + expect(window.localStorage.getItem("theme")).toBe("light"); + }); + + it("reads and applies stored theme on mount", async () => { + window.localStorage.setItem("theme", "dark"); + + render(createElement(ThemeToggle)); + + await waitFor(() => { + expect(document.documentElement).toHaveClass("dark"); + }); + expect(screen.getByRole("button", { name: "Switch to light mode" })).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/transcript.test.ts b/tests/unit/transcript.test.ts new file mode 100644 index 00000000..91cdd83f --- /dev/null +++ b/tests/unit/transcript.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, it } from "vitest"; + +import { + buildOutputLinesFromTranscriptEntries, + createTranscriptEntryFromLine, + mergeTranscriptEntriesWithHistory, + sortTranscriptEntries, + type TranscriptEntry, +} from "@/features/agents/state/transcript"; + +const createEntry = (params: { + line: string; + sessionKey?: string; + source: "local-send" | "runtime-chat" | "runtime-agent" | "history" | "legacy"; + sequence: number; + timestampMs?: number; + runId?: string | null; + role?: "user" | "assistant" | "tool" | "system" | "other"; + kind?: "meta" | "user" | "assistant" | "thinking" | "tool"; + confirmed?: boolean; + entryId?: string; +}): TranscriptEntry => { + const entry = createTranscriptEntryFromLine({ + line: params.line, + sessionKey: params.sessionKey ?? "agent:agent-1:studio:test-session", + source: params.source, + sequenceKey: params.sequence, + timestampMs: params.timestampMs, + runId: params.runId, + role: params.role, + kind: params.kind, + confirmed: params.confirmed, + entryId: params.entryId, + }); + if (!entry) { + throw new Error("Expected transcript entry"); + } + return entry; +}; + +describe("transcript", () => { + it("orders local user turns before assistant text at equal timestamps", () => { + const entries = sortTranscriptEntries([ + createEntry({ + line: "assistant reply", + source: "runtime-chat", + sequence: 2, + timestampMs: 1000, + role: "assistant", + kind: "assistant", + }), + createEntry({ + line: "> hello", + source: "local-send", + sequence: 1, + timestampMs: 1000, + role: "user", + kind: "user", + }), + ]); + + expect(buildOutputLinesFromTranscriptEntries(entries)).toEqual(["> hello", "assistant reply"]); + }); + + it("keeps sequence order when only one entry has a timestamp", () => { + const entries = sortTranscriptEntries([ + createEntry({ + line: "assistant reply", + source: "runtime-chat", + sequence: 1, + role: "assistant", + kind: "assistant", + }), + createEntry({ + line: "> hello", + source: "local-send", + sequence: 2, + timestampMs: 1_000, + role: "user", + kind: "user", + }), + ]); + + expect(buildOutputLinesFromTranscriptEntries(entries)).toEqual([ + "assistant reply", + "> hello", + ]); + }); + + it("keeps sequence order when entries share the same timestamp", () => { + const entries = sortTranscriptEntries([ + createEntry({ + line: "assistant reply", + source: "runtime-chat", + sequence: 1, + timestampMs: 1_000, + role: "assistant", + kind: "assistant", + }), + createEntry({ + line: "> hello", + source: "local-send", + sequence: 2, + timestampMs: 1_000, + role: "user", + kind: "user", + }), + ]); + + expect(buildOutputLinesFromTranscriptEntries(entries)).toEqual([ + "assistant reply", + "> hello", + ]); + }); + + it("keeps assistant thinking blocks together when timestamps tie", () => { + const entries = sortTranscriptEntries([ + createEntry({ + line: "_plan_", + source: "runtime-chat", + sequence: 1, + timestampMs: 2_000, + role: "assistant", + kind: "thinking", + }), + createEntry({ + line: "answer", + source: "runtime-chat", + sequence: 2, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + }), + createEntry({ + line: "> next question", + source: "local-send", + sequence: 3, + timestampMs: 2_000, + role: "user", + kind: "user", + }), + ]); + + expect(buildOutputLinesFromTranscriptEntries(entries)).toEqual([ + "_plan_", + "answer", + "> next question", + ]); + }); + + it("merges history entries by confirming optimistic local entries", () => { + const existing = [ + createEntry({ + line: "> hello", + source: "local-send", + sequence: 1, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: false, + }), + createEntry({ + line: "assistant reply", + source: "runtime-chat", + sequence: 2, + timestampMs: 1500, + role: "assistant", + kind: "assistant", + confirmed: false, + }), + ]; + + const history = [ + createEntry({ + line: "> hello", + source: "history", + sequence: 10, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:hello", + }), + createEntry({ + line: "assistant reply", + source: "history", + sequence: 11, + timestampMs: 1500, + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "history:reply", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(merged.entries).toHaveLength(2); + expect(merged.confirmedCount).toBe(2); + expect(merged.mergedCount).toBe(0); + expect(merged.entries.every((entry) => entry.confirmed)).toBe(true); + }); + + it("reconciles history replay against an already confirmed runtime assistant entry", () => { + const existing = [ + createEntry({ + line: "previous assistant answer", + source: "runtime-chat", + sequence: 20, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + runId: "run-previous", + confirmed: true, + entryId: "run:run-previous:assistant:final", + }), + ]; + + const history = [ + createEntry({ + line: "previous assistant answer", + source: "history", + sequence: 50, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "history:assistant:previous", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(buildOutputLinesFromTranscriptEntries(merged.entries)).toEqual([ + "previous assistant answer", + ]); + expect(merged.entries).toHaveLength(1); + expect(merged.mergedCount).toBe(0); + expect(merged.confirmedCount).toBe(1); + }); + + it("matches each existing assistant candidate at most once per merge pass", () => { + const existing = [ + createEntry({ + line: "same assistant answer", + source: "runtime-chat", + sequence: 20, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + runId: "run-previous", + confirmed: true, + entryId: "run:run-previous:assistant:final", + }), + ]; + + const history = [ + createEntry({ + line: "same assistant answer", + source: "history", + sequence: 50, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "history:assistant:1", + }), + createEntry({ + line: "same assistant answer", + source: "history", + sequence: 51, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "history:assistant:2", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(buildOutputLinesFromTranscriptEntries(merged.entries)).toEqual([ + "same assistant answer", + "same assistant answer", + ]); + expect(merged.entries).toHaveLength(2); + expect(merged.confirmedCount).toBe(1); + expect(merged.mergedCount).toBe(1); + }); + + it("keeps repeated identical messages as separate entries", () => { + const existing = [ + createEntry({ + line: "> ping", + source: "local-send", + sequence: 1, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:1", + }), + createEntry({ + line: "> ping", + source: "local-send", + sequence: 2, + timestampMs: 3000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:2", + }), + ]; + + const history = [ + createEntry({ + line: "> ping", + source: "history", + sequence: 10, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:1", + }), + createEntry({ + line: "> ping", + source: "history", + sequence: 11, + timestampMs: 3000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:2", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(merged.entries).toHaveLength(2); + expect(buildOutputLinesFromTranscriptEntries(merged.entries)).toEqual(["> ping", "> ping"]); + expect(merged.entries[0]?.timestampMs).toBe(1000); + expect(merged.entries[1]?.timestampMs).toBe(3000); + }); + + it("reports conflicts when multiple optimistic candidates are possible", () => { + const existing = [ + createEntry({ + line: "> hello", + source: "local-send", + sequence: 1, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:a", + }), + createEntry({ + line: "> hello", + source: "local-send", + sequence: 2, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:b", + }), + ]; + + const history = [ + createEntry({ + line: "> hello", + source: "history", + sequence: 10, + timestampMs: 1000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:hello", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(merged.conflictCount).toBe(1); + expect(merged.entries).toHaveLength(2); + const confirmed = merged.entries.filter((entry) => entry.confirmed); + expect(confirmed).toHaveLength(1); + }); + + it("matches history entries even when local and gateway clocks are far apart", () => { + const existing = [ + createEntry({ + line: "> hello", + source: "local-send", + sequence: 1, + timestampMs: 1_000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:hello", + }), + ]; + + const history = [ + createEntry({ + line: "> hello", + source: "history", + sequence: 10, + timestampMs: 600_000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:hello", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(merged.entries).toHaveLength(1); + expect(merged.entries[0]?.confirmed).toBe(true); + expect(merged.entries[0]?.timestampMs).toBe(600_000); + }); + + it("prefers canonical history timestamps to preserve final message order", () => { + const existing = [ + createEntry({ + line: "> hello", + source: "local-send", + sequence: 1, + timestampMs: 10_000, + role: "user", + kind: "user", + confirmed: false, + entryId: "local:user", + }), + createEntry({ + line: "assistant reply", + source: "runtime-chat", + sequence: 2, + timestampMs: 5_000, + role: "assistant", + kind: "assistant", + confirmed: false, + entryId: "local:assistant", + }), + ]; + + const history = [ + createEntry({ + line: "> hello", + source: "history", + sequence: 10, + timestampMs: 1_000, + role: "user", + kind: "user", + confirmed: true, + entryId: "history:user", + }), + createEntry({ + line: "assistant reply", + source: "history", + sequence: 11, + timestampMs: 2_000, + role: "assistant", + kind: "assistant", + confirmed: true, + entryId: "history:assistant", + }), + ]; + + const merged = mergeTranscriptEntriesWithHistory({ + existingEntries: existing, + historyEntries: history, + }); + + expect(buildOutputLinesFromTranscriptEntries(merged.entries)).toEqual([ + "> hello", + "assistant reply", + ]); + expect(merged.entries[0]?.timestampMs).toBe(1_000); + expect(merged.entries[1]?.timestampMs).toBe(2_000); + }); +}); diff --git a/tests/unit/useAgentSettingsMutationController.test.ts b/tests/unit/useAgentSettingsMutationController.test.ts new file mode 100644 index 00000000..16fc99c6 --- /dev/null +++ b/tests/unit/useAgentSettingsMutationController.test.ts @@ -0,0 +1,908 @@ +import { createElement, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, waitFor } from "@testing-library/react"; + +import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation"; +import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder"; +import type { CronRunResult } from "@/lib/cron/types"; +import type { MutationBlockState } from "@/features/agents/operations/mutationLifecycleWorkflow"; + +import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController"; +import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; +import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation"; +import { updateAgentPermissionsViaStudio } from "@/features/agents/operations/agentPermissionsOperation"; +import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow"; +import { runCronJobNow, removeCronJob } from "@/lib/cron/types"; +import { shouldAwaitDisconnectRestartForRemoteMutation } from "@/lib/gateway/gatewayReloadMode"; +import { + readGatewayAgentSkillsAllowlist, + updateGatewayAgentSkillsAllowlist, +} from "@/lib/gateway/agentConfig"; +import { removeSkillFromGateway } from "@/lib/skills/remove"; +import { installSkill, loadAgentSkillStatus, updateSkill } from "@/lib/skills/types"; + +let restartBlockHookParams: + | { + block: MutationBlockState | null; + onTimeout: () => void; + onRestartComplete: ( + block: MutationBlockState, + ctx: { isCancelled: () => boolean } + ) => void | Promise<void>; + } + | null = null; + +vi.mock("@/features/agents/operations/useGatewayRestartBlock", () => ({ + useGatewayRestartBlock: (params: { + block: MutationBlockState | null; + onTimeout: () => void; + onRestartComplete: ( + block: MutationBlockState, + ctx: { isCancelled: () => boolean } + ) => void | Promise<void>; + }) => { + restartBlockHookParams = { + block: params.block, + onTimeout: params.onTimeout, + onRestartComplete: params.onRestartComplete, + }; + }, +})); + +vi.mock("@/features/agents/operations/deleteAgentOperation", () => ({ + deleteAgentViaStudio: vi.fn(), +})); + +vi.mock("@/features/agents/operations/cronCreateOperation", () => ({ + performCronCreateFlow: vi.fn(), +})); + +vi.mock("@/features/agents/operations/agentPermissionsOperation", async () => { + const actual = await vi.importActual< + typeof import("@/features/agents/operations/agentPermissionsOperation") + >("@/features/agents/operations/agentPermissionsOperation"); + return { + ...actual, + updateAgentPermissionsViaStudio: vi.fn(), + }; +}); + +vi.mock("@/features/agents/operations/mutationLifecycleWorkflow", async () => { + const actual = await vi.importActual< + typeof import("@/features/agents/operations/mutationLifecycleWorkflow") + >("@/features/agents/operations/mutationLifecycleWorkflow"); + return { + ...actual, + runAgentConfigMutationLifecycle: vi.fn(), + }; +}); + +vi.mock("@/lib/cron/types", async () => { + const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types"); + return { + ...actual, + runCronJobNow: vi.fn(), + removeCronJob: vi.fn(), + listCronJobs: vi.fn(async () => ({ jobs: [] })), + }; +}); + +vi.mock("@/lib/gateway/gatewayReloadMode", () => ({ + shouldAwaitDisconnectRestartForRemoteMutation: vi.fn(async () => false), +})); + +vi.mock("@/lib/gateway/agentConfig", async () => { + const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>( + "@/lib/gateway/agentConfig" + ); + return { + ...actual, + readGatewayAgentSkillsAllowlist: vi.fn(async () => undefined), + updateGatewayAgentSkillsAllowlist: vi.fn(async () => undefined), + }; +}); + +vi.mock("@/lib/skills/types", () => ({ + loadAgentSkillStatus: vi.fn(async () => ({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + })), + installSkill: vi.fn(async () => ({ + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + })), + updateSkill: vi.fn(async () => ({ + ok: true, + skillKey: "browser", + config: {}, + })), +})); + +vi.mock("@/lib/skills/remove", () => ({ + removeSkillFromGateway: vi.fn(async () => ({ + removed: true, + removedPath: "/tmp/workspace/skills/browser", + source: "openclaw-workspace", + })), +})); + +type ControllerValue = ReturnType<typeof useAgentSettingsMutationController>; + +const draft: AgentPermissionsDraft = { + commandMode: "ask", + webAccess: true, + fileTools: false, +}; + +const createCronDraft = (): CronCreateDraft => ({ + templateId: "custom", + name: "Nightly sync", + taskText: "Sync project status.", + scheduleKind: "every", + everyAmount: 30, + everyUnit: "minutes", + deliveryMode: "announce", + deliveryChannel: "last", +}); + +const renderController = (overrides?: Partial<Parameters<typeof useAgentSettingsMutationController>[0]>) => { + const setError = vi.fn(); + const clearInspectSidebar = vi.fn(); + const setInspectSidebarCapabilities = vi.fn(); + const dispatchUpdateAgent = vi.fn(); + const setMobilePaneChat = vi.fn(); + const loadAgents = vi.fn(async () => undefined); + const refreshGatewayConfigSnapshot = vi.fn(async () => null); + const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => { + await run(); + }); + const client = { + call: vi.fn(async () => ({})), + }; + + const params: Parameters<typeof useAgentSettingsMutationController>[0] = { + client: client as never, + status: "connected", + isLocalGateway: false, + agents: [{ agentId: "agent-1", name: "Agent One", sessionKey: "session-1" }] as never, + hasCreateBlock: false, + enqueueConfigMutation, + gatewayConfigSnapshot: null, + settingsRouteActive: false, + inspectSidebarAgentId: null, + inspectSidebarTab: null, + loadAgents, + refreshGatewayConfigSnapshot, + clearInspectSidebar, + setInspectSidebarCapabilities, + dispatchUpdateAgent, + setMobilePaneChat, + setError, + ...(overrides ?? {}), + }; + + const valueRef: { current: ControllerValue | null } = { current: null }; + const Probe = ({ onValue }: { onValue: (next: ControllerValue) => void }) => { + const value = useAgentSettingsMutationController(params); + useEffect(() => { + onValue(value); + }, [onValue, value]); + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + render( + createElement(Probe, { + onValue: (next) => { + valueRef.current = next; + }, + }) + ); + + return { + getValue: () => { + if (!valueRef.current) throw new Error("hook value unavailable"); + return valueRef.current; + }, + setError, + clearInspectSidebar, + setInspectSidebarCapabilities, + dispatchUpdateAgent, + setMobilePaneChat, + loadAgents, + refreshGatewayConfigSnapshot, + enqueueConfigMutation, + }; +}; + +describe("useAgentSettingsMutationController", () => { + const mockedDeleteAgentViaStudio = vi.mocked(deleteAgentViaStudio); + const mockedPerformCronCreateFlow = vi.mocked(performCronCreateFlow); + const mockedRunCronJobNow = vi.mocked(runCronJobNow); + const mockedRemoveCronJob = vi.mocked(removeCronJob); + const mockedRunLifecycle = vi.mocked(runAgentConfigMutationLifecycle); + const mockedUpdateAgentPermissions = vi.mocked(updateAgentPermissionsViaStudio); + const mockedShouldAwaitRemoteRestart = vi.mocked(shouldAwaitDisconnectRestartForRemoteMutation); + const mockedReadGatewayAgentSkillsAllowlist = vi.mocked(readGatewayAgentSkillsAllowlist); + const mockedUpdateGatewayAgentSkillsAllowlist = vi.mocked(updateGatewayAgentSkillsAllowlist); + const mockedLoadAgentSkillStatus = vi.mocked(loadAgentSkillStatus); + const mockedInstallSkill = vi.mocked(installSkill); + const mockedRemoveSkillFromGateway = vi.mocked(removeSkillFromGateway); + const mockedUpdateSkill = vi.mocked(updateSkill); + + beforeEach(() => { + restartBlockHookParams = null; + mockedDeleteAgentViaStudio.mockReset(); + mockedPerformCronCreateFlow.mockReset(); + mockedRunCronJobNow.mockReset(); + mockedRemoveCronJob.mockReset(); + mockedRunLifecycle.mockReset(); + mockedUpdateAgentPermissions.mockReset(); + mockedShouldAwaitRemoteRestart.mockReset(); + mockedReadGatewayAgentSkillsAllowlist.mockReset(); + mockedUpdateGatewayAgentSkillsAllowlist.mockReset(); + mockedLoadAgentSkillStatus.mockReset(); + mockedInstallSkill.mockReset(); + mockedRemoveSkillFromGateway.mockReset(); + mockedUpdateSkill.mockReset(); + mockedShouldAwaitRemoteRestart.mockResolvedValue(false); + mockedReadGatewayAgentSkillsAllowlist.mockResolvedValue(undefined); + mockedUpdateGatewayAgentSkillsAllowlist.mockResolvedValue(undefined); + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + mockedInstallSkill.mockResolvedValue({ + ok: true, + message: "Installed", + stdout: "", + stderr: "", + code: 0, + }); + mockedRemoveSkillFromGateway.mockResolvedValue({ + removed: true, + removedPath: "/tmp/workspace/skills/browser", + source: "openclaw-workspace", + }); + mockedUpdateSkill.mockResolvedValue({ + ok: true, + skillKey: "browser", + config: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("delete_denied_by_guard_does_not_run_delete_side_effect", async () => { + const ctx = renderController({ status: "disconnected" }); + + await act(async () => { + await ctx.getValue().handleDeleteAgent("agent-1"); + }); + + expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled(); + expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled(); + }); + + it("delete_cancelled_by_confirmation_does_not_run_delete_side_effect", async () => { + vi.spyOn(window, "confirm").mockReturnValue(false); + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleDeleteAgent("agent-1"); + }); + + expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled(); + expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled(); + }); + + it("reserved_main_delete_sets_error_and_skips_enqueue", async () => { + const ctx = renderController({ + agents: [{ agentId: "main", name: "Main", sessionKey: "main-session" }] as never, + }); + + await act(async () => { + await ctx.getValue().handleDeleteAgent("main"); + }); + + expect(ctx.setError).toHaveBeenCalledWith("The main agent cannot be deleted."); + expect(ctx.enqueueConfigMutation).not.toHaveBeenCalled(); + expect(mockedDeleteAgentViaStudio).not.toHaveBeenCalled(); + }); + + it("cron_delete_is_denied_while_run_busy_without_changing_error_state", async () => { + mockedRunCronJobNow.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { ok: true, ran: true } satisfies CronRunResult; + }); + const ctx = renderController(); + + await act(async () => { + void ctx.getValue().handleRunCronJob("agent-1", "job-running"); + }); + await waitFor(() => { + expect(ctx.getValue().cronRunBusyJobId).toBe("job-running"); + }); + + await act(async () => { + await ctx.getValue().handleDeleteCronJob("agent-1", "job-delete"); + }); + + expect(mockedRemoveCronJob).not.toHaveBeenCalled(); + expect(ctx.getValue().settingsCronError).toBeNull(); + }); + + it("allowed_rename_and_delete_delegate_to_lifecycle_runner", async () => { + vi.spyOn(window, "confirm").mockReturnValue(true); + mockedRunLifecycle.mockImplementation(async ({ deps }) => { + deps.setQueuedBlock(); + deps.setMutatingBlock(); + await deps.executeMutation(); + deps.clearBlock(); + return true; + }); + mockedDeleteAgentViaStudio.mockResolvedValue({ trashed: { trashDir: "", moved: [] }, restored: null }); + + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleRenameAgent("agent-1", "Renamed"); + }); + await act(async () => { + await ctx.getValue().handleDeleteAgent("agent-1"); + }); + + expect(mockedRunLifecycle).toHaveBeenCalledTimes(2); + expect(mockedDeleteAgentViaStudio).toHaveBeenCalledTimes(1); + }); + + it("permissions_update_keeps_load_refresh_and_focus_side_effects", async () => { + mockedUpdateAgentPermissions.mockResolvedValue(undefined); + const callOrder: string[] = []; + const ctx = renderController({ + loadAgents: vi.fn(async () => { + callOrder.push("loadAgents"); + }), + refreshGatewayConfigSnapshot: vi.fn(async () => { + callOrder.push("refresh"); + return null; + }), + }); + + await act(async () => { + await ctx.getValue().handleUpdateAgentPermissions("agent-1", draft); + }); + + expect(mockedUpdateAgentPermissions).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + sessionKey: "session-1", + draft, + }) + ); + expect(callOrder).toEqual(["loadAgents", "refresh"]); + expect(ctx.setInspectSidebarCapabilities).toHaveBeenCalledWith("agent-1"); + expect(ctx.setMobilePaneChat).toHaveBeenCalled(); + }); + + it("exposes_restart_block_state_and_timeout_completion_handlers", async () => { + mockedRunLifecycle.mockImplementation(async ({ deps }) => { + deps.setQueuedBlock(); + deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false }); + return true; + }); + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleRenameAgent("agent-1", "Renamed"); + }); + await waitFor(() => { + expect(ctx.getValue().hasRenameMutationBlock).toBe(true); + expect(ctx.getValue().restartingMutationBlock?.phase).toBe("awaiting-restart"); + expect(ctx.getValue().hasRestartBlockInProgress).toBe(true); + }); + + await waitFor(() => { + expect(restartBlockHookParams?.block).not.toBeNull(); + }); + + await act(async () => { + restartBlockHookParams?.onTimeout(); + }); + expect(ctx.setError).toHaveBeenCalledWith("Gateway restart timed out after renaming the agent."); + + mockedRunLifecycle.mockImplementation(async ({ deps }) => { + deps.setQueuedBlock(); + deps.patchBlockAwaitingRestart({ phase: "awaiting-restart", sawDisconnect: false }); + return true; + }); + await act(async () => { + await ctx.getValue().handleRenameAgent("agent-1", "Renamed Again"); + }); + await waitFor(() => { + expect(restartBlockHookParams?.block?.phase).toBe("awaiting-restart"); + }); + + await act(async () => { + await restartBlockHookParams?.onRestartComplete( + restartBlockHookParams.block as MutationBlockState, + { isCancelled: () => false } + ); + }); + expect(ctx.loadAgents).toHaveBeenCalled(); + expect(ctx.setMobilePaneChat).toHaveBeenCalled(); + await waitFor(() => { + expect(ctx.getValue().restartingMutationBlock).toBeNull(); + }); + }); + + it("create_cron_handler_delegates_to_create_operation", async () => { + mockedPerformCronCreateFlow.mockResolvedValue("created"); + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleCreateCronJob("agent-1", createCronDraft()); + }); + + expect(mockedPerformCronCreateFlow).toHaveBeenCalledTimes(1); + }); + + it("loads_skills_when_settings_skills_tab_is_active", async () => { + const report = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + mockedLoadAgentSkillStatus.mockResolvedValue(report); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1"); + expect(ctx.getValue().settingsSkillsReport).toEqual(report); + }); + }); + + it("loads_skills_when_settings_system_tab_is_active", async () => { + const report = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }; + mockedLoadAgentSkillStatus.mockResolvedValue(report); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "system", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledWith(expect.anything(), "agent-1"); + expect(ctx.getValue().settingsSkillsReport).toEqual(report); + }); + }); + + it("use_all_and_disable_all_skills_write_via_config_queue", async () => { + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleUseAllSkills("agent-1"); + await ctx.getValue().handleDisableAllSkills("agent-1"); + }); + + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-agent-skills" }) + ); + expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ agentId: "agent-1", mode: "all" }) + ); + expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ agentId: "agent-1", mode: "none" }) + ); + expect(ctx.loadAgents).toHaveBeenCalledTimes(2); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(2); + expect(mockedLoadAgentSkillStatus).not.toHaveBeenCalled(); + }); + + it("sets_selected_skills_allowlist_via_config_queue", async () => { + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" github ", "slack", "github"]); + }); + + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-agent-skills" }) + ); + expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + mode: "allowlist", + skillNames: ["github", "slack"], + }) + ); + expect(ctx.loadAgents).toHaveBeenCalledTimes(1); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + }); + + it("rejects_empty_selected_skills_allowlist_before_gateway_call", async () => { + const ctx = renderController(); + + await act(async () => { + await ctx.getValue().handleSetSkillsAllowlist("agent-1", [" ", ""]); + }); + + expect(mockedUpdateGatewayAgentSkillsAllowlist).not.toHaveBeenCalled(); + expect(ctx.getValue().settingsSkillsError).toBe( + "Cannot set selected skills mode: choose at least one skill." + ); + }); + + it("installs_skill_dependencies_with_per_skill_busy_and_message_state", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser"); + }); + + expect(mockedInstallSkill).toHaveBeenCalledWith(expect.anything(), { + name: "browser", + installId: "install-browser", + timeoutMs: 120000, + }); + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-skill-setup" }) + ); + expect(ctx.getValue().settingsSkillsBusyKey).toBeNull(); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "success", + message: "Installed", + }); + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2); + }); + + it("refreshes_skills_after_system_setup_mutation_when_system_tab_is_active", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "system", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await ctx.getValue().handleInstallSkill("agent-1", "browser", "browser", "install-browser"); + }); + + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(2); + }); + + it("removes_skill_files_with_per_skill_busy_and_message_state", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [ + { + name: "browser", + description: "", + source: "openclaw-workspace", + bundled: false, + filePath: "/tmp/workspace/skills/browser/SKILL.md", + baseDir: "/tmp/workspace/skills/browser", + skillKey: "browser", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + }, + ], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(ctx.getValue().settingsSkillsReport?.workspaceDir).toBe("/tmp/workspace"); + }); + + await act(async () => { + await ctx.getValue().handleRemoveSkill("agent-1", { + skillKey: "browser", + source: "openclaw-workspace", + baseDir: "/tmp/workspace/skills/browser", + }); + }); + + expect(mockedRemoveSkillFromGateway).toHaveBeenCalledWith({ + skillKey: "browser", + source: "openclaw-workspace", + baseDir: "/tmp/workspace/skills/browser", + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + }); + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-skill-setup" }) + ); + expect(ctx.getValue().settingsSkillsBusyKey).toBeNull(); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "success", + message: "Skill removed from gateway files", + }); + }); + + it("saves_skill_api_key_via_config_queue_and_refreshes_skills", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123"); + }); + await act(async () => { + await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser"); + }); + + expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), { + skillKey: "browser", + apiKey: "token-123", + }); + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-skill-setup" }) + ); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123"); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "success", + message: "API key saved", + }); + }); + + it("toggles_global_skill_enabled_via_skill_update", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + await ctx.getValue().handleSetSkillGlobalEnabled("agent-1", "browser", false); + }); + + expect(mockedUpdateSkill).toHaveBeenCalledWith(expect.anything(), { + skillKey: "browser", + enabled: false, + }); + expect(ctx.enqueueConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ kind: "update-skill-setup" }) + ); + expect(ctx.refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "success", + message: "Skill disabled globally", + }); + }); + + it("preserves_api_key_draft_and_sets_error_message_when_save_fails", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + mockedUpdateSkill.mockRejectedValue(new Error("invalid key")); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + ctx.getValue().handleSkillApiKeyDraftChange("browser", "token-123"); + }); + await act(async () => { + await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser"); + }); + + expect(ctx.getValue().settingsSkillApiKeyDrafts.browser).toBe("token-123"); + expect(ctx.getValue().settingsSkillsError).toBe("invalid key"); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "error", + message: "invalid key", + }); + }); + + it("rejects_empty_api_key_before_gateway_call", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(mockedLoadAgentSkillStatus).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + ctx.getValue().handleSkillApiKeyDraftChange("browser", " "); + }); + await act(async () => { + await ctx.getValue().handleSaveSkillApiKey("agent-1", "browser"); + }); + + expect(mockedUpdateSkill).not.toHaveBeenCalled(); + expect(ctx.getValue().settingsSkillsError).toBe("API key cannot be empty."); + expect(ctx.getValue().settingsSkillMessages.browser).toEqual({ + kind: "error", + message: "API key cannot be empty.", + }); + }); + + it("disabling_one_skill_from_implicit_all_writes_explicit_allowlist", async () => { + mockedLoadAgentSkillStatus.mockResolvedValue({ + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [ + { + name: "github", + description: "", + source: "shared", + bundled: false, + filePath: "/tmp/skills/github/SKILL.md", + baseDir: "/tmp/skills/github", + skillKey: "github", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + }, + { + name: "browser", + description: "", + source: "bundled", + bundled: true, + filePath: "/tmp/skills/browser/SKILL.md", + baseDir: "/tmp/skills/browser", + skillKey: "browser", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + }, + { + name: "slack", + description: "", + source: "shared", + bundled: false, + filePath: "/tmp/skills/slack/SKILL.md", + baseDir: "/tmp/skills/slack", + skillKey: "slack", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: true, + requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, + configChecks: [], + install: [], + }, + { + name: "apple-notes", + description: "", + source: "openclaw-managed", + bundled: false, + filePath: "/tmp/skills/apple-notes/SKILL.md", + baseDir: "/tmp/skills/apple-notes", + skillKey: "apple-notes", + always: false, + disabled: false, + blockedByAllowlist: false, + eligible: false, + requirements: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + missing: { bins: [], anyBins: [], env: [], config: [], os: ["darwin"] }, + configChecks: [], + install: [], + }, + ], + }); + const ctx = renderController({ + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + inspectSidebarTab: "skills", + }); + + await waitFor(() => { + expect(ctx.getValue().settingsSkillsReport?.skills.length).toBe(4); + }); + + await act(async () => { + await ctx.getValue().handleSetSkillEnabled("agent-1", "browser", false); + }); + + expect(mockedReadGatewayAgentSkillsAllowlist).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "agent-1" }) + ); + expect(mockedUpdateGatewayAgentSkillsAllowlist).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentId: "agent-1", + mode: "allowlist", + skillNames: ["github", "slack"], + }) + ); + }); +}); diff --git a/tests/unit/useChatInteractionController.test.ts b/tests/unit/useChatInteractionController.test.ts new file mode 100644 index 00000000..dfbd01b0 --- /dev/null +++ b/tests/unit/useChatInteractionController.test.ts @@ -0,0 +1,551 @@ +import { createElement, useEffect } from "react"; +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController"; +import type { AgentState } from "@/features/agents/state/store"; +import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation"; + +vi.mock("@/features/agents/operations/chatSendOperation", () => ({ + sendChatMessageViaStudio: vi.fn(async () => undefined), +})); + +const createAgent = (overrides?: Partial<AgentState>): AgentState => { + const base: AgentState = { + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:studio:test-session", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: "run-1", + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + queuedMessages: [], + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + }; + const merged = { ...base, ...(overrides ?? {}) }; + return { + ...merged, + historyFetchLimit: merged.historyFetchLimit ?? null, + historyFetchedCount: merged.historyFetchedCount ?? null, + historyMaybeTruncated: merged.historyMaybeTruncated ?? false, + }; +}; + +type ControllerValue = ReturnType<typeof useChatInteractionController>; +type GatewayStatus = "disconnected" | "connecting" | "connected"; +type InteractionDispatchAction = + | { type: "updateAgent"; agentId: string; patch: Partial<AgentState> } + | { type: "appendOutput"; agentId: string; line: string } + | { type: "enqueueQueuedMessage"; agentId: string; message: string } + | { type: "removeQueuedMessage"; agentId: string; index: number } + | { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }; +type CallFn = (method: string, params: unknown) => Promise<unknown>; +type DispatchFn = (action: InteractionDispatchAction) => void; +type ErrorFn = (message: string) => void; +type RunTrackingFn = (runId?: string | null) => void; +type HistoryInFlightFn = (sessionKey: string) => void; +type AgentIdFn = (agentId: string) => void; +type VoidFn = () => void; + +type RenderControllerContext = { + getValue: () => ControllerValue; + unmount: () => void; + setAgents: (next: AgentState[]) => void; + call: ReturnType<typeof vi.fn<CallFn>>; + dispatch: ReturnType<typeof vi.fn<DispatchFn>>; + setError: ReturnType<typeof vi.fn<ErrorFn>>; + clearRunTracking: ReturnType<typeof vi.fn<RunTrackingFn>>; + clearHistoryInFlight: ReturnType<typeof vi.fn<HistoryInFlightFn>>; + clearSpecialUpdateMarker: ReturnType<typeof vi.fn<AgentIdFn>>; + clearSpecialLatestUpdateInFlight: ReturnType<typeof vi.fn<AgentIdFn>>; + setInspectSidebarNull: ReturnType<typeof vi.fn<VoidFn>>; + setMobilePaneChat: ReturnType<typeof vi.fn<VoidFn>>; +}; + +const renderController = ( + overrides?: Partial<{ + status: GatewayStatus; + agents: AgentState[]; + call: CallFn; + dispatch: DispatchFn; + setError: ErrorFn; + clearRunTracking: RunTrackingFn; + clearHistoryInFlight: HistoryInFlightFn; + clearSpecialUpdateMarker: AgentIdFn; + clearSpecialLatestUpdateInFlight: AgentIdFn; + setInspectSidebarNull: VoidFn; + setMobilePaneChat: VoidFn; + }> +): RenderControllerContext => { + let agents = overrides?.agents ?? [createAgent()]; + const call = vi.fn<CallFn>(overrides?.call ?? (async () => ({}))); + const dispatch = vi.fn<DispatchFn>(overrides?.dispatch ?? (() => undefined)); + const setError = vi.fn<ErrorFn>(overrides?.setError ?? (() => undefined)); + const clearRunTracking = vi.fn<RunTrackingFn>( + overrides?.clearRunTracking ?? (() => undefined) + ); + const clearHistoryInFlight = vi.fn<HistoryInFlightFn>( + overrides?.clearHistoryInFlight ?? (() => undefined) + ); + const clearSpecialUpdateMarker = vi.fn<AgentIdFn>( + overrides?.clearSpecialUpdateMarker ?? (() => undefined) + ); + const clearSpecialLatestUpdateInFlight = vi.fn<AgentIdFn>( + overrides?.clearSpecialLatestUpdateInFlight ?? (() => undefined) + ); + const setInspectSidebarNull = vi.fn<VoidFn>( + overrides?.setInspectSidebarNull ?? (() => undefined) + ); + const setMobilePaneChat = vi.fn<VoidFn>(overrides?.setMobilePaneChat ?? (() => undefined)); + + const valueRef: { current: ControllerValue | null } = { current: null }; + + const Probe = ({ + onValue, + }: { + onValue: (value: ControllerValue) => void; + }) => { + const value = useChatInteractionController({ + client: { + call, + }, + status: overrides?.status ?? "connected", + agents, + dispatch, + setError, + getAgents: () => agents, + clearRunTracking, + clearHistoryInFlight, + clearSpecialUpdateMarker, + clearSpecialLatestUpdateInFlight, + setInspectSidebarNull, + setMobilePaneChat, + }); + useEffect(() => { + onValue(value); + }, [onValue, value]); + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + const rendered = render( + createElement(Probe, { + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + + return { + getValue: () => { + if (!valueRef.current) throw new Error("controller value unavailable"); + return valueRef.current; + }, + unmount: () => { + rendered.unmount(); + }, + setAgents: (next) => { + agents = next; + rendered.rerender( + createElement(Probe, { + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + }, + call, + dispatch, + setError, + clearRunTracking, + clearHistoryInFlight, + clearSpecialUpdateMarker, + clearSpecialLatestUpdateInFlight, + setInspectSidebarNull, + setMobilePaneChat, + }; +}; + +describe("useChatInteractionController", () => { + const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio); + const originalRaf = globalThis.requestAnimationFrame; + const originalCaf = globalThis.cancelAnimationFrame; + + beforeEach(() => { + vi.useFakeTimers(); + mockedSendChatMessageViaStudio.mockReset(); + mockedSendChatMessageViaStudio.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + vi.restoreAllMocks(); + }); + + it("flushes pending draft and cancels debounce timer", async () => { + const ctx = renderController(); + + act(() => { + ctx.getValue().handleDraftChange("agent-1", "first"); + ctx.getValue().handleDraftChange("agent-1", "second"); + }); + expect(ctx.dispatch).not.toHaveBeenCalled(); + + act(() => { + ctx.getValue().flushPendingDraft("agent-1"); + }); + + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "updateAgent", + agentId: "agent-1", + patch: { draft: "second" }, + }); + + await vi.advanceTimersByTimeAsync(1000); + const draftUpdates = ctx.dispatch.mock.calls + .map(([action]: [InteractionDispatchAction]) => action) + .filter( + (action) => + action.type === "updateAgent" && + action.agentId === "agent-1" && + action.patch?.draft === "second" + ); + expect(draftUpdates).toHaveLength(1); + }); + + it("clears pending draft timer/value and live patch before send", async () => { + let queuedFrame: ((time: number) => void) | null = null; + globalThis.requestAnimationFrame = vi.fn((callback: (time: number) => void) => { + queuedFrame = callback; + return 77; + }); + globalThis.cancelAnimationFrame = vi.fn(); + + const ctx = renderController(); + + act(() => { + ctx.getValue().handleDraftChange("agent-1", "queued draft"); + ctx.getValue().queueLivePatch("agent-1", { streamText: "pending stream" }); + }); + + await act(async () => { + await ctx.getValue().handleSend("agent-1", "session-1", " hello world "); + }); + + expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + sessionKey: "session-1", + message: "hello world", + }) + ); + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(77); + + await vi.advanceTimersByTimeAsync(300); + expect( + ctx.dispatch.mock.calls.some( + ([action]: [InteractionDispatchAction]) => + action.type === "updateAgent" && + action.agentId === "agent-1" && + action.patch?.draft === "queued draft" + ) + ).toBe(false); + + if (queuedFrame) { + act(() => { + queuedFrame?.(0); + }); + } + expect( + ctx.dispatch.mock.calls.some( + ([action]: [InteractionDispatchAction]) => + action.type === "updateAgent" && + action.agentId === "agent-1" && + action.patch?.streamText === "pending stream" + ) + ).toBe(false); + }); + + it("queues messages instead of sending while the agent is running", async () => { + const ctx = renderController({ + agents: [createAgent({ status: "running", queuedMessages: [] })], + }); + + await act(async () => { + await ctx.getValue().handleSend("agent-1", "session-1", " follow up "); + }); + + expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled(); + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "enqueueQueuedMessage", + agentId: "agent-1", + message: "follow up", + }); + }); + + it("drains one queued message when an agent becomes idle", async () => { + const ctx = renderController({ + agents: [createAgent({ status: "running", queuedMessages: ["next message"] })], + }); + + act(() => { + ctx.setAgents([ + createAgent({ + status: "idle", + sessionKey: "agent:agent-1:studio:drain", + queuedMessages: ["next message"], + }), + ]); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "shiftQueuedMessage", + agentId: "agent-1", + expectedMessage: "next message", + }); + expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent-1", + sessionKey: "agent:agent-1:studio:drain", + message: "next message", + }) + ); + }); + + it("does not drain queued messages while disconnected", async () => { + const ctx = renderController({ + status: "disconnected", + agents: [createAgent({ status: "idle", queuedMessages: ["keep queued"] })], + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled(); + expect( + ctx.dispatch.mock.calls.some( + ([action]: [InteractionDispatchAction]) => action.type === "shiftQueuedMessage" + ) + ).toBe(false); + }); + + it("removes a queued message by index", () => { + const ctx = renderController({ + agents: [createAgent({ queuedMessages: ["first", "second"] })], + }); + + act(() => { + ctx.getValue().removeQueuedMessage("agent-1", 0); + }); + + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "removeQueuedMessage", + agentId: "agent-1", + index: 0, + }); + }); + + it("deduplicates stop-run while busy and clears busy state after success", async () => { + let resolveAbort: ((value?: void | PromiseLike<void>) => void) | undefined; + const abortPromise = new Promise<void>((resolve) => { + resolveAbort = resolve; + }); + const call = vi.fn(async (method: string) => { + if (method === "chat.abort") { + await abortPromise; + return {}; + } + return {}; + }); + + const ctx = renderController({ call }); + + let firstCall: Promise<void> | null = null; + act(() => { + firstCall = ctx.getValue().handleStopRun("agent-1", " session-1 "); + }); + expect(ctx.getValue().stopBusyAgentId).toBe("agent-1"); + + await act(async () => { + await ctx.getValue().handleStopRun("agent-1", "session-1"); + }); + expect(call).toHaveBeenCalledTimes(1); + + resolveAbort?.(); + await act(async () => { + await firstCall; + }); + expect(ctx.getValue().stopBusyAgentId).toBeNull(); + }); + + it("reports stop-run failures and clears busy state", async () => { + const logSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const ctx = renderController({ + call: vi.fn(async (method: string) => { + if (method === "chat.abort") { + throw new Error("abort failed"); + } + return {}; + }), + }); + + await act(async () => { + await ctx.getValue().handleStopRun("agent-1", "session-1"); + }); + + expect(ctx.setError).toHaveBeenCalledWith("abort failed"); + expect(logSpy).toHaveBeenCalledWith("abort failed"); + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: "agent-1", + line: "Stop failed: abort failed", + }); + expect(ctx.getValue().stopBusyAgentId).toBeNull(); + }); + + it("runs new-session side effects in sequence and updates agent state", async () => { + const order: string[] = []; + const call = vi.fn(async (method: string) => { + if (method === "sessions.reset") { + order.push("sessions.reset"); + } + return {}; + }); + const dispatch = vi.fn<DispatchFn>((action) => { + if (action.type === "updateAgent") { + order.push("dispatch:updateAgent"); + } + }); + const clearRunTracking = vi.fn(() => { + order.push("clearRunTracking"); + }); + const clearHistoryInFlight = vi.fn(() => { + order.push("clearHistoryInFlight"); + }); + const clearSpecialUpdateMarker = vi.fn(() => { + order.push("clearSpecialUpdateMarker"); + }); + const clearSpecialLatestUpdateInFlight = vi.fn(() => { + order.push("clearSpecialLatestUpdateInFlight"); + }); + const setInspectSidebarNull = vi.fn(() => { + order.push("setInspectSidebarNull"); + }); + const setMobilePaneChat = vi.fn(() => { + order.push("setMobilePaneChat"); + }); + + const ctx = renderController({ + call, + dispatch, + clearRunTracking, + clearHistoryInFlight, + clearSpecialUpdateMarker, + clearSpecialLatestUpdateInFlight, + setInspectSidebarNull, + setMobilePaneChat, + agents: [ + createAgent({ + agentId: "agent-1", + runId: "run-42", + sessionKey: " session-42 ", + }), + ], + }); + + await act(async () => { + await ctx.getValue().handleNewSession("agent-1"); + }); + + expect(call).toHaveBeenCalledWith("sessions.reset", { key: "session-42" }); + expect(order).toEqual([ + "sessions.reset", + "clearRunTracking", + "clearHistoryInFlight", + "clearSpecialUpdateMarker", + "clearSpecialLatestUpdateInFlight", + "dispatch:updateAgent", + "setInspectSidebarNull", + "setMobilePaneChat", + ]); + }); + + it("appends output when new-session fails", async () => { + const ctx = renderController({ + agents: [ + createAgent({ + agentId: "agent-1", + sessionKey: " ", + }), + ], + }); + + await act(async () => { + await ctx.getValue().handleNewSession("agent-1"); + }); + + expect(ctx.setError).toHaveBeenCalledWith("Missing session key for agent."); + expect(ctx.dispatch).toHaveBeenCalledWith({ + type: "appendOutput", + agentId: "agent-1", + line: "New session failed: Missing session key for agent.", + }); + }); + + it("cleans up draft timers and queued frame on unmount", async () => { + globalThis.requestAnimationFrame = vi.fn(() => 555); + globalThis.cancelAnimationFrame = vi.fn(); + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + + const ctx = renderController(); + + act(() => { + ctx.getValue().handleDraftChange("agent-1", "queued"); + ctx.getValue().queueLivePatch("agent-1", { streamText: "delta" }); + }); + + ctx.unmount(); + + expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(555); + expect(clearTimeoutSpy).toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(300); + expect( + ctx.dispatch.mock.calls.some( + ([action]: [InteractionDispatchAction]) => + action.type === "updateAgent" && + action.agentId === "agent-1" && + action.patch?.draft === "queued" + ) + ).toBe(false); + }); +}); diff --git a/tests/unit/useGatewayConfigSyncController.test.ts b/tests/unit/useGatewayConfigSyncController.test.ts new file mode 100644 index 00000000..ac573912 --- /dev/null +++ b/tests/unit/useGatewayConfigSyncController.test.ts @@ -0,0 +1,372 @@ +import { createElement, useEffect, useState } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useGatewayConfigSyncController } from "@/features/agents/operations/useGatewayConfigSyncController"; +import type { GatewayModelChoice, GatewayModelPolicySnapshot } from "@/lib/gateway/models"; +import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig"; +import type { GatewayClient } from "@/lib/gateway/GatewayClient"; + +vi.mock("@/lib/gateway/agentConfig", async () => { + const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>( + "@/lib/gateway/agentConfig" + ); + return { + ...actual, + updateGatewayAgentOverrides: vi.fn(async () => undefined), + }; +}); + +type ProbeValue = { + gatewayConfigSnapshot: GatewayModelPolicySnapshot | null; + gatewayModels: GatewayModelChoice[]; + gatewayModelsError: string | null; + refreshGatewayConfigSnapshot: () => Promise<GatewayModelPolicySnapshot | null>; +}; + +type RenderControllerContext = { + getValue: () => ProbeValue; + rerenderWith: ( + overrides: Partial<{ + status: "disconnected" | "connecting" | "connected"; + settingsRouteActive: boolean; + inspectSidebarAgentId: string | null; + logError: (message: string, err: unknown) => void; + }> + ) => void; + call: ReturnType<typeof vi.fn>; + enqueueConfigMutation: ReturnType<typeof vi.fn>; + loadAgents: ReturnType<typeof vi.fn>; + logError: (message: string, err: unknown) => void; +}; + +const countMethodCalls = (callMock: ReturnType<typeof vi.fn>, method: string) => { + return callMock.mock.calls.filter(([calledMethod]) => calledMethod === method).length; +}; + +type RenderControllerParams = { + status: "disconnected" | "connecting" | "connected"; + settingsRouteActive: boolean; + inspectSidebarAgentId: string | null; + initialGatewayConfigSnapshot?: GatewayModelPolicySnapshot | null; + isDisconnectLikeError: (err: unknown) => boolean; + logError: (message: string, err: unknown) => void; +}; + +const renderController = ( + overrides?: Partial< + RenderControllerParams & { + call: ReturnType<typeof vi.fn>; + enqueueConfigMutation: ReturnType<typeof vi.fn>; + loadAgents: ReturnType<typeof vi.fn>; + } + > +): RenderControllerContext => { + const call = + overrides?.call ?? + vi.fn(async (method: string) => { + if (method === "config.get") { + return { config: {} }; + } + if (method === "models.list") { + return { models: [] }; + } + throw new Error(`Unhandled method: ${method}`); + }); + const enqueueConfigMutation = + overrides?.enqueueConfigMutation ?? + vi.fn(async ({ run }: { run: () => Promise<void> }) => { + await run(); + }); + const loadAgents = overrides?.loadAgents ?? vi.fn(async () => undefined); + const logError = (overrides?.logError ?? vi.fn()) as (message: string, err: unknown) => void; + + let currentParams: RenderControllerParams = { + status: "connected" as const, + settingsRouteActive: false, + inspectSidebarAgentId: null as string | null, + isDisconnectLikeError: overrides?.isDisconnectLikeError ?? (() => false), + logError, + ...overrides, + }; + + const valueRef: { current: ProbeValue | null } = { current: null }; + + const Probe = ({ + params, + onValue, + }: { + params: typeof currentParams; + onValue: (value: ProbeValue) => void; + }) => { + const [client] = useState(() => ({ call })); + const [gatewayConfigSnapshot, setGatewayConfigSnapshot] = useState<GatewayModelPolicySnapshot | null>( + params.initialGatewayConfigSnapshot ?? null + ); + const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]); + const [gatewayModelsError, setGatewayModelsError] = useState<string | null>(null); + + const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({ + client: client as unknown as GatewayClient, + status: params.status, + settingsRouteActive: params.settingsRouteActive, + inspectSidebarAgentId: params.inspectSidebarAgentId, + gatewayConfigSnapshot, + setGatewayConfigSnapshot, + setGatewayModels, + setGatewayModelsError, + enqueueConfigMutation: enqueueConfigMutation as (params: { + kind: "repair-sandbox-tool-allowlist"; + label: string; + run: () => Promise<void>; + }) => Promise<void>, + loadAgents: loadAgents as () => Promise<void>, + isDisconnectLikeError: params.isDisconnectLikeError, + logError: params.logError, + }); + + useEffect(() => { + onValue({ + gatewayConfigSnapshot, + gatewayModels, + gatewayModelsError, + refreshGatewayConfigSnapshot, + }); + }, [gatewayConfigSnapshot, gatewayModels, gatewayModelsError, onValue, refreshGatewayConfigSnapshot]); + + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + const rendered = render( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + + return { + getValue: () => { + if (!valueRef.current) throw new Error("controller value unavailable"); + return valueRef.current; + }, + rerenderWith: (nextOverrides) => { + currentParams = { + ...currentParams, + ...nextOverrides, + }; + rendered.rerender( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + }, + call, + enqueueConfigMutation, + loadAgents, + logError, + }; +}; + +describe("useGatewayConfigSyncController", () => { + const mockedUpdateGatewayAgentOverrides = vi.mocked(updateGatewayAgentOverrides); + + beforeEach(() => { + mockedUpdateGatewayAgentOverrides.mockReset(); + mockedUpdateGatewayAgentOverrides.mockResolvedValue(); + }); + + it("clears models, model error, and snapshot when disconnected", async () => { + const call = vi.fn(async (method: string) => { + if (method === "config.get") { + return { config: { agents: { list: [] } } }; + } + if (method === "models.list") { + return { models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }] }; + } + throw new Error(`Unhandled method: ${method}`); + }); + + const ctx = renderController({ call, status: "connected" }); + + await waitFor(() => { + expect(ctx.getValue().gatewayModels).toEqual([ + { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + ]); + }); + + ctx.rerenderWith({ status: "disconnected" }); + + await waitFor(() => { + expect(ctx.getValue().gatewayModels).toEqual([]); + expect(ctx.getValue().gatewayModelsError).toBeNull(); + expect(ctx.getValue().gatewayConfigSnapshot).toBeNull(); + }); + }); + + it("still loads models when config.get fails", async () => { + const call = vi.fn(async (method: string) => { + if (method === "config.get") { + throw new Error("config failed"); + } + if (method === "models.list") { + return { + models: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], + }; + } + throw new Error(`Unhandled method: ${method}`); + }); + const logError = vi.fn(); + const ctx = renderController({ call, logError }); + + await waitFor(() => { + expect(ctx.getValue().gatewayModels).toEqual([ + { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + ]); + }); + + expect(countMethodCalls(call, "models.list")).toBe(1); + expect(logError).toHaveBeenCalledWith("Failed to load gateway config.", expect.any(Error)); + }); + + it("captures model loading errors and clears models", async () => { + const call = vi.fn(async (method: string) => { + if (method === "config.get") { + return { config: { agents: { list: [] } } }; + } + if (method === "models.list") { + throw new Error("models unavailable"); + } + throw new Error(`Unhandled method: ${method}`); + }); + const logError = vi.fn(); + const ctx = renderController({ call, logError }); + + await waitFor(() => { + expect(ctx.getValue().gatewayModels).toEqual([]); + expect(ctx.getValue().gatewayModelsError).toBe("models unavailable"); + }); + + expect(logError).toHaveBeenCalledWith("Failed to load gateway models.", expect.any(Error)); + }); + + it("runs settings-route refresh only when inspect agent id is present", async () => { + const call = vi.fn(async (method: string) => { + if (method === "config.get") { + return { config: { agents: { list: [] } } }; + } + if (method === "models.list") { + return { models: [] }; + } + throw new Error(`Unhandled method: ${method}`); + }); + + renderController({ + call, + status: "connected", + settingsRouteActive: true, + inspectSidebarAgentId: null, + }); + + await waitFor(() => { + expect(countMethodCalls(call, "config.get")).toBe(1); + expect(countMethodCalls(call, "models.list")).toBe(1); + }); + + const callEligible = vi.fn(async (method: string) => { + if (method === "config.get") { + return { config: { agents: { list: [] } } }; + } + if (method === "models.list") { + return { models: [] }; + } + throw new Error(`Unhandled method: ${method}`); + }); + + renderController({ + call: callEligible, + status: "connected", + settingsRouteActive: true, + inspectSidebarAgentId: "agent-1", + }); + + await waitFor(() => { + expect(countMethodCalls(callEligible, "config.get")).toBeGreaterThanOrEqual(2); + expect(countMethodCalls(callEligible, "models.list")).toBe(1); + }); + }); + + it("enqueues sandbox repair once for eligible agents", async () => { + const brokenSnapshot = { + config: { + agents: { + list: [ + { + id: "agent-broken", + sandbox: { mode: "all" }, + tools: { + sandbox: { + tools: { + allow: [], + }, + }, + }, + }, + ], + }, + }, + } as unknown as GatewayModelPolicySnapshot; + + const call = vi.fn(async (method: string) => { + if (method === "config.get") { + return brokenSnapshot; + } + if (method === "models.list") { + return { models: [] }; + } + throw new Error(`Unhandled method: ${method}`); + }); + + const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => { + await run(); + }); + const loadAgents = vi.fn(async () => undefined); + + const ctx = renderController({ + call, + enqueueConfigMutation, + loadAgents, + initialGatewayConfigSnapshot: brokenSnapshot, + }); + + await waitFor(() => { + expect(enqueueConfigMutation).toHaveBeenCalledTimes(1); + expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1); + expect(loadAgents).toHaveBeenCalledTimes(1); + }); + + ctx.rerenderWith({ status: "connected" }); + await act(async () => { + await Promise.resolve(); + }); + + expect(enqueueConfigMutation).toHaveBeenCalledTimes(1); + expect(mockedUpdateGatewayAgentOverrides).toHaveBeenCalledTimes(1); + }); + + it("returns null when refresh is called while disconnected", async () => { + const call = vi.fn(async () => { + throw new Error("should not call gateway when disconnected"); + }); + const ctx = renderController({ call, status: "disconnected" }); + + const result = await ctx.getValue().refreshGatewayConfigSnapshot(); + + expect(result).toBeNull(); + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useGatewayConnection.test.ts b/tests/unit/useGatewayConnection.test.ts new file mode 100644 index 00000000..51d684f0 --- /dev/null +++ b/tests/unit/useGatewayConnection.test.ts @@ -0,0 +1,218 @@ +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const ORIGINAL_ENV = { ...process.env }; + +const setupAndImportHook = async (gatewayUrl: string | null) => { + process.env = { ...ORIGINAL_ENV }; + if (gatewayUrl === null) { + delete process.env.NEXT_PUBLIC_GATEWAY_URL; + } else { + process.env.NEXT_PUBLIC_GATEWAY_URL = gatewayUrl; + } + + vi.resetModules(); + vi.spyOn(console, "info").mockImplementation(() => {}); + + const captured: { url: string | null; token: unknown; authScopeKey: unknown } = { + url: null, + token: null, + authScopeKey: null, + }; + + vi.doMock("../../src/lib/gateway/openclaw/GatewayBrowserClient", () => { + class GatewayBrowserClient { + connected = false; + private opts: { + onHello?: (hello: unknown) => void; + onEvent?: (event: unknown) => void; + onClose?: (info: { code: number; reason: string }) => void; + onGap?: (info: { expected: number; received: number }) => void; + }; + + constructor(opts: Record<string, unknown>) { + captured.url = typeof opts.url === "string" ? opts.url : null; + captured.token = "token" in opts ? opts.token : null; + captured.authScopeKey = "authScopeKey" in opts ? opts.authScopeKey : null; + this.opts = { + onHello: typeof opts.onHello === "function" ? (opts.onHello as (hello: unknown) => void) : undefined, + onEvent: typeof opts.onEvent === "function" ? (opts.onEvent as (event: unknown) => void) : undefined, + onClose: typeof opts.onClose === "function" ? (opts.onClose as (info: { code: number; reason: string }) => void) : undefined, + onGap: typeof opts.onGap === "function" ? (opts.onGap as (info: { expected: number; received: number }) => void) : undefined, + }; + } + + start() { + this.connected = true; + this.opts.onHello?.({ type: "hello-ok", protocol: 1 }); + } + + stop() { + this.connected = false; + this.opts.onClose?.({ code: 1000, reason: "stopped" }); + } + + async request<T = unknown>(method: string, params: unknown): Promise<T> { + void method; + void params; + return {} as T; + } + } + + return { GatewayBrowserClient }; + }); + + const mod = await import("@/lib/gateway/GatewayClient"); + return { + useGatewayConnection: mod.useGatewayConnection as (settingsCoordinator: { + loadSettings: () => Promise<unknown>; + loadSettingsEnvelope?: () => Promise<unknown>; + schedulePatch: (patch: unknown) => void; + flushPending: () => Promise<void>; + }) => { + gatewayUrl: string; + token: string; + localGatewayDefaults: { url: string; token: string } | null; + useLocalGatewayDefaults: () => void; + }, + captured, + }; +}; + +describe("useGatewayConnection", () => { + afterEach(() => { + cleanup(); + process.env = { ...ORIGINAL_ENV }; + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("defaults_to_env_url_when_set", async () => { + const { useGatewayConnection } = await setupAndImportHook("ws://example.test:1234"); + const coordinator = { + loadSettings: async () => null, + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => + createElement( + "div", + { "data-testid": "gatewayUrl" }, + useGatewayConnection(coordinator).gatewayUrl + ); + + render(createElement(Probe)); + + await waitFor(() => { + expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://example.test:1234"); + }); + }); + + it("falls_back_to_local_default_when_env_unset", async () => { + const { useGatewayConnection } = await setupAndImportHook(null); + const coordinator = { + loadSettings: async () => null, + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => + createElement( + "div", + { "data-testid": "gatewayUrl" }, + useGatewayConnection(coordinator).gatewayUrl + ); + + render(createElement(Probe)); + + await waitFor(() => { + expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789"); + }); + }); + + it("connects_via_studio_proxy_ws_and_does_not_pass_token", async () => { + const { useGatewayConnection, captured } = await setupAndImportHook(null); + const coordinator = { + loadSettings: async () => null, + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => { + useGatewayConnection(coordinator); + return createElement("div", null, "ok"); + }; + + render(createElement(Probe)); + + await waitFor(() => { + expect(captured.url).toBe("ws://localhost:3000/api/gateway/ws"); + }); + expect(captured.token).toBe(""); + expect(captured.authScopeKey).toBe("ws://localhost:18789"); + }); + + it("applies_local_defaults_from_settings_envelope", async () => { + const { useGatewayConnection } = await setupAndImportHook(null); + const coordinator = { + loadSettings: async () => ({ + version: 1, + gateway: null, + focused: {}, + avatars: {}, + }), + loadSettingsEnvelope: async () => ({ + settings: { + version: 1, + gateway: { url: "wss://remote.example", token: "remote-token" }, + focused: {}, + avatars: {}, + }, + localGatewayDefaults: { url: "ws://localhost:18789", token: "local-token" }, + }), + schedulePatch: () => {}, + flushPending: async () => {}, + }; + + const Probe = () => { + const state = useGatewayConnection(coordinator); + return createElement( + "div", + null, + createElement("div", { "data-testid": "gatewayUrl" }, state.gatewayUrl), + createElement("div", { "data-testid": "token" }, state.token), + createElement( + "div", + { "data-testid": "localDefaultsUrl" }, + state.localGatewayDefaults?.url ?? "" + ), + createElement( + "button", + { + type: "button", + onClick: state.useLocalGatewayDefaults, + "data-testid": "useLocalDefaults", + }, + "use" + ) + ); + }; + + render(createElement(Probe)); + + await waitFor(() => { + expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("wss://remote.example"); + }); + expect(screen.getByTestId("token")).toHaveTextContent("remote-token"); + expect(screen.getByTestId("localDefaultsUrl")).toHaveTextContent("ws://localhost:18789"); + + fireEvent.click(screen.getByTestId("useLocalDefaults")); + + await waitFor(() => { + expect(screen.getByTestId("gatewayUrl")).toHaveTextContent("ws://localhost:18789"); + }); + expect(screen.getByTestId("token")).toHaveTextContent("local-token"); + }); +}); diff --git a/tests/unit/useRuntimeSyncController.test.ts b/tests/unit/useRuntimeSyncController.test.ts new file mode 100644 index 00000000..0c7ba5fb --- /dev/null +++ b/tests/unit/useRuntimeSyncController.test.ts @@ -0,0 +1,368 @@ +import { createElement, useEffect } from "react"; +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController"; +import type { AgentState } from "@/features/agents/state/store"; + +import { + executeAgentReconcileCommands, + runAgentReconcileOperation, +} from "@/features/agents/operations/agentReconcileOperation"; +import { + executeHistorySyncCommands, + runHistorySyncOperation, +} from "@/features/agents/operations/historySyncOperation"; +import type { GatewayGapInfo } from "@/lib/gateway/GatewayClient"; + +vi.mock("@/features/agents/operations/historySyncOperation", () => ({ + runHistorySyncOperation: vi.fn(async () => []), + executeHistorySyncCommands: vi.fn(), +})); + +vi.mock("@/features/agents/operations/agentReconcileOperation", () => ({ + runAgentReconcileOperation: vi.fn(async () => []), + executeAgentReconcileCommands: vi.fn(), +})); + +const createAgent = (overrides?: Partial<AgentState>): AgentState => ({ + agentId: "agent-1", + name: "Agent One", + sessionKey: "agent:agent-1:main", + status: "idle", + sessionCreated: true, + awaitingUserInput: false, + hasUnseenActivity: false, + outputLines: [], + lastResult: null, + lastDiff: null, + runId: null, + runStartedAt: null, + streamText: null, + thinkingTrace: null, + latestOverride: null, + latestOverrideKind: null, + lastAssistantMessageAt: null, + lastActivityAt: null, + latestPreview: null, + lastUserMessage: null, + draft: "", + sessionSettingsSynced: true, + historyLoadedAt: null, + historyFetchLimit: null, + historyFetchedCount: null, + historyMaybeTruncated: false, + toolCallingEnabled: true, + showThinkingTraces: true, + model: "openai/gpt-5", + thinkingLevel: "medium", + avatarSeed: "seed-1", + avatarUrl: null, + ...(overrides ?? {}), +}); + +type RuntimeSyncControllerValue = ReturnType<typeof useRuntimeSyncController>; + +type RenderControllerContext = { + getValue: () => RuntimeSyncControllerValue; + rerenderWith: ( + overrides: Partial<Parameters<typeof useRuntimeSyncController>[0]> + ) => void; + unmount: () => void; + dispatch: ReturnType<typeof vi.fn>; + clearRunTracking: ReturnType<typeof vi.fn>; + call: ReturnType<typeof vi.fn>; + onGap: ReturnType<typeof vi.fn>; + getGapHandler: () => ((info: GatewayGapInfo) => void) | null; + unsubscribeGap: ReturnType<typeof vi.fn>; +}; + +const renderController = ( + overrides?: Partial<Parameters<typeof useRuntimeSyncController>[0]> +): RenderControllerContext => { + const dispatch = vi.fn(); + const clearRunTracking = vi.fn(); + const call = vi.fn(async (method: string) => { + if (method === "status") { + return { sessions: { recent: [], byAgent: [] } }; + } + if (method === "sessions.preview") { + return { ts: 123, previews: [] }; + } + return {}; + }); + let gapHandler: ((info: GatewayGapInfo) => void) | null = null; + const unsubscribeGap = vi.fn(); + const onGap = vi.fn((handler: (info: GatewayGapInfo) => void) => { + gapHandler = handler; + return unsubscribeGap; + }); + + let currentParams: Parameters<typeof useRuntimeSyncController>[0] = { + client: { + call, + onGap, + } as never, + status: "connected", + agents: [createAgent({ status: "running", historyLoadedAt: 1000, runId: "run-1" })], + focusedAgentId: null, + focusedAgentRunning: false, + dispatch, + clearRunTracking, + isDisconnectLikeError: () => false, + defaultHistoryLimit: 200, + maxHistoryLimit: 5000, + ...(overrides ?? {}), + }; + + const valueRef: { current: RuntimeSyncControllerValue | null } = { current: null }; + + const Probe = ({ + params, + onValue, + }: { + params: Parameters<typeof useRuntimeSyncController>[0]; + onValue: (value: RuntimeSyncControllerValue) => void; + }) => { + const value = useRuntimeSyncController(params); + useEffect(() => { + onValue(value); + }, [onValue, value]); + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + const rendered = render( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + + return { + getValue: () => { + if (!valueRef.current) { + throw new Error("runtime sync controller value unavailable"); + } + return valueRef.current; + }, + rerenderWith: (nextOverrides) => { + currentParams = { + ...currentParams, + ...nextOverrides, + }; + rendered.rerender( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + }, + unmount: () => { + rendered.unmount(); + }, + dispatch, + clearRunTracking, + call, + onGap, + getGapHandler: () => gapHandler, + unsubscribeGap, + }; +}; + +describe("useRuntimeSyncController", () => { + const mockedRunHistorySyncOperation = vi.mocked(runHistorySyncOperation); + const mockedExecuteHistorySyncCommands = vi.mocked(executeHistorySyncCommands); + const mockedRunAgentReconcileOperation = vi.mocked(runAgentReconcileOperation); + const mockedExecuteAgentReconcileCommands = vi.mocked(executeAgentReconcileCommands); + + beforeEach(() => { + vi.useFakeTimers(); + mockedRunHistorySyncOperation.mockReset(); + mockedRunHistorySyncOperation.mockResolvedValue([]); + mockedExecuteHistorySyncCommands.mockReset(); + mockedRunAgentReconcileOperation.mockReset(); + mockedRunAgentReconcileOperation.mockResolvedValue([]); + mockedExecuteAgentReconcileCommands.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("runs reconcile immediately and every 3000ms while connected then cleans up", async () => { + const ctx = renderController({ + focusedAgentId: null, + focusedAgentRunning: false, + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2999); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(3000); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(3); + + ctx.unmount(); + await vi.advanceTimersByTimeAsync(6000); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(3); + }); + + it("polls focused running history every 4500ms and stops when focus no longer running", async () => { + const ctx = renderController({ + agents: [createAgent({ status: "running", historyLoadedAt: 1234 })], + focusedAgentId: "agent-1", + focusedAgentRunning: true, + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4500); + expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(2); + + ctx.rerenderWith({ + agents: [createAgent({ status: "idle", historyLoadedAt: 1234 })], + focusedAgentId: "agent-1", + focusedAgentRunning: false, + }); + + await vi.advanceTimersByTimeAsync(9000); + expect(mockedRunHistorySyncOperation).toHaveBeenCalledTimes(2); + }); + + it("bootstraps history only for connected sessions missing loaded history", async () => { + renderController({ + status: "connected", + focusedAgentId: null, + focusedAgentRunning: false, + agents: [ + createAgent({ agentId: "agent-1", sessionCreated: true, historyLoadedAt: null }), + createAgent({ agentId: "agent-2", sessionCreated: true, historyLoadedAt: 1234 }), + createAgent({ agentId: "agent-3", sessionCreated: false, historyLoadedAt: null }), + ], + }); + + await act(async () => { + await Promise.resolve(); + }); + + const bootstrappedAgentIds = mockedRunHistorySyncOperation.mock.calls + .map(([arg]) => (arg as { agentId: string }).agentId) + .filter((agentId) => agentId === "agent-1" || agentId === "agent-2" || agentId === "agent-3"); + + expect(bootstrappedAgentIds).toContain("agent-1"); + expect(bootstrappedAgentIds).not.toContain("agent-2"); + expect(bootstrappedAgentIds).not.toContain("agent-3"); + }); + + it("loads summary snapshot when status transitions to connected", async () => { + const ctx = renderController({ + status: "disconnected", + focusedAgentId: null, + focusedAgentRunning: false, + agents: [createAgent({ sessionCreated: true, historyLoadedAt: 1234 })], + }); + + expect(ctx.call).not.toHaveBeenCalledWith("status", {}); + + ctx.rerenderWith({ status: "connected" }); + await act(async () => { + await Promise.resolve(); + }); + + expect(ctx.call).toHaveBeenCalledWith("status", {}); + expect(ctx.call).toHaveBeenCalledWith("sessions.preview", { + keys: ["agent:agent-1:main"], + limit: 8, + maxChars: 240, + }); + }); + + it("handles gap recovery by triggering summary refresh and reconcile", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ctx = renderController({ + agents: [createAgent({ status: "running", historyLoadedAt: 1234, runId: "run-1" })], + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(ctx.onGap).toHaveBeenCalledTimes(1); + const handler = ctx.getGapHandler(); + if (!handler) { + throw new Error("expected gap handler to be registered"); + } + + mockedRunAgentReconcileOperation.mockClear(); + ctx.call.mockClear(); + + await act(async () => { + handler({ expected: 10, received: 11 }); + await Promise.resolve(); + }); + expect(ctx.call).toHaveBeenCalledWith("status", {}); + expect(ctx.call).toHaveBeenCalledWith("sessions.preview", { + keys: ["agent:agent-1:main"], + limit: 8, + maxChars: 240, + }); + expect(mockedRunAgentReconcileOperation).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith("Gateway event gap expected 10, received 11."); + }); + + it("unsubscribes gap listener on unmount", () => { + const ctx = renderController(); + ctx.unmount(); + expect(ctx.unsubscribeGap).toHaveBeenCalledTimes(1); + }); + + it("clears history in-flight tracking when requested", async () => { + const inFlightSeen: boolean[] = []; + mockedRunHistorySyncOperation.mockImplementation( + async ({ agentId, getAgent, inFlightSessionKeys }) => { + const agent = getAgent(agentId); + if (!agent) return []; + const sessionKey = agent.sessionKey; + inFlightSeen.push(inFlightSessionKeys.has(sessionKey)); + inFlightSessionKeys.add(sessionKey); + return []; + } + ); + + const ctx = renderController({ + status: "disconnected", + agents: [createAgent({ sessionKey: "agent:agent-1:main", historyLoadedAt: null })], + focusedAgentId: null, + focusedAgentRunning: false, + }); + + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1"); + }); + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1"); + }); + act(() => { + ctx.getValue().clearHistoryInFlight("agent:agent-1:main"); + }); + await act(async () => { + await ctx.getValue().loadAgentHistory("agent-1"); + }); + + expect(inFlightSeen).toEqual([false, true, false]); + }); +}); diff --git a/tests/unit/useSettingsRouteController.test.ts b/tests/unit/useSettingsRouteController.test.ts new file mode 100644 index 00000000..6dc8eace --- /dev/null +++ b/tests/unit/useSettingsRouteController.test.ts @@ -0,0 +1,376 @@ +import { createElement, useEffect } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { + useSettingsRouteController, + type SettingsRouteController, + type UseSettingsRouteControllerParams, +} from "@/features/agents/operations/useSettingsRouteController"; +import type { + InspectSidebarState, + SettingsRouteTab, +} from "@/features/agents/operations/settingsRouteWorkflow"; + +type OverrideParams = Partial< + Omit< + UseSettingsRouteControllerParams, + | "flushPendingDraft" + | "dispatchSelectAgent" + | "setInspectSidebar" + | "setMobilePaneChat" + | "setPersonalityHasUnsavedChanges" + | "push" + | "replace" + | "confirmDiscard" + > +>; + +type RenderControllerContext = { + getValue: () => SettingsRouteController; + rerenderWith: (overrides: OverrideParams) => void; + flushPendingDraft: ReturnType<typeof vi.fn<(agentId: string | null) => void>>; + dispatchSelectAgent: ReturnType<typeof vi.fn<(agentId: string | null) => void>>; + setInspectSidebar: ReturnType< + typeof vi.fn< + ( + next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState) + ) => void + > + >; + setMobilePaneChat: ReturnType<typeof vi.fn<() => void>>; + setPersonalityHasUnsavedChanges: ReturnType<typeof vi.fn<(next: boolean) => void>>; + push: ReturnType<typeof vi.fn<(href: string) => void>>; + replace: ReturnType<typeof vi.fn<(href: string) => void>>; + confirmDiscard: ReturnType<typeof vi.fn<() => boolean>>; +}; + +const renderController = ( + overrides?: OverrideParams, + callbackOverrides?: Partial< + Pick< + UseSettingsRouteControllerParams, + | "flushPendingDraft" + | "dispatchSelectAgent" + | "setInspectSidebar" + | "setMobilePaneChat" + | "setPersonalityHasUnsavedChanges" + | "push" + | "replace" + | "confirmDiscard" + > + > +): RenderControllerContext => { + const flushPendingDraft = vi.fn<(agentId: string | null) => void>( + callbackOverrides?.flushPendingDraft ?? (() => undefined) + ); + const dispatchSelectAgent = vi.fn<(agentId: string | null) => void>( + callbackOverrides?.dispatchSelectAgent ?? (() => undefined) + ); + const setInspectSidebar = vi.fn< + ( + next: InspectSidebarState | ((current: InspectSidebarState) => InspectSidebarState) + ) => void + >(callbackOverrides?.setInspectSidebar ?? (() => undefined)); + const setMobilePaneChat = vi.fn<() => void>( + callbackOverrides?.setMobilePaneChat ?? (() => undefined) + ); + const setPersonalityHasUnsavedChanges = vi.fn<(next: boolean) => void>( + callbackOverrides?.setPersonalityHasUnsavedChanges ?? (() => undefined) + ); + const push = vi.fn<(href: string) => void>(callbackOverrides?.push ?? (() => undefined)); + const replace = vi.fn<(href: string) => void>(callbackOverrides?.replace ?? (() => undefined)); + const confirmDiscard = vi.fn<() => boolean>(callbackOverrides?.confirmDiscard ?? (() => true)); + + let currentParams: UseSettingsRouteControllerParams = { + settingsRouteActive: false, + settingsRouteAgentId: null, + status: "connected", + agentsLoadedOnce: true, + selectedAgentId: null, + focusedAgentId: null, + personalityHasUnsavedChanges: false, + activeTab: "personality", + inspectSidebar: null, + agents: [{ agentId: "agent-1" }], + flushPendingDraft, + dispatchSelectAgent, + setInspectSidebar, + setMobilePaneChat, + setPersonalityHasUnsavedChanges, + push, + replace, + confirmDiscard, + ...overrides, + }; + + const valueRef: { current: SettingsRouteController | null } = { current: null }; + + const Probe = ({ + params, + onValue, + }: { + params: UseSettingsRouteControllerParams; + onValue: (value: SettingsRouteController) => void; + }) => { + const value = useSettingsRouteController(params); + useEffect(() => { + onValue(value); + }, [onValue, value]); + return createElement("div", { "data-testid": "probe" }, "ok"); + }; + + const rendered = render( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + + return { + getValue: () => { + if (!valueRef.current) throw new Error("controller value unavailable"); + return valueRef.current; + }, + rerenderWith: (nextOverrides) => { + currentParams = { + ...currentParams, + ...nextOverrides, + }; + rendered.rerender( + createElement(Probe, { + params: currentParams, + onValue: (value) => { + valueRef.current = value; + }, + }) + ); + }, + flushPendingDraft, + dispatchSelectAgent, + setInspectSidebar, + setMobilePaneChat, + setPersonalityHasUnsavedChanges, + push, + replace, + confirmDiscard, + }; +}; + +describe("useSettingsRouteController", () => { + it("blocks back-to-chat when personality discard is declined", () => { + const ctx = renderController( + { + settingsRouteActive: true, + activeTab: "personality", + personalityHasUnsavedChanges: true, + }, + { + confirmDiscard: () => false, + } + ); + + act(() => { + ctx.getValue().handleBackToChat(); + }); + + expect(ctx.confirmDiscard).toHaveBeenCalledTimes(1); + expect(ctx.setPersonalityHasUnsavedChanges).not.toHaveBeenCalled(); + expect(ctx.push).not.toHaveBeenCalled(); + }); + + it("changes settings tab only after confirmed discard and clears dirty flag", () => { + const ctx = renderController( + { + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + inspectSidebar: { agentId: "agent-1", tab: "personality" }, + activeTab: "personality", + personalityHasUnsavedChanges: true, + }, + { + confirmDiscard: () => true, + } + ); + + act(() => { + ctx.getValue().handleSettingsRouteTabChange("capabilities"); + }); + + expect(ctx.confirmDiscard).toHaveBeenCalledTimes(1); + expect(ctx.setPersonalityHasUnsavedChanges).toHaveBeenCalledWith(false); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "capabilities", + }); + }); + + it("runs open-settings commands in order and encodes route", () => { + const order: string[] = []; + const ctx = renderController( + { + focusedAgentId: "focused-agent", + inspectSidebar: null, + }, + { + flushPendingDraft: () => { + order.push("flush"); + }, + dispatchSelectAgent: () => { + order.push("select"); + }, + setInspectSidebar: () => { + order.push("inspect"); + }, + setMobilePaneChat: () => { + order.push("pane"); + }, + push: (href) => { + order.push(`push:${href}`); + }, + } + ); + order.length = 0; + + act(() => { + ctx.getValue().handleOpenAgentSettingsRoute("agent 2"); + }); + + expect(order).toEqual([ + "flush", + "select", + "inspect", + "pane", + "push:/?settingsAgentId=agent%202", + ]); + }); + + it("keeps fleet-select behavior parity", () => { + const ctx = renderController({ + focusedAgentId: "focused-agent", + inspectSidebar: { agentId: "agent-1", tab: "automations" }, + }); + + act(() => { + ctx.getValue().handleFleetSelectAgent("agent-9"); + }); + + expect(ctx.flushPendingDraft).toHaveBeenCalledWith("focused-agent"); + expect(ctx.dispatchSelectAgent).toHaveBeenCalledWith("agent-9"); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-9", + tab: "automations", + }); + expect(ctx.setMobilePaneChat).toHaveBeenCalledTimes(1); + }); + + it("redirects to root when route agent is missing after load", async () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "missing-agent", + status: "connected", + agentsLoadedOnce: true, + agents: [{ agentId: "agent-1" }], + }); + + await waitFor(() => { + expect(ctx.replace).toHaveBeenCalledWith("/"); + }); + }); + + it("syncs route agent into inspect sidebar and selected agent", async () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + selectedAgentId: null, + inspectSidebar: null, + agents: [{ agentId: "agent-1" }], + }); + + await waitFor(() => { + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "personality", + }); + expect(ctx.dispatchSelectAgent).toHaveBeenCalledWith("agent-1"); + }); + }); + + it("does not dispatch or mutate when non-route selection is already aligned", async () => { + const ctx = renderController({ + settingsRouteActive: false, + selectedAgentId: "agent-1", + focusedAgentId: "agent-1", + inspectSidebar: null, + agents: [{ agentId: "agent-1" }], + }); + + await waitFor(() => { + expect(ctx.dispatchSelectAgent).not.toHaveBeenCalled(); + expect(ctx.setInspectSidebar).not.toHaveBeenCalled(); + expect(ctx.replace).not.toHaveBeenCalled(); + }); + }); + + it("does not call confirm when switching non-personality tabs", () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + inspectSidebar: { agentId: "agent-1", tab: "capabilities" }, + activeTab: "capabilities" satisfies SettingsRouteTab, + personalityHasUnsavedChanges: true, + }); + + act(() => { + ctx.getValue().handleSettingsRouteTabChange("automations"); + }); + + expect(ctx.confirmDiscard).not.toHaveBeenCalled(); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "automations", + }); + }); + + it("switches to skills tab without discard prompt when leaving capabilities", () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + inspectSidebar: { agentId: "agent-1", tab: "capabilities" }, + activeTab: "capabilities" satisfies SettingsRouteTab, + personalityHasUnsavedChanges: true, + }); + + act(() => { + ctx.getValue().handleSettingsRouteTabChange("skills"); + }); + + expect(ctx.confirmDiscard).not.toHaveBeenCalled(); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "skills", + }); + }); + + it("switches to system tab without discard prompt when leaving skills", () => { + const ctx = renderController({ + settingsRouteActive: true, + settingsRouteAgentId: "agent-1", + inspectSidebar: { agentId: "agent-1", tab: "skills" }, + activeTab: "skills" satisfies SettingsRouteTab, + personalityHasUnsavedChanges: true, + }); + + act(() => { + ctx.getValue().handleSettingsRouteTabChange("system"); + }); + + expect(ctx.confirmDiscard).not.toHaveBeenCalled(); + expect(ctx.setInspectSidebar).toHaveBeenCalledWith({ + agentId: "agent-1", + tab: "system", + }); + }); +}); diff --git a/tests/unit/uuid.test.ts b/tests/unit/uuid.test.ts new file mode 100644 index 00000000..2c59fbfb --- /dev/null +++ b/tests/unit/uuid.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +import { randomUUID } from "@/lib/uuid"; + +describe("randomUUID", () => { + it("uses crypto.randomUUID when available", () => { + const cryptoLike = { randomUUID: vi.fn(() => "fixed-uuid") }; + expect(randomUUID(cryptoLike)).toBe("fixed-uuid"); + expect(cryptoLike.randomUUID).toHaveBeenCalledTimes(1); + }); + + it("uses crypto.getRandomValues when randomUUID is missing", () => { + const getRandomValues = vi.fn((arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = i; + return arr; + }); + + const out = randomUUID({ getRandomValues }); + expect(getRandomValues).toHaveBeenCalledTimes(1); + expect(out).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + it("falls back when no crypto APIs are available", () => { + const out = randomUUID(null); + expect(out).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); +}); diff --git a/tests/unit/worktreeHelpers.test.ts b/tests/unit/worktreeHelpers.test.ts new file mode 100644 index 00000000..ecc26df0 --- /dev/null +++ b/tests/unit/worktreeHelpers.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentInstruction } from "@/lib/text/message-extract"; + +describe("buildAgentInstruction", () => { + it("returns trimmed message text", () => { + const message = buildAgentInstruction({ + message: "Ship it", + }); + + expect(message).toBe("Ship it"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index cf9c65d3..e6972278 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,16 +23,21 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", + "**/*.mts", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" + ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + ".next" + ] }