diff --git a/.bazelignore b/.bazelignore index eecc7e6b..d6e24604 100644 --- a/.bazelignore +++ b/.bazelignore @@ -1,2 +1,4 @@ .loopal/worktrees .claude/worktrees +node_modules +apps/desktop/node_modules diff --git a/.bazelrc b/.bazelrc index 580e4f42..9d7afb41 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,6 @@ # Common common --enable_bzlmod +common:windows --enable_runfiles # Build build --jobs=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd9be741..c811ab0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,30 @@ jobs: - name: Rustfmt check run: bazel build //... --config=rustfmt + + desktop-e2e: + name: Desktop E2E + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.14.0 + with: + bazelisk-cache: true + repository-cache: true + + - name: Electron UI and real Loopal Host E2E + run: >- + bazel test + //apps/desktop:e2e + //apps/desktop:e2e_host + //apps/desktop:e2e_llm_backend + --test_tag_filters=e2e + --test_output=errors + + - name: Optimized sidecar staging smoke + run: bazel test //apps/desktop:staging_smoke -c opt --test_output=errors + + - name: Build desktop distribution + run: bazel build //apps/desktop:dist -c opt diff --git a/.gitignore b/.gitignore index 1029a7aa..0557967e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,13 @@ benchmarks/terminal_bench/bin/ # Loopal memory runtime (auto-managed) .loopal/memory-events/archive/ .loopal/memory-events/*.jsonl.gz + +# Node / Electron desktop +node_modules/ +apps/desktop/out/ +apps/desktop/dist/ +apps/desktop/release/ +apps/desktop/coverage/ +apps/desktop/playwright-report/ +apps/desktop/test-results/ +apps/desktop/.vite/ diff --git a/BUILD.bazel b/BUILD.bazel index 5505afee..1dd35a09 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,184 +1,15 @@ -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("//build_defs/rust:platforms.bzl", "loopal_platforms") +load("//build_defs/rust:root_binary.bzl", "loopal_binary") +load("//build_defs/rust:root_tests.bzl", "loopal_root_tests") +load("//build_defs/web:npm_workspace.bzl", "desktop_npm_workspace") load("//tools:version_env.bzl", "version_env") -# Cross-compilation platforms -platform(name = "linux-x86_64", constraint_values = ["@platforms//os:linux", "@platforms//cpu:x86_64"]) -platform(name = "linux-aarch64", constraint_values = ["@platforms//os:linux", "@platforms//cpu:aarch64"]) -platform(name = "macos-x86_64", constraint_values = ["@platforms//os:macos", "@platforms//cpu:x86_64"]) -platform(name = "macos-aarch64", constraint_values = ["@platforms//os:macos", "@platforms//cpu:aarch64"]) -platform(name = "windows-x86_64", constraint_values = ["@platforms//os:windows", "@platforms//cpu:x86_64"]) +desktop_npm_workspace() -version_env(name = "version_env") - -# Main binary -rust_binary( - name = "loopal", - srcs = glob(["src/**/*.rs"]), - edition = "2024", - rustc_env_files = [":version_env"], - stamp = 1, - deps = [ - "//crates/loopal-acp", - "//crates/loopal-agent-client", - "//crates/loopal-agent-hub", - "//crates/loopal-agent-server", - "//crates/loopal-backend", - "//crates/loopal-config", - "//crates/loopal-context", - "//crates/loopal-decision-api", - "//crates/loopal-git", - "//crates/loopal-hub-vault", - "//crates/loopal-ipc", - "//crates/loopal-meta-hub", - "//crates/loopal-protocol", - "//crates/loopal-provider-api", - "//crates/loopal-runtime", - "//crates/loopal-secret-runtime", - "//crates/loopal-session", - "//crates/loopal-storage", - "//crates/loopal-telemetry", - "//crates/loopal-tool-api", - "//crates/loopal-tui", - "//crates/loopal-vault-age", - "//crates/loopal-vault-api", - "//crates/loopal-view-state", - "//crates/tools/process/background:loopal-tool-background", - "@crates//:anyhow", - "@crates//:chrono", - "@crates//:clap", - "@crates//:dirs", - "@crates//:secrecy", - "@crates//:serde", - "@crates//:serde_json", - "@crates//:tokio", - "@crates//:tracing", - "@crates//:tracing-appender", - "@crates//:tracing-subscriber", - "@crates//:uuid", - ], - visibility = ["//visibility:public"], -) - -# Root integration test (system IPC test) — needs subprocess spawn, bypass sandbox -rust_test( - name = "system_ipc_test", - srcs = ["tests/system_ipc_test.rs"], - edition = "2024", - data = [":loopal"], - env = { - "LOOPAL_BINARY": "$(rootpath :loopal)", - }, - local = True, - deps = [ - "//crates/loopal-ipc", - "//crates/loopal-protocol", - "@crates//:serde_json", - "@crates//:tokio", - "@crates//:tempfile", - ], -) - -# Hub lifecycle e2e — covers --hub-only handshake, discovery record, -# token-channel handoff, and shutdown cleanup. -rust_test( - name = "hub_lifecycle_test", - srcs = ["tests/hub_lifecycle_test.rs"], - edition = "2024", - data = [":loopal"], - env = { - "LOOPAL_BINARY": "$(rootpath :loopal)", - }, - local = True, - deps = [ - "@crates//:tokio", - "@crates//:tempfile", - "@crates//:anyhow", - "@crates//:serde_json", - ], -) +loopal_platforms() -# Join-hub e2e — regression guard for the spawn-shim bug where -# `--join-hub` / `--hub-name` were silently dropped from the hub-only -# child argv. Verifies a real spawned hub TCP-connects to the address -# and sends `meta/register` with the env token + flag-supplied name. -rust_test( - name = "join_hub_e2e_test", - srcs = ["tests/join_hub_e2e_test.rs"], - edition = "2024", - data = [":loopal"], - env = { - "LOOPAL_BINARY": "$(rootpath :loopal)", - }, - local = True, - deps = [ - "@crates//:tokio", - "@crates//:tempfile", - "@crates//:anyhow", - "@crates//:serde_json", - ], -) - -# Hub-only deadlock regression — spawns `--hub-only` with a project settings -# file that configures an unresponsive stdio MCP server. Detects the -# reverse-IPC deadlock where hub_bootstrap awaits agent/start while -# session_start's synchronous path tries to call hub/mcp/snapshot back. -# `#[ignore]` until Phase 1 lands (run with `--ignored` to verify on main). -rust_test( - name = "hub_only_mcp_deadlock_test", - srcs = ["tests/hub_only_mcp_deadlock_test.rs"], - edition = "2024", - data = [":loopal"], - env = { - "LOOPAL_BINARY": "$(rootpath :loopal)", - }, - local = True, - deps = [ - "@crates//:tokio", - "@crates//:tempfile", - "@crates//:serde_json", - ], -) - -# Bootstrap typestate e2e — spawns `--hub-only` and asserts ALIVE/READY -# emit within the layered handshake budget (proxy 8s < start_agent 20s < -# handshake 30s). Skipping any state in the chain (bind_listener → -# register_handlers → spawn_agent_process → start_root_agent) breaks the -# guarantee and is caught here. -rust_test( - name = "bootstrap_typestate_e2e_test", - srcs = ["tests/bootstrap_typestate_e2e_test.rs"], - edition = "2024", - data = [":loopal"], - env = { - "LOOPAL_BINARY": "$(rootpath :loopal)", - }, - local = True, - deps = [ - "@crates//:tokio", - "@crates//:tempfile", - ], -) +version_env(name = "version_env") -# Unit tests for #[cfg(test)] modules in src/ — currently covers the -# CLI passthrough invariants (ChildPassthroughArgs::to_args completeness, -# round-trip, parent-only-flag isolation, resume_intent, apply_overrides). -rust_test( - name = "loopal-unit-test", - crate = ":loopal", - edition = "2024", -) +loopal_binary() -# Architectural boundary enforcement — fails build if wire-format types -# leak outside the provider / context / display layer, or if -# compile-time-killed APIs (ContextStore::from_messages, TurnStore::turns_mut, -# loopal-message) try to come back. -rust_test( - name = "architecture_boundary_test", - srcs = ["tests/architecture_boundary_test.rs"], - edition = "2024", - local = True, - deps = [ - "@crates//:regex", - "@crates//:walkdir", - ], -) +loopal_root_tests() diff --git a/CLAUDE.md b/CLAUDE.md index f6916b91..fa917520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,325 +1,176 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Contributor guidance for agents working in this repository. Product behavior lives in +code and tests; architecture and layout facts belong in the linked documentation. -## ⛔ HARD RULE — NO OVER-COMMENTING +## Hard rule: do not over-comment -Code is the single source of truth. Before adding ANY comment, ask: does this line tell a fluent reader something they CANNOT see in the code itself? If no, do not write it. +Before adding a comment, ask whether it tells a fluent reader something that cannot be +seen in the code. If not, omit it. -**FORBIDDEN by default:** -- Module-level `//!` block comments that summarize what the module does -- `///` doc comments above pub fn / pub struct that restate the signature -- `///` doc comments above tests (the test name IS the doc) -- Field-level doc comments that just describe the field's name in English -- "Why we organize the file this way" / architecture narration in source files +Forbidden by default: -**Allowed only when truly needed:** -- A short `// reason:` inline note when a line does something non-obvious that would otherwise look wrong (e.g. workaround for a bug, ordering constraint, performance hack) -- Public API doc when the contract has invariants or panics that aren't visible in types +- module/doc comments that paraphrase a module, signature, field, or test name; +- architecture narration in source files; +- step-by-step comments above self-explanatory code. -When in doubt, delete. Re-deleting comments later is wasted effort. +A short `// reason:` is appropriate for a non-obvious constraint, workaround, ordering +requirement, or safety invariant. Public API documentation is appropriate when the +contract, panic, or invariant is not visible in types. -## Build & Test Commands (Bazel) +## Build and test: Bazel only ```bash -bazel build //:loopal # Build main binary -bazel build //... # Build everything -bazel test //... # Run all tests -bazel test //crates/loopal-tui:suite # Run tests for a single crate -bazel build //... --config=clippy # Clippy lint (must pass with zero warnings) -bazel build //... --config=rustfmt # Rustfmt check -bazel build //:loopal -c opt # Optimized release build -bazel build //:loopal -c opt --config=macos-arm # Cross-compile for macOS ARM64 +bazel build //:loopal # main binary +bazel build //... # all build targets +bazel test //... # all tests +bazel test //crates/loopal-tui:suite # one crate suite +bazel build //... --config=clippy # zero-warning Clippy +bazel build //... --config=rustfmt # formatting check +bazel build //:loopal -c opt # optimized binary +bazel build //:loopal -c opt --config=macos-arm ``` +Desktop commands are indexed in +[docs/desktop/testing.md](./docs/desktop/testing.md) and +[docs/desktop/build-and-release.md](./docs/desktop/build-and-release.md). Do not invoke +Vite, Vitest, Playwright, electron-builder, npm scripts, or Cargo directly. + ### Dependency management -External deps are managed via `crate_universe` from `Cargo.toml` / `Cargo.lock`. -After adding/updating a dependency in `Cargo.toml`: +There is no Cargo workspace and no checked-in `Cargo.toml` / `Cargo.lock`. External Rust +crates are declared with `crate.spec()` in `MODULE.bazel`; Bazel records resolution in +`MODULE.bazel.lock`. ```bash -CARGO_BAZEL_REPIN=1 bazel sync --only=crates # Re-pin external crates +bazel sync --only=crates +bazel build //... ``` -## Architecture +JavaScript dependencies use `package.json` and `pnpm-lock.yaml`, imported by Bazel's npm +extension. pnpm may update the lockfile; Bazel still owns every build/test action. -Loopal is an AI coding agent with a TUI, structured as 17 Rust crates in a layered architecture. Data flows top-down; each layer only depends on layers below it. +## Architecture and repository boundaries -``` -src/main.rs (bootstrap + CLI) - ├─ loopal-tui Terminal UI (ratatui). Event loop, input handling, views. - ├─ loopal-runtime Agent loop engine. Orchestrates: input → middleware → LLM → tools → repeat. - ├─ loopal-kernel Central registry. Owns tool/provider/hook registries + MCP manager. - ├─ loopal-context Context pipeline. Middleware chain for message compaction/limits. - ├─ loopal-provider LLM providers (Anthropic, OpenAI, Google, OpenAI-compat). SSE streaming. - ├─ loopal-tools Built-in tools (Read, Write, Edit, Bash, Grep, Glob, Ls, WebFetch). - ├─ loopal-mcp Model Context Protocol client. Spawns MCP servers, discovers tools. - ├─ loopal-hooks Pre/post tool-use lifecycle hooks executed as shell commands. - ├─ loopal-storage Session + message persistence (~/.loopal/sessions/). - ├─ loopal-config 5-layer config merge + Settings/HookConfig/SandboxConfig types. - ├─ loopal-provider-api Provider/Middleware traits + ChatParams/StreamChunk/ModelInfo. - ├─ loopal-tool-api Tool trait + PermissionLevel/Mode/Decision + truncate_output. - ├─ loopal-protocol Envelope, AgentEvent, ControlCommand, AgentMode, AgentStatus. - ├─ loopal-message Message, ContentBlock, normalize_messages. - └─ loopal-error LoopalError + all sub-error types (Provider/Tool/Config/Storage/Hook/Mcp). -``` +Loopal contains 42 top-level Rust crates, additional fine-grained tool packages under +`crates/tools`, and the Electron application under `apps/desktop`. -### Key data flow +- [Repository layout](./docs/repository-layout.md) is the source of truth for directories + and allowed dependencies. +- [Architecture principles](./docs/architecture.md) own Hub/Agent resource decisions. +- [Desktop documentation](./docs/desktop/README.md) owns Electron runtime, experience, + verification, and release contracts. -**Multi-process architecture (default):** +```text +src/main.rs + -> src/cli + src/bootstrap + -> Hub / MetaHub / Agent process adapters + -> runtime, kernel, provider, tools, Session, storage, and protocol crates -``` -TUI Process ──stdio IPC──→ Agent Server Process ←──TCP──→ IDE / CLI - │ - Agent Loop + Kernel +TUI / ACP / Desktop + -> typed Hub and Agent protocols + -> root and child Agent processes + -> input -> context -> LLM -> tools -> persistence -> next turn ``` -- TUI connects to Agent Server via stdio IPC (`loopal-agent-client`) -- Agent Server also opens a TCP listener for external clients (IDE, CLI) -- External clients discover the TCP port via `{tmp}/loopal/run/.json` -- Multiple clients can join the same session (`agent/join`) or create independent sessions -- ACP (`--acp` mode) bridges IDE's `session/*` protocol to Agent Server's `agent/*` IPC protocol - -**IPC protocol methods** (`agent/*` over JSON-RPC 2.0): -- Lifecycle: `initialize`, `agent/start`, `agent/shutdown` -- Data: `agent/message` (Envelope), `agent/control` (ControlCommand) -- Events: `agent/event` (notification), `agent/interrupt` (notification) -- Interactive: `agent/permission` (request/response), `agent/question` (request/response) -- Multi-client: `agent/join` (join existing session), `agent/list` (list sessions) - -### Agent loop cycle (runtime) - -`AgentLoopRunner::run()` in `agent_loop/runner.rs`: -1. Wait for user input -2. Execute middleware pipeline (compaction, context guard) -3. Stream LLM response (text + tool calls) -4. Record assistant message -5. If tool calls: check permissions → parallel execute → loop -6. If no tool calls: wait for next input +Dependency direction is inward: API/protocol crates know no concrete UI or provider; +runtime/domain crates know no TUI or Electron; transports and UIs depend on contracts; +root bootstrap selects concrete adapters. Root `src/` is composition, not a library. ### Extension points -- **New tool**: Implement `Tool` (or `TypedTool

`) → register in `builtin/mod.rs`. **MUST** declare `secret_eligible_params()` (no default — forces explicit choice about secret exposure). Bash returns `&["command", "env"]`; everything else returns `&[]`. Write-tools also add `precheck` rejecting `` via `loopal_secret_runtime::WIRE_REF_MARKER`. -- **New LLM provider**: Implement `Provider` trait → register in `kernel/provider_registry.rs` -- **New middleware**: Implement `Middleware` trait → add to pipeline in `bootstrap.rs` -- **MCP tools**: Configure `mcp_servers` in settings.json → auto-discovered at startup - -## MCP startup model - -MCP server spawn does not block `agent/start`. The Kernel holds a `Arc` strategy with two implementations: - -- **`LocalMcpProvider`** (root agent): owns `Arc>`. `spawn_background(configs)` fires the `start_all` future on a background `tokio::spawn` and returns immediately. `wait_until_settled(timeout)` races the background task against a deadline. -- **`McpProxyClient`** (sub-agent, depth > 0): forwards `list_tools` / `call_tool` / `snapshot` to root via `hub/mcp/*` IPC. Hub forwards those to `"main"` agent's `agent/mcp/*` handler, which calls the root's `LocalMcpProvider`. **Sub-agents do not spawn MCP processes** — they share root's connections. - -`build_kernel_from_config` orchestrates: -1. `kernel.spawn_mcp()` — fire-and-forget (no-op for proxy) -2. `kernel.finalize_mcp_tools(LOOPAL_MCP_STARTUP_WAIT_SECS)` — bounded wait (default 5s), then register `McpToolAdapter` for every `(server, tool)` pair the provider reports -3. Slow servers settle in the background; subsequent reconnects register their tools via `kernel.register_mcp_tools_for_server(name)` - -`McpConnection::connect` is wrapped in `tokio::time::timeout(config.timeout_ms)` defensively — bottoms out at the configured per-server limit even if the underlying rmcp transport ignores it. - -## Vault + Secret runtime - -Zero-trust secret management. LLM never sees plaintext. Architecture is split -into two layers: a **generic vault** (encrypted KV storage) and a **Loopal-specific -runtime** (LLM-facing placeholder + redaction). - -### Layered architecture - -``` -loopal-vault-api Vault trait + AuditSink trait + VaultError + VaultOp - ↑ (~80 lines; pure trait crate, depended on by all downstream) -loopal-vault-age AgeVault impl + identity/recipients/editor + `loopal vault` CLI - ↑ (age+yaml backend; swappable for keychain/KMS in future) -loopal-secret-runtime template + resolver + redactor + hooks + JsonlAuditSink - ↑ (LLM-safety layer; depends only on vault-api trait) -loopal-config build_secret_store: instantiates AgeVault with JsonlAuditSink -loopal-mcp/kernel/ inject Arc; expand_to_plaintext for spawn-time secrets -loopal-runtime tool_pipeline hooks: apply_resolver + apply_redactor -``` - -### Data flow (three directions) - -``` - ┌─────────────────────────────────────┐ - │ LLM (only sees ) │ - └──▲────────────────────────────────▲─┘ - │ outbound │ tool_result (redacted) - prompt assembly │ - ──────────────── │ - {{secret:X}} → │ - loopal-secret-runtime::translate_outbound │ - │ - ┌─────────────┴─────────────┐ - │ Redactor │ - │ plaintext → placeholder │ - │ (BEFORE overflow-to-file)│ - └─────────────▲─────────────┘ - │ - tool stdout (plaintext briefly here) - │ - ┌─────────────────┴─────────────────┐ - │ Tool execute (Bash/Fetch/MCP) │ - │ ↑ plaintext only in child env │ - └─────────────────▲─────────────────┘ - │ resolved tool args - ┌─────────────────┴─────────────────┐ - │ Resolver (whitelist only) │ - │ → plaintext │ - └─────────────────▲─────────────────┘ - │ tool_use (placeholder) - │ from LLM -``` - -### Components +- Tool: implement `loopal_tool_api::Tool`, place it in the matching `crates/tools` or + Agent tool family, and register it through tool assembly. +- Provider: implement `loopal_provider_api::Provider` under `loopal-provider` and + register it in `loopal-kernel`. +- Context stage: put reusable behavior in `loopal-context` and wire it from runtime. +- MCP: extend the Hub-owned/proxied boundary; never add a second subprocess owner. +- Desktop: add typed shared contracts first, privileged platform/Main behavior second, + and UI/tests/CSS in the matching `workbench/contrib` feature. -- `loopal-vault-api::Vault` — async trait `get / list_names / put / delete / rekey` -- `loopal-vault-api::AuditSink` — trait the vault calls on every op; `VaultOp` enum covers Decrypted / Encrypted / Rekeyed / RecipientChanged -- `loopal-vault-age::AgeVault` — default per-vault impl (age + yaml + SSH identity + recipients); `loopal-vault-age::cli` is the `loopal vault [--name ]` (legacy `vault@` accepted) + `loopal vaults` subcommands -- `loopal-secret-runtime::MergedVault` — composes multiple named vaults into a single flat `Vault` view (default-first + alphabetical, conflict warn) -- `loopal-secret-runtime::{template, resolver, redactor, hooks}` — placeholder syntax + tool argument substitution + output scrubbing -- `loopal-secret-runtime::JsonlAuditSink` — `impl AuditSink` writing `~/.loopal/telemetry/secret_access.jsonl` with mode 0600; also records runtime `Resolved` / `Redacted` events -- `loopal-config::build_secret_store` — instantiates `AgeVault::with_audit(..., JsonlAuditSink)` so vault ops and runtime hooks share one audit log -- `loopal-runtime::tool_pipeline` — calls `loopal_secret_runtime::apply_resolver` (Hook 2) before execute, `apply_redactor` (Hook 3) before overflow-to-file +## MCP startup contract -### Per-tool checklist (adding a new tool) +MCP startup never blocks `agent/start`. The root strategy starts configured connections +in the background and performs only a bounded initial wait. Slow servers settle later +and register tools dynamically. Subagents use `McpProxyClient`; they do not spawn their +own MCP processes. Every connection attempt is bounded by its configured timeout. -`Tool::secret_eligible_params() -> &'static [&'static str]` has **no default**. -You must declare it explicitly. For typed tools (`TypedTool

`), the same method -is on the typed trait — the `TypedBridge` forwards it to `Tool` automatically, -so a missing declaration is a compile error in either path. +## Vault and secret safety -| Tool kind | Return | -|---|---| -| Reads only (Read/Glob/Grep/Ls/...) | `&[]` | -| Writes to user files (Write/Edit/MultiEdit/ApplyPatch) | `&[]` AND add `precheck` rejecting `` (check string fields for `WIRE_REF_MARKER`) | -| Executes shell/network with secret-bearing args (Bash) | List of field names whose string values may legitimately contain ``, e.g. `&["command", "env"]` | -| MCP tool adapter | `&[]` (MCP gets secrets via spawn-time env, not tool args) | +The LLM and persisted Session data see placeholders, never plaintext: -### Vault file layout - -``` -/.loopal/vaults/ -├── default.vault/ # implicit default (init creates it) -│ ├── store.age # age-encrypted YAML (input: git ✓) -│ ├── recipients # SSH pubkeys, one per line (input: git ✓) -│ ├── .gitignore # auto-generated, excludes *.lock + *.tmp.* -│ ├── store.age.lock # cross-process write lock (gitignored) -│ └── store.age.tmp. # atomic-write tempfile (gitignored) -├── production.vault/ # additional vault, independent recipients/ACL -│ └── ... -└── personal.vault/ # can be added to root .gitignore to keep local-only - └── ... +```text +loopal-vault-api -> loopal-vault-age +loopal-secret-runtime -> loopal-config / MCP / kernel / runtime ``` -CLI commands: - -- **Vault set operations** (`loopal vaults `): - - `init []` — create a vault; name defaults to `default` - - `list` — list all vaults (`*` marks the default) - - `remove ` — delete a vault (forces `'rotated'` confirmation) -- **Single-vault operations** (`loopal vault [--name ] `): - - `vault ` (no `--name`) targets the default vault - - `vault@ ` (legacy syntax, normalized to `--name ` before clap parsing) - - Ops: `set [--value ]` (stdin recommended), `get `, `list`, `edit`, `rekey`, `recipients {add | remove

MANUAL COMPACTION SUMMARY MARKER" }, + { "type": "usage", "input": 65, "output": 12 }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "MANUAL COMPACTION SUMMARY MARKER" }, + "chunks": [ + { "type": "text", "text": "Post-compaction request used the summary boundary." }, + { "type": "usage", "input": 25, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-compat-tool-semantics.json b/apps/desktop/e2e/fixtures/llm/provider-compat-tool-semantics.json new file mode 100644 index 00000000..265b25f5 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-compat-tool-semantics.json @@ -0,0 +1,60 @@ +{ + "version": 2, + "name": "provider-compat-tool-semantics", + "calls": [ + { + "expect": { + "protocol": "openai_compat", "model": "deepseek-reasoner", + "userContains": "Exercise compatible tool failure", "minTools": 1, + "thinkingEnabled": true + }, + "chunks": [ + { "type": "thinking", "text": "COMPAT TOOL FAILURE REASONING" }, + { "type": "thinking_signature", "signature": "compat-tool-reasoning-signature" }, + { "type": "tool_use", "id": "compat-missing-read", "name": "Read", + "input": { "file_path": "${PROJECT}/missing-compat-provider.txt" } }, + { "type": "usage", "input": 21, "output": 7, "thinking": 5 }, + { "type": "done" } + ] + }, + { + "expect": { + "protocol": "openai_compat", "model": "deepseek-reasoner", + "toolResultId": "compat-missing-read", "bodyContains": "COMPAT TOOL FAILURE REASONING", + "assistantBlockTypes": ["thinking", "tool_use"] + }, + "chunks": [ + { "type": "text", "text": "Compatible provider observed the failed tool result." }, + { "type": "usage", "input": 27, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { + "protocol": "openai_compat", "model": "deepseek-reasoner", + "userContains": "Start a compatible cancellable tool", "minTools": 1 + }, + "chunks": [ + { "type": "tool_use", "id": "compat-cancel-bash", "name": "Bash", + "input": { + "command": "printf 'COMPAT TOOL EARLY\\n'; sleep 3; printf 'late\\n' > '${PROJECT}/compat-late.txt'", + "timeout": 60 + } }, + { "type": "usage", "input": 31, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { + "protocol": "openai_compat", "model": "deepseek-reasoner", + "userContains": "Recover compatible tool cancellation", + "toolResultId": "compat-cancel-bash", "bodyContains": "Interrupted by user" + }, + "chunks": [ + { "type": "text", "text": "Compatible provider retained the cancelled tool result." }, + { "type": "usage", "input": 37, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-context-recovery.json b/apps/desktop/e2e/fixtures/llm/provider-context-recovery.json new file mode 100644 index 00000000..a8534afa --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-context-recovery.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "name": "provider-context-recovery", + "calls": [ + { + "expect": { "userContains": "Seed recovery context one" }, + "chunks": [ + { "type": "text", "text": "RECOVERY SEED ONE" }, + { "type": "usage", "input": 45, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Seed recovery context two" }, + "chunks": [ + { "type": "text", "text": "RECOVERY SEED TWO" }, + { "type": "usage", "input": 60, "output": 7 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Trigger context overflow recovery" }, + "status": 400, + "body": { + "type": "error", + "error": { "type": "invalid_request_error", "message": "prompt is too long" } + } + }, + { + "expect": { + "bodyContains": "You produce structured working state summaries" + }, + "chunks": [ + { "type": "delay", "ms": 1000 }, + { "type": "text", "text": "CONTEXT RECOVERY SUMMARY" }, + { "type": "usage", "input": 70, "output": 12 }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "CONTEXT RECOVERY SUMMARY" }, + "chunks": [ + { "type": "text", "text": "Context overflow recovered after real compaction." }, + { "type": "usage", "input": 30, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-continuations.json b/apps/desktop/e2e/fixtures/llm/provider-continuations.json new file mode 100644 index 00000000..f3de6079 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-continuations.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "name": "provider-continuations", + "calls": [ + { + "expect": { "userContains": "Exercise pause continuation" }, + "chunks": [ + { "type": "text", "text": "PAUSE PARTIAL" }, + { "type": "usage", "input": 11, "output": 4 }, + { "type": "done", "reason": "pause_turn" } + ] + }, + { + "expect": { "bodyContains": "PAUSE PARTIAL" }, + "chunks": [ + { "type": "text", "text": "Pause continuation completed." }, + { "type": "usage", "input": 15, "output": 5 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Discard max token tool" }, + "chunks": [ + { "type": "text", "text": "MAX TOOL PARTIAL" }, + { + "type": "tool_use", + "id": "max-tool-write", + "name": "Write", + "input": { "file_path": "${PROJECT}/must-not-exist-max.txt", "content": "bad" } + }, + { "type": "usage", "input": 17, "output": 7 }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "MAX TOOL PARTIAL", "bodyExcludes": "max-tool-write" }, + "chunks": [ + { "type": "text", "text": "Max-token tool was discarded safely." }, + { "type": "usage", "input": 20, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Recover dropped stream" }, + "chunks": [ + { "type": "text", "text": "STREAM DROPPED AFTER PARTIAL" }, + { + "type": "tool_use", + "id": "dropped-tool-write", + "name": "Write", + "input": { "file_path": "${PROJECT}/must-not-exist-stream.txt", "content": "bad" } + }, + { "type": "disconnect" } + ] + }, + { + "expect": { + "bodyContains": "STREAM DROPPED AFTER PARTIAL", + "bodyExcludes": "dropped-tool-write" + }, + "chunks": [ + { "type": "text", "text": "Dropped stream continued without running its tool." }, + { "type": "usage", "input": 22, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Reach continuation cap" }, + "chunks": [ + { "type": "text", "text": "CAP SEGMENT ONE" }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "CAP SEGMENT ONE" }, + "chunks": [ + { "type": "text", "text": "CAP SEGMENT TWO" }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "CAP SEGMENT TWO" }, + "chunks": [ + { "type": "text", "text": "CAP SEGMENT THREE" }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "CAP SEGMENT THREE" }, + "chunks": [ + { "type": "text", "text": "CAP SEGMENT FOUR" }, + { "type": "usage", "input": 25, "output": 9 }, + { "type": "done", "reason": "max_tokens" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-controls.json b/apps/desktop/e2e/fixtures/llm/provider-controls.json new file mode 100644 index 00000000..0759d8f6 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-controls.json @@ -0,0 +1,73 @@ +{ + "version": 1, + "name": "provider-controls", + "calls": [ + { + "expect": { "userContains": "Seed control history" }, + "chunks": [ + { "type": "text", "text": "CONTROL BASELINE MARKER" }, + { "type": "usage", "input": 15, "output": 5 }, + { "type": "done" } + ] + }, + { + "expect": { + "model": "claude-sonnet-4-6", + "userContains": "Observe high thinking controls", + "bodyContains": "\"effort\":\"high\"" + }, + "chunks": [ + { "type": "thinking", "text": "CONTROLLED THINKING STREAM" }, + { "type": "thinking_signature", "signature": "controlled-thinking-signature" }, + { "type": "text", "text": "High thinking and model switch reached the provider." }, + { "type": "usage", "input": 22, "output": 9 }, + { "type": "done" } + ] + }, + { + "expect": { + "model": "claude-sonnet-4-6", + "userContains": "Observe disabled thinking controls", + "thinkingEnabled": false + }, + "chunks": [ + { "type": "text", "text": "Disabled thinking reached the provider." }, + { "type": "usage", "input": 24, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { + "model": "claude-sonnet-4-6", + "userContains": "Verify clear request history", + "bodyExcludes": "CONTROL BASELINE MARKER", + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "Clear removed prior model history." }, + { "type": "usage", "input": 12, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Seed rewind second turn" }, + "chunks": [ + { "type": "text", "text": "REWIND SECOND MARKER" }, + { "type": "usage", "input": 18, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "Verify rewind request history", + "bodyExcludes": "REWIND SECOND MARKER", + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "Rewind removed later model history." }, + { "type": "usage", "input": 12, "output": 6 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-conversation-semantics.json b/apps/desktop/e2e/fixtures/llm/provider-conversation-semantics.json new file mode 100644 index 00000000..cf8d7e73 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-conversation-semantics.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "name": "provider-conversation-semantics", + "calls": [ + { + "expect": { + "protocol": "anthropic", + "userContains": "Render deterministic conversation semantics", + "messageCount": 1 + }, + "chunks": [ + { + "type": "text", + "text": "# Conversation semantics\n\n## Native structure\n\nParagraph with **bold detail** and `inline-code`.\n\n> Quoted **bold** and `quoted-code`.\n\n- Unordered alpha\n- [Unsafe HTTP](http://example.com/http)\n\n1. Ordered first\n2. Ordered second\n\n```typescript\nconst safe = \"\"\n```\n\n[HTTPS reference](https://example.com/secure)\n\n[Unsafe script](javascript:evil)\n\n[Unsafe relative](/private)" + }, + { "type": "usage", "input": 28, "output": 46 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-cron-idle-lifecycle.json b/apps/desktop/e2e/fixtures/llm/provider-cron-idle-lifecycle.json new file mode 100644 index 00000000..72d46ef3 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-cron-idle-lifecycle.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "name": "provider-cron-idle-lifecycle", + "calls": [ + { + "expect": { "userContains": "Schedule a durable idle wake" }, + "chunks": [{ "type": "tool_use", "id": "cron-goal", "name": "create_goal", + "input": { "objective": "Verify durable scheduled wake behavior" } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "cron-goal" }, + "chunks": [{ "type": "tool_use", "id": "cron-create", "name": "CronCreate", "input": { + "cron": "* * * * *", "prompt": "DURABLE CRON WAKE MARKER", + "recurring": true, "durable": true } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "cron-create", "bodyContains": "durable" }, + "chunks": [{ "type": "tool_use", "id": "cron-idle", "name": "request_idle", "input": { + "reason": "Waiting for the durable Desktop cron fixture", + "max_idle_duration_secs": 120, "expected_wake_signal": "cron" } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "userContains": "DURABLE CRON WAKE MARKER" }, + "chunks": [ + { "type": "tool_use", "id": "cron-list-after-wake", "name": "CronList", "input": {} }, + { "type": "tool_use", "id": "cron-goal-complete", "name": "update_goal", + "input": { "status": "complete" } }, + { "type": "usage" }, { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "cron-list-after-wake", "bodyContains": "cron-goal-complete" }, + "chunks": [{ "type": "text", "text": "Durable cron woke the suspended production Session." }, + { "type": "usage" }, { "type": "done" }] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-electron-relaunch.json b/apps/desktop/e2e/fixtures/llm/provider-electron-relaunch.json new file mode 100644 index 00000000..0f0de15c --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-electron-relaunch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "name": "provider-electron-relaunch", + "calls": [ + { + "expect": { + "userContains": "PRE_RELAUNCH_USER_MARKER", + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "PRE_RELAUNCH_ASSISTANT_MARKER" }, + { "type": "usage", "input": 11, "output": 5 }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "POST_RELAUNCH_USER_MARKER", + "bodyContains": "PRE_RELAUNCH_USER_MARKER\",\"type\":\"text\"}],\"role\":\"user\"},{\"content\":[{\"text\":\"PRE_RELAUNCH_ASSISTANT_MARKER", + "messageCount": 3 + }, + "chunks": [ + { "type": "text", "text": "POST_RELAUNCH_ASSISTANT_MARKER" }, + { "type": "usage", "input": 29, "output": 7 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-global-skills.json b/apps/desktop/e2e/fixtures/llm/provider-global-skills.json new file mode 100644 index 00000000..ca183748 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-global-skills.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "name": "provider-global-skills", + "calls": [ + { + "expect": { + "protocol": "anthropic", + "userContains": "GLOBAL_SKILL_EDITED_BODY_MARKER", + "messageCount": 1 + }, + "chunks": [ + { + "type": "text", + "text": "Global Skill invocation reached the production runtime." + }, + { "type": "usage", "input": 31, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-goal-loop.json b/apps/desktop/e2e/fixtures/llm/provider-goal-loop.json new file mode 100644 index 00000000..8090de60 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-goal-loop.json @@ -0,0 +1,47 @@ +{ + "version": 2, + "name": "provider-goal-loop", + "calls": [ + { + "expect": { "userContains": "Start a deterministic persistent goal" }, + "chunks": [ + { + "type": "tool_use", + "id": "goal-loop-create", + "name": "create_goal", + "input": { "objective": "Exercise the autonomous Desktop goal loop" } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "goal-loop-create" }, + "chunks": [ + { "type": "text", "text": "The goal remains active after its first phase." }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "Continue working toward the active thread goal", + "bodyContains": "Exercise the autonomous Desktop goal loop" + }, + "chunks": [ + { + "type": "tool_use", + "id": "goal-loop-complete", + "name": "update_goal", + "input": { "status": "complete" } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "goal-loop-complete" }, + "chunks": [ + { "type": "text", "text": "Goal continuation completed without another human turn." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-google-partial.json b/apps/desktop/e2e/fixtures/llm/provider-google-partial.json new file mode 100644 index 00000000..5302f717 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-google-partial.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "name": "provider-google-partial", + "calls": [ + { + "expect": { + "protocol": "google", "model": "gemini-2.0-flash", + "userContains": "Recover a partial Google stream" + }, + "chunks": [ + { "type": "text", "text": "GOOGLE PARTIAL STREAM MARKER" }, + { "type": "invalid_sse", "data": "{invalid-google-sse" } + ] + }, + { + "expect": { + "protocol": "google", "model": "gemini-2.0-flash", + "bodyContains": "GOOGLE PARTIAL STREAM MARKER" + }, + "chunks": [ + { "type": "text", "text": "Google recovered its partial stream through continuation." }, + { "type": "usage", "input": 29, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-google-safety.json b/apps/desktop/e2e/fixtures/llm/provider-google-safety.json new file mode 100644 index 00000000..4d0363c2 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-google-safety.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "name": "provider-google-safety", + "calls": [ + { + "expect": { "protocol": "google", "userContains": "Exercise Google safety terminal" }, + "rawSse": [ + "{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Google safety terminal completed.\"}]},\"finishReason\":\"SAFETY\"}]}" + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-governance.json b/apps/desktop/e2e/fixtures/llm/provider-governance.json new file mode 100644 index 00000000..5fc82756 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-governance.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "name": "provider-governance", + "calls": [ + { + "expect": { "userContains": "Degeneration sample one" }, + "chunks": [ + { "type": "text", "text": "IDENTICAL DEGENERATION OUTPUT" }, + { "type": "usage", "input": 10, "output": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Degeneration sample two" }, + "chunks": [ + { "type": "text", "text": "IDENTICAL DEGENERATION OUTPUT" }, + { "type": "usage", "input": 11, "output": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Degeneration sample three" }, + "chunks": [ + { "type": "text", "text": "IDENTICAL DEGENERATION OUTPUT" }, + { "type": "usage", "input": 12, "output": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Degeneration sample four" }, + "chunks": [ + { "type": "text", "text": "IDENTICAL DEGENERATION OUTPUT" }, + { "type": "usage", "input": 13, "output": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Degeneration sample five" }, + "chunks": [ + { "type": "text", "text": "IDENTICAL DEGENERATION OUTPUT" }, + { "type": "usage", "input": 14, "output": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Resume after degeneration" }, + "chunks": [ + { + "type": "tool_use", + "id": "governance-progress-read", + "name": "Read", + "input": { "file_path": "${PROJECT}/README.md" } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "governance-progress-read" }, + "chunks": [ + { "type": "text", "text": "Fresh human progress reopened governance." }, + { "type": "usage", "input": 18, "output": 6 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-http-errors.json b/apps/desktop/e2e/fixtures/llm/provider-http-errors.json new file mode 100644 index 00000000..cc36268f --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-http-errors.json @@ -0,0 +1,74 @@ +{ + "version": 1, + "name": "provider-http-errors", + "calls": [ + { + "expect": { "userContains": "Recover one server error" }, + "status": 503, + "body": { "error": { "type": "overloaded_error", "message": "fixture overloaded" } } + }, + { + "expect": { "userContains": "Recover one server error" }, + "chunks": [ + { "type": "text", "text": "Recovered after one HTTP 503." }, + { "type": "usage", "input": 14, "output": 6 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise fatal authentication" }, + "status": 401, + "body": { "error": { "type": "authentication_error", "message": "fixture denied" } } + }, + { + "expect": { "userContains": "Recover after fatal provider error" }, + "chunks": [ + { "type": "text", "text": "Session recovered after a fatal provider turn." }, + { "type": "usage", "input": 16, "output": 7 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 1" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 2" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 3" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 4" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 5" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit 6" } } + }, + { + "expect": { "userContains": "Exhaust provider retries" }, + "status": 429, + "retryAfterMs": 0, + "body": { "error": { "type": "rate_limit_error", "message": "fixture limit final" } } + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-image-matrix.json b/apps/desktop/e2e/fixtures/llm/provider-image-matrix.json new file mode 100644 index 00000000..777e5470 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-image-matrix.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "name": "provider-image-matrix", + "calls": [ + { + "expect": { + "userContains": "Exercise provider image input", + "imageBlockCount": 1, + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "Provider image adapter completed its model turn." }, + { "type": "usage", "input": 19, "output": 7 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-interrupt.json b/apps/desktop/e2e/fixtures/llm/provider-interrupt.json new file mode 100644 index 00000000..33a39c00 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-interrupt.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "name": "provider-interrupt", + "calls": [ + { + "expect": { "userContains": "Hold this provider stream" }, + "chunks": [ + { "type": "text", "text": "STREAM START BEFORE INTERRUPT" }, + { "type": "delay", "ms": 30000 }, + { "type": "text", "text": "LATE CHUNK MUST NOT RENDER" }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Recover after interrupt" }, + "chunks": [ + { "type": "text", "text": "Recovered after cancelling the real HTTP stream." }, + { "type": "usage" }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-matrix.json b/apps/desktop/e2e/fixtures/llm/provider-matrix.json new file mode 100644 index 00000000..9897bdb3 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-matrix.json @@ -0,0 +1,28 @@ +{ + "version": 2, + "name": "provider-matrix", + "calls": [ + { + "expect": { "userContains": "Exercise provider wire", "minTools": 1 }, + "chunks": [ + { "type": "thinking", "text": "Inspecting the deterministic workspace." }, + { + "type": "tool_use", + "id": "provider-readme", + "name": "Read", + "input": { "file_path": "${PROJECT}/README.md" } + }, + { "type": "usage", "input": 23, "output": 8, "thinking": 4 }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Loopal Desktop E2E" }, + "chunks": [ + { "type": "text", "text": "Provider wire completed through the production runtime." }, + { "type": "usage", "input": 31, "output": 10 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-max-tokens.json b/apps/desktop/e2e/fixtures/llm/provider-max-tokens.json new file mode 100644 index 00000000..a0d085cf --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-max-tokens.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "name": "provider-max-tokens", + "calls": [ + { + "expect": { "userContains": "Exercise max token continuation" }, + "chunks": [ + { "type": "text", "text": "PARTIAL MAX TOKEN OUTPUT" }, + { "type": "usage", "input": 18, "output": 12 }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "PARTIAL MAX TOKEN OUTPUT" }, + "chunks": [ + { "type": "text", "text": "Continuation completed over a second HTTP request." }, + { "type": "usage", "input": 30, "output": 10 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-mcp-rich.json b/apps/desktop/e2e/fixtures/llm/provider-mcp-rich.json new file mode 100644 index 00000000..fe110fd5 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-mcp-rich.json @@ -0,0 +1,73 @@ +{ + "version": 2, + "name": "provider-mcp-rich", + "calls": [ + { + "expect": { "userContains": "Exercise rich MCP content" }, + "chunks": [ + { "type": "tool_use", "id": "mcp-rich-call", "name": "fixture_rich", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "mcp-rich-call", "bodyContains": "fixture embedded body" }, + "chunks": [ + { "type": "text", "text": "Rich MCP content reached the model." }, + { "type": "usage", "input": 31, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise MCP error result" }, + "chunks": [ + { "type": "tool_use", "id": "mcp-error-call", "name": "fixture_error", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "mcp-error-call", "bodyContains": "fixture MCP error result" }, + "chunks": [ + { "type": "text", "text": "MCP error result was preserved for the model." }, + { "type": "usage", "input": 43, "output": 9 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise MCP transport reconnect" }, + "chunks": [ + { + "type": "tool_use", "id": "mcp-reconnect-call", "name": "fixture_reconnect", + "input": { "marker": "${ROOT}/mcp-reconnect-marker.txt" } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "mcp-reconnect-call", "bodyContains": "reconnected after transport close" }, + "chunks": [ + { "type": "text", "text": "MCP transport reconnected and retried exactly once." }, + { "type": "usage", "input": 55, "output": 10 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Start cancellable MCP tool" }, + "chunks": [ + { "type": "tool_use", "id": "mcp-slow-call", "name": "fixture_slow", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "Recover after cancelling MCP tool", + "toolResultId": "mcp-slow-call", + "bodyContains": "Interrupted by user" + }, + "chunks": [ + { "type": "text", "text": "Session remained reusable after MCP cancellation." }, + { "type": "usage", "input": 67, "output": 11 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-mcp.json b/apps/desktop/e2e/fixtures/llm/provider-mcp.json new file mode 100644 index 00000000..7ba8ee0b --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-mcp.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "name": "provider-mcp", + "calls": [ + { + "expect": { + "userContains": "Exercise real stdio MCP echo", + "bodyContains": "fixture_echo", + "minTools": 1 + }, + "chunks": [ + { + "type": "tool_use", + "id": "fixture-echo-call", + "name": "fixture_echo", + "input": { "message": "desktop-mcp-roundtrip" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "fixture-echo-call", + "bodyContains": "fixture_echo result: desktop-mcp-roundtrip" + }, + "chunks": [ + { + "type": "text", + "text": "Real stdio MCP tool completed through the production model loop." + }, + { "type": "usage", "input": 32, "output": 11 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-memory.json b/apps/desktop/e2e/fixtures/llm/provider-memory.json new file mode 100644 index 00000000..6c9438d5 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-memory.json @@ -0,0 +1,166 @@ +{ + "version": 2, + "name": "provider-memory", + "calls": [ + { + "expect": { + "userContains": "ACTIVE_SESSION_MEMORY_DISABLED", + "bodyContains": "memory_recall" + }, + "chunks": [ + { + "type": "tool_use", "id": "memory-disabled", "name": "Memory", + "input": { "observation": "ACTIVE_SESSION_MEMORY_DISABLED must not be recorded" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "memory-disabled", "bodyContains": "Memory is not enabled" + }, + "chunks": [ + { "type": "text", "text": "The already-running Session kept memory disabled." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "NEW_SESSION_MEMORY_OBSERVATION" }, + "chunks": [ + { + "type": "tool_use", "id": "memory-new-session", "name": "Memory", + "input": { + "observation": "NEW_SESSION_MEMORY_OBSERVATION: desktopmemoryrank observations persist across Desktop restarts" + } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "memory-new-session", "bodyContains": "Noted." }, + "chunks": [ + { "type": "text", "text": "The new Session queued the memory observation." }, + { "type": "done" } + ] + }, + { + "expect": { + "bodyContains": "NEW_SESSION_MEMORY_OBSERVATION", "minTools": 1 + }, + "chunks": [ + { + "type": "tool_use", "id": "observer-recall", "name": "memory_recall", + "input": { "query": "desktopmemoryrank", "depth": 0 } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "observer-recall" }, + "chunks": [ + { + "type": "tool_use", "id": "observer-write", "name": "Write", + "input": { + "file_path": "${PROJECT}/.loopal/memory/memory-observer-roundtrip.md", + "content": "---\nname: Memory Observer Roundtrip\ndescription: Desktop memory observation persistence contract\ntype: project\ncreated_at: 2099-01-01\nupdated_at: 2099-01-01\nttl_days: 90\nrelated: []\n---\n\nOBSERVER PERSISTED CONTRACT: desktopmemoryrank observations survive Desktop Session restarts.\n" + } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "observer-write" }, + "chunks": [ + { "type": "text", "text": "Memory observation persisted by the Knowledge Manager." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "RESTARTED_SESSION_MEMORY_OBSERVATION" }, + "chunks": [ + { + "type": "tool_use", "id": "memory-restarted-session", "name": "Memory", + "input": { + "observation": "RESTARTED_SESSION_MEMORY_OBSERVATION: the desktopmemoryrank rule is unchanged" + } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "memory-restarted-session", "bodyContains": "Noted." }, + "chunks": [ + { "type": "text", "text": "The restarted Session queued another observation." }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "RESTARTED_SESSION_MEMORY_OBSERVATION" }, + "chunks": [ + { "type": "text", "text": "No memory update needed; the observation is redundant." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "RECALL_OBSERVER_MEMORY_BEFORE_IMPORTANCE" }, + "chunks": [ + { + "type": "tool_use", "id": "main-recall-before", "name": "memory_recall", + "input": { "query": "desktopmemoryrank", "depth": 0, "max_results": 2 } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "main-recall-before", "bodyContains": "OBSERVER PERSISTED CONTRACT" + }, + "chunks": [ + { "type": "text", "text": "The model received the recalled observer memory." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "TAG_BASELINE_MEMORY_IMPORTANCE" }, + "chunks": [ + { + "type": "tool_use", "id": "main-importance", "name": "memory_set_importance", + "input": { + "node": "stable-desktop-contract", "importance": 10, + "tags": ["desktop-e2e"], "note": "verify restart persistence" + } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "main-importance", + "bodyContains": "Tagged `stable-desktop-contract` with importance=10" + }, + "chunks": [ + { "type": "text", "text": "The importance result reached the model." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "RECALL_MEMORY_AFTER_RESTART" }, + "chunks": [ + { + "type": "tool_use", "id": "main-recall-after", "name": "memory_recall", + "input": { "query": "desktopmemoryrank", "depth": 0, "max_results": 2 } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "main-recall-after", "bodyContains": "OBSERVER PERSISTED CONTRACT" + }, + "chunks": [ + { "type": "text", "text": "Restarted recall preserved memory and importance ordering." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-openai-failed.json b/apps/desktop/e2e/fixtures/llm/provider-openai-failed.json new file mode 100644 index 00000000..0d5bfbc7 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-openai-failed.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "name": "provider-openai-failed", + "calls": [ + { + "expect": { "protocol": "openai_responses", "userContains": "Trigger OpenAI failed" }, + "rawSse": [ + "{\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"invalid_request_error\",\"message\":\"sensitive upstream detail\"}}}" + ] + }, + { + "expect": { "protocol": "openai_responses", "userContains": "Recover from OpenAI failed" }, + "chunks": [ + { "type": "text", "text": "OpenAI Session recovered after a structured terminal failure." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-openai-preheader.json b/apps/desktop/e2e/fixtures/llm/provider-openai-preheader.json new file mode 100644 index 00000000..a6111dbb --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-openai-preheader.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "name": "provider-openai-preheader", + "calls": [ + { + "expect": { + "protocol": "openai_responses", "model": "gpt-4.1", + "userContains": "Recover OpenAI before headers" + }, + "closeBeforeHeaders": true + }, + { + "expect": { + "protocol": "openai_responses", "model": "gpt-4.1", + "userContains": "Recover OpenAI before headers" + }, + "chunks": [ + { "type": "text", "text": "OpenAI recovered after its pre-header transport failure." }, + { "type": "usage", "input": 19, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-plan-approval.json b/apps/desktop/e2e/fixtures/llm/provider-plan-approval.json new file mode 100644 index 00000000..f0823586 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-plan-approval.json @@ -0,0 +1,76 @@ +{ + "version": 2, + "name": "provider-plan-approval", + "calls": [ + { + "expect": { "userContains": "Enter deterministic plan mode" }, + "chunks": [ + { "type": "tool_use", "id": "enter-plan-one", "name": "EnterPlanMode", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "enter-plan-one" }, + "chunks": [ + { "type": "text", "text": "Plan mode is ready for fixture seeding." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Request a rejected plan review" }, + "chunks": [ + { "type": "tool_use", "id": "exit-plan-reject", "name": "ExitPlanMode", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "exit-plan-reject", "bodyContains": "User rejected the plan" }, + "chunks": [ + { "type": "text", "text": "The rejected plan returned to the model for revision." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Request an edited plan approval" }, + "chunks": [ + { "type": "tool_use", "id": "exit-plan-edited", "name": "ExitPlanMode", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "exit-plan-edited", "bodyContains": "EDITED PLAN MARKER" }, + "chunks": [ + { "type": "text", "text": "The edited approval returned to the model and restored Act mode." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Enter plan mode for direct approval" }, + "chunks": [ + { "type": "tool_use", "id": "enter-plan-two", "name": "EnterPlanMode", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "enter-plan-two" }, + "chunks": [ + { "type": "text", "text": "Second plan review is ready." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Request direct plan approval" }, + "chunks": [ + { "type": "tool_use", "id": "exit-plan-approve", "name": "ExitPlanMode", "input": {} }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "exit-plan-approve", "bodyContains": "DIRECT PLAN MARKER" }, + "chunks": [ + { "type": "text", "text": "The direct approval returned to the model." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-preheader-retry.json b/apps/desktop/e2e/fixtures/llm/provider-preheader-retry.json new file mode 100644 index 00000000..ba18a94c --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-preheader-retry.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "name": "provider-preheader-retry", + "calls": [ + { + "expect": { "userContains": "Recover from a pre-header disconnect" }, + "closeBeforeHeaders": true + }, + { + "expect": { "userContains": "Recover from a pre-header disconnect" }, + "chunks": [ + { "type": "text", "text": "Recovered after the transport disappeared before headers." }, + { "type": "usage", "input": 17, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-retry-interrupt.json b/apps/desktop/e2e/fixtures/llm/provider-retry-interrupt.json new file mode 100644 index 00000000..5d746b87 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-retry-interrupt.json @@ -0,0 +1,23 @@ +{ + "version": 2, + "name": "provider-retry-interrupt", + "calls": [ + { + "expect": { "userContains": "Wait on a long provider retry" }, + "status": 429, + "retryAfterMs": 30000, + "body": { + "type": "error", + "error": { "type": "rate_limit_error", "message": "long deterministic backoff" } + } + }, + { + "expect": { "userContains": "Recover after interrupting retry wait" }, + "chunks": [ + { "type": "text", "text": "Recovered without waiting for the cancelled backoff." }, + { "type": "usage", "input": 19, "output": 7 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-retry.json b/apps/desktop/e2e/fixtures/llm/provider-retry.json new file mode 100644 index 00000000..d8faa6f4 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-retry.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "name": "provider-retry", + "calls": [ + { + "expect": { "userContains": "Recover from a scripted rate limit" }, + "status": 429, + "retryAfterMs": 2000, + "body": { + "type": "error", + "error": { "type": "rate_limit_error", "message": "mock capacity reached" } + } + }, + { + "expect": { "userContains": "Recover from a scripted rate limit" }, + "chunks": [ + { "type": "text", "text": "Recovered through the real provider retry loop." }, + { "type": "usage", "input": 21, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-secret-vault.json b/apps/desktop/e2e/fixtures/llm/provider-secret-vault.json new file mode 100644 index 00000000..7dcc9d0a --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-secret-vault.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "name": "provider-secret-vault", + "calls": [ + { + "expect": { + "userContains": "Exercise the production secret boundary", + "minTools": 1 + }, + "chunks": [ + { + "type": "tool_use", + "id": "secret-bash", + "name": "Bash", + "input": { + "command": "printf 'observed=%s\\n' \"$TOKEN\"", + "env": { "TOKEN": "" } + } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "secret-bash", + "bodyContains": "observed=", + "bodyExcludes": "LOOPAL_DESKTOP_E2E_SECRET_7c19f2a84d" + }, + "chunks": [ + { "type": "text", "text": "The production secret boundary stayed redacted." }, + { "type": "usage", "input": 37, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-server-block-recovery.json b/apps/desktop/e2e/fixtures/llm/provider-server-block-recovery.json new file mode 100644 index 00000000..85755806 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-server-block-recovery.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "name": "provider-server-block-recovery", + "calls": [ + { + "expect": { "userContains": "Trigger server block recovery" }, + "chunks": [ + { + "type": "server_tool_use", + "id": "search-server-1", + "name": "web_search", + "input": { "query": "deterministic server block history" } + }, + { + "type": "server_tool_result", + "block_type": "web_search_tool_result", + "tool_use_id": "search-server-1", + "content": [ + { "type": "web_search_result", "title": "History", "text": "fixture" } + ] + }, + { + "type": "tool_use", + "id": "server-recovery-read", + "name": "Read", + "input": { "file_path": "${PROJECT}/README.md" } + }, + { "type": "usage", "input": 32, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "server-recovery-read", + "bodyContains": "search-server-1" + }, + "status": 400, + "body": { + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "web_search block without a corresponding tool_result" + } + } + }, + { + "expect": { + "toolResultId": "server-recovery-read", + "bodyExcludes": "search-server-1" + }, + "chunks": [ + { "type": "text", "text": "Server blocks condensed and the request recovered." }, + { "type": "usage", "input": 28, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-server-search-google.json b/apps/desktop/e2e/fixtures/llm/provider-server-search-google.json new file mode 100644 index 00000000..ed25570b --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-server-search-google.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "name": "provider-server-search-google", + "calls": [ + { + "expect": { + "protocol": "google", + "userContains": "Use native Google server search", + "minTools": 1 + }, + "chunks": [ + { + "type": "server_tool_result", + "block_type": "web_search_tool_result", + "tool_use_id": "google_grounding_1", + "content": [ + { + "url": "https://fixture.loopal.invalid/google-search", + "title": "Fixture Google source" + } + ] + }, + { "type": "text", "text": "Google native search rendered." }, + { "type": "usage", "input": 29, "output": 8 }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { + "protocol": "google", + "userContains": "Use native Google server search", + "bodyContains": "Fixture Google source", + "messageCount": 2, + "assistantBlockTypes": ["server_tool_use", "server_tool_result", "text"], + "serverBlockCount": 2 + }, + "chunks": [ + { "type": "text", "text": "Google search history remained ordered." }, + { "type": "usage", "input": 41, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-server-search-openai.json b/apps/desktop/e2e/fixtures/llm/provider-server-search-openai.json new file mode 100644 index 00000000..7668d781 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-server-search-openai.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "name": "provider-server-search-openai", + "calls": [ + { + "expect": { + "protocol": "openai_responses", + "userContains": "Use native OpenAI server search", + "minTools": 1 + }, + "chunks": [ + { "type": "thinking", "text": "Selecting a server search query." }, + { "type": "thinking_signature", "signature": "rs_openai_search_1" }, + { + "type": "server_tool_use", + "id": "ws_openai_search_1", + "name": "WebSearch", + "input": { "query": "Loopal native OpenAI search contract" } + }, + { "type": "text", "text": "OpenAI native search rendered." }, + { "type": "usage", "input": 31, "output": 9, "thinking": 4 }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { + "protocol": "openai_responses", + "userContains": "Use native OpenAI server search", + "messageCount": 2, + "assistantBlockTypes": ["reasoning", "web_search_call", "message"], + "serverBlockCount": 1 + }, + "chunks": [ + { "type": "text", "text": "OpenAI search history remained ordered." }, + { "type": "usage", "input": 43, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-settings-anthropic.json b/apps/desktop/e2e/fixtures/llm/provider-settings-anthropic.json new file mode 100644 index 00000000..bace6468 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-settings-anthropic.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "name": "provider-settings-anthropic", + "calls": [{ + "expect": { + "protocol": "anthropic", "model": "claude-opus-4-8", + "userContains": "Use Anthropic saved in Desktop Settings" + }, + "chunks": [ + { "type": "text", "text": "Desktop Settings activated the authenticated Anthropic provider." }, + { "type": "usage", "input": 17, "output": 8 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-settings-google.json b/apps/desktop/e2e/fixtures/llm/provider-settings-google.json new file mode 100644 index 00000000..a978e074 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-settings-google.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "name": "provider-settings-google", + "calls": [{ + "expect": { + "protocol": "google", "model": "gemini-2.0-flash", + "userContains": "Use Google saved in Desktop Settings" + }, + "chunks": [ + { "type": "text", "text": "Desktop Settings activated the authenticated Google provider." }, + { "type": "usage", "input": 19, "output": 8 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-settings-openai-compatible.json b/apps/desktop/e2e/fixtures/llm/provider-settings-openai-compatible.json new file mode 100644 index 00000000..5156cc67 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-settings-openai-compatible.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "name": "provider-settings-openai-compatible", + "calls": [ + { + "expect": { + "protocol": "openai_compat", + "model": "compat-e2e/deepseek-reasoner", + "userContains": "Use compatible provider saved in Desktop Settings" + }, + "chunks": [ + { "type": "thinking", "text": "Settings route verified. " }, + { "type": "text", "text": "Desktop Settings activated the authenticated compatible provider." }, + { "type": "usage", "input": 19, "output": 10, "thinking": 4 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-settings-openai.json b/apps/desktop/e2e/fixtures/llm/provider-settings-openai.json new file mode 100644 index 00000000..94ff06f7 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-settings-openai.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "name": "provider-settings-openai", + "calls": [ + { + "expect": { + "protocol": "openai_responses", + "model": "gpt-4.1", + "userContains": "Use the provider saved in Desktop Settings", + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "Desktop Settings selected the OpenAI model." }, + { "type": "usage", "input": 17, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-skill.json b/apps/desktop/e2e/fixtures/llm/provider-skill.json new file mode 100644 index 00000000..46642f5f --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-skill.json @@ -0,0 +1,32 @@ +{ + "version": 2, + "name": "provider-skill", + "calls": [ + { + "expect": { + "protocol": "anthropic", + "userContains": "SKILL_E2E_BODY_MARKER", + "bodyContains": "Run the deterministic Desktop skill contract", + "messageCount": 1 + }, + "chunks": [ + { "type": "text", "text": "Skill invocation reached the production runtime." }, + { "type": "usage", "input": 27, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { + "protocol": "anthropic", + "userContains": "SKILL_E2E_AFTER_RELAUNCH", + "bodyContains": "SKILL_E2E_BODY_MARKER", + "messageCount": 3 + }, + "chunks": [ + { "type": "text", "text": "Skill history survived the Electron relaunch." }, + { "type": "usage", "input": 41, "output": 9 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-state-anthropic.json b/apps/desktop/e2e/fixtures/llm/provider-state-anthropic.json new file mode 100644 index 00000000..51fb29ee --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-state-anthropic.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "name": "provider-state-anthropic", + "calls": [{ + "expect": { + "protocol": "anthropic", "model": "claude-opus-4-8", + "userContains": "Observe Anthropic provider state", "thinkingEnabled": true + }, + "chunks": [ + { "type": "thinking", "text": "ANTHROPIC THINKING STATE MARKER 12345678901234567890" }, + { "type": "delay", "ms": 1200 }, + { "type": "thinking_signature", "signature": "state-anthropic-signature" }, + { "type": "text", "text": "ANTHROPIC STREAMING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "usage", "input": 37, "output": 13, "thinking": 11, + "cache_creation": 3, "cache_read": 5 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-state-google.json b/apps/desktop/e2e/fixtures/llm/provider-state-google.json new file mode 100644 index 00000000..d7061262 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-state-google.json @@ -0,0 +1,33 @@ +{ + "version": 2, + "name": "provider-state-google", + "calls": [{ + "expect": { + "protocol": "google", "model": "gemini-2.5-flash-preview-04-17", + "userContains": "Observe Google provider state", "thinkingEnabled": true + }, + "chunks": [ + { "type": "thinking", "text": "GOOGLE THINKING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "thinking_signature", "signature": "state-google-signature" }, + { "type": "text", "text": "GOOGLE STREAMING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "tool_use", "id": "google-state-read", "name": "Read", + "input": { "file_path": "${PROJECT}/README.md" } }, + { "type": "usage", "input": 37, "output": 13, "thinking": 11, + "cache_creation": 3, "cache_read": 5 }, + { "type": "done" } + ] + }, { + "expect": { + "protocol": "google", "model": "gemini-2.5-flash-preview-04-17", + "bodyContains": "state-google-signature", + "assistantBlockTypes": ["thinking", "text", "tool_use"] + }, + "chunks": [ + { "type": "text", "text": "GOOGLE SIGNATURE REPLAY MARKER" }, + { "type": "usage", "input": 37, "output": 13 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-state-openai-compat.json b/apps/desktop/e2e/fixtures/llm/provider-state-openai-compat.json new file mode 100644 index 00000000..5c98d39e --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-state-openai-compat.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "name": "provider-state-openai-compat", + "calls": [{ + "expect": { + "protocol": "openai_compat", "model": "deepseek-reasoner", + "userContains": "Observe compatible provider state", "thinkingEnabled": true, + "bodyContains": "\"reasoning_effort\":\"high\"" + }, + "chunks": [ + { "type": "thinking", "text": "COMPAT THINKING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "thinking_signature", "signature": "state-compat-signature" }, + { "type": "text", "text": "COMPAT STREAMING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "usage", "input": 37, "output": 13, "thinking": 11, + "cache_creation": 3, "cache_read": 5 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-state-openai.json b/apps/desktop/e2e/fixtures/llm/provider-state-openai.json new file mode 100644 index 00000000..fb4155b6 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-state-openai.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "name": "provider-state-openai", + "calls": [{ + "expect": { + "protocol": "openai_responses", "model": "o3", + "bodyContains": "Observe OpenAI provider state", "thinkingEnabled": true + }, + "chunks": [ + { "type": "thinking", "text": "OPENAI THINKING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "thinking_signature", "signature": "state-openai-signature" }, + { "type": "text", "text": "OPENAI STREAMING STATE MARKER" }, + { "type": "delay", "ms": 1200 }, + { "type": "usage", "input": 37, "output": 13, "thinking": 11, + "cache_creation": 3, "cache_read": 5 }, + { "type": "done" } + ] + }] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-stream-faults.json b/apps/desktop/e2e/fixtures/llm/provider-stream-faults.json new file mode 100644 index 00000000..9ba5a475 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-stream-faults.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "name": "provider-stream-faults", + "calls": [ + { + "expect": { "userContains": "Recover malformed partial stream" }, + "chunks": [ + { "type": "text", "text": "MALFORMED PARTIAL RESPONSE" }, + { "type": "invalid_sse", "data": "{not-json" } + ] + }, + { + "expect": { "bodyContains": "MALFORMED PARTIAL RESPONSE" }, + "chunks": [ + { "type": "text", "text": "Malformed SSE recovered through continuation." }, + { "type": "usage", "input": 19, "output": 7 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Handle empty stream disconnect" }, + "chunks": [ + { "type": "disconnect" } + ] + }, + { + "expect": { "userContains": "Continue after empty disconnect" }, + "chunks": [ + { "type": "text", "text": "Session remained usable after an empty disconnect." }, + { "type": "usage", "input": 21, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Recover thinking-only disconnect" }, + "chunks": [ + { "type": "thinking", "text": "THINKING BEFORE DISCONNECT" }, + { "type": "thinking_signature", "signature": "partial-thinking-signature" }, + { "type": "disconnect" } + ] + }, + { + "expect": { "bodyContains": "partial-thinking-signature" }, + "chunks": [ + { "type": "text", "text": "Thinking-only disconnect recovered." }, + { "type": "usage", "input": 23, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Recover server-only disconnect" }, + "chunks": [ + { + "type": "server_tool_use", + "id": "partial-server-tool", + "name": "web_search", + "input": { "query": "partial server stream" } + }, + { + "type": "server_tool_result", + "block_type": "web_search_tool_result", + "tool_use_id": "partial-server-tool", + "content": { "text": "PARTIAL SERVER RESULT" } + }, + { "type": "disconnect" } + ] + }, + { + "expect": { "bodyContains": "partial-server-tool" }, + "chunks": [ + { "type": "text", "text": "Server-only disconnect recovered." }, + { "type": "usage", "input": 25, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-task-lifecycle.json b/apps/desktop/e2e/fixtures/llm/provider-task-lifecycle.json new file mode 100644 index 00000000..34db5967 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-task-lifecycle.json @@ -0,0 +1,83 @@ +{ + "version": 2, + "name": "provider-task-lifecycle", + "calls": [ + { + "expect": { "userContains": "Build a persisted task dependency lifecycle" }, + "chunks": [{ "type": "tool_use", "id": "task-create-foundation", "name": "TaskCreate", + "input": { "subject": "Foundation task", "description": "Complete before dependent work." } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-create-foundation" }, + "chunks": [{ "type": "tool_use", "id": "task-create-dependent", "name": "TaskCreate", + "input": { "subject": "Dependent task", "description": "Wait for the foundation task." } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-create-dependent" }, + "chunks": [{ "type": "tool_use", "id": "task-foundation-start", "name": "TaskUpdate", + "input": { "taskId": "1", "status": "in_progress", "activeForm": "Running foundation lifecycle", "addBlocks": ["2"] } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-foundation-start" }, + "chunks": [{ "type": "tool_use", "id": "task-dependent-block", "name": "TaskUpdate", + "input": { "taskId": "2", "addBlockedBy": ["1"] } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-dependent-block" }, + "chunks": [{ "type": "tool_use", "id": "task-get-dependent", "name": "TaskGet", + "input": { "taskId": "2" } }, { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-get-dependent", "bodyContains": "Dependent task" }, + "chunks": [{ "type": "tool_use", "id": "task-list", "name": "TaskList", "input": {} }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-list", "bodyContains": "Foundation task" }, + "chunks": [{ "type": "tool_use", "id": "task-foundation-complete", "name": "TaskUpdate", + "input": { "taskId": "1", "status": "completed" } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-foundation-complete" }, + "chunks": [{ "type": "tool_use", "id": "task-dependent-start", "name": "TaskUpdate", + "input": { "taskId": "2", "status": "in_progress", "activeForm": "Running dependent lifecycle" } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-dependent-start" }, + "chunks": [{ "type": "text", "text": "Task lifecycle is ready for a Session restart." }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "userContains": "Complete the persisted dependent task" }, + "chunks": [{ "type": "tool_use", "id": "task-list-after-restart", "name": "TaskList", + "input": {} }, { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-list-after-restart", "bodyContains": "completed" }, + "chunks": [{ "type": "tool_use", "id": "task-get-after-restart", "name": "TaskGet", + "input": { "taskId": "2" } }, { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-get-after-restart", "bodyContains": "in_progress" }, + "chunks": [{ "type": "tool_use", "id": "task-dependent-complete", "name": "TaskUpdate", + "input": { "taskId": "2", "status": "completed" } }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-dependent-complete" }, + "chunks": [{ "type": "tool_use", "id": "task-list-final", "name": "TaskList", "input": {} }, + { "type": "usage" }, { "type": "done" }] + }, + { + "expect": { "toolResultId": "task-list-final", "bodyContains": "completed" }, + "chunks": [{ "type": "text", "text": "Persisted task lifecycle completed after restart." }, + { "type": "usage" }, { "type": "done" }] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-terminal-max.json b/apps/desktop/e2e/fixtures/llm/provider-terminal-max.json new file mode 100644 index 00000000..ce544b3e --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-terminal-max.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "name": "provider-terminal-max", + "calls": [ + { + "expect": { "userContains": "Exercise provider max token terminal" }, + "chunks": [ + { "type": "text", "text": "PROVIDER TERMINAL PARTIAL" }, + { "type": "usage", "input": 13, "output": 5 }, + { "type": "done", "reason": "max_tokens" } + ] + }, + { + "expect": { "bodyContains": "PROVIDER TERMINAL PARTIAL" }, + "chunks": [ + { "type": "text", "text": "Provider-specific max token continuation completed." }, + { "type": "usage", "input": 18, "output": 7 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-tool-interrupt.json b/apps/desktop/e2e/fixtures/llm/provider-tool-interrupt.json new file mode 100644 index 00000000..6f97310c --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-tool-interrupt.json @@ -0,0 +1,32 @@ +{ + "version": 2, + "name": "provider-tool-interrupt", + "calls": [ + { + "expect": { "userContains": "Start an interruptible model tool" }, + "chunks": [ + { + "type": "tool_use", + "id": "interrupt-bash", + "name": "Bash", + "input": { + "command": "printf 'TOOL EARLY\\n'; sleep 3; printf 'TOOL LATE\\n'; printf 'late\\n' > '${PROJECT}/tool-late-marker.txt'", + "timeout": 60 + } + }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "Recover after cancelling a model tool", + "toolResultId": "interrupt-bash", + "bodyContains": "Interrupted by user" + }, + "chunks": [ + { "type": "text", "text": "The Session remained reusable after tool cancellation." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-tools.json b/apps/desktop/e2e/fixtures/llm/provider-tools.json new file mode 100644 index 00000000..ff6fb336 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-tools.json @@ -0,0 +1,129 @@ +{ + "version": 1, + "name": "provider-tools", + "calls": [ + { + "expect": { "userContains": "Exercise server tools" }, + "chunks": [ + { + "type": "server_tool_use", + "id": "web-search-1", + "name": "web_search", + "input": { "query": "Loopal deterministic contract" }, + "inputFragments": ["{\"query\":", "\"Loopal deterministic contract\"}"] + }, + { + "type": "server_tool_result", + "block_type": "web_search_tool_result", + "tool_use_id": "web-search-1", + "content": [ + { "type": "web_search_result", "title": "Fixture result", "text": "SERVER RESULT BODY" } + ] + }, + { "type": "text", "text": "Server-side search blocks rendered." }, + { "type": "usage", "input": 31, "output": 9, "cache_creation": 5, "cache_read": 7 }, + { "type": "done" } + ] + }, + { + "expect": { + "userContains": "Exercise failed tools", + "bodyContains": "[server tool 'web_search' result condensed]" + }, + "chunks": [ + { + "type": "tool_use", + "id": "missing-read", + "name": "Read", + "input": { "file_path": "${PROJECT}/missing-file.txt" } + }, + { + "type": "tool_use", + "id": "unknown-tool", + "name": "NoSuchDesktopTool", + "input": { "marker": "unknown" } + }, + { "type": "usage", "input": 35, "output": 12 }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "missing-read", "bodyContains": "unknown-tool" }, + "chunks": [ + { "type": "text", "text": "Both tool failures returned to the model." }, + { "type": "usage", "input": 40, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise parallel tools" }, + "chunks": [ + { + "type": "tool_use", + "id": "parallel-readme", + "name": "Read", + "input": { "file_path": "${PROJECT}/README.md" } + }, + { + "type": "tool_use", + "id": "parallel-source", + "name": "Read", + "input": { "file_path": "${PROJECT}/src/main.rs" } + }, + { "type": "usage", "input": 44, "output": 10 }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "parallel-readme", "bodyContains": "parallel-source" }, + "chunks": [ + { "type": "text", "text": "Parallel tool batch completed." }, + { "type": "usage", "input": 48, "output": 7 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise tool progress" }, + "chunks": [ + { + "type": "tool_use", + "id": "progress-bash", + "name": "Bash", + "input": { + "command": "printf 'MODEL TOOL START\\n'; sleep 8; printf 'MODEL TOOL END\\n'", + "timeout": 15 + } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "progress-bash" }, + "chunks": [ + { "type": "text", "text": "Long-running tool progress completed." }, + { "type": "usage", "input": 52, "output": 8 }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Exercise image tool result" }, + "chunks": [ + { + "type": "tool_use", + "id": "read-image-result", + "name": "ReadImage", + "input": { "file_path": "${PROJECT}/pixel.png" } + }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "read-image-result", "bodyContains": "\"type\":\"image\"" }, + "chunks": [ + { "type": "text", "text": "Image tool output reached the model request." }, + { "type": "usage", "input": 54, "output": 8 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/provider-user-images.json b/apps/desktop/e2e/fixtures/llm/provider-user-images.json new file mode 100644 index 00000000..5c51ce45 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/provider-user-images.json @@ -0,0 +1,28 @@ +{ + "version": 2, + "name": "provider-user-images", + "calls": [ + { + "expect": { + "userContains": "Describe the attached pixel", + "bodyContains": "\"media_type\":\"image/png\"" + }, + "chunks": [ + { "type": "text", "text": "The caption and image reached the production provider." }, + { "type": "usage", "input": 29, "output": 9 }, + { "type": "done" } + ] + }, + { + "expect": { + "messageCount": 3, + "bodyContains": "\"media_type\":\"image/png\"" + }, + "chunks": [ + { "type": "text", "text": "The image-only turn also reached the production provider." }, + { "type": "usage", "input": 37, "output": 10 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/runtime-resources.json b/apps/desktop/e2e/fixtures/llm/runtime-resources.json new file mode 100644 index 00000000..dbce431e --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/runtime-resources.json @@ -0,0 +1,107 @@ +{ + "version": 1, + "name": "runtime-resources", + "calls": [ + { + "expect": { "userContains": "Create deterministic runtime resources" }, + "chunks": [ + { + "type": "tool_use", + "id": "goal-create", + "name": "create_goal", + "input": { "objective": "Verify Desktop runtime resource panels" } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "goal-create" }, + "chunks": [ + { + "type": "tool_use", + "id": "task-create", + "name": "TaskCreate", + "input": { + "subject": "Inspect runtime resource panels", + "description": "Keep a pending task visible in LoopalDesktop." + } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "task-create" }, + "chunks": [ + { + "type": "tool_use", + "id": "background-start", + "name": "Bash", + "input": { + "command": "sleep 120", + "run_in_background": true, + "description": "Desktop fixture background process" + } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "background-start" }, + "chunks": [ + { + "type": "tool_use", + "id": "cron-create", + "name": "CronCreate", + "input": { + "cron": "0 0 1 1 *", + "prompt": "Run the deterministic Desktop fixture check", + "recurring": true, + "durable": false + } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "cron-create" }, + "chunks": [ + { + "type": "tool_use", + "id": "resource-write", + "name": "Write", + "input": { + "file_path": "${PROJECT}/runtime-fixture.txt", + "content": "runtime fixture artifact\n" + } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "resource-write" }, + "chunks": [ + { + "type": "tool_use", + "id": "goal-complete", + "name": "update_goal", + "input": { "status": "complete" } + }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "goal-complete" }, + "chunks": [ + { "type": "text", "text": "All deterministic runtime resources are ready." }, + { "type": "usage", "input": 60, "output": 14 }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/session-directories.json b/apps/desktop/e2e/fixtures/llm/session-directories.json new file mode 100644 index 00000000..a0fa79a2 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/session-directories.json @@ -0,0 +1,118 @@ +{ + "version": 1, + "name": "session-directories", + "calls": [ + { + "expect": { "userContains": "Verify the plain directory" }, + "chunks": [ + { + "type": "tool_use", + "id": "plain-pwd", + "name": "Bash", + "input": { "command": "pwd" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "plain-pwd", + "bodyContains": "${ROOT}/session-directories/plain workspace" + }, + "chunks": [ + { "type": "text", "text": "Plain directory verified." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Verify the Git directory directly" }, + "chunks": [ + { + "type": "tool_use", + "id": "git-direct-pwd", + "name": "Bash", + "input": { "command": "pwd; git branch --show-current" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "git-direct-pwd", + "bodyContains": "${ROOT}/session-directories/git workspace" + }, + "chunks": [ + { "type": "text", "text": "Git directory verified." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Verify the isolated worktree" }, + "chunks": [ + { + "type": "tool_use", + "id": "worktree-pwd", + "name": "Bash", + "input": { + "command": "pwd; git branch --show-current; printf 'isolated\\n' > worktree-only.txt" + } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "worktree-pwd", + "bodyContains": "loopal-wt-e2e-isolated" + }, + "chunks": [ + { "type": "text", "text": "Worktree isolation verified." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Verify the restarted isolated worktree" }, + "chunks": [ + { + "type": "tool_use", + "id": "restarted-worktree-pwd", + "name": "Bash", + "input": { "command": "pwd; git branch --show-current; test -f worktree-only.txt" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "restarted-worktree-pwd", + "bodyContains": "loopal-wt-e2e-isolated" + }, + "chunks": [ + { "type": "text", "text": "Restarted worktree verified." }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Verify the relaunched isolated worktree" }, + "chunks": [ + { + "type": "tool_use", + "id": "relaunched-worktree-pwd", + "name": "Bash", + "input": { "command": "pwd; git branch --show-current; test -f worktree-only.txt" } + }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "relaunched-worktree-pwd", + "bodyContains": "loopal-wt-e2e-isolated" + }, + "chunks": [ + { "type": "text", "text": "Relaunched worktree verified." }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/subagent-process-lifecycle.json b/apps/desktop/e2e/fixtures/llm/subagent-process-lifecycle.json new file mode 100644 index 00000000..e8a27a0c --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/subagent-process-lifecycle.json @@ -0,0 +1,87 @@ +{ + "version": 2, + "name": "subagent-process-lifecycle", + "calls": [ + { + "expect": { "userContains": "Spawn the interruptible process child" }, + "chunks": [{ + "type": "tool_use", "id": "spawn-interrupt", "name": "Agent", + "input": { + "action": "spawn", "name": "interrupt-child", + "prompt": "Hold the interrupt child stream", "run_in_background": true + } + }, { "type": "done" }] + }, + { + "expect": { "userContains": "Hold the interrupt child stream" }, + "chunks": [ + { "type": "text", "text": "INTERRUPT CHILD STREAM ACTIVE" }, + { "type": "delay", "ms": 30000 }, + { "type": "text", "text": "INTERRUPT CHILD LATE OUTPUT" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-interrupt", "bodyContains": "spawned in background" }, + "chunks": [{ "type": "text", "text": "Interrupt child is running." }, { "type": "done" }] + }, + { + "expect": { "userContains": "INTERRUPT CHILD STREAM ACTIVE" }, + "chunks": [{ "type": "text", "text": "Root observed interrupted child completion." }, { "type": "done" }] + }, + { + "expect": { "userContains": "Spawn the stop cleanup child" }, + "chunks": [{ + "type": "tool_use", "id": "spawn-stop", "name": "Agent", + "input": { + "action": "spawn", "name": "stop-child", + "prompt": "Hold the stop child stream", "run_in_background": true + } + }, { "type": "done" }] + }, + { + "expect": { "userContains": "Hold the stop child stream" }, + "chunks": [ + { "type": "text", "text": "STOP CHILD STREAM ACTIVE" }, + { "type": "delay", "ms": 30000 }, + { "type": "text", "text": "STOP CHILD LATE OUTPUT" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-stop", "bodyContains": "spawned in background" }, + "chunks": [{ "type": "text", "text": "Stop child is running." }, { "type": "done" }] + }, + { + "expect": { "userContains": "Recover after stopping the Session" }, + "chunks": [{ "type": "text", "text": "Session recovered after stop cleanup." }, { "type": "done" }] + }, + { + "expect": { "userContains": "Spawn the restart cleanup child" }, + "chunks": [{ + "type": "tool_use", "id": "spawn-restart", "name": "Agent", + "input": { + "action": "spawn", "name": "restart-child", + "prompt": "Hold the restart child stream", "run_in_background": true + } + }, { "type": "done" }] + }, + { + "expect": { "userContains": "Hold the restart child stream" }, + "chunks": [ + { "type": "text", "text": "RESTART CHILD STREAM ACTIVE" }, + { "type": "delay", "ms": 30000 }, + { "type": "text", "text": "RESTART CHILD LATE OUTPUT" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-restart", "bodyContains": "spawned in background" }, + "chunks": [{ "type": "text", "text": "Restart child is running." }, { "type": "done" }] + }, + { + "expect": { "userContains": "Recover after restarting the Session" }, + "chunks": [{ "type": "text", "text": "Session recovered after restart cleanup." }, { "type": "done" }] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/subagent-ui-interrupt.json b/apps/desktop/e2e/fixtures/llm/subagent-ui-interrupt.json new file mode 100644 index 00000000..d9115ca7 --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/subagent-ui-interrupt.json @@ -0,0 +1,40 @@ +{ + "version": 2, + "name": "subagent-ui-interrupt", + "calls": [ + { + "expect": { "userContains": "Spawn the UI interrupt child" }, + "chunks": [{ + "type": "tool_use", "id": "spawn-ui-interrupt", "name": "Agent", + "input": { + "action": "spawn", "name": "ui-interrupt-child", + "prompt": "Hold the UI interrupt child stream", "run_in_background": true + } + }, { "type": "done" }] + }, + { + "expect": { "userContains": "Hold the UI interrupt child stream" }, + "chunks": [ + { "type": "text", "text": "UI CHILD STREAM ACTIVE" }, + { "type": "delay", "ms": 30000 }, + { "type": "text", "text": "UI CHILD LATE OUTPUT" }, + { "type": "done" } + ] + }, + { + "expect": { + "toolResultId": "spawn-ui-interrupt", + "bodyContains": "spawned in background" + }, + "chunks": [{ "type": "text", "text": "UI child is running in background." }, { "type": "done" }] + }, + { + "expect": { "userContains": "UI CHILD STREAM ACTIVE" }, + "chunks": [{ "type": "text", "text": "Root observed the UI child interrupt." }, { "type": "done" }] + }, + { + "expect": { "userContains": "Continue after the UI child interrupt" }, + "chunks": [{ "type": "text", "text": "Root remained usable after the UI interrupt." }, { "type": "done" }] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/llm/topology-model-lifecycle.json b/apps/desktop/e2e/fixtures/llm/topology-model-lifecycle.json new file mode 100644 index 00000000..e6c7406d --- /dev/null +++ b/apps/desktop/e2e/fixtures/llm/topology-model-lifecycle.json @@ -0,0 +1,111 @@ +{ + "version": 1, + "name": "topology-model-lifecycle", + "calls": [ + { + "expect": { "userContains": "Spawn parallel model children" }, + "chunks": [ + { + "type": "tool_use", "id": "spawn-alpha", "name": "Agent", + "input": { "action": "spawn", "name": "parallel-alpha", "prompt": "Return ALPHA CHILD RESULT" } + }, + { + "type": "tool_use", "id": "spawn-beta", "name": "Agent", + "input": { "action": "spawn", "name": "parallel-beta", "prompt": "Return BETA CHILD RESULT" } + }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Return ALPHA CHILD RESULT" }, + "chunks": [ + { "type": "text", "text": "ALPHA CHILD RESULT" }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Return BETA CHILD RESULT" }, + "chunks": [ + { "type": "text", "text": "BETA CHILD RESULT" }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-alpha", "bodyContains": "spawn-beta" }, + "chunks": [ + { "type": "text", "text": "Parallel child results returned to root." }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Spawn a failing model child" }, + "chunks": [ + { + "type": "tool_use", "id": "spawn-failed", "name": "Agent", + "input": { "action": "spawn", "name": "failed-child", "prompt": "Fail this child provider call" } + }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Fail this child provider call" }, + "status": 400, + "body": { "error": { "type": "invalid_request_error", "message": "scripted child failure" } } + }, + { + "expect": { "toolResultId": "spawn-failed" }, + "chunks": [ + { "type": "text", "text": "Failed child result returned to root." }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "userContains": "Spawn a nested model child" }, + "chunks": [ + { + "type": "tool_use", "id": "spawn-parent", "name": "Agent", + "input": { "action": "spawn", "name": "parent-child", "prompt": "Spawn nested grandchild now" } + }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Spawn nested grandchild now" }, + "chunks": [ + { + "type": "tool_use", "id": "spawn-grandchild", "name": "Agent", + "input": { "action": "spawn", "name": "nested-grandchild", "prompt": "Return GRANDCHILD RESULT" } + }, + { "type": "done" } + ] + }, + { + "expect": { "bodyContains": "Return GRANDCHILD RESULT" }, + "chunks": [ + { "type": "text", "text": "GRANDCHILD RESULT" }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-grandchild" }, + "chunks": [ + { "type": "text", "text": "PARENT RECEIVED GRANDCHILD RESULT" }, + { "type": "usage" }, + { "type": "done" } + ] + }, + { + "expect": { "toolResultId": "spawn-parent" }, + "chunks": [ + { "type": "text", "text": "Nested child result returned to root." }, + { "type": "usage" }, + { "type": "done" } + ] + } + ] +} diff --git a/apps/desktop/e2e/fixtures/mcp/fixture-echo.mjs b/apps/desktop/e2e/fixtures/mcp/fixture-echo.mjs new file mode 100644 index 00000000..50b3a9a0 --- /dev/null +++ b/apps/desktop/e2e/fixtures/mcp/fixture-echo.mjs @@ -0,0 +1,121 @@ +import { existsSync, writeFileSync } from 'node:fs' +import { createInterface } from 'node:readline' + +const objectSchema = (properties = {}, required = []) => ({ + type: 'object', properties, required, additionalProperties: false, +}) +const tools = [{ + name: 'fixture_echo', + description: 'Echo a deterministic value from the Desktop MCP fixture.', + inputSchema: objectSchema({ message: { type: 'string' } }, ['message']), +}, { + name: 'fixture_rich', + description: 'Return every supported rich MCP content block.', + inputSchema: objectSchema(), +}, { + name: 'fixture_error', + description: 'Return an MCP-level error result.', + inputSchema: objectSchema(), +}, { + name: 'fixture_reconnect', + description: 'Close once, then succeed after automatic reconnect.', + inputSchema: objectSchema({ marker: { type: 'string' } }, ['marker']), +}, { + name: 'fixture_slow', + description: 'Complete late so the runtime can cancel the call.', + inputSchema: objectSchema(), +}] + +function respond(id, result) { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`) +} + +function fail(id, code, message) { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`) +} + +function callTool(id, params) { + if (params.name === 'fixture_echo' && typeof params.arguments?.message === 'string') { + respond(id, { content: [{ + type: 'text', text: `fixture_echo result: ${params.arguments.message}`, + }], isError: false }) + return + } + if (params.name === 'fixture_rich') { + respond(id, { content: [ + { type: 'text', text: 'fixture rich text' }, + { type: 'image', mimeType: 'image/png', data: 'iVBORw0KGgo=' }, + { type: 'resource', resource: { + uri: 'fixture://embedded', mimeType: 'text/plain', text: 'fixture embedded body', + } }, + { + type: 'resource_link', uri: 'fixture://linked', name: 'fixture-linked', + description: 'Fixture resource link', mimeType: 'text/plain', + }, + ], isError: false }) + return + } + if (params.name === 'fixture_error') { + respond(id, { + content: [{ type: 'text', text: 'fixture MCP error result' }], isError: true, + }) + return + } + if (params.name === 'fixture_reconnect' && typeof params.arguments?.marker === 'string') { + if (!existsSync(params.arguments.marker)) { + writeFileSync(params.arguments.marker, 'closed once\n') + process.exit(17) + } + respond(id, { content: [{ + type: 'text', text: 'fixture reconnected after transport close', + }], isError: false }) + return + } + if (params.name === 'fixture_slow') { + setTimeout(() => respond(id, { content: [{ + type: 'text', text: 'fixture slow late result', + }], isError: false }), 4_000) + return + } + fail(id, -32602, `Invalid fixture tool call: ${String(params.name)}`) +} + +function handle(message) { + if (!Object.hasOwn(message, 'id')) return + const { id, method, params = {} } = message + if (method === 'initialize') { + respond(id, { + protocolVersion: params.protocolVersion ?? '2025-06-18', + capabilities: { + tools: { listChanged: false }, resources: { listChanged: false }, + prompts: { listChanged: false }, + }, + serverInfo: { name: 'loopal-desktop-fixture', version: '2.0.0' }, + instructions: 'Use the fixture tools, resource, and prompt for Desktop MCP tests.', + }) + return + } + if (method === 'tools/list') return respond(id, { tools }) + if (method === 'tools/call') return callTool(id, params) + if (method === 'resources/list') return respond(id, { resources: [{ + uri: 'fixture://resource', name: 'fixture-resource', + description: 'Deterministic fixture resource', mimeType: 'text/plain', + }] }) + if (method === 'resources/read') return respond(id, { contents: [{ + uri: params.uri, mimeType: 'text/plain', text: 'fixture resource contents', + }] }) + if (method === 'prompts/list') return respond(id, { prompts: [{ + name: 'fixture_prompt', description: 'Deterministic fixture prompt', arguments: [], + }] }) + if (method === 'prompts/get') return respond(id, { + description: 'Deterministic fixture prompt', + messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt body' } }], + }) + if (method === 'ping') return respond(id, {}) + fail(id, -32601, `Unsupported fixture method: ${String(method)}`) +} + +createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', (line) => { + try { handle(JSON.parse(line)) } + catch { fail(null, -32700, 'Invalid JSON') } +}) diff --git a/apps/desktop/e2e/fixtures/outside.txt b/apps/desktop/e2e/fixtures/outside.txt new file mode 100644 index 00000000..06d10a57 --- /dev/null +++ b/apps/desktop/e2e/fixtures/outside.txt @@ -0,0 +1 @@ +outside diff --git a/apps/desktop/e2e/fixtures/plugins/global-skills/LOOPAL.md b/apps/desktop/e2e/fixtures/plugins/global-skills/LOOPAL.md new file mode 100644 index 00000000..ee908786 --- /dev/null +++ b/apps/desktop/e2e/fixtures/plugins/global-skills/LOOPAL.md @@ -0,0 +1,3 @@ +PLUGIN_GLOBAL_INSTRUCTIONS_MARKER + +Use the global-skills fixture only to verify Desktop plugin discovery. diff --git a/apps/desktop/e2e/fixtures/plugins/global-skills/settings.json b/apps/desktop/e2e/fixtures/plugins/global-skills/settings.json new file mode 100644 index 00000000..74f4b46b --- /dev/null +++ b/apps/desktop/e2e/fixtures/plugins/global-skills/settings.json @@ -0,0 +1,9 @@ +{ + "mcp_servers": { + "fixture-mcp": { + "type": "stdio", + "command": "fixture-mcp", + "enabled": false + } + } +} diff --git a/apps/desktop/e2e/fixtures/plugins/global-skills/skills/plugin-check.md b/apps/desktop/e2e/fixtures/plugins/global-skills/skills/plugin-check.md new file mode 100644 index 00000000..3aecc9bb --- /dev/null +++ b/apps/desktop/e2e/fixtures/plugins/global-skills/skills/plugin-check.md @@ -0,0 +1,7 @@ +--- +description: Verify plugin-contributed Skill discovery +--- + +PLUGIN_SKILL_BODY_MARKER + +Report the plugin arguments: $ARGUMENTS diff --git a/apps/desktop/e2e/fixtures/workspaces/basic/README.md b/apps/desktop/e2e/fixtures/workspaces/basic/README.md new file mode 100644 index 00000000..5db0bb2d --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/basic/README.md @@ -0,0 +1,3 @@ +# Loopal Desktop E2E + +This workspace is a deterministic local fixture for Desktop and Loopal integration tests. diff --git a/apps/desktop/e2e/fixtures/workspaces/basic/pixel.png.base64 b/apps/desktop/e2e/fixtures/workspaces/basic/pixel.png.base64 new file mode 100644 index 00000000..a2005777 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/basic/pixel.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII= diff --git a/apps/desktop/e2e/fixtures/workspaces/basic/src/main.rs b/apps/desktop/e2e/fixtures/workspaces/basic/src/main.rs new file mode 100644 index 00000000..63c61707 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/basic/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("ready"); +} diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/MEMORY.md b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/MEMORY.md new file mode 100644 index 00000000..1e06d78e --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/MEMORY.md @@ -0,0 +1,12 @@ +--- +generated_from: fixture +generated_at: 2000-01-01T00:00:00Z +node_count: 1 +edge_count: 0 +--- + +# Memory Index + +## Reference + +- [[stable-desktop-contract]] — deterministic memory importance ordering baseline diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/stable-desktop-contract.md b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/stable-desktop-contract.md new file mode 100644 index 00000000..09172830 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/memory/stable-desktop-contract.md @@ -0,0 +1,11 @@ +--- +name: Stable Desktop Contract +description: Reference baseline for deterministic memory importance ordering +type: reference +created_at: 2000-01-01 +updated_at: 2000-01-01 +ttl_days: null +related: [] +--- + +The desktopmemoryrank baseline remains deliberately less salient until the model tags it. diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/settings.local.json b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/settings.local.json new file mode 100644 index 00000000..135bff21 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/.loopal/settings.local.json @@ -0,0 +1,7 @@ +{ + "permission_mode": "bypass", + "memory": { + "batch_window_ms": 250, + "consolidation_interval_days": 0 + } +} diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/README.md b/apps/desktop/e2e/fixtures/workspaces/memory/README.md new file mode 100644 index 00000000..e04980f1 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/README.md @@ -0,0 +1,3 @@ +# Loopal Desktop Memory E2E + +This workspace isolates all persistent-memory behavior exercised by Desktop tests. diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/pixel.png.base64 b/apps/desktop/e2e/fixtures/workspaces/memory/pixel.png.base64 new file mode 100644 index 00000000..a2005777 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/pixel.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII= diff --git a/apps/desktop/e2e/fixtures/workspaces/memory/src/main.rs b/apps/desktop/e2e/fixtures/workspaces/memory/src/main.rs new file mode 100644 index 00000000..162ac28c --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/memory/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("memory fixture ready"); +} diff --git a/apps/desktop/e2e/fixtures/workspaces/skill/.loopal/skills/desktop-check.md b/apps/desktop/e2e/fixtures/workspaces/skill/.loopal/skills/desktop-check.md new file mode 100644 index 00000000..287707de --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/skill/.loopal/skills/desktop-check.md @@ -0,0 +1,5 @@ +--- +description: Run the deterministic Desktop skill contract +--- +SKILL_E2E_BODY_MARKER +Apply the Desktop skill to $ARGUMENTS. diff --git a/apps/desktop/e2e/fixtures/workspaces/skill/README.md b/apps/desktop/e2e/fixtures/workspaces/skill/README.md new file mode 100644 index 00000000..6fa71e54 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/skill/README.md @@ -0,0 +1,3 @@ +# Loopal Desktop Skill E2E + +This workspace verifies real project Skill routing and persistence. diff --git a/apps/desktop/e2e/fixtures/workspaces/skill/pixel.png.base64 b/apps/desktop/e2e/fixtures/workspaces/skill/pixel.png.base64 new file mode 100644 index 00000000..a2005777 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/skill/pixel.png.base64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII= diff --git a/apps/desktop/e2e/fixtures/workspaces/skill/src/main.rs b/apps/desktop/e2e/fixtures/workspaces/skill/src/main.rs new file mode 100644 index 00000000..99bea4a5 --- /dev/null +++ b/apps/desktop/e2e/fixtures/workspaces/skill/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("skill-ready"); +} diff --git a/apps/desktop/e2e/real/host/attention/host-attention-topology.spec.ts b/apps/desktop/e2e/real/host/attention/host-attention-topology.spec.ts new file mode 100644 index 00000000..4bd9e0c1 --- /dev/null +++ b/apps/desktop/e2e/real/host/attention/host-attention-topology.spec.ts @@ -0,0 +1,155 @@ +import { expect, test, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../../support/electron/electron-fixture' +import { selectSettingsSection } from '../../../support/settings/settings-helpers' + +test('resolves real questions, approvals, and retained child topology', async () => { + const desktop = await launchDesktop('real', 'attention-topology') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + await expect(page.getByTestId('runtime-status')).toContainText( + 'Ready for input', { timeout: 30_000 }, + ) + await expect(page.getByLabel('Message Loopal')).toBeEnabled({ timeout: 30_000 }) + await expect(page.getByRole('tab', { name: 'Agents', exact: true })).toHaveCount(0) + const target = await configureInteractiveRuntime(page) + + await send(page, 'Ask me which verification path to use') + const questions = page.getByTestId('questions-pane') + await expect(questions).toContainText('Verification: Choose a verification path') + await expect(questions).toContainText('Auto-answering') + await questions.getByRole('button', { name: /Thorough/ }).click() + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(questions).toHaveCount(0) + await expect(page.getByTestId('conversation')).toContainText( + 'Question answer reached the real runtime.', { timeout: 20_000 }, + ) + await setDecisionMode(page, target, 'manual') + + await send(page, 'Try a write that I will deny') + const approvals = page.getByTestId('permissions-pane') + await expect(approvals).toContainText('Allow Write') + await approvals.getByRole('button', { name: 'Deny' }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'Denied write was handled and the runtime continued.', { timeout: 20_000 }, + ) + await expect(readFile(join(desktop.project, 'denied.txt'), 'utf8')).rejects.toThrow() + + await send(page, 'Perform the approved write') + await expect(approvals).toContainText('Allow Write') + await approvals.getByRole('button', { name: 'Allow', exact: true }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'Approved write completed through the real runtime.', { timeout: 20_000 }, + ) + await expect.poll(() => readFile(join(desktop.project, 'approved.txt'), 'utf8')) + .toBe('real approval path\n') + const artifactsTab = page.getByRole('tab', { name: 'Artifacts', exact: true }) + await artifactsTab.click() + await expect(page.getByTestId('artifacts-pane')).toContainText('approved.txt') + + await send(page, 'Spawn the retained desktop child') + await expect(approvals).toContainText('Allow Agent') + await approvals.getByRole('button', { name: 'Allow', exact: true }).click() + await expect(questions).toContainText('Choose a verification path', { timeout: 30_000 }) + await expect(questions).toContainText('Agent question · desktop-child') + await questions.getByRole('button', { name: /Fast/ }).click() + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'Root received the retained child result.', { timeout: 40_000 }, + ) + + await expect(artifactsTab).toHaveAttribute('aria-selected', 'true') + const agentsTab = page.getByRole('tab', { name: 'Agents', exact: true }) + await agentsTab.click() + const agents = page.getByTestId('agents-pane') + await agents.getByRole('button', { name: 'All', exact: true }).click() + const child = agents.locator('[data-agent-id="desktop-child"]') + await expect(child).toContainText('completed', { timeout: 30_000 }) + await child.click() + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText('Viewing desktop-child · completed') + await expect(conversation).toContainText('Finish after answering the verification question.') + await expect(conversation).toContainText('Q1 (Choose a verification path): Fast') + await expect(conversation).toContainText('CHILD RESPONSE RETAINED') + await expect(page.getByLabel('Message desktop-child')).toBeDisabled() + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await expect(page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Clear' })).toBeDisabled() + + const detail = await page.evaluate( + (id) => window.loopalDesktop.openSession(id), target.sessionId, + ) + expect(detail.agents.find((agent) => agent.id === 'desktop-child')?.conversation?.length) + .toBeGreaterThan(0) + await expect.poll(() => desktop.llm!.requests()).toHaveLength(11) + const requests = await desktop.llm!.requests() + expect(requests.every((request) => request.matched && request.apiKeyPresent)).toBe(true) + expect(requests.some((request) => ( + request.lastUserText.includes('Finish after answering the verification question.') + ))).toBe(true) + expect(requests.some((request) => request.toolResultIds.includes('child-ask-path'))).toBe(true) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'attention-topology', remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +interface RuntimeTarget { + readonly sessionId: string + readonly runtimeId: string + readonly generation: number + readonly agentId: string +} + +async function configureInteractiveRuntime(page: Page): Promise { + const target = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + }) + await page.evaluate(async (value) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'decision', mode: 'classifier' }, + }) + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'ask_any_write' }, + }) + }, target) + await expect.poll(async () => page.evaluate(async (value) => { + const detail = await window.loopalDesktop.openSession(value.sessionId) + const root = detail.agents.find((agent) => agent.id === value.agentId) + return `${root?.decisionMode}/${root?.permissionMode}` + }, target)).toBe('classifier/ask_any_write') + return target +} + +async function setDecisionMode( + page: Page, target: RuntimeTarget, mode: 'manual' | 'classifier', +): Promise { + await page.evaluate(async ({ value, nextMode }) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'decision', mode: nextMode }, + }) + }, { value: target, nextMode: mode }) + await expect.poll(async () => page.evaluate(async (value) => { + const detail = await window.loopalDesktop.openSession(value.sessionId) + return detail.agents.find((agent) => agent.id === value.agentId)?.decisionMode + }, target)).toBe(mode) +} + +async function send(page: Page, text: string): Promise { + await page.getByLabel('Message Loopal').fill(text) + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText(text) +} diff --git a/apps/desktop/e2e/real/host/code-host.spec.ts b/apps/desktop/e2e/real/host/code-host.spec.ts new file mode 100644 index 00000000..5f5a9c44 --- /dev/null +++ b/apps/desktop/e2e/real/host/code-host.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' + +test('runs real workspace, Git, worktree, and watch services', async () => { + const desktop = await launchDesktop('real') + try { + await waitForHostStatus(desktop.page, 'ready') + const context = await desktop.page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return { + workspaceId: bootstrap.workspaces[0]!.id, + } + }) + const workspaceId = context.workspaceId + + const listing = await desktop.page.evaluate( + (id) => window.loopalDesktop.listDirectory({ workspaceId: id, path: '' }), + workspaceId, + ) + expect(listing.entries.map((entry) => entry.name)).toEqual( + expect.arrayContaining(['README.md', 'src']), + ) + + const initial = await readMain(desktop.page, workspaceId) + await writeFile(join(desktop.project, 'src', 'main.rs'), 'fn main() { println!("external"); }\n') + await expect(desktop.page.evaluate( + ({ id, version }) => window.loopalDesktop.writeFile({ + workspaceId: id, + path: 'src/main.rs', + content: 'stale\n', + expectedVersion: version, + }), + { id: workspaceId, version: initial.version }, + )).rejects.toThrow(/expected/i) + + const current = await readMain(desktop.page, workspaceId) + const saved = await desktop.page.evaluate( + ({ id, version }) => window.loopalDesktop.writeFile({ + workspaceId: id, + path: 'src/main.rs', + content: 'fn main() { println!("saved"); }\n', + expectedVersion: version, + }), + { id: workspaceId, version: current.version }, + ) + expect(saved.content).toContain('saved') + + const search = await desktop.page.evaluate( + (id) => window.loopalDesktop.searchWorkspace({ workspaceId: id, query: 'saved' }), + workspaceId, + ) + expect(search.matches[0]).toMatchObject({ path: 'src/main.rs', line: 1 }) + const diff = await desktop.page.evaluate( + (id) => window.loopalDesktop.gitDiff({ workspaceId: id, path: 'src/main.rs' }), + workspaceId, + ) + expect(diff.original).toContain('ready') + expect(diff.modified).toContain('saved') + + await desktop.page.evaluate( + (id) => window.loopalDesktop.gitStage({ workspaceId: id, path: 'src/main.rs' }), + workspaceId, + ) + let status = await desktop.page.evaluate( + (id) => window.loopalDesktop.gitStatus(id), + workspaceId, + ) + expect(status.changes.find((item) => item.path === 'src/main.rs')?.indexStatus).toBe('M') + await desktop.page.evaluate( + (id) => window.loopalDesktop.gitUnstage({ workspaceId: id, path: 'src/main.rs' }), + workspaceId, + ) + status = await desktop.page.evaluate((id) => window.loopalDesktop.gitStatus(id), workspaceId) + expect(status.changes.find((item) => item.path === 'src/main.rs')?.worktreeStatus).toBe('M') + + const worktree = await desktop.page.evaluate( + (id) => window.loopalDesktop.createWorktree({ workspaceId: id, name: 'desktop-e2e' }), + workspaceId, + ) + expect(worktree).toMatchObject({ id: 'desktop-e2e', isMain: false, hasChanges: false }) + const worktrees = await desktop.page.evaluate( + (id) => window.loopalDesktop.listWorktrees(id), + workspaceId, + ) + expect(worktrees.some((item) => item.id === 'desktop-e2e')).toBe(true) + await desktop.page.evaluate( + (id) => window.loopalDesktop.removeWorktree({ + workspaceId: id, + name: 'desktop-e2e', + force: false, + }), + workspaceId, + ) + + await assertRootConfinement(desktop.page, workspaceId) + await assertWatch(desktop.page, desktop.project) + } finally { + await closeDesktop(desktop) + } +}) + +async function readMain(page: import('@playwright/test').Page, workspaceId: string) { + return page.evaluate( + (id) => window.loopalDesktop.readFile({ workspaceId: id, path: 'src/main.rs' }), + workspaceId, + ) +} + +async function assertRootConfinement( + page: import('@playwright/test').Page, + workspaceId: string, +): Promise { + await expect(page.evaluate( + (id) => window.loopalDesktop.readFile({ workspaceId: id, path: '../outside.txt' }), + workspaceId, + )).rejects.toThrow(/workspace|path/i) + if (process.platform !== 'win32') { + await expect(page.evaluate( + (id) => window.loopalDesktop.readFile({ workspaceId: id, path: 'escape-link' }), + workspaceId, + )).rejects.toThrow(/workspace|root|escape/i) + } +} + +async function assertWatch(page: import('@playwright/test').Page, project: string): Promise { + await page.evaluate(() => { + const state = window as typeof window & { workspaceEvents?: string[] } + state.workspaceEvents = [] + window.loopalDesktop.onEvent((event) => { + if (event.type === 'file_changed') state.workspaceEvents?.push(event.path) + }) + }) + await writeFile(join(project, 'watch-marker.txt'), 'watch me\n') + await expect.poll(() => page.evaluate(() => { + const state = window as typeof window & { workspaceEvents?: string[] } + return state.workspaceEvents?.includes('watch-marker.txt') ?? false + })).toBe(true) +} diff --git a/apps/desktop/e2e/real/host/federation/host-federation-workbench.spec.ts b/apps/desktop/e2e/real/host/federation/host-federation-workbench.spec.ts new file mode 100644 index 00000000..64c81bfc --- /dev/null +++ b/apps/desktop/e2e/real/host/federation/host-federation-workbench.spec.ts @@ -0,0 +1,136 @@ +import { expect, test, type Page } from '@playwright/test' +import { + closeDesktop, launchDesktop, relaunchDesktop, type DesktopFixture, waitForHostStatus, +} from '../../../support/electron/electron-fixture' +import { + createSessionDirectory, queueSessionDirectories, +} from '../../../support/fixtures/session-directory-fixture' +import { createFromDirectory } from '../../../support/fixtures/session-directory-ui' + +interface FederationRuntime { + readonly sessionId: string + readonly runtimeId: string + readonly generation: number +} + +test('persists a real Federation while two exact runtimes join and leave independently', async () => { + let desktop = await launchDesktop('real') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + await expectBackground(desktop) + await page.evaluate(async () => { + const current = await window.loopalDesktop.getMetaHubSettings() + await window.loopalDesktop.updateMetaHubSettings({ + address: '', hubName: current.hubName, + joinOnStart: true, startLocalOnLaunch: false, + }) + }) + await page.getByRole('button', { name: 'Federation', exact: true }).click() + await page.getByTestId('federation-start').click() + await expect(page.getByTestId('federation-local-state')).toHaveText('running', { + timeout: 15_000, + }) + await page.getByRole('button', { name: 'Conversation', exact: true }).click() + + const firstId = await selectedSessionId(page) + const secondDirectory = await createSessionDirectory(desktop, 'federated-host', false) + await queueSessionDirectories(desktop, [secondDirectory.path]) + await createFromDirectory(page, 'directory') + await expect(page.locator('.session-card')).toHaveCount(2, { timeout: 30_000 }) + const secondId = await selectedSessionId(page) + expect(secondId).not.toBe(firstId) + await joinFromMenu(page, firstId) + await joinFromMenu(page, secondId) + + await expect.poll(() => federationState(page, [firstId, secondId]), { + timeout: 20_000, + }).toMatchObject({ + local: { state: 'running', address: expect.any(String) }, + settings: { startLocalOnLaunch: true, joinOnStart: false }, + states: [ + { state: 'connected', hubName: expect.any(String), topologySize: 2 }, + { state: 'connected', hubName: expect.any(String), topologySize: 2 }, + ], + }) + const joined = await federationState(page, [firstId, secondId]) + expect(joined.states[0]!.hubName).not.toBe(joined.states[1]!.hubName) + + await leaveFromMenu(page, firstId) + await expect.poll(() => federationState(page, [firstId, secondId]), { + timeout: 15_000, + }).toMatchObject({ + local: { state: 'running' }, + states: [{ state: 'disconnected' }, { state: 'connected' }], + }) + + desktop = await relaunchDesktop(desktop) + await expectBackground(desktop) + await waitForHostStatus(desktop.page, 'ready') + await expect.poll(() => federationState(desktop.page, [firstId, secondId]), { + timeout: 30_000, + }).toMatchObject({ + local: { state: 'running', address: expect.any(String) }, + settings: { startLocalOnLaunch: true, joinOnStart: false }, + connectedCount: 0, liveCount: 1, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function joinFromMenu(page: Page, sessionId: string): Promise { + await page.locator(`[data-session-id="${sessionId}"]`).click({ button: 'right' }) + const menu = page.getByTestId('session-context-menu') + await expect(menu).toBeVisible() + await menu.getByRole('menuitem', { name: 'Join Federation' }).click() + await expect(menu).toHaveCount(0) +} + +async function leaveFromMenu(page: Page, sessionId: string): Promise { + await page.locator(`[data-session-id="${sessionId}"]`).click({ button: 'right' }) + const menu = page.getByTestId('session-context-menu') + await expect(menu).toBeVisible() + await menu.getByRole('menuitem', { name: 'Leave Federation' }).click() + await expect(menu).toHaveCount(0) +} + +async function selectedSessionId(page: Page): Promise { + const value = await page.locator('.session-card.selected').getAttribute('data-session-id') + if (!value) throw new Error('No selected session') + return value +} + +async function federationState(page: Page, sessionIds: readonly string[]) { + return page.evaluate(async (ids) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const targets = ids.map((sessionId): FederationRuntime | undefined => { + const session = bootstrap.sessions.find((item) => item.id === sessionId) + const runtime = bootstrap.runtimes.find((item) => item.id === session?.activeRuntimeId) + if (!runtime) return undefined + return { sessionId, runtimeId: runtime.id, generation: runtime.generation } + }) + const states = await Promise.all(targets.map(async (target) => { + if (!target) return { state: 'unavailable', topologySize: 0 } + const state = await api.getMetaHubStatus(target) + return { + state: state.state, hubName: state.hubName, + topologySize: state.topology.length, + } + })) + return { + local: await api.getLocalMetaHubStatus(), + settings: await api.getMetaHubSettings(), states, + connectedCount: states.filter(({ state }) => state === 'connected').length, + liveCount: states.filter(({ state }) => state !== 'unavailable').length, + } + }, sessionIds) +} + +async function expectBackground(desktop: DesktopFixture): Promise { + expect(await desktop.app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + return { visible: window?.isVisible(), focused: window?.isFocused() } + })).toEqual({ visible: false, focused: false }) +} diff --git a/apps/desktop/e2e/real/host/federation/host-metahub-auth.spec.ts b/apps/desktop/e2e/real/host/federation/host-metahub-auth.spec.ts new file mode 100644 index 00000000..8f34d0a1 --- /dev/null +++ b/apps/desktop/e2e/real/host/federation/host-metahub-auth.spec.ts @@ -0,0 +1,64 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../../support/electron/electron-fixture' +import { + startMetaHub, stopProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +test('rejects a bad MetaHub token without leaking it and recovers in place', async () => { + const meta = await startMetaHub() + const desktop = await launchDesktop('real') + const rejectedToken = `rejected-${Date.now()}-secret` + try { + const result = await desktop.page.evaluate(async ({ address, bad, good }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.updateMetaHubSettings({ + address, hubName: 'hub-auth', token: bad, + joinOnStart: false, startLocalOnLaunch: false, + }) + let failure = '' + try { await api.joinMetaHub(target) } catch (error) { failure = String(error) } + const rejectedSettings = await api.getMetaHubSettings() + await api.updateMetaHubSettings({ + address, hubName: 'hub-auth', token: good, + joinOnStart: false, startLocalOnLaunch: false, + }) + const joined = await api.joinMetaHub(target) + return { failure, rejectedSettings, joined } + }, { address: meta.address, bad: rejectedToken, good: meta.token }) + + expect(result.failure.toLowerCase()).toContain('token') + expect(result.failure).not.toContain(rejectedToken) + expect(result.rejectedSettings).toMatchObject({ tokenConfigured: true }) + expect(result.rejectedSettings).not.toHaveProperty('token') + expect(result.joined).toMatchObject({ state: 'connected', hubName: 'hub-auth' }) + expect(await filesContaining(desktop.root, rejectedToken)).toEqual([]) + } finally { + await closeDesktop(desktop) + await stopProcess(meta.child) + } +}) + +async function filesContaining(root: string, marker: string): Promise { + const matches: string[] = [] + async function visit(path: string): Promise { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) await visit(child) + else if (entry.isFile()) { + const contents = await readFile(child).catch(() => Buffer.alloc(0)) + if (contents.includes(Buffer.from(marker))) matches.push(child) + } + } + } + await visit(root) + return matches +} diff --git a/apps/desktop/e2e/real/host/federation/host-metahub-recovery.spec.ts b/apps/desktop/e2e/real/host/federation/host-metahub-recovery.spec.ts new file mode 100644 index 00000000..5e6b97e6 --- /dev/null +++ b/apps/desktop/e2e/real/host/federation/host-metahub-recovery.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test' +import { + closeDesktop, launchDesktop, type DesktopFixture, +} from '../../../support/electron/electron-fixture' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +test('removes a crashed remote Hub and accepts one clean same-name replacement', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real') + const sessionId = await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta, 'hub-restart') + await expect.poll(() => remoteCount(desktop!, sessionId), { timeout: 15_000 }).toBe(1) + + remote.probe.close() + await stopProcess(remote.child) + remote = undefined + await expect.poll(() => remoteCount(desktop!, sessionId), { timeout: 15_000 }).toBe(0) + + remote = await startRemoteHub(desktop, meta, 'hub-restart') + await expect.poll(() => remoteCount(desktop!, sessionId), { timeout: 15_000 }).toBe(1) + const state = await desktop.page.evaluate( + (id) => window.loopalDesktop.openSession(id), sessionId, + ) + expect(state.metaHub?.hubs.filter((hub) => hub.name === 'hub-restart')).toHaveLength(1) + expect(state.metaHub?.topology.filter((agent) => agent.id === 'hub-restart/main')) + .toHaveLength(1) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function joinDesktop(desktop: DesktopFixture, meta: MetaHubProcess): Promise { + return desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.updateMetaHubSettings({ + address, hubName: 'hub-a', token, + joinOnStart: false, startLocalOnLaunch: false, + }) + await api.joinMetaHub(target) + return sessionId + }, { address: meta.address, token: meta.token }) +} + +async function remoteCount(desktop: DesktopFixture, sessionId: string): Promise { + const detail = await desktop.page.evaluate( + (id) => window.loopalDesktop.openSession(id), sessionId, + ) + return detail.metaHub?.topology.filter((agent) => agent.id === 'hub-restart/main').length ?? 0 +} diff --git a/apps/desktop/e2e/real/host/federation/host-metahub-topology.spec.ts b/apps/desktop/e2e/real/host/federation/host-metahub-topology.spec.ts new file mode 100644 index 00000000..036f0576 --- /dev/null +++ b/apps/desktop/e2e/real/host/federation/host-metahub-topology.spec.ts @@ -0,0 +1,165 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { createInterface } from 'node:readline' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' +import { + closeDesktop, + launchDesktop, + loopalBinaryPath, + type DesktopFixture, +} from '../../../support/electron/electron-fixture' +import { isolatedTestEnvironment } from '../../../support/providers/mock-llm-fixture' + +interface MetaFixture { + readonly child: ChildProcess + readonly address: string + readonly token: string +} + +test('streams remote Hub joins and leaves into the central qualified Agent topology', async () => { + const meta = await startMetaHub() + let desktop: DesktopFixture | undefined + let remote: ChildProcess | undefined + try { + desktop = await launchDesktop('real') + const target = await desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((value) => value.sessionId === sessionId)! + await api.updateMetaHubSettings({ + address, hubName: 'hub-a', token, joinOnStart: false, startLocalOnLaunch: false, + }) + const target = { sessionId, runtimeId: runtime.id, generation: runtime.generation } + await api.joinMetaHub(target) + return target + }, { address: meta.address, token: meta.token }) + await desktop.page.getByRole('button', { name: 'Federation', exact: true }).click() + await expect(desktop.page.getByTestId('primary-workspace')) + .toHaveAttribute('data-workspace', 'federation') + await expect(desktop.page.getByRole('tab', { name: 'Agents', exact: true })).toHaveCount(0) + remote = await startRemoteHub(desktop, meta) + + const remoteNode = desktop.page.locator('[data-agent-id="hub-b/main"]') + await expect(remoteNode).toBeVisible({ timeout: 15_000 }) + await expect(remoteNode).toContainText('hub-b') + const projected = await desktop.page.evaluate(async (sessionId) => ( + (await window.loopalDesktop.openSession(sessionId)).agents + .find((agent) => agent.id === 'hub-b/main') + ), target.sessionId) + expect(projected).toMatchObject({ + qualifiedName: 'hub-b/main', hubPath: ['hub-b'], controllable: false, + }) + + await stop(remote) + remote = undefined + await expect(remoteNode).toHaveCount(0, { timeout: 15_000 }) + await desktop.page.evaluate(async (value) => { + await window.loopalDesktop.disconnectMetaHub(value) + }, target) + await expect(desktop.page.getByTestId('federation-workspace')).toContainText( + 'Start a Federation for your Loopal sessions.', + ) + } finally { + if (remote) await stop(remote) + if (desktop) await closeDesktop(desktop) + await stop(meta.child) + } +}) + +async function startMetaHub(): Promise { + const child = spawn(loopalBinaryPath(), [ + '--meta-hub', '127.0.0.1:0', '--meta-hub-parent-pid', String(process.pid), + ], { env: isolatedTestEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }) + child.stderr?.resume() + try { + const value = await waitForLine(child, 'LOOPAL_METAHUB ', 10_000) + return { + child, + address: String(value.address), + token: String(value.token), + } + } catch (error) { + await stop(child) + throw error + } +} + +async function startRemoteHub(desktop: DesktopFixture, meta: MetaFixture): Promise { + const home = join(desktop.root, 'remote-home') + await mkdir(home) + const child = spawn(loopalBinaryPath(), [ + 'desktop', 'serve', '--parent-pid', String(process.pid), '--ephemeral', + '--join-hub', meta.address, '--hub-name', 'hub-b', + ], { + cwd: desktop.project, + env: isolatedTestEnvironment({ + HOME: home, + LOOPAL_META_HUB_TOKEN: meta.token, + ANTHROPIC_API_KEY: desktop.llm!.apiKey, + ANTHROPIC_BASE_URL: desktop.llm!.baseUrl, + LOOPAL_OTEL_ENABLED: 'false', + LOOPAL_MCP_STARTUP_WAIT_SECS: '1', + }), + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stderr?.resume() + try { + await waitForLine(child, 'LOOPAL_DESKTOP ', 20_000, (value) => value.phase === 'ready') + return child + } catch (error) { + await stop(child) + throw error + } +} + +async function waitForLine( + child: ChildProcess, + prefix: string, + timeout: number, + accept: (value: Record) => boolean = () => true, +): Promise> { + return new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity }) + let settled = false + const timer = setTimeout(() => done(new Error(`Timed out waiting for ${prefix.trim()}`)), timeout) + const done = (error?: Error, value?: Record): void => { + if (settled) return + settled = true + clearTimeout(timer); lines.close(); child.off('exit', exited); child.off('error', failed) + error ? reject(error) : resolve(value!) + } + const exited = (): void => done(new Error('Loopal fixture exited before ready')) + const failed = (error: Error): void => done(error) + child.once('exit', exited) + child.once('error', failed) + lines.on('line', (line) => { + if (!line.startsWith(prefix)) return + try { + const value = JSON.parse(line.slice(prefix.length)) as Record + if (accept(value)) done(undefined, value) + } catch { done(new Error('Invalid Loopal fixture handshake')) } + }) + }) +} + +async function stop(child: ChildProcess): Promise { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + if (await waitForExit(child, 3_000)) return + child.kill('SIGKILL') + await waitForExit(child, 1_000) +} + +function waitForExit(child: ChildProcess, timeout: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { child.off('exit', exited); resolve(false) }, timeout) + const exited = (): void => { + clearTimeout(timer) + resolve(true) + } + child.once('exit', exited) + }) +} diff --git a/apps/desktop/e2e/real/host/federation/host-metahub.spec.ts b/apps/desktop/e2e/real/host/federation/host-metahub.spec.ts new file mode 100644 index 00000000..173fb812 --- /dev/null +++ b/apps/desktop/e2e/real/host/federation/host-metahub.spec.ts @@ -0,0 +1,151 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { promisify } from 'node:util' +import { expect, test } from '@playwright/test' +import { + closeDesktop, launchDesktop, relaunchDesktop, waitForHostStatus, +} from '../../../support/electron/electron-fixture' + +const execFile = promisify(execFileCallback) + +test('manages a real local MetaHub and rejoins it through the sidecar', async () => { + const desktop = await launchDesktop('real') + try { + const result = await desktop.page.evaluate(async () => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { sessionId, runtimeId: runtime.id, generation: runtime.generation } + const local = await api.startLocalMetaHub({ bindAddress: '127.0.0.1:0' }) + const settings = await api.getMetaHubSettings() + const joined = await api.joinMetaHub(target) + const detail = await api.openSession(sessionId) + const disconnected = await api.disconnectMetaHub(target) + const rejoined = await api.joinMetaHub(target) + await api.disconnectMetaHub(target) + const stopped = await api.stopLocalMetaHub() + return { local, settings, joined, projected: detail.metaHub, disconnected, rejoined, stopped } + }) + expect(result.local).toMatchObject({ state: 'running', address: expect.any(String) }) + expect(result.settings).toMatchObject({ + tokenConfigured: true, + address: result.local.address, + }) + expect(Object.keys(result.settings)).not.toContain('token') + expect(result.joined).toMatchObject({ + state: 'connected', + hubs: [expect.objectContaining({ status: 'connected', agentCount: 1 })], + }) + expect(result.joined.topology).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'main', lifecycle: 'running' }), + ])) + expect(result.projected?.state).toBe('connected') + expect(result.disconnected).toMatchObject({ state: 'disconnected', hubs: [] }) + expect(result.rejoined.state).toBe('connected') + expect(result.stopped).toEqual({ state: 'stopped' }) + } finally { + await closeDesktop(desktop) + } +}) + +test('clears a crashed managed MetaHub credential before relaunch', async () => { + test.skip(process.platform === 'win32', 'process tree fixture uses ps') + let desktop = await launchDesktop('real') + try { + await desktop.page.evaluate(async () => { + const api = window.loopalDesktop + const local = await api.startLocalMetaHub({ bindAddress: '127.0.0.1:0' }) + if (local.state !== 'running' || !local.address) { + throw new Error('local MetaHub did not start') + } + const settings = await api.getMetaHubSettings() + await api.updateMetaHubSettings({ + address: local.address, + hubName: settings.hubName, + joinOnStart: true, + startLocalOnLaunch: false, + }) + }) + const parentPid = desktop.app.process().pid! + let managedPid: number | undefined + await expect.poll(async () => { + managedPid = await localMetaHubPid(parentPid) + return managedPid + }, { timeout: 10_000 }).not.toBeUndefined() + process.kill(managedPid!, 'SIGKILL') + await expect.poll(async () => desktop.page.evaluate(async () => ( + await window.loopalDesktop.getLocalMetaHubStatus() + ).state), { timeout: 10_000 }).toBe('failed') + + const stopped = await desktop.page.evaluate(async () => { + await window.loopalDesktop.stopLocalMetaHub() + return window.loopalDesktop.getMetaHubSettings() + }) + expect(stopped).toMatchObject({ + address: '', joinOnStart: false, tokenConfigured: false, + }) + + desktop = await relaunchDesktop(desktop) + await waitForHostStatus(desktop.page, 'ready') + const relaunched = await desktop.page.evaluate(async () => ({ + settings: await window.loopalDesktop.getMetaHubSettings(), + bootstrap: await window.loopalDesktop.bootstrap(), + })) + expect(relaunched.settings).toMatchObject({ + address: '', joinOnStart: false, tokenConfigured: false, + }) + expect(relaunched.bootstrap.runtimes).toContainEqual( + expect.objectContaining({ state: 'ready' }), + ) + } finally { + await closeDesktop(desktop) + } +}) + +test('starts a managed MetaHub without implicitly joining a session after relaunch', async () => { + let desktop = await launchDesktop('real') + try { + await desktop.page.evaluate(async () => { + const current = await window.loopalDesktop.getMetaHubSettings() + await window.loopalDesktop.updateMetaHubSettings({ + address: '', hubName: current.hubName, + joinOnStart: false, startLocalOnLaunch: true, + }) + }) + desktop = await relaunchDesktop(desktop) + await waitForHostStatus(desktop.page, 'ready') + const readState = async () => desktop.page.evaluate(async () => { + const api = window.loopalDesktop + const local = await api.getLocalMetaHubStatus() + const settings = await api.getMetaHubSettings() + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const detail = await api.openSession(sessionId) + return { local, settings, connected: detail.metaHub?.state } + }) + await expect.poll(readState, { timeout: 20_000 }).toMatchObject({ + local: { state: 'running', address: expect.any(String) }, + settings: { + startLocalOnLaunch: true, tokenConfigured: true, + address: expect.any(String), + }, + connected: 'disconnected', + }) + const state = await readState() + expect(state.settings.address).toBe(state.local.address) + expect(state.settings).not.toHaveProperty('token') + } finally { + await closeDesktop(desktop) + } +}) + +async function localMetaHubPid(parentPid: number): Promise { + const { stdout } = await execFile('ps', ['-axo', 'pid=,ppid=,command=']) + for (const line of stdout.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line) + if (match && Number(match[2]) === parentPid && match[3]!.includes(' --meta-hub ')) { + return Number(match[1]) + } + } + return undefined +} diff --git a/apps/desktop/e2e/real/host/host-control-ack.spec.ts b/apps/desktop/e2e/real/host/host-control-ack.spec.ts new file mode 100644 index 00000000..b0d36752 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-control-ack.spec.ts @@ -0,0 +1,79 @@ +import { expect, test, type Page } from '@playwright/test' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' + +test('acknowledges only controls applied by the real runtime', async () => { + const desktop = await launchDesktop('real') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + const target = await runtimeTarget(page) + + await page.evaluate(async (value) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'mode', mode: 'plan' }, + }) + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }) + }, target) + await expect.poll(() => agentConfig(page, target)).toBe('plan/bypass') + + const unsupported = await controlError(page, target, { + type: 'decision', mode: 'agent', + }) + expect(unsupported).toContain("decision mode 'agent' is not implemented") + await expect.poll(() => agentConfig(page, target)).toBe('plan/bypass') + + const stale = await controlError(page, { ...target, generation: target.generation + 1 }, { + type: 'mode', mode: 'act', + }) + expect(stale).toContain('Session runtime is gone') + await expect.poll(() => agentConfig(page, target)).toBe('plan/bypass') + } finally { + await closeDesktop(desktop) + } +}) + +interface Target { + readonly sessionId: string + readonly runtimeId: string + readonly generation: number + readonly agentId: string +} + +async function runtimeTarget(page: Page): Promise { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + }) +} + +async function agentConfig(page: Page, target: Target): Promise { + return page.evaluate(async (value) => { + const detail = await window.loopalDesktop.openSession(value.sessionId) + const agent = detail.agents.find((item) => item.id === value.agentId) + return `${agent?.mode}/${agent?.permissionMode}` + }, target) +} + +async function controlError( + page: Page, + target: Target, + command: { type: 'decision'; mode: 'agent' } | { type: 'mode'; mode: 'act' }, +): Promise { + return page.evaluate(async ({ value, next }) => { + try { + await window.loopalDesktop.controlAgent({ target: value, command: next }) + return '' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + }, { value: target, next: command }) +} diff --git a/apps/desktop/e2e/real/host/host-handshake.spec.ts b/apps/desktop/e2e/real/host/host-handshake.spec.ts new file mode 100644 index 00000000..d932d952 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-handshake.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test' +import { basename } from 'node:path' +import { + launchDesktop, shutdownDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' +import { + listHosts, processIsRunning, sendMarker, waitForHosts, +} from '../../support/runtime/host-process' + +test('runs the bundled Loopal CLI through handshake, Hub RPC, and shutdown', async () => { + const desktop = await launchDesktop('real') + let hostPid = 0 + try { + await waitForHostStatus(desktop.page, 'ready') + await expect(desktop.page.getByTestId('active-session-title')).toContainText( + basename(desktop.project), + ) + hostPid = (await waitForHosts(desktop.home, 1))[0]!.pid + await sendMarker(desktop.page, 'Reply with ok') + await shutdownDesktop(desktop) + await expect.poll(() => processIsRunning(hostPid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => listHosts(desktop.home).then((items) => items.length)).toBe(0) + } finally { + await shutdownDesktop(desktop).catch(() => undefined) + await desktop.cleanup() + } +}) diff --git a/apps/desktop/e2e/real/host/host-lifecycle.spec.ts b/apps/desktop/e2e/real/host/host-lifecycle.spec.ts new file mode 100644 index 00000000..c37ad251 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-lifecycle.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' + +test('opens a stopped real session read-only until explicit restart', async () => { + const desktop = await launchDesktop('real', 'lifecycle') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + const composer = page.getByLabel('Message Loopal') + await expect(composer).toBeEnabled({ timeout: 30_000 }) + const initial = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { sessionId, runtimeId: runtime.id, generation: runtime.generation } + }) + + await composer.fill('Retain this turn across Stop') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'REAL LIFECYCLE RESPONSE', { timeout: 20_000 }, + ) + await page.getByRole('button', { name: 'Stop session', exact: true }).click() + await waitForHostStatus(page, 'stopped', 20_000) + await expect(page.getByRole('button', { name: 'Stop session', exact: true })).toBeDisabled() + await expect(page.getByRole('button', { name: 'Restart session' })).toBeEnabled() + await expect(composer).toBeDisabled() + + const stopped = await page.evaluate(async (sessionId) => { + const first = await window.loopalDesktop.openSession(sessionId) + const second = await window.loopalDesktop.openSession(sessionId) + const bootstrap = await window.loopalDesktop.bootstrap() + let sendError = '' + try { await window.loopalDesktop.sendMessage(sessionId, 'must not spawn') } + catch (error) { sendError = String(error) } + return { + first, second, sendError, hostStatus: bootstrap.hostStatus, + runtimes: bootstrap.runtimes.filter((item) => item.sessionId === sessionId), + } + }, initial.sessionId) + expect(stopped.first.session).toMatchObject({ status: 'stopped' }) + expect(stopped.first.session.activeRuntimeId).toBeUndefined() + expect(stopped.first.conversation).toEqual(stopped.second.conversation) + expect(stopped.first.conversation.map((entry) => entry.text).join('\n')) + .toContain('REAL LIFECYCLE RESPONSE') + expect(stopped.hostStatus).toBe('stopped') + expect(stopped.runtimes).toEqual([ + expect.objectContaining({ + id: initial.runtimeId, generation: initial.generation, state: 'stopped', + }), + ]) + expect(stopped.sendError).toContain('restart it first') + + await page.getByRole('button', { name: 'Restart session', exact: true }).click() + await waitForHostStatus(page, 'ready') + await expect(page.getByRole('button', { name: 'Stop session', exact: true })).toBeEnabled() + await expect(composer).toBeEnabled({ timeout: 30_000 }) + await expect.poll(async () => page.evaluate(async (sessionId) => { + const detail = await window.loopalDesktop.openSession(sessionId) + const bootstrap = await window.loopalDesktop.bootstrap() + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId) + return { + runtimeId: runtime?.id, generation: runtime?.generation, + state: runtime?.state, + activeMatches: runtime?.id === detail.session.activeRuntimeId, + } + }, initial.sessionId)).toMatchObject({ + generation: initial.generation + 1, state: 'ready', activeMatches: true, + }) + await composer.fill('Continue after the restarted runtime') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'RESTARTED RUNTIME RETAINED MODEL CONTEXT', { timeout: 20_000 }, + ) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[1]).toMatchObject({ messageCount: 3 }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/host/host-limits.spec.ts b/apps/desktop/e2e/real/host/host-limits.spec.ts new file mode 100644 index 00000000..90917786 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-limits.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' + +test('bounds workspace responses without losing the Desktop Host', async () => { + test.setTimeout(90_000) + const desktop = await launchDesktop('real') + try { + await waitForHostStatus(desktop.page, 'ready') + const workspaceId = await desktop.page.evaluate(async () => ( + (await window.loopalDesktop.bootstrap()).workspaces[0]!.id + )) + const large = join(desktop.project, 'large.txt') + + await writeFile(large, 'x'.repeat(10_000_000)) + await expect(desktop.page.evaluate(async ({ id }) => { + const document = await window.loopalDesktop.readFile({ workspaceId: id, path: 'large.txt' }) + return document.content.length + }, { id: workspaceId })).resolves.toBe(10_000_000) + + await writeFile(large, 'x'.repeat(10_000_001)) + await expect(desktop.page.evaluate( + (id) => window.loopalDesktop.readFile({ workspaceId: id, path: 'large.txt' }), + workspaceId, + )).rejects.toThrow(/file_too_large|10 MB/i) + + await writeFile(large, `${'界'.repeat(2_000)}needle\n`) + const search = await desktop.page.evaluate( + (id) => window.loopalDesktop.searchWorkspace({ workspaceId: id, query: 'needle' }), + workspaceId, + ) + expect(search.truncated).toBe(true) + expect(new TextEncoder().encode(search.matches[0]!.preview).length).toBeLessThanOrEqual(4_000) + + await writeFile(join(desktop.project, 'README.md'), `${'changed\n'.repeat(1_200_000)}`) + await expect(desktop.page.evaluate( + (id) => window.loopalDesktop.gitDiff({ workspaceId: id, path: 'README.md' }), + workspaceId, + )).rejects.toThrow(/response_too_large|response limit|8 MiB/i) + + const alive = await desktop.page.evaluate( + (id) => window.loopalDesktop.listDirectory({ workspaceId: id, path: '' }), + workspaceId, + ) + expect(alive.entries.some((entry) => entry.name === 'README.md')).toBe(true) + await waitForHostStatus(desktop.page, 'ready') + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/host/host-mcp-settings.spec.ts b/apps/desktop/e2e/real/host/host-mcp-settings.spec.ts new file mode 100644 index 00000000..7ad32904 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-mcp-settings.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from '@playwright/test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('persists redacted typed MCP definitions through the real Rust Host', async () => { + const desktop = await launchDesktop('real') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + const directory = join(desktop.project, '.loopal') + const path = join(directory, 'settings.local.json') + await mkdir(directory, { recursive: true }) + await writeFile(path, JSON.stringify({ + unknown_root: { keep: true }, + mcp_servers: { + local_tools: { + type: 'stdio', command: 'before', args: [], enabled: true, timeout_ms: 30_000, + env: { KEEP: 'preserved-secret', REMOVE: 'removed-secret' }, + future_field: { keep: true }, + }, + other: { + type: 'streamable-http', url: 'https://other.example.test/mcp', enabled: true, + headers: { Authorization: 'other-secret' }, + }, + }, + })) + const before = await page.evaluate( + () => window.loopalDesktop.listMcpServers('local-workspace'), + ) + expect(before.servers.find((server) => server.name === 'local_tools')).toMatchObject({ + env: [{ name: 'KEEP', configured: true }, { name: 'REMOVE', configured: true }], + }) + expect(JSON.stringify(before)).not.toMatch(/preserved-secret|removed-secret|other-secret/) + + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'mcp') + await expect(page.getByTestId('loopal-mcp-settings')).toContainText('local_tools') + const updated = await page.evaluate(() => window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', server: { + type: 'stdio', name: 'local_tools', command: 'node', args: ['server.js'], + enabled: true, timeoutMs: 12_000, sharing: 'spawn-tree', + cwdIsolation: { arg: '--user-data-dir', cacheSubdir: 'local-tools' }, + secretPatches: [ + { target: 'env', name: 'TOKEN', operation: 'set', value: 'new-env-secret' }, + { target: 'env', name: 'REMOVE', operation: 'remove' }, + ], + }, + })) + expect(JSON.stringify(updated)).not.toMatch(/new-env-secret|preserved-secret/) + let raw = JSON.parse(await readFile(path, 'utf8')) + expect(raw.unknown_root.keep).toBe(true) + expect(raw.mcp_servers.local_tools.env).toEqual({ + KEEP: 'preserved-secret', TOKEN: 'new-env-secret', + }) + expect(raw.mcp_servers.local_tools.future_field.keep).toBe(true) + expect(raw.mcp_servers.other.headers.Authorization).toBe('other-secret') + + const disabled = await page.evaluate(() => window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', server: { + type: 'stdio', name: 'local_tools', command: 'node', args: ['server.js'], + enabled: false, timeoutMs: 12_000, sharing: 'spawn-tree', cwdIsolation: null, + secretPatches: [], + }, + })) + expect(disabled.servers.find((server) => server.name === 'local_tools')?.enabled).toBe(false) + const reenabled = await page.evaluate(() => window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', server: { + type: 'stdio', name: 'local_tools', command: 'node', args: ['server.js'], + enabled: true, timeoutMs: 12_000, sharing: 'spawn-tree', cwdIsolation: null, + secretPatches: [], + }, + })) + expect(reenabled.servers.find((server) => server.name === 'local_tools')?.enabled).toBe(true) + raw = JSON.parse(await readFile(path, 'utf8')) + expect(raw.mcp_servers.local_tools.env.KEEP).toBe('preserved-secret') + expect(raw.mcp_servers.local_tools.env.TOKEN).toBe('new-env-secret') + + const withHttp = await page.evaluate(() => window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', server: { + type: 'streamable-http', name: 'remote_tools', url: 'https://mcp.example.test/api', + enabled: true, timeoutMs: 15_000, sharing: 'hub-singleton', secretPatches: [{ + target: 'header', name: 'Authorization', operation: 'set', value: 'Bearer new-header-secret', + }], + }, + })) + expect(JSON.stringify(withHttp)).not.toContain('new-header-secret') + raw = JSON.parse(await readFile(path, 'utf8')) + expect(raw.mcp_servers.remote_tools.headers.Authorization).toBe('Bearer new-header-secret') + + const bootstrap = await page.evaluate(() => window.loopalDesktop.bootstrap()) + await page.evaluate((sessionId) => window.loopalDesktop.restartSession(sessionId), + bootstrap.activeSessionId!) + const afterRestart = await page.evaluate( + () => window.loopalDesktop.listMcpServers('local-workspace'), + ) + expect(afterRestart.servers.map((server) => server.name)).toEqual(expect.arrayContaining([ + 'local_tools', 'remote_tools', + ])) + await page.evaluate(() => window.loopalDesktop.deleteMcpServer({ + workspaceId: 'local-workspace', name: 'local_tools', + })) + const deleted = await page.evaluate(() => window.loopalDesktop.deleteMcpServer({ + workspaceId: 'local-workspace', name: 'remote_tools', + })) + expect(deleted.servers.some((server) => ['local_tools', 'remote_tools'].includes(server.name))) + .toBe(false) + raw = JSON.parse(await readFile(path, 'utf8')) + expect(raw.mcp_servers.local_tools.enabled).toBe(false) + expect(raw.mcp_servers.local_tools.env).toBeUndefined() + expect(raw.mcp_servers.remote_tools.headers).toBeUndefined() + expect(raw.mcp_servers.other.headers.Authorization).toBe('other-secret') + await expect(page.evaluate( + () => window.loopalDesktop.listMcpServers('outside-workspace'), + )).rejects.toThrow(/Unknown workspace|unknown workspace/) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/host/host-relaunch.spec.ts b/apps/desktop/e2e/real/host/host-relaunch.spec.ts new file mode 100644 index 00000000..2e5e35da --- /dev/null +++ b/apps/desktop/e2e/real/host/host-relaunch.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { realpath } from 'node:fs/promises' +import { + launchDesktop, relaunchDesktop, shutdownDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' +import { + activeSessionId, processIsRunning, sendMarker, sessionCard, + shutdownAndAssertClean, storedSession, waitForHosts, waitForSessionHost, +} from '../../support/runtime/host-process' + +test('restores a persisted Loopal Session after the Electron app relaunches', async () => { + let desktop = await launchDesktop('real') + const observedPids = new Set() + try { + await waitForHostStatus(desktop.page, 'ready') + const persistedId = await activeSessionId(desktop.page) + const original = (await waitForHosts(desktop.home, 1))[0]! + observedPids.add(original.pid) + await sendMarker(desktop.page, 'persisted session marker') + const canonicalProject = await realpath(desktop.project) + await expect.poll(() => storedSession(desktop.home, persistedId)).toMatchObject({ + id: persistedId, cwd: canonicalProject, + }) + + desktop = await relaunchDesktop(desktop) + await expect.poll(() => processIsRunning(original.pid), { timeout: 10_000 }).toBe(false) + await waitForHostStatus(desktop.page, 'ready') + await expect.poll(() => storedSession(desktop.home, persistedId)).toMatchObject({ + id: persistedId, + }) + const freshHosts = await waitForHosts(desktop.home, 1) + freshHosts.forEach(({ pid }) => observedPids.add(pid)) + await expect(sessionCard(desktop.page, persistedId)).toBeVisible() + await sessionCard(desktop.page, persistedId).click() + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'persisted session marker', { timeout: 30_000 }, + ) + const restored = await waitForSessionHost(desktop.home, persistedId, original.pid) + observedPids.add(restored.pid) + expect(restored.pid).not.toBe(original.pid) + await shutdownAndAssertClean(desktop, observedPids) + } finally { + await shutdownDesktop(desktop).catch(() => undefined) + await desktop.cleanup() + } +}) diff --git a/apps/desktop/e2e/real/host/host-rich.spec.ts b/apps/desktop/e2e/real/host/host-rich.spec.ts new file mode 100644 index 00000000..3b991e69 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-rich.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test' +import { + closeDesktop, launchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' + +test('renders rich state from the real Loopal Host', async () => { + const desktop = await launchDesktop('real', 'production-rich') + try { + await waitForHostStatus(desktop.page, 'ready') + await expect(desktop.page.getByRole('tab', { name: 'Tasks', exact: true })).toHaveCount(0) + const composer = desktop.page.getByLabel('Message Loopal') + await composer.fill('Inspect the project and report with rich formatting') + await desktop.page.getByRole('button', { name: 'Send' }).click() + + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation.getByText('Inspecting the real Host workspace.')).toBeVisible({ + timeout: 15_000, + }) + await expect(desktop.page.getByTestId('runtime-status')).toContainText('Thinking') + + const readTool = conversation.getByTestId('tool-invocation').filter({ hasText: /^Read/ }) + await expect(readTool).toBeVisible({ timeout: 15_000 }) + await expect(readTool.getByLabel('Completed')).toBeVisible({ timeout: 15_000 }) + await readTool.locator(':scope > summary').click() + await expect(readTool).toContainText('Loopal Desktop E2E') + + await expect(conversation.getByText('Preparing a real tool call.')).toBeVisible() + const assistantStream = conversation.locator('[data-message-role="assistant"].streaming') + await expect(assistantStream.getByLabel('Streaming')).toBeVisible() + await expect(desktop.page.getByTestId('runtime-status')).toContainText('Streaming') + + await expect(conversation.getByRole('heading', { name: 'Real Host verified' })).toBeVisible({ + timeout: 20_000, + }) + await expect(conversation.getByText('Markdown rendered')).toBeVisible() + await expect(conversation.getByText('Production stream quote')).toBeVisible() + await expect(conversation.getByText('fn contract() -> bool { true }')).toBeVisible() + await expect(conversation.getByRole('link', { name: 'Loopal reference' })) + .toHaveAttribute('href', 'https://example.com/loopal') + await expect(conversation.getByText('final streamed chunk')).toBeVisible() + await expect(desktop.page.getByTestId('runtime-status')).toContainText('Ready for input') + const detail = await desktop.page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) + const root = detail.agents.find((agent) => agent.id === 'main')! + expect(root.view?.thinkingActive).toBe(false) + expect(root.telemetry?.thinkingTokens).toBeGreaterThan(0) + expect(root.conversation?.some((entry) => ( + entry.role === 'thinking' && entry.text.includes('Inspecting the real Host workspace.') + ))).toBe(true) + + const taskTab = desktop.page.getByRole('tab', { name: 'Tasks', exact: true }) + await taskTab.click() + await expect(taskTab).toHaveAttribute('aria-selected', 'true') + const tasks = desktop.page.getByTestId('tasks-pane') + await expect(tasks).toContainText('Verify the real Desktop Host') + await expect(tasks).toContainText('Plan · 0/1') + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(3) + expect(requests[0]).toMatchObject({ + protocol: 'anthropic', model: 'claude-opus-4-8', stream: true, + hasSystem: true, apiKeyPresent: true, protocolVersionPresent: true, matched: true, + }) + expect(requests[0]!.toolNames).toEqual(expect.arrayContaining([ + 'Read', 'TaskCreate', 'create_goal', + ])) + expect(requests[1]!.toolResultIds).toContain('read-project') + expect(requests[2]!.toolResultIds).toContain('create-task') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'production-rich', served: 3, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/host/host-runtime-isolation.spec.ts b/apps/desktop/e2e/real/host/host-runtime-isolation.spec.ts new file mode 100644 index 00000000..1b5c536c --- /dev/null +++ b/apps/desktop/e2e/real/host/host-runtime-isolation.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test' +import { + launchDesktop, shutdownDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' +import { + createSessionDirectory, queueSessionDirectories, +} from '../../support/fixtures/session-directory-fixture' +import { createFromDirectory } from '../../support/fixtures/session-directory-ui' +import { + activeSessionId, processIsRunning, sendMarker, sessionCard, + shutdownAndAssertClean, waitForHosts, waitForSessionHost, +} from '../../support/runtime/host-process' + +test('isolates two live Hosts and replaces only the restarted Session runtime', async () => { + const desktop = await launchDesktop('real') + const observedPids = new Set() + try { + await waitForHostStatus(desktop.page, 'ready') + const firstId = await activeSessionId(desktop.page) + const firstHost = (await waitForHosts(desktop.home, 1))[0]! + observedPids.add(firstHost.pid) + expect(firstHost.root_session_id).toBe(firstId) + await sendMarker(desktop.page, 'session A marker') + + const secondDirectory = await createSessionDirectory(desktop, 'second-host', false) + await queueSessionDirectories(desktop, [secondDirectory.path]) + await createFromDirectory(desktop.page, 'directory') + await expect(desktop.page.locator('[data-session-id]')).toHaveCount(2) + const secondId = await activeSessionId(desktop.page) + expect(secondId).not.toBe(firstId) + const twoHosts = await waitForHosts(desktop.home, 2) + twoHosts.forEach(({ pid }) => observedPids.add(pid)) + expect(new Set(twoHosts.map((item) => item.root_session_id))).toEqual( + new Set([firstId, secondId]), + ) + await sendMarker(desktop.page, 'session B marker') + + await sessionCard(desktop.page, firstId).click() + await expect(desktop.page.getByTestId('conversation')).toContainText('session A marker') + await expect(desktop.page.getByTestId('conversation')).not.toContainText('session B marker') + await desktop.page.evaluate((sessionId) => ( + window.loopalDesktop.stopSession(sessionId) + ), firstId) + const remaining = await waitForHosts(desktop.home, 1) + expect(remaining[0]!.root_session_id).toBe(secondId) + await expect.poll(() => processIsRunning(firstHost.pid), { timeout: 10_000 }).toBe(false) + expect(processIsRunning(remaining[0]!.pid)).toBe(true) + + await desktop.page.evaluate((sessionId) => ( + window.loopalDesktop.restartSession(sessionId) + ), firstId) + const restarted = await waitForSessionHost(desktop.home, firstId, firstHost.pid) + observedPids.add(restarted.pid) + expect(restarted.pid).not.toBe(firstHost.pid) + await expect(sessionCard(desktop.page, firstId).locator('small')).toHaveText( + 'Waiting', { timeout: 30_000 }, + ) + await expect(desktop.page.getByTestId('conversation')).toContainText('session A marker') + await sessionCard(desktop.page, secondId).click() + await expect(desktop.page.getByTestId('conversation')).toContainText('session B marker') + await expect(desktop.page.getByTestId('conversation')).not.toContainText('session A marker') + await shutdownAndAssertClean(desktop, observedPids) + } finally { + await shutdownDesktop(desktop).catch(() => undefined) + await desktop.cleanup() + } +}) diff --git a/apps/desktop/e2e/real/host/host-settings.spec.ts b/apps/desktop/e2e/real/host/host-settings.spec.ts new file mode 100644 index 00000000..042ec7b4 --- /dev/null +++ b/apps/desktop/e2e/real/host/host-settings.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from '@playwright/test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, relaunchDesktop, waitForHostStatus, +} from '../../support/electron/electron-fixture' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +const settings = { + model: 'desktop-persisted-model', modelRouting: { + default: '', summarization: '', classification: 'desktop-classifier-model', refine: '', + }, permissionMode: 'ask_dangerous' as const, + decisionMode: 'classifier' as const, sandboxPolicy: 'read_only' as const, + thinking: { type: 'effort' as const, level: 'high' as const }, + maxContextTokens: 180_000, memoryEnabled: false, microcompactIdleMinutes: 15, + telemetryEnabled: false, outputStyle: 'engineer', +} + +test('persists redacted Loopal defaults globally without rewriting the workspace', async () => { + let desktop = await launchDesktop('real') + try { + await waitForHostStatus(desktop.page, 'ready') + const globalDirectory = join(desktop.home, '.loopal') + const globalPath = join(globalDirectory, 'settings.json') + const projectDirectory = join(desktop.project, '.loopal') + const localPath = join(projectDirectory, 'settings.local.json') + await Promise.all([ + mkdir(globalDirectory, { recursive: true }), + mkdir(projectDirectory, { recursive: true }), + ]) + await writeFile(join(projectDirectory, 'settings.json'), JSON.stringify({ + model_routing: { summarization: 'inherited-summary-model' }, + })) + await writeFile(globalPath, JSON.stringify({ + model: 'before', + providers: { anthropic: { + api_key: 'existing-provider-value', unknown_provider_field: 'preserve-me', + } }, + unknown_global: { preserve: true }, + })) + const projectLocal = { + sandbox: { network: { denied_domains: ['blocked.test'] } }, + output_style: 'workspace-only-style', unknown_project: { preserve: true }, + } + await writeFile(localPath, JSON.stringify(projectLocal)) + + const before = await desktop.page.evaluate( + () => window.loopalDesktop.getLoopalSettings('local-workspace'), + ) + expect(before.settings.model).toBe('before') + expect(before.settings.modelRouting.summarization).toBe('') + expect(before.settings.outputStyle).toBe('') + expect(before.resolvedEntries).toContainEqual({ + key: 'model_routing.summarization', value: 'inherited-summary-model', + }) + expect(before.resolvedEntries).toContainEqual({ + key: 'output_style', value: 'workspace-only-style', + }) + expect(before.configuredProviders).toContain('anthropic') + expect(before.providers.anthropic.apiKeyConfigured).toBe(true) + expect(JSON.stringify(before)).not.toContain('existing-provider-value') + expect(before.settingSources.every((source) => !source.includes(desktop.root))).toBe(true) + + const beforeRuntime = (await desktop.page.evaluate( + () => window.loopalDesktop.bootstrap(), + )).runtimes[0]! + await desktop.page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(desktop.page, 'loopal') + const pane = desktop.page.getByTestId('loopal-default-settings') + await expect(pane.getByLabel('Default model')).toHaveValue('before') + await pane.getByLabel('Default model').fill(settings.model) + await pane.getByLabel('Summarization override').fill('') + await pane.getByLabel('Classification override').fill( + settings.modelRouting.classification, + ) + await pane.getByLabel('Default permission mode').selectOption(settings.permissionMode) + await pane.getByLabel('Default decision mode').selectOption(settings.decisionMode) + await pane.getByLabel('Default sandbox policy').selectOption(settings.sandboxPolicy) + await pane.getByLabel('Default thinking').selectOption('effort:high') + await pane.getByLabel(/Max context tokens/).fill(String(settings.maxContextTokens)) + await pane.getByLabel(/Microcompact idle minutes/).fill( + String(settings.microcompactIdleMinutes), + ) + await setChecked(pane.getByLabel('Enable project memory'), settings.memoryEnabled) + await setChecked(pane.getByLabel('Enable telemetry'), settings.telemetryEnabled) + await pane.getByLabel('Output style').fill(settings.outputStyle) + await selectSettingsSection(desktop.page, 'providers') + await pane.getByLabel('Enable OpenAI').check() + await pane.getByLabel('OpenAI base URL').fill('https://proxy.example.test/v1') + await pane.getByLabel('OpenAI API key environment').fill('LOOPAL_OPENAI_KEY') + await pane.getByLabel('OpenAI API key', { exact: true }).fill('new-provider-write-only-value') + await pane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(pane.getByRole('status')).toContainText('new or restarted Sessions') + const raw = JSON.parse(await readFile(globalPath, 'utf8')) + expect(raw.providers.anthropic.api_key).toBe('existing-provider-value') + expect(raw.providers.anthropic.unknown_provider_field).toBe('preserve-me') + expect(raw.providers.openai.api_key).toBe('new-provider-write-only-value') + expect(raw.providers.openai.base_url).toBe('https://proxy.example.test/v1') + expect(raw.providers.openai.api_key_env).toBe('LOOPAL_OPENAI_KEY') + expect(raw.model_routing.summarization).toBeNull() + expect(raw.model_routing.classification).toBe('desktop-classifier-model') + expect(raw.unknown_global.preserve).toBe(true) + expect(raw.sandbox.policy).toBe('read_only') + expect(JSON.parse(await readFile(localPath, 'utf8'))).toEqual(projectLocal) + + const reread = await desktop.page.evaluate( + () => window.loopalDesktop.getLoopalSettings('local-workspace'), + ) + expect(reread.settings).toEqual(settings) + expect(reread.providers.openai).toEqual({ + enabled: true, baseUrl: 'https://proxy.example.test/v1', + apiKeyEnv: 'LOOPAL_OPENAI_KEY', apiKeyConfigured: true, + }) + expect(JSON.stringify(reread)).not.toContain('existing-provider-value') + expect(JSON.stringify(reread)).not.toContain('new-provider-write-only-value') + expect(reread.resolvedEntries.find((entry) => + entry.key === 'providers.openai.api_key')?.value).toBe('********') + expect(reread.resolvedEntries.find((entry) => + entry.key === 'providers.openai.api_key_env')?.value).toBe('********') + expect(reread.resolvedEntries).toContainEqual({ + key: 'model_routing.summarization', value: 'inherited-summary-model', + }) + await selectSettingsSection(desktop.page, 'loopal') + await pane.getByText('Advanced resolved config').click() + await pane.getByLabel('Search resolved config').fill('providers.openai.api_key') + await expect(pane.getByRole('cell', { name: '********' }).first()).toBeVisible() + await desktop.page.getByRole('button', { name: 'Close settings' }).click() + desktop = await relaunchDesktop(desktop) + await waitForHostStatus(desktop.page, 'ready') + await desktop.page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(desktop.page, 'loopal') + const reloaded = desktop.page.getByTestId('loopal-default-settings') + await expect(reloaded.getByLabel('Default model')).toHaveValue(settings.model) + await expect(reloaded.getByLabel('Summarization override')).toHaveValue('') + await expect(reloaded.getByLabel('Output style')).toHaveValue(settings.outputStyle) + expect(JSON.parse(await readFile(localPath, 'utf8'))).toEqual(projectLocal) + await selectSettingsSection(desktop.page, 'providers') + await reloaded.getByTestId('provider-openai') + .getByRole('button', { name: 'Clear API key' }).click() + await reloaded.getByRole('button', { name: 'Save provider settings' }).click() + await expect(reloaded.getByRole('status')).toContainText('new or restarted Sessions') + const clearedRaw = JSON.parse(await readFile(globalPath, 'utf8')) + expect(clearedRaw.providers.openai.api_key).toBeNull() + expect(clearedRaw.providers.anthropic.api_key).toBe('existing-provider-value') + const cleared = await desktop.page.evaluate( + () => window.loopalDesktop.getLoopalSettings('local-workspace'), + ) + expect(cleared.providers.openai.apiKeyConfigured).toBe(false) + expect(JSON.stringify(cleared)).not.toContain('existing-provider-value') + await reloaded.getByRole('button', { name: 'Restart current Session' }).click() + await expect(reloaded.getByRole('status')).toContainText('restarted with the saved') + const afterRuntime = (await desktop.page.evaluate( + () => window.loopalDesktop.bootstrap(), + )).runtimes.find((runtime) => runtime.sessionId === beforeRuntime.sessionId)! + expect(afterRuntime.generation).toBeGreaterThan(beforeRuntime.generation) + await expect.poll(async () => desktop.page.evaluate(async (sessionId) => { + const detail = await window.loopalDesktop.openSession(sessionId) + return detail.agents.find((agent) => !agent.parentId)?.model + }, beforeRuntime.sessionId), { timeout: 30_000 }).toBe(settings.model) + expect(JSON.parse(await readFile(localPath, 'utf8'))).toEqual(projectLocal) + await expect(desktop.page.evaluate( + () => window.loopalDesktop.getLoopalSettings('outside-workspace'), + )).rejects.toThrow(/Unknown workspace|unknown workspace/) + } finally { + await closeDesktop(desktop) + } +}) + +async function setChecked(locator: import('@playwright/test').Locator, checked: boolean) { + if (await locator.isChecked() !== checked) await locator.click() +} diff --git a/apps/desktop/e2e/real/host/session-directory.spec.ts b/apps/desktop/e2e/real/host/session-directory.spec.ts new file mode 100644 index 00000000..6e2481bc --- /dev/null +++ b/apps/desktop/e2e/real/host/session-directory.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from '@playwright/test' +import { access, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, relaunchDesktop, +} from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' +import { + createSessionDirectory, gitOutput, queueSessionDirectories, +} from '../../support/fixtures/session-directory-fixture' +import { + createFromDirectory, enableTools, expectActiveDirectory, expectHidden, + openWithSelectedDirectory, sessionCount, stopActive, stopAllLiveSessions, +} from '../../support/fixtures/session-directory-ui' +test('creates directory and isolated worktree Sessions without focusing Electron', async () => { + let desktop = await launchDesktop('real', 'session-directories') + try { + const page = desktop.page + const current = page.getByTestId('current-session-list') + await expect(current).toBeVisible() + await expect(page.getByTestId('history-session-list')).toHaveCount(0) + await expect(page.getByLabel('Active workspace')).toHaveCount(0) + await expect(page.getByLabel('Active session')).toHaveCount(0) + const plain = await createSessionDirectory(desktop, 'plain workspace', false) + const git = await createSessionDirectory(desktop, 'git workspace', true) + const worktreeGit = await createSessionDirectory(desktop, 'worktree workspace', true) + const gitSubdirectory = join(worktreeGit.path, 'src') + expect(await gitOutput(desktop, worktreeGit.path, + ['ls-files', '--error-unmatch', 'src/main.rs'])).toBe('src/main.rs') + await queueSessionDirectories(desktop, + [null, plain.path, git.path, gitSubdirectory, worktreeGit.path]) + const initialSessions = await sessionCount(desktop.page) + + await desktop.page.locator('.new-session').click() + const firstDialog = desktop.page.getByTestId('new-session-dialog') + await expect(firstDialog).toBeVisible() + await expect(firstDialog).toContainText(/New session/i) + await firstDialog.getByTestId('session-directory').click() + await expect(firstDialog.getByTestId('create-session-confirm')).toBeDisabled() + await expectHidden(desktop) + expect(await sessionCount(desktop.page)).toBe(initialSessions) + await firstDialog.getByTestId('create-session-cancel').click() + await expect(firstDialog).toHaveCount(0) + expect(await sessionCount(desktop.page)).toBe(initialSessions) + + await createFromDirectory(desktop.page, 'directory') + await expectActiveDirectory(desktop.page, plain.path, 'folder') + await enableTools(desktop.page) + await send(desktop.page, 'Verify the plain directory') + await expect(desktop.page.getByTestId('conversation')).toContainText( + plain.path, { timeout: 20_000 }, + ) + await expect(desktop.page.getByTestId('conversation')) + .toContainText('Plain directory verified.', { timeout: 20_000 }) + const plainSession = (await activeDetail(page)).session + await expect(current.locator(`[data-session-id="${plainSession.id}"]`)).toBeVisible() + const visibleIds = await current.locator('.session-card').evaluateAll((cards) => ( + cards.map((card) => card.getAttribute('data-session-id')) + )) + const visibleWorkspaceCount = await page.evaluate(async (ids) => new Set( + (await window.loopalDesktop.bootstrap()).sessions + .filter(({ id }) => ids.includes(id)).map(({ workspaceId }) => workspaceId), + ).size, visibleIds) + expect(visibleWorkspaceCount).toBeGreaterThanOrEqual(2) + await stopActive(desktop.page) + await expect(current.locator(`[data-session-id="${plainSession.id}"]`)).toHaveCount(0) + await expect(page.getByTestId('history-session-list')).toHaveCount(0) + const search = page.getByLabel('Search sessions') + await search.fill(plainSession.title) + const history = page.getByTestId('history-session-list') + await expect(history.locator(`[data-session-id="${plainSession.id}"]`)).toBeVisible() + await history.locator(`[data-session-id="${plainSession.id}"]`).click() + await expect(page.getByTestId('active-session-title')).toHaveText(plainSession.title) + await search.fill('') + await createFromDirectory(desktop.page, 'directory', true) + await expectActiveDirectory(desktop.page, git.path, 'folder') + expect(await gitOutput(desktop, git.path, ['branch', '--show-current'])).toBe('main') + expect(((await gitOutput(desktop, git.path, ['worktree', 'list', '--porcelain'])) + .match(/^worktree /gm) ?? [])).toHaveLength(1) + await enableTools(desktop.page) + await send(desktop.page, 'Verify the Git directory directly') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Git directory verified.', { timeout: 20_000 }, + ) + await stopAllLiveSessions(desktop.page) + expect(await gitOutput(desktop, worktreeGit.path, ['status', '--porcelain'])).toBe('') + await createFromDirectory(desktop.page, 'worktree', true, 'e2e-isolated') + const worktreeRoot = join(worktreeGit.path, '.loopal', 'worktrees', 'e2e-isolated') + const worktreeDirectory = join(worktreeRoot, 'src') + await expectActiveDirectory(desktop.page, worktreeDirectory, 'git_worktree') + expect(await gitOutput(desktop, worktreeDirectory, ['branch', '--show-current'])) + .toBe('loopal-wt-e2e-isolated') + expect(await gitOutput(desktop, worktreeGit.path, ['status', '--porcelain'])).toBe('') + await enableTools(desktop.page) + await send(desktop.page, 'Verify the isolated worktree') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Worktree isolation verified.', { timeout: 20_000 }, + ) + await expect(access(join(worktreeDirectory, 'worktree-only.txt'))).resolves.toBeUndefined() + await expect(access(join(gitSubdirectory, 'worktree-only.txt'))).rejects.toThrow() + + const worktreeSession = (await activeDetail(desktop.page)).session.id + await desktop.page.evaluate((id) => window.loopalDesktop.stopSession(id), worktreeSession) + await desktop.page.evaluate((id) => window.loopalDesktop.restartSession(id), worktreeSession) + await ready(desktop.page) + await expectActiveDirectory(desktop.page, worktreeDirectory, 'git_worktree') + await enableTools(desktop.page) + await send(desktop.page, 'Verify the restarted isolated worktree') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Restarted worktree verified.', { timeout: 20_000 }, + ) + await ready(desktop.page) + + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + await expectActiveDirectory(desktop.page, worktreeDirectory, 'git_worktree') + await enableTools(desktop.page) + await send(desktop.page, 'Verify the relaunched isolated worktree') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Relaunched worktree verified.', { timeout: 20_000 }, + ) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'session-directories', served: 10, remaining: 0, verified: true, + }) + + await queueSessionDirectories(desktop, [worktreeGit.path]) + const beforeFailure = await sessionCount(desktop.page) + const dirtyFile = join(worktreeGit.path, 'dirty-e2e.txt') + await writeFile(dirtyFile, 'not committed\n') + await openWithSelectedDirectory(desktop.page) + const duplicate = desktop.page.getByTestId('new-session-dialog') + await duplicate.getByTestId('launch-worktree').check() + await expect(duplicate).toContainText(/uncommitted changes are not copied/i) + await duplicate.getByTestId('worktree-name').fill('../escape') + await expect(duplicate.getByTestId('create-session-confirm')).toBeDisabled() + await duplicate.getByTestId('worktree-name').fill('e2e-isolated') + await duplicate.getByTestId('create-session-confirm').click() + await expect(duplicate.getByTestId('session-create-error')) + .toContainText(/exists|already|已存在/i) + expect(await sessionCount(desktop.page)).toBe(beforeFailure) + await duplicate.getByTestId('create-session-cancel').click() + await rm(dirtyFile) + expect(await gitOutput(desktop, worktreeGit.path, ['status', '--porcelain'])).toBe('') + + await desktop.page.getByRole('button', { name: 'Settings' }).click() + await desktop.page.getByTestId('desktop-language').selectOption('zh-CN') + await desktop.page.getByRole('button', { name: '关闭设置' }).click() + await expect(desktop.page.locator('html')).toHaveAttribute('lang', 'zh-CN') + await expect(desktop.page.getByLabel('搜索会话')).toBeVisible() + await expect(desktop.page.getByTestId('current-session-list')).toContainText('当前会话') + await expect(desktop.page.getByTestId('history-session-list')).toHaveCount(0) + await desktop.page.locator('.new-session').click() + const chinese = desktop.page.getByTestId('new-session-dialog') + await expect(chinese).toContainText('新建会话') + await expect(chinese).toContainText('运行目录') + await expect(chinese.getByTestId('session-directory')).toContainText('选择目录…') + await expect(chinese.getByTestId('create-session-confirm')).toContainText('创建会话') + await expect(chinese.getByTestId('create-session-cancel')).toContainText('取消') + await chinese.getByTestId('create-session-cancel').click() + await expectHidden(desktop) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/attention/host-attention-answers.spec.ts b/apps/desktop/e2e/real/provider/attention/host-attention-answers.spec.ts new file mode 100644 index 00000000..8ade3e87 --- /dev/null +++ b/apps/desktop/e2e/real/provider/attention/host-attention-answers.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../../support/runtime/llm-e2e-helpers' + +test('returns Other, multi-select, and cancellation answers to the real model loop', async () => { + const desktop = await launchDesktop('real', 'attention-answers') + try { + const page = desktop.page + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'decision', mode: 'manual' }, + }), target) + const questions = page.getByTestId('questions-pane') + const conversation = page.getByTestId('conversation') + + await send(page, 'Ask for a custom answer') + await expect(questions).toContainText('Describe the verification style') + await questions.getByRole('textbox', { + name: /Other answer for .*Describe the verification style/, + }).fill('custom fixture answer') + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(conversation).toContainText( + 'Custom free-text answer reached the model.', { timeout: 20_000 }, + ) + await expect(questions).toHaveCount(0) + await ready(page) + + await send(page, 'Ask multiple questions') + await expect(questions).toContainText('Pick several checks') + await questions.getByRole('button', { name: /Build/ }).click() + await questions.getByRole('button', { name: /Tests/ }).click() + await questions.getByRole('textbox', { + name: /Other answer for .*Pick several checks/, + }).fill('custom multi check') + await questions.getByRole('button', { name: /Deep/ }).click() + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(conversation).toContainText( + 'Multi-select and custom answers reached the model.', { timeout: 20_000 }, + ) + await expect(questions).toHaveCount(0) + await ready(page) + + await send(page, 'Ask a cancellable question') + await expect(questions).toContainText('Cancel this verification request') + await questions.getByRole('button', { name: 'Cancel', exact: true }).click() + await expect(conversation).toContainText( + 'Question cancellation reached the model.', { timeout: 20_000 }, + ) + await expect(questions).toHaveCount(0) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(6) + expect(requests[1]!.toolResultIds).toContain('ask-custom') + expect(requests[3]!.toolResultIds).toContain('ask-multiple') + expect(requests[5]!.toolResultIds).toContain('ask-cancel') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 6, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/attention/host-attention-classifier.spec.ts b/apps/desktop/e2e/real/provider/attention/host-attention-classifier.spec.ts new file mode 100644 index 00000000..1d57fef2 --- /dev/null +++ b/apps/desktop/e2e/real/provider/attention/host-attention-classifier.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../../support/runtime/llm-e2e-helpers' + +test('runs question and permission classifier success and fallback paths', async () => { + const desktop = await launchDesktop('real', 'attention-classifier') + try { + const page = desktop.page + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'decision', mode: 'classifier' }, + }) + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'ask_any_write' }, + }) + }, target) + const questions = page.getByTestId('questions-pane') + const permissions = page.getByTestId('permissions-pane') + const conversation = page.getByTestId('conversation') + + await send(page, 'Let the classifier answer the inferable question') + await expect(questions).toContainText('Auto-answering', { timeout: 20_000 }) + await expect(conversation).toContainText('Classifier answer won the real race.', { + timeout: 20_000, + }) + await expect(questions).toHaveCount(0) + await ready(page) + + await send(page, 'Make the classifier fail then ask me') + await expect(questions).toContainText('Auto-answer unavailable', { timeout: 20_000 }) + await questions.getByRole('button', { name: /Manual/ }).click() + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(conversation).toContainText( + 'Manual fallback answered after classifier failure.', { timeout: 20_000 }, + ) + await ready(page) + + await send(page, 'Classifier allow this write') + await expect(conversation).toContainText('Permission classifier allowed the write.', { + timeout: 20_000, + }) + await expect(permissions).toHaveCount(0) + await expect.poll(() => readFile(join(desktop.project, 'classifier-allowed.txt'), 'utf8')) + .toBe('allowed\n') + await ready(page) + + await send(page, 'Classifier deny this write') + await expect(conversation).toContainText( + 'Permission classifier denial returned to the model.', { timeout: 20_000 }, + ) + await expect(permissions).toHaveCount(0) + await expect(readFile(join(desktop.project, 'classifier-denied.txt'), 'utf8')) + .rejects.toThrow() + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(12) + for (const index of [1, 4]) expect(requests[index]).toMatchObject({ + messageCount: 1, toolCount: 0, maxTokens: 512, + }) + for (const index of [7, 10]) expect(requests[index]).toMatchObject({ + messageCount: 1, toolCount: 0, maxTokens: 256, + }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 12, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/attention/host-attention-session-permission.spec.ts b/apps/desktop/e2e/real/provider/attention/host-attention-session-permission.spec.ts new file mode 100644 index 00000000..f97832a2 --- /dev/null +++ b/apps/desktop/e2e/real/provider/attention/host-attention-session-permission.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../../support/runtime/llm-e2e-helpers' + +test('persists an allow-for-session decision across model tool calls', async () => { + const desktop = await launchDesktop('real', 'attention-session-permission') + try { + const page = desktop.page + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'decision', mode: 'manual' }, + }) + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'ask_any_write' }, + }) + }, target) + const approvals = page.getByTestId('permissions-pane') + const conversation = page.getByTestId('conversation') + + await send(page, 'Grant a session write') + await expect(approvals).toContainText('Allow Write') + await approvals.getByRole('button', { name: 'Allow for session' }).click() + await expect(conversation).toContainText( + 'Session permission stored after the first write.', { timeout: 20_000 }, + ) + await expect.poll(() => readFile(join(desktop.project, 'session-one.txt'), 'utf8')) + .toBe('one\n') + await ready(page) + + await send(page, 'Reuse the session write grant') + await expect(conversation).toContainText( + 'Second write reused the session permission.', { timeout: 20_000 }, + ) + await expect(approvals).toHaveCount(0) + await expect.poll(() => readFile(join(desktop.project, 'session-two.txt'), 'utf8')) + .toBe('two\n') + await ready(page) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 4, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/federation/host-metahub-data-plane.spec.ts b/apps/desktop/e2e/real/provider/federation/host-metahub-data-plane.spec.ts new file mode 100644 index 00000000..698e8db2 --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-metahub-data-plane.spec.ts @@ -0,0 +1,166 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop, type DesktopFixture } from '../../../support/electron/electron-fixture' +import { ready } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +test('routes model turns across MetaHub once before and after reconnect', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-data-plane') + await ready(desktop.page) + const target = await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta) + await expect.poll(async () => topologyConnection(desktop!, target.sessionId), { + timeout: 15_000, + }).toEqual({ + state: 'connected', localHub: 'hub-a', hubs: ['hub-a', 'hub-b'], + remote: { + id: 'hub-b/main', name: 'main', hub: 'hub-b', hubPath: ['hub-b'], + lifecycle: 'running', + }, + }) + + await exchange(desktop, remote, 'ONE', 5) + const reconnect = await desktop.page.evaluate(async (value) => { + const disconnected = await window.loopalDesktop.disconnectMetaHub(value) + const joined = await window.loopalDesktop.joinMetaHub(value) + return { disconnected: disconnected.state, joined: joined.state } + }, target) + expect(reconnect).toEqual({ disconnected: 'disconnected', joined: 'connected' }) + await exchange(desktop, remote, 'TWO', 10) + + const requests = await desktop.llm!.requests() + for (const marker of [ + '[from: hub-b/main] REMOTE REQUEST ONE', + '[from: hub-a/main] DESKTOP MODEL REPLY ONE', + '[from: hub-b/main] REMOTE REQUEST TWO', + '[from: hub-a/main] DESKTOP MODEL REPLY TWO', + ]) { + expect(requests.filter((request) => request.lastUserText.includes(marker))).toHaveLength(1) + } + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 10, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +test('delivers one reply when MetaHub reconnects during a model turn', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-inflight-reconnect') + await ready(desktop.page) + const target = await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta) + await expect.poll(async () => topologyConnection(desktop!, target.sessionId), { + timeout: 15_000, + }).toMatchObject({ state: 'connected', hubs: ['hub-a', 'hub-b'] }) + + await remote.probe.startModelTurn('REMOTE INFLIGHT INITIATE') + await expect(desktop.page.locator('[data-message-role="user"]') + .filter({ hasText: 'REMOTE INFLIGHT REQUEST' })).toHaveCount(1) + await expect.poll(() => desktop!.llm!.state(), { timeout: 10_000 }) + .toMatchObject({ inFlight: 1 }) + const reconnect = await desktop.page.evaluate(async (value) => { + const disconnected = await window.loopalDesktop.disconnectMetaHub(value) + const joined = await window.loopalDesktop.joinMetaHub(value) + return [disconnected.state, joined.state] + }, target) + expect(reconnect).toEqual(['disconnected', 'connected']) + + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'DESKTOP INFLIGHT ACK', { timeout: 20_000 }, + ) + await expect.poll(() => remoteInboxCount(remote!, 'DESKTOP INFLIGHT REPLY'), { + timeout: 15_000, + }).toBe(1) + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 5, remaining: 0, inFlight: 0, unmatchedRequests: 0, verified: true, + }) + await desktop.page.waitForTimeout(1_000) + expect(remoteInboxCount(remote, 'DESKTOP INFLIGHT REPLY')).toBe(1) + const requests = await desktop.llm!.requests() + expect(requests.filter((request) => request.lastUserText.includes( + '[from: hub-b/main] REMOTE INFLIGHT REQUEST', + ))).toHaveLength(1) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function joinDesktop(desktop: DesktopFixture, meta: MetaHubProcess) { + return desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((value) => value.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.controlAgent({ target, command: { type: 'permission', mode: 'bypass' } }) + await api.updateMetaHubSettings({ + address, hubName: 'hub-a', token, joinOnStart: false, startLocalOnLaunch: false, + }) + await api.joinMetaHub(target) + return target + }, { address: meta.address, token: meta.token }) +} + +async function exchange( + desktop: DesktopFixture, remote: RemoteHubProcess, suffix: 'ONE' | 'TWO', served: number, +): Promise { + await remote.probe.startModelTurn(`REMOTE INITIATE ${suffix}`) + const messages = desktop.page.locator('[data-message-role="user"]') + const incoming = messages.filter({ hasText: `REMOTE REQUEST ${suffix}` }) + await expect(incoming).toHaveCount(1) + await expect(incoming).toContainText('From · hub-b/main') + await expect(desktop.page.getByTestId('conversation')).toContainText( + `DESKTOP LOCAL ACK ${suffix}`, { timeout: 15_000 }, + ) + await expect.poll(() => remoteInboxCount(remote, `DESKTOP MODEL REPLY ${suffix}`), { + timeout: 15_000, + }).toBe(1) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ served }) +} + +function remoteInboxCount(remote: RemoteHubProcess, marker: string): number { + return remote.probe.notifications().filter((value) => { + const encoded = JSON.stringify(value) + return encoded.includes('InboxEnqueued') + && encoded.includes('hub-a') && encoded.includes(marker) + }).length +} + +async function topologyConnection(desktop: DesktopFixture, sessionId: string) { + const detail = await desktop.page.evaluate( + (value) => window.loopalDesktop.openSession(value), sessionId, + ) + const state = detail.metaHub + const remote = state?.topology.find((agent) => agent.id === 'hub-b/main') + return { + state: state?.state, + localHub: state?.hubName, + hubs: state?.hubs.map((hub) => hub.name).sort(), + remote: remote ? { + id: remote.id, name: remote.name, hub: remote.hub, + hubPath: remote.hubPath, lifecycle: remote.lifecycle, + } : undefined, + } +} diff --git a/apps/desktop/e2e/real/provider/federation/host-provider-metahub-cross-hub-agent.spec.ts b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-cross-hub-agent.spec.ts new file mode 100644 index 00000000..5a319933 --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-cross-hub-agent.spec.ts @@ -0,0 +1,195 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { + closeDesktop, launchDesktop, relaunchDesktop, type DesktopFixture, + waitForHostStatus, +} from '../../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +const childResult = 'REMOTE CHILD PROVIDER COMPLETION 7E92' + +test('auto-joins and completes a model-driven Agent on a remote Hub once', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-cross-hub-agent') + await ready(desktop.page) + remote = await startRemoteHub(desktop, meta) + await saveAutoJoin(desktop.page, meta) + + desktop = await relaunchDesktop(desktop) + await readyAfterAutoJoin(desktop) + await expect.poll(() => cluster(desktop!.page), { timeout: 15_000 }).toEqual({ + state: 'connected', hubName: expect.stringMatching(/^hub-a-.+-g\d+-[a-z0-9]+$/), + hubs: [expect.stringMatching(/^hub-a-.+-g\d+-[a-z0-9]+$/), 'hub-b'], + remoteMain: 'running', + }) + + const before = await runtimeTarget(desktop.page) + const restarted = await desktop.page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), before.sessionId, + ) + expect(restarted.generation).toBeGreaterThan(before.generation) + await ready(desktop.page) + await expect.poll(() => cluster(desktop!.page), { timeout: 15_000 }).toEqual({ + state: 'connected', hubName: expect.stringMatching(/^hub-a-.+-g\d+-[a-z0-9]+$/), + hubs: [expect.stringMatching(/^hub-a-.+-g\d+-[a-z0-9]+$/), 'hub-b'], + remoteMain: 'running', + }) + + const target = await runtimeTarget(desktop.page) + expect(target.generation).toBe(restarted.generation) + await desktop.page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await send(desktop.page, 'Exercise the cross-hub Agent lifecycle') + + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation).toContainText('Remote child status is running.', { + timeout: 10_000, + }) + await desktop.page.getByRole('button', { name: 'Federation', exact: true }).click() + const remoteNode = desktop.page.getByTestId('federation-workspace') + .locator('[data-qualified-agent-id="hub-b/remote-worker"]') + await expect(remoteNode).toBeVisible({ timeout: 8_000 }) + await expect(remoteNode).toContainText('running') + await expect.poll(() => projectedChild(desktop!.page), { timeout: 8_000 }).toEqual([ + qualifiedChild('running'), + ]) + + await desktop.page.getByRole('button', { name: 'Conversation', exact: true }).click() + await expect(conversation).toContainText( + 'Cross-hub result observed exactly once.', { timeout: 20_000 }, + ) + await desktop.page.getByRole('button', { name: 'Federation', exact: true }).click() + await expect(remoteNode).toContainText('remote-worker', { timeout: 10_000 }) + await expect(remoteNode).toHaveAttribute('data-lifecycle', 'finished', { timeout: 10_000 }) + await expect(desktop.page.getByTestId('federation-workspace') + .locator('[data-qualified-agent-id="hub-a/remote-worker"]')).toHaveCount(0) + await expect.poll(() => projectedChild(desktop!.page), { timeout: 10_000 }).toEqual([ + qualifiedChild('completed'), + ]) + await desktop.page.getByRole('button', { name: 'Conversation', exact: true }).click() + const completion = conversation.locator('[data-message-role="user"]') + .filter({ hasText: childResult }) + await expect(completion).toHaveCount(1) + await expect(completion).toContainText('From · hub-b/remote-worker') + await assertToolChain(conversation) + + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 7, remaining: 0, requestCount: 7, unmatchedRequests: 0, + inFlight: 0, verified: true, + }) + await desktop.page.waitForTimeout(1_500) + expect(await desktop.llm!.state()).toMatchObject({ + served: 7, remaining: 0, requestCount: 7, unmatchedRequests: 0, + inFlight: 0, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(7) + expect(requests.every((request) => request.matched)).toBe(true) + expect(requests.filter((request) => ( + request.lastUserText.includes(`Return ${childResult}`) + ))).toHaveLength(1) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function saveAutoJoin(page: Page, meta: MetaHubProcess): Promise { + const saved = await page.evaluate(async ({ address, token }) => ( + window.loopalDesktop.updateMetaHubSettings({ + address, hubName: 'hub-a', token, joinOnStart: true, startLocalOnLaunch: false, + }) + ), { address: meta.address, token: meta.token }) + expect(saved).toMatchObject({ + hubName: 'hub-a', joinOnStart: true, tokenConfigured: true, + }) +} + +async function readyAfterAutoJoin(desktop: DesktopFixture): Promise { + const page = desktop.page + await waitForHostStatus(page, 'ready') + try { + await expect(page.getByTestId('runtime-status')).toContainText( + 'Ready for input', { timeout: 15_000 }, + ) + await expect(page.getByLabel('Message Loopal')).toBeEnabled({ timeout: 5_000 }) + } catch (error) { + const state = await desktop.llm!.state() + const requests = (await desktop.llm!.requests()).slice(-8).map((request) => ({ + sequence: request.sequence, matched: request.matched, + lastUserText: request.lastUserText.slice(0, 300), + toolResultIds: request.toolResultIds, + })) + const runtime = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const detail = await window.loopalDesktop.openSession(bootstrap.activeSessionId!) + return { + session: detail.session, metaHub: detail.metaHub, + agents: detail.agents.map((agent) => ({ + id: agent.id, status: agent.status, error: agent.error, + telemetry: agent.telemetry, + })), + conversation: detail.conversation.slice(-8).map((entry) => ({ + role: entry.role, text: entry.text.slice(0, 300), inbox: entry.inbox, + })), + } + }) + throw new Error(`auto-join runtime did not become idle: ${JSON.stringify({ state, requests, runtime })}`, { cause: error }) + } +} + +async function cluster(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const detail = await window.loopalDesktop.openSession(bootstrap.activeSessionId!) + return { + state: detail.metaHub?.state, + hubName: detail.metaHub?.hubName, + hubs: detail.metaHub?.hubs.map((hub) => hub.name).sort() ?? [], + remoteMain: detail.metaHub?.topology.find((agent) => agent.id === 'hub-b/main')?.lifecycle, + } + }) +} + +async function projectedChild(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const detail = await window.loopalDesktop.openSession(bootstrap.activeSessionId!) + return detail.agents.filter((agent) => ( + agent.id === 'remote-worker' || agent.id === 'hub-b/remote-worker' + )).map((agent) => ({ + id: agent.id, qualifiedName: agent.qualifiedName, status: agent.status, + parentId: agent.parentId, hubPath: agent.hubPath, controllable: agent.controllable, + })) + }) +} + +function qualifiedChild(status: 'running' | 'completed') { + return { + id: 'hub-b/remote-worker', qualifiedName: 'hub-b/remote-worker', status, + parentId: 'main', hubPath: ['hub-b'], controllable: status === 'running', + } +} + +async function assertToolChain(conversation: Locator): Promise { + const tools = conversation.getByTestId('tool-invocation') + const listed = tools.filter({ hasText: /^ListHubs/ }) + await expect(listed).toHaveCount(1) + await expect(listed).toContainText("Connected to MetaHub as 'hub-a-") + const agent = tools.filter({ hasText: /^Agent/ }) + await expect(agent).toHaveCount(3) + await expect(agent.filter({ hasText: '"target_hub": "hub-b"' })).toHaveCount(1) + await expect(agent.filter({ hasText: '"action": "status"' })).toContainText('Running') + await expect(agent.filter({ hasText: '"action": "result"' })).toContainText(childResult) + for (let index = 0; index < 3; index += 1) await expect(agent.nth(index)).toContainText('Completed') +} diff --git a/apps/desktop/e2e/real/provider/federation/host-provider-metahub-multi-hub.spec.ts b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-multi-hub.spec.ts new file mode 100644 index 00000000..e8e6423e --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-multi-hub.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop, type DesktopFixture } from '../../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +const results = { + 'worker-b': 'REMOTE HUB B RESULT 84B7', + 'worker-c': 'REMOTE HUB C RESULT 52C9', +} as const + +test('runs two remote Agents on independent Hubs and completes each once', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let hubB: RemoteHubProcess | undefined + let hubC: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-multi-hub-agent') + await ready(desktop.page) + ;[hubB, hubC] = await Promise.all([ + startRemoteHub(desktop, meta, 'hub-b'), + startRemoteHub(desktop, meta, 'hub-c'), + ]) + assertIndependentProcesses(hubB, hubC) + const target = await connect(desktop.page, meta) + await expect.poll(() => cluster(desktop!.page), { timeout: 15_000 }).toEqual({ + state: 'connected', hubName: 'hub-a', hubs: ['hub-a', 'hub-b', 'hub-c'], + mains: ['hub-b/main:running', 'hub-c/main:running'], + }) + + await send(desktop.page, 'Exercise two remote Hubs concurrently') + await expect.poll(() => children(desktop!.page), { timeout: 8_000 }).toEqual([ + expectedChild('hub-b', 'worker-b', 'running', true), + expectedChild('hub-c', 'worker-c', 'running', true), + ]) + await assertRawTopology(desktop.page, 'running') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Both remote agents are running.', { timeout: 20_000 }, + ) + + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation).toContainText('Hub B result observed exactly once.', { + timeout: 20_000, + }) + await expect(conversation).toContainText('Hub C result observed exactly once.', { + timeout: 20_000, + }) + await expect.poll(() => children(desktop!.page), { timeout: 10_000 }).toEqual([ + expectedChild('hub-b', 'worker-b', 'completed', false), + expectedChild('hub-c', 'worker-c', 'completed', false), + ]) + await assertRawTopology(desktop.page, 'finished') + await assertExactlyOnce(conversation) + await assertTools(conversation) + + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 8, remaining: 0, requestCount: 8, unmatchedRequests: 0, + inFlight: 0, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(8) + expect(requests.every((request) => request.matched)).toBe(true) + for (const result of Object.values(results)) { + expect(requests.filter((request) => request.lastUserText.includes(`Return ${result}`))) + .toHaveLength(1) + } + expect(target.agentId).toBe('main') + } finally { + hubB?.probe.close(); hubC?.probe.close() + if (hubB) await stopProcess(hubB.child) + if (hubC) await stopProcess(hubC.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +function assertIndependentProcesses(a: RemoteHubProcess, b: RemoteHubProcess): void { + expect(a.child.pid).toBeGreaterThan(0) + expect(b.child.pid).toBeGreaterThan(0) + expect(a.child.pid).not.toBe(b.child.pid) + expect(() => process.kill(a.child.pid!, 0)).not.toThrow() + expect(() => process.kill(b.child.pid!, 0)).not.toThrow() +} + +async function connect(page: Page, meta: MetaHubProcess) { + const target = await runtimeTarget(page) + await page.evaluate(async ({ target: value, address, token }) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }) + await window.loopalDesktop.updateMetaHubSettings({ + address, hubName: 'hub-a', token, joinOnStart: false, startLocalOnLaunch: false, + }) + await window.loopalDesktop.joinMetaHub(value) + }, { target, address: meta.address, token: meta.token }) + return target +} + +async function cluster(page: Page) { + const detail = await active(page) + return { + state: detail.metaHub?.state, hubName: detail.metaHub?.hubName, + hubs: detail.metaHub?.hubs.map((hub) => hub.name).sort(), + mains: detail.metaHub?.topology.filter((agent) => ( + agent.id === 'hub-b/main' || agent.id === 'hub-c/main' + )).map((agent) => `${agent.id}:${agent.lifecycle}`).sort(), + } +} + +async function children(page: Page) { + const detail = await active(page) + return detail.agents.filter((agent) => agent.id.includes('/worker-')).map((agent) => ({ + id: agent.id, qualifiedName: agent.qualifiedName, parentId: agent.parentId, + hubPath: agent.hubPath, status: agent.status, controllable: agent.controllable, + })).sort((a, b) => a.id.localeCompare(b.id)) +} + +function expectedChild( + hub: 'hub-b' | 'hub-c', name: 'worker-b' | 'worker-c', + status: 'running' | 'completed', controllable: boolean, +) { + return { + id: `${hub}/${name}`, qualifiedName: `${hub}/${name}`, parentId: 'main', + hubPath: [hub], status, controllable, + } +} + +async function assertRawTopology(page: Page, lifecycle: 'running' | 'finished'): Promise { + const topology = (await active(page)).metaHub!.topology + for (const [hub, name] of [['hub-b', 'worker-b'], ['hub-c', 'worker-c']] as const) { + expect(topology.find((agent) => agent.id === `${hub}/${name}`)).toMatchObject({ + name, hub, hubPath: [hub], parentId: 'hub-a/main', lifecycle, + }) + } +} + +async function assertExactlyOnce(conversation: Locator): Promise { + for (const [name, result] of Object.entries(results)) { + const completion = conversation.locator('[data-message-role="user"]').filter({ hasText: result }) + await expect(completion).toHaveCount(1) + await expect(completion).toContainText(`From · ${name === 'worker-b' ? 'hub-b' : 'hub-c'}/${name}`) + } +} + +async function assertTools(conversation: Locator): Promise { + const agents = conversation.getByTestId('tool-invocation').filter({ hasText: /^Agent/ }) + await expect(agents).toHaveCount(4) + for (const [hub, name] of [['hub-b', 'worker-b'], ['hub-c', 'worker-c']] as const) { + await expect(agents.filter({ hasText: `"target_hub": "${hub}"` })).toHaveCount(1) + await expect(agents.filter({ hasText: `"name": "${name}"` })).toHaveCount(2) + } +} + +async function active(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) +} diff --git a/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-failure.spec.ts b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-failure.spec.ts new file mode 100644 index 00000000..cc9a33b1 --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-failure.spec.ts @@ -0,0 +1,96 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop, type DesktopFixture } from '../../../support/electron/electron-fixture' +import { ready, send } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +test('projects a remote child provider failure and returns it to root', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-remote-failure') + await ready(desktop.page) + await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta) + await expect.poll(() => remoteMain(desktop!.page), { timeout: 15_000 }).toBe('running') + + await send(desktop.page, 'Spawn the remote provider failure child') + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation).toContainText( + 'Root observed the remote provider failure.', { timeout: 20_000 }, + ) + await expect.poll(() => failedChild(desktop!.page), { timeout: 15_000 }).toEqual({ + projected: { + id: 'hub-b/remote-failed', status: 'failed', controllable: false, + error: expect.stringContaining('scripted remote child failure'), + }, + topology: { + id: 'hub-b/remote-failed', lifecycle: 'failed', + error: expect.stringContaining('scripted remote child failure'), + }, + }) + const agent = conversation.getByTestId('tool-invocation').filter({ hasText: /^Agent/ }) + await expect(agent).toHaveCount(1) + await expect(agent).toContainText('scripted remote child failure') + + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 3, remaining: 0, requestCount: 3, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + expect((await desktop.llm!.requests()).every((request) => request.matched)).toBe(true) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function joinDesktop(desktop: DesktopFixture, meta: MetaHubProcess): Promise { + await desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.controlAgent({ target, command: { type: 'permission', mode: 'bypass' } }) + await api.updateMetaHubSettings({ + address, token, hubName: 'hub-a', joinOnStart: false, startLocalOnLaunch: false, + }) + await api.joinMetaHub(target) + }, { address: meta.address, token: meta.token }) +} + +async function detail(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) +} + +async function remoteMain(page: Page): Promise { + return (await detail(page)).metaHub?.topology + .find((agent) => agent.id === 'hub-b/main')?.lifecycle +} + +async function failedChild(page: Page) { + const value = await detail(page) + const projected = value.agents.find((agent) => agent.id === 'hub-b/remote-failed') + const topology = value.metaHub?.topology.find((agent) => agent.id === 'hub-b/remote-failed') + return { + projected: projected && { + id: projected.id, status: projected.status, + controllable: projected.controllable, error: projected.error, + }, + topology: topology && { + id: topology.id, lifecycle: topology.lifecycle, error: topology.error, + }, + } +} diff --git a/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-interrupt.spec.ts b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-interrupt.spec.ts new file mode 100644 index 00000000..3933f6c2 --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-interrupt.spec.ts @@ -0,0 +1,104 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop, type DesktopFixture } from '../../../support/electron/electron-fixture' +import { ready, send } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +const active = 'REMOTE INTERRUPT STREAM ACTIVE 84F1' +const late = 'REMOTE INTERRUPT LATE OUTPUT 84F1' + +test('interrupts a running remote child without late completion', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-remote-interrupt') + await ready(desktop.page) + const target = await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta) + await expect.poll(() => cluster(desktop!.page), { timeout: 15_000 }).toEqual({ + state: 'connected', hubs: ['hub-a', 'hub-b'], remoteMain: 'running', + }) + + await send(desktop.page, 'Spawn the remote interrupt child') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Remote cancellable child is running.', { timeout: 15_000 }, + ) + await expect.poll(() => child(desktop!.page), { timeout: 15_000 }).toMatchObject({ + id: 'hub-b/remote-cancel', status: 'running', controllable: true, + }) + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ inFlight: 1 }) + + await desktop.page.evaluate(async ({ root, agentId }) => { + await window.loopalDesktop.interruptAgent({ ...root, agentId }) + }, { root: target, agentId: 'hub-b/remote-cancel' }) + await expect.poll(() => desktop!.llm!.state(), { timeout: 15_000 }).toMatchObject({ + clientDisconnects: 1, served: 4, remaining: 0, inFlight: 0, + unmatchedRequests: 0, verified: true, + }) + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Root observed remote child interruption.', { timeout: 15_000 }, + ) + await expect.poll(() => child(desktop!.page), { timeout: 15_000 }).toMatchObject({ + id: 'hub-b/remote-cancel', status: 'completed', controllable: false, + }) + + await desktop.page.waitForTimeout(1_500) + await expect(desktop.page.getByTestId('conversation')).not.toContainText(late) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(4) + expect(requests.every((request) => request.matched)).toBe(true) + expect(requests.filter((request) => request.lastUserText.includes(active))).toHaveLength(1) + expect(await desktop.llm!.state()).toMatchObject({ + clientDisconnects: 1, served: 4, remaining: 0, inFlight: 0, verified: true, + }) + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function joinDesktop(desktop: DesktopFixture, meta: MetaHubProcess) { + return desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.controlAgent({ target, command: { type: 'permission', mode: 'bypass' } }) + await api.updateMetaHubSettings({ + address, token, hubName: 'hub-a', joinOnStart: false, startLocalOnLaunch: false, + }) + await api.joinMetaHub(target) + return target + }, { address: meta.address, token: meta.token }) +} + +async function cluster(page: Page) { + const detail = await detailOf(page) + return { + state: detail.metaHub?.state, + hubs: detail.metaHub?.hubs.map((hub) => hub.name).sort() ?? [], + remoteMain: detail.metaHub?.topology + .find((agent) => agent.id === 'hub-b/main')?.lifecycle, + } +} + +async function child(page: Page) { + return (await detailOf(page)).agents.find((agent) => agent.id === 'hub-b/remote-cancel') +} + +async function detailOf(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) +} diff --git a/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-question.spec.ts b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-question.spec.ts new file mode 100644 index 00000000..5370bb67 --- /dev/null +++ b/apps/desktop/e2e/real/provider/federation/host-provider-metahub-remote-question.spec.ts @@ -0,0 +1,93 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop, type DesktopFixture } from '../../../support/electron/electron-fixture' +import { ready, send } from '../../../support/runtime/llm-e2e-helpers' +import { + startMetaHub, startRemoteHub, stopProcess, + type MetaHubProcess, type RemoteHubProcess, +} from '../../../support/federation/metahub-data-plane-fixture' + +test('returns a Desktop answer to AskUser on a remote child', async () => { + let meta: MetaHubProcess | undefined + let desktop: DesktopFixture | undefined + let remote: RemoteHubProcess | undefined + try { + meta = await startMetaHub() + desktop = await launchDesktop('real', 'metahub-remote-question') + await ready(desktop.page) + await joinDesktop(desktop, meta) + remote = await startRemoteHub(desktop, meta) + await expect.poll(() => cluster(desktop!.page), { timeout: 15_000 }).toEqual({ + state: 'connected', hubs: ['hub-a', 'hub-b'], remoteMain: 'running', + }) + remote.probe.close() + await desktop.page.waitForTimeout(500) + + await send(desktop.page, 'Spawn the remote question child') + const questions = desktop.page.getByTestId('questions-pane') + await expect(questions).toContainText('Choose remote verification', { timeout: 20_000 }) + await expect(questions).toContainText('Agent question · hub-b/remote-question') + await questions.getByRole('button', { name: /Fast/ }).click() + await questions.getByRole('button', { name: 'Submit answers' }).click() + await expect(questions).toHaveCount(0) + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Root observed the remote question answer.', { timeout: 20_000 }, + ) + await expect.poll(() => child(desktop!.page), { timeout: 15_000 }).toMatchObject({ + id: 'hub-b/remote-question', status: 'completed', controllable: false, + }) + + await expect.poll(() => desktop!.llm!.state()).toMatchObject({ + served: 4, remaining: 0, requestCount: 4, unmatchedRequests: 0, + inFlight: 0, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(4) + expect(requests.every((request) => request.matched)).toBe(true) + expect(requests[2]!.toolResultIds).toContain('remote-ask') + } finally { + remote?.probe.close() + if (remote) await stopProcess(remote.child) + if (desktop) await closeDesktop(desktop) + if (meta) await stopProcess(meta.child) + } +}) + +async function joinDesktop(desktop: DesktopFixture, meta: MetaHubProcess) { + return desktop.page.evaluate(async ({ address, token }) => { + const api = window.loopalDesktop + const bootstrap = await api.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + const target = { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + await api.controlAgent({ target, command: { type: 'permission', mode: 'bypass' } }) + await api.updateMetaHubSettings({ + address, token, hubName: 'hub-a', joinOnStart: false, startLocalOnLaunch: false, + }) + await api.joinMetaHub(target) + return target + }, { address: meta.address, token: meta.token }) +} + +async function cluster(page: Page) { + const detail = await detailOf(page) + return { + state: detail.metaHub?.state, + hubs: detail.metaHub?.hubs.map((hub) => hub.name).sort() ?? [], + remoteMain: detail.metaHub?.topology + .find((agent) => agent.id === 'hub-b/main')?.lifecycle, + } +} + +async function child(page: Page) { + return (await detailOf(page)).agents.find((agent) => agent.id === 'hub-b/remote-question') +} + +async function detailOf(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-adapter-failures.spec.ts b/apps/desktop/e2e/real/provider/host-provider-adapter-failures.spec.ts new file mode 100644 index 00000000..c8033114 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-adapter-failures.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('OpenAI retries a transport closed before response headers', async () => { + const desktop = await launchDesktop('real', 'provider-openai-preheader', {}, 'openai') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Recover OpenAI before headers') + await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 }) + await expect(conversation).toContainText( + 'OpenAI recovered after its pre-header transport failure.', { timeout: 20_000 }, + ) + await expect(conversation).not.toContainText('Retrying in 2.0s') + expect((await desktop.llm!.requests()).map((request) => request.protocol)) + .toEqual(['openai_responses', 'openai_responses']) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, scriptedDisconnects: 1, + unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('Google retains partial output and auto-continues a malformed stream', async () => { + const desktop = await launchDesktop('real', 'provider-google-partial', {}, 'google') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Recover a partial Google stream') + await expect(conversation).toContainText('GOOGLE PARTIAL STREAM MARKER', { + timeout: 15_000, + }) + await expect(conversation).toContainText('SSE parse error', { timeout: 15_000 }) + await expect(conversation).toContainText( + 'Response stream ended unexpectedly. Auto-continuing (1/3)', + ) + await expect(conversation).toContainText( + 'Google recovered its partial stream through continuation.', { timeout: 20_000 }, + ) + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests.every((request) => request.protocol === 'google')).toBe(true) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('OpenAI-compatible returns failed and cancelled tools to the model', async () => { + const desktop = await launchDesktop( + 'real', 'provider-compat-tool-semantics', {}, 'openai_compat', + ) + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + + await send(page, 'Exercise compatible tool failure') + const failed = conversation.getByTestId('tool-invocation') + .filter({ hasText: 'missing-compat-provider.txt' }) + await expect(failed.getByLabel('Failed')).toBeVisible({ timeout: 15_000 }) + await expect(conversation).toContainText( + 'Compatible provider observed the failed tool result.', { timeout: 20_000 }, + ) + await ready(page) + + await send(page, 'Start a compatible cancellable tool') + const cancelled = conversation.getByTestId('tool-invocation') + .filter({ hasText: 'COMPAT TOOL EARLY' }) + await expect(cancelled.getByLabel('Running')).toBeVisible({ timeout: 15_000 }) + await expect(cancelled).toContainText('COMPAT TOOL EARLY') + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Interrupt' }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(cancelled.getByLabel('Cancelled')).toBeVisible({ timeout: 15_000 }) + await page.waitForTimeout(3_500) + await expect(readFile(join(desktop.project, 'compat-late.txt'), 'utf8')).rejects.toThrow() + + await send(page, 'Recover compatible tool cancellation') + await expect(conversation).toContainText( + 'Compatible provider retained the cancelled tool result.', { timeout: 20_000 }, + ) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(4) + expect(requests.every((request) => request.protocol === 'openai_compat')).toBe(true) + expect(requests[1]!.toolResultIds).toContain('compat-missing-read') + expect(requests[1]!.assistantBlockTypes).toEqual(['thinking', 'tool_use']) + expect(requests[3]!.toolResultIds).toContain('compat-cancel-bash') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 4, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-background-lifecycle.spec.ts b/apps/desktop/e2e/real/provider/host-provider-background-lifecycle.spec.ts new file mode 100644 index 00000000..2955706e --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-background-lifecycle.spec.ts @@ -0,0 +1,84 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +test('projects completed, failed, killed, and restart-cleared background processes', async () => { + const desktop = await launchDesktop('real', 'provider-background-lifecycle') + try { + const page = desktop.page + await ready(page) + await setBypass(page) + await send(page, 'Run deterministic background lifecycles') + await expect(page.getByTestId('conversation')).toContainText( + 'Background lifecycle processes are running.', { timeout: 20_000 }, + ) + await ready(page) + + const tab = page.getByRole('tab', { name: 'Background', exact: true }) + await expect(tab).toBeVisible() + await tab.click() + const pane = page.getByTestId('background-tasks-pane') + const success = task(pane, 'Successful background fixture') + const failure = task(pane, 'Failed background fixture') + const guard = task(pane, 'Killable background fixture') + await expect(guard).toContainText('running') + await expect(guard).toContainText('BG_GUARD_START', { timeout: 15_000 }) + await expect(success).toContainText('completed', { timeout: 20_000 }) + await expect(success).toContainText('BG_SUCCESS_DONE') + await expect(success).toContainText('Exit code 0') + await expect(failure).toContainText('failed', { timeout: 20_000 }) + await expect(failure).toContainText('BG_FAILURE_ERR') + await expect(failure).toContainText('Exit code 7') + + await guard.getByRole('button', { + name: 'Kill background task Killable background fixture', + }).click() + await expect(tab).toHaveCount(0, { timeout: 20_000 }) + await expect.poll(async () => backgroundState(page)).toEqual([ + expect.objectContaining({ description: 'Failed background fixture', status: 'failed', exitCode: 7 }), + expect.objectContaining({ description: 'Killable background fixture', status: 'killed' }), + expect.objectContaining({ description: 'Successful background fixture', status: 'completed', exitCode: 0 }), + ]) + + const initial = await runtimeTarget(page) + await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), initial.sessionId, + ) + await expect.poll(async () => (await runtimeTarget(page)).generation, { + timeout: 30_000, + }).toBe(initial.generation + 1) + await ready(page) + await expect(page.getByRole('tab', { name: 'Background', exact: true })).toHaveCount(0) + expect((await activeDetail(page)).view?.backgroundTasks).toEqual([]) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[1]!.toolResultIds).toEqual(expect.arrayContaining([ + 'background-success', 'background-failure', 'background-guard', + ])) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'provider-background-lifecycle', served: 2, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +function task(pane: Locator, description: string): Locator { + return pane.locator('.background-task').filter({ hasText: description }) +} + +async function backgroundState(page: Page) { + return (await activeDetail(page)).view?.backgroundTasks.map((item) => ({ + description: item.description, status: item.status, + exitCode: item.exitCode, output: item.output, + })).sort((left, right) => left.description.localeCompare(right.description)) +} + +async function setBypass(page: Page): Promise { + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-command-surface.spec.ts b/apps/desktop/e2e/real/provider/host-provider-command-surface.spec.ts new file mode 100644 index 00000000..628550e4 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-command-surface.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } from '@playwright/test' +import { + closeDesktop, launchDesktop, type DesktopFixture, +} from '../../support/electron/electron-fixture' +import { activeDetail, ready } from '../../support/runtime/llm-e2e-helpers' + +test('routes slash controls locally and expands dynamic Skills through the real Hub', async () => { + const desktop = await launchDesktop( + 'real', 'provider-command-surface', {}, 'anthropic', 'skill', + ) + try { + const page = desktop.page + const input = page.getByLabel('Message Loopal') + const conversation = page.getByTestId('conversation') + await ready(page) + await expectHidden(desktop) + + await input.fill('/p') + const menu = page.getByTestId('command-menu') + await expect(menu).toBeVisible() + const plan = menu.locator('[data-command-name="/plan"]') + const permission = menu.locator('[data-command-name="/permission"]') + await expect(plan).toHaveAttribute('aria-selected', 'true') + await expect(permission).toBeVisible() + await input.press('ArrowDown') + await expect(permission).toHaveAttribute('aria-selected', 'true') + await input.press('ArrowUp') + await expect(plan).toHaveAttribute('aria-selected', 'true') + await input.press('Enter') + await expect(input).toHaveValue('') + await expect(input).toBeFocused() + await expect.poll(() => agentField(page, 'mode')).toBe('plan') + await expectNoLlm(desktop) + + await input.fill('/permission bypass') + await input.press('Enter') + await expect(input).toHaveValue('') + await expect.poll(() => agentField(page, 'permissionMode')).toBe('bypass') + await expectNoLlm(desktop) + await expect(conversation).not.toContainText('/plan') + await expect(conversation).not.toContainText('/permission bypass') + + await input.fill('/permission unrestricted') + await input.press('Enter') + await expect(page.getByTestId('command-error')).toBeVisible() + await expect(input).toHaveValue('/permission unrestricted') + await expect(input).toBeFocused() + await expectNoLlm(desktop) + + await input.fill('COMMAND_SURFACE_NORMAL_PROMPT') + await input.press('Enter') + await expect(conversation).toContainText( + 'Ordinary prompt crossed the model boundary once.', { timeout: 20_000 }, + ) + await ready(page) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + requestCount: 1, served: 1, remaining: 1, unmatchedRequests: 0, + }) + + await input.fill('/desktop') + const skill = page.getByTestId('command-menu') + .locator('[data-command-name="/desktop-check"]') + await expect(skill).toBeVisible() + await input.press('Tab') + await expect(input).toHaveValue('/desktop-check ') + await input.pressSequentially('alpha beta') + await input.press('Enter') + await expect(conversation).toContainText( + 'Dynamic Skill was expanded by the production Hub.', { timeout: 20_000 }, + ) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ + lastUserText: 'COMMAND_SURFACE_NORMAL_PROMPT', matched: true, + }) + expect(requests[1]!.lastUserText).toContain('SKILL_E2E_BODY_MARKER') + expect(requests[1]!.lastUserText).toContain('alpha beta') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + requestCount: 2, served: 2, remaining: 0, + unmatchedRequests: 0, verified: true, + }) + await expectHidden(desktop) + } finally { + await closeDesktop(desktop) + } +}) + +async function agentField( + page: Page, field: 'mode' | 'permissionMode', +): Promise { + const detail = await activeDetail(page) + return detail.agents.find((agent) => agent.id === 'main')?.[field] +} + +async function expectNoLlm(desktop: DesktopFixture): Promise { + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + requestCount: 0, served: 0, remaining: 2, unmatchedRequests: 0, + }) +} + +async function expectHidden(desktop: DesktopFixture): Promise { + expect(await desktop.app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + return { visible: window?.isVisible(), focused: window?.isFocused() } + })).toEqual({ visible: false, focused: false }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-compaction.spec.ts b/apps/desktop/e2e/real/provider/host-provider-compaction.spec.ts new file mode 100644 index 00000000..9764c5b0 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-compaction.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('runs manual model summarization and uses the compacted boundary next turn', async () => { + const desktop = await launchDesktop('real', 'provider-compaction') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + for (const message of ['Manual compact seed one', 'Manual compact seed two']) { + await send(page, message) + await ready(page) + } + + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + const controls = page.getByRole('group', { name: 'Agent controls' }) + await controls.getByLabel('Compact instructions').fill('Preserve contract markers') + await controls.getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + + await expect(page.getByTestId('runtime-status')).toContainText('Compacting', { + timeout: 15_000, + }) + await expect(conversation.locator('.conversation-banner')).toContainText( + 'summarizing context', + ) + await expect(conversation).toContainText('Context compacted (manual)', { timeout: 30_000 }) + await expect(conversation.locator('.conversation-banner')).toHaveCount(0) + await ready(page) + + await send(page, 'Use the compacted context now') + await expect(conversation).toContainText( + 'Post-compaction request used the summary boundary.', { timeout: 20_000 }, + ) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(4) + expect(requests[2]!.hasSystem).toBe(true) + expect(requests[3]!.lastUserText).toContain('Use the compacted context now') + const detail = await activeDetail(page) + expect(detail.view?.compactBanner).toBeNull() + expect(detail.conversation.some((entry) => ( + entry.role === 'system' && entry.text.includes('Context compacted (manual)') + ))).toBe(true) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 4, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-continuations.spec.ts b/apps/desktop/e2e/real/provider/host-provider-continuations.spec.ts new file mode 100644 index 00000000..15952e4f --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-continuations.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' + +test('projects every continuation reason and enforces the continuation cap', async () => { + const desktop = await launchDesktop('real', 'provider-continuations') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + + await send(page, 'Exercise pause continuation') + await expect(conversation).toContainText('Pause continuation completed.', { timeout: 20_000 }) + await expect(autoContinuations(page).last()).toContainText( + 'Provider paused the turn. Auto-continuing (1/3)', + ) + await ready(page) + + await send(page, 'Discard max token tool') + await expect(conversation).toContainText( + 'Max-token tool was discarded safely.', { timeout: 20_000 }, + ) + await expect(autoContinuations(page).last()).toContainText( + 'Output truncated during tool calls (max_tokens); incomplete tools discarded. ' + + 'Auto-continuing (1/3)', + ) + await expect(readFile(join(desktop.project, 'must-not-exist-max.txt'), 'utf8')) + .rejects.toThrow() + await ready(page) + await expect(discardedTool(conversation, 'must-not-exist-max.txt').getByLabel('Stale')) + .toBeVisible() + await expect(discardedTool(conversation, 'must-not-exist-max.txt').getByLabel('Queued')) + .toHaveCount(0) + await expect(discardedTool(conversation, 'must-not-exist-max.txt').getByLabel('Running')) + .toHaveCount(0) + + await send(page, 'Recover dropped stream') + await expect(conversation).toContainText( + 'Response stream ended unexpectedly', { timeout: 20_000 }, + ) + await expect(conversation).toContainText( + 'Dropped stream continued without running its tool.', { timeout: 20_000 }, + ) + await expect(autoContinuations(page).last()).toContainText( + 'Response stream ended unexpectedly. Auto-continuing (1/3)', + ) + await expect(readFile(join(desktop.project, 'must-not-exist-stream.txt'), 'utf8')) + .rejects.toThrow() + await ready(page) + await expect(discardedTool(conversation, 'must-not-exist-stream.txt').getByLabel('Stale')) + .toBeVisible() + await expect(discardedTool(conversation, 'must-not-exist-stream.txt').getByLabel('Queued')) + .toHaveCount(0) + await expect(discardedTool(conversation, 'must-not-exist-stream.txt').getByLabel('Running')) + .toHaveCount(0) + + await send(page, 'Reach continuation cap') + await expect(conversation).toContainText('CAP SEGMENT FOUR', { timeout: 20_000 }) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(10) + expect(requests[1]!.lastUserText).toBe('[Continue from where you left off]') + expect(requests[3]!.assistantBlockTypes).not.toContain('tool_use') + expect(requests[3]!.toolResultIds).not.toContain('max-tool-write') + expect(requests[5]!.assistantBlockTypes).not.toContain('tool_use') + expect(requests[5]!.toolResultIds).not.toContain('dropped-tool-write') + expect(requests.slice(6)).toHaveLength(4) + const detail = await activeDetail(page) + expect(detail.conversation.filter((entry) => entry.role === 'user')).toHaveLength(4) + expect(detail.agents.find((agent) => agent.id === 'main')?.telemetry?.turnCount).toBe(4) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 10, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +function autoContinuations(page: Page) { + return page.locator('[data-message-role="system"]').filter({ hasText: 'Auto-continuing' }) +} + +function discardedTool(conversation: Locator, marker: string) { + return conversation.getByTestId('tool-invocation').filter({ hasText: marker }).last() +} diff --git a/apps/desktop/e2e/real/provider/host-provider-controls.spec.ts b/apps/desktop/e2e/real/provider/host-provider-controls.spec.ts new file mode 100644 index 00000000..ed151020 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-controls.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('applies model, thinking, mode, and clear controls to real provider requests', async () => { + const desktop = await launchDesktop('real', 'provider-controls') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Seed control history') + await expect(conversation).toContainText('CONTROL BASELINE MARKER', { timeout: 20_000 }) + await ready(page) + + await openControls(page) + const controls = page.getByRole('group', { name: 'Agent controls' }) + await controls.getByRole('textbox', { name: 'Agent model' }).fill('claude-sonnet-4-6') + await controls.getByRole('button', { name: 'Apply agent model' }).click() + await expect(controls.getByRole('textbox', { name: 'Agent model' })) + .toHaveValue('claude-sonnet-4-6') + await expect(controls.getByLabel('Thinking configuration')).toBeEnabled() + await controls.getByLabel('Thinking configuration').selectOption('high') + await expect(controls.getByLabel('Thinking configuration')).toHaveValue('high') + const agentMode = controls.getByLabel('Agent mode', { exact: true }) + await expect(agentMode).toBeEnabled() + await agentMode.selectOption('plan') + await expect(agentMode).toHaveValue('plan') + await closeControls(page) + + await send(page, 'Observe high thinking controls') + await expect(conversation).toContainText('CONTROLLED THINKING STREAM', { timeout: 20_000 }) + await expect(conversation).toContainText( + 'High thinking and model switch reached the provider.', { timeout: 20_000 }, + ) + await ready(page) + + await openControls(page) + await controls.getByLabel('Thinking configuration').selectOption('disabled') + await expect(controls.getByLabel('Thinking configuration')).toHaveValue('disabled') + await agentMode.selectOption('act') + await expect(agentMode).toHaveValue('act') + await closeControls(page) + await send(page, 'Observe disabled thinking controls') + await expect(conversation).toContainText( + 'Disabled thinking reached the provider.', { timeout: 20_000 }, + ) + await ready(page) + + await openControls(page) + await controls.getByRole('button', { name: 'Clear' }).click() + await expect(conversation.locator('[data-message-role]:not([data-message-role="system"])')) + .toHaveCount(0) + await closeControls(page) + await send(page, 'Verify clear request history') + await expect(conversation).toContainText('Clear removed prior model history.', { + timeout: 20_000, + }) + await ready(page) + + await send(page, 'Seed rewind second turn') + await expect(conversation).toContainText('REWIND SECOND MARKER', { timeout: 20_000 }) + await ready(page) + await openControls(page) + await controls.getByLabel('Rewind turn index').fill('0') + await controls.getByRole('button', { name: 'Rewind', exact: true }).click() + await expect(conversation.locator('[data-message-role]:not([data-message-role="system"])')) + .toHaveCount(0) + await closeControls(page) + await send(page, 'Verify rewind request history') + await expect(conversation).toContainText('Rewind removed later model history.', { + timeout: 20_000, + }) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(6) + expect(requests[1]).toMatchObject({ model: 'claude-sonnet-4-6', thinkingEnabled: true }) + expect(requests[2]).toMatchObject({ model: 'claude-sonnet-4-6', thinkingEnabled: false }) + expect(requests[3]).toMatchObject({ model: 'claude-sonnet-4-6', messageCount: 1 }) + expect(requests[5]).toMatchObject({ model: 'claude-sonnet-4-6', messageCount: 1 }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 6, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function openControls(page: Page): Promise { + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await expect(page.getByRole('group', { name: 'Agent controls' })).toBeVisible() +} + +async function closeControls(page: Page): Promise { + await page.getByRole('button', { name: 'Close settings' }).click() +} diff --git a/apps/desktop/e2e/real/provider/host-provider-conversation-semantics.spec.ts b/apps/desktop/e2e/real/provider/host-provider-conversation-semantics.spec.ts new file mode 100644 index 00000000..07ed5a8c --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-conversation-semantics.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Locator } from '@playwright/test' +import { + closeDesktop, launchDesktop, type DesktopFixture, +} from '../../support/electron/electron-fixture' +import { ready } from '../../support/runtime/llm-e2e-helpers' + +const prompt = 'Render deterministic conversation semantics' + +test('keeps a right-aligned conversation primary and renders safe Markdown', async () => { + const desktop = await launchDesktop('real', 'provider-conversation-semantics') + try { + const page = desktop.page + await ready(page) + await expectHidden(desktop) + await expect(page.getByTestId('primary-workspace')) + .toHaveAttribute('data-workspace', 'conversation') + await expect(page.getByTestId('inspector')).toHaveCount(0) + await expect(page.getByTestId('session-panel-zone')).toHaveCount(0) + + await page.getByLabel('Message Loopal').fill(prompt) + await page.getByRole('button', { name: 'Send' }).click() + const transcript = page.getByTestId('conversation') + const user = transcript.locator('[data-message-role="user"]').filter({ hasText: prompt }) + await expect(user).toHaveCount(1) + const answer = transcript.locator('[data-message-role="assistant"]').filter({ + has: page.getByRole('heading', { name: 'Conversation semantics' }), + }) + await expect(answer).toBeVisible({ timeout: 20_000 }) + await ready(page) + + await expectMarkdown(answer) + await expectSafeLinks(answer) + await expectTranscriptGeometry(transcript, user, answer) + await expect(page.getByLabel('Message Loopal')).toBeInViewport() + await expect(page.getByTestId('session-panel-zone')).toHaveCount(0) + await expectHidden(desktop) + + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'provider-conversation-semantics', served: 1, requestCount: 1, + remaining: 0, unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function expectMarkdown(answer: Locator): Promise { + await expect(answer.locator('h1')).toHaveText('Conversation semantics') + await expect(answer.locator('h2')).toHaveText('Native structure') + await expect(answer.locator('.rich-text > p strong')).toHaveText('bold detail') + await expect(answer.locator('.rich-text > p code')).toHaveText('inline-code') + await expect(answer.locator('blockquote strong')).toHaveText('bold') + await expect(answer.locator('blockquote code')).toHaveText('quoted-code') + await expect(answer.locator('ul > li')).toHaveCount(2) + await expect(answer.locator('ol > li')).toHaveText(['Ordered first', 'Ordered second']) + const fence = answer.locator('pre[data-language="typescript"] > code') + await expect(fence).toContainText('') + await expect(answer.locator('script, iframe, object')).toHaveCount(0) +} + +async function expectSafeLinks(answer: Locator): Promise { + const link = answer.getByRole('link', { name: 'HTTPS reference' }) + await expect(link).toHaveAttribute('href', 'https://example.com/secure') + await expect(link).toHaveAttribute('target', '_blank') + await expect(link).toHaveAttribute('rel', 'noreferrer') + for (const label of ['Unsafe HTTP', 'Unsafe script', 'Unsafe relative']) { + const text = answer.getByText(label, { exact: true }) + await expect(text).toBeVisible() + expect(await text.evaluate((node) => node.closest('a') === null)).toBe(true) + } + await expect(answer.locator( + 'a[href^="http:"], a[href^="javascript:"], a[href^="file:"], a[href^="/"]', + )).toHaveCount(0) +} + +async function expectTranscriptGeometry( + transcript: Locator, user: Locator, answer: Locator, +): Promise { + const [feed, userBox, answerBox] = await Promise.all([ + transcript.locator('.conversation-feed').boundingBox(), + user.boundingBox(), answer.boundingBox(), + ]) + expect(feed && userBox && answerBox).toBeTruthy() + const userRight = userBox!.x + userBox!.width + const answerRight = answerBox!.x + answerBox!.width + const feedRight = feed!.x + feed!.width + expect(userBox!.x).toBeGreaterThan(answerBox!.x) + expect(userBox!.width).toBeLessThan(answerBox!.width) + expect(Math.abs(userRight - answerRight)).toBeLessThanOrEqual(2) + expect(Math.abs(userRight - feedRight)).toBeLessThanOrEqual(2) +} + +async function expectHidden(desktop: DesktopFixture): Promise { + expect(await desktop.app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + return { visible: window?.isVisible(), focused: window?.isFocused() } + })).toEqual({ visible: false, focused: false }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-cron-idle-lifecycle.spec.ts b/apps/desktop/e2e/real/provider/host-provider-cron-idle-lifecycle.spec.ts new file mode 100644 index 00000000..ca4e7b6e --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-cron-idle-lifecycle.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('queues a durable cron while suspended and wakes the idle model after unsuspend', async () => { + test.setTimeout(180_000) + const desktop = await launchDesktop('real', 'provider-cron-idle-lifecycle') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await setBypass(page) + await avoidMinuteBoundary(page) + await send(page, 'Schedule a durable idle wake') + const idle = conversation.getByTestId('tool-invocation').filter({ hasText: 'request_idle' }) + await expect(idle.getByLabel('Completed')).toBeVisible({ timeout: 30_000 }) + await expect(conversation).toContainText( + /Automatic continuation paused: model requested until /u, { timeout: 20_000 }, + ) + await ready(page) + + const dock = page.getByTestId('session-panel-zone') + await expect(dock.locator('[role="tabpanel"]:visible')).toHaveCount(0) + await expect(page.getByRole('tab', { name: 'Tasks', exact: true })).toBeVisible() + const scheduledTab = page.getByRole('tab', { name: 'Scheduled', exact: true }) + await scheduledTab.click() + const scheduled = page.getByTestId('scheduled-work-pane') + await expect(scheduled).toBeVisible() + await expect(scheduled).toContainText('DURABLE CRON WAKE MARKER') + const before = await cronState(page) + expect(before).toMatchObject({ recurring: true, durable: true }) + if (!before.nextFireAt) throw new Error('durable cron did not expose its next fire') + + await setSuspended(page, true) + await expect(conversation).toContainText('Automatic continuation paused: user suspend') + await waitPast(page, before.nextFireAt) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 3, requestCount: 3, remaining: 2, + }) + + await setSuspended(page, false) + await expect(conversation).toContainText( + 'Durable cron woke the suspended production Session.', { timeout: 30_000 }, + ) + await expect(conversation).toContainText('Automatic continuation resumed.') + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(5) + expect(requests[3]!.lastUserText).toContain('DURABLE CRON WAKE MARKER') + expect(requests[4]!.toolResultIds).toEqual(expect.arrayContaining([ + 'cron-list-after-wake', 'cron-goal-complete', + ])) + + await scheduledTab.click() + await expect(dock.locator('[role="tabpanel"]:visible')).toHaveCount(0) + const initial = await runtimeTarget(page) + await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), initial.sessionId, + ) + await expect.poll(async () => (await runtimeTarget(page)).generation, { + timeout: 30_000, + }).toBe(initial.generation + 1) + await ready(page) + await expect(page.getByRole('tab', { name: 'Tasks', exact: true })).toHaveCount(0) + const restoredTab = page.getByRole('tab', { name: 'Scheduled', exact: true }) + await expect(dock.locator('[role="tabpanel"]:visible')).toHaveCount(0) + await restoredTab.click() + const restored = page.getByTestId('scheduled-work-pane') + await expect(restored).toBeVisible() + await expect(restored).toContainText( + 'DURABLE CRON WAKE MARKER', + ) + await page.getByRole('button', { + name: 'Delete scheduled work DURABLE CRON WAKE MARKER', + }).click() + await expect(restoredTab).toHaveCount(0) + await expect.poll(async () => (await activeDetail(page)).view?.crons).toEqual([]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'provider-cron-idle-lifecycle', served: 5, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function cronState(page: Page) { + const cron = (await activeDetail(page)).view?.crons[0] + if (!cron) throw new Error('scheduled cron did not reach the Desktop projection') + return cron +} + +async function setBypass(page: Page): Promise { + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) +} + +async function setSuspended(page: Page, suspended: boolean): Promise { + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + const controls = page.getByRole('group', { name: 'Agent controls' }) + await controls.getByRole('button', { name: suspended ? 'Suspend' : 'Unsuspend' }).click() + await expect(controls.getByRole('button', { + name: suspended ? 'Unsuspend' : 'Suspend', + })).toBeVisible({ timeout: 20_000 }) + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(page.getByTestId('runtime-status')).toContainText( + suspended ? 'Suspended' : /Running|Ready for input/u, { timeout: 20_000 }, + ) +} + +async function avoidMinuteBoundary(page: Page): Promise { + const seconds = new Date().getUTCSeconds() + if (seconds >= 43) await page.waitForTimeout((62 - seconds) * 1_000) +} + +async function waitPast(page: Page, timestamp: string): Promise { + const delay = Math.max(0, new Date(timestamp).getTime() - Date.now() + 2_000) + await page.waitForTimeout(delay) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-desktop-demo.spec.ts b/apps/desktop/e2e/real/provider/host-provider-desktop-demo.spec.ts new file mode 100644 index 00000000..7595addd --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-desktop-demo.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready } from '../../support/runtime/llm-e2e-helpers' + +test('manual demo completes its work and returns to conversation', async () => { + const desktop = await launchDesktop('real', 'desktop-demo') + try { + const page = desktop.page + await ready(page) + await page.getByLabel('Message Loopal').fill('Show the production Desktop path') + await page.getByRole('button', { name: 'Send' }).click() + + const conversation = page.getByTestId('conversation') + await expect(conversation.getByRole('heading', { + name: 'Loopal Desktop is connected', + })).toBeVisible({ timeout: 20_000 }) + await ready(page) + + await expect(conversation.getByRole('list')).toContainText( + 'The conversation remains the primary surface.', + ) + await expect(conversation.locator('blockquote')).toContainText( + 'without an automatic continuation loop', + ) + await expect(conversation.locator('pre[data-language="sh"]')).toContainText( + 'bazel test //apps/desktop:unit', + ) + await expect(conversation).not.toContainText('Degeneration detected') + await expect(page.getByTestId('session-panel-zone')).toHaveCount(0) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'desktop-demo', served: 4, requestCount: 4, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-electron-relaunch.spec.ts b/apps/desktop/e2e/real/provider/host-provider-electron-relaunch.spec.ts new file mode 100644 index 00000000..5f2e4314 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-electron-relaunch.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page } from '@playwright/test' +import { + closeDesktop, launchDesktop, relaunchDesktop, +} from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' + +const beforeUser = 'PRE_RELAUNCH_USER_MARKER' +const beforeAssistant = 'PRE_RELAUNCH_ASSISTANT_MARKER' +const afterUser = 'POST_RELAUNCH_USER_MARKER' +const afterAssistant = 'POST_RELAUNCH_ASSISTANT_MARKER' + +test('retains provider history across Electron and sidecar relaunch', async () => { + let desktop = await launchDesktop('real', 'provider-electron-relaunch') + try { + await ready(desktop.page) + const initial = await runtimeState(desktop.page) + expect(initial).toMatchObject({ + generation: 1, runtimeCount: 1, state: 'ready', sessionStatus: 'waiting', + activeMatches: true, + }) + const initialElectronPid = desktop.app.process().pid + + await send(desktop.page, beforeUser) + await expect(desktop.page.getByTestId('conversation')).toContainText( + beforeAssistant, { timeout: 20_000 }, + ) + await ready(desktop.page) + await expectHistory(desktop.page, 1) + + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + expect(desktop.app.process().pid).not.toBe(initialElectronPid) + const restored = await runtimeState(desktop.page) + expect(restored).toMatchObject({ + sessionId: initial.sessionId, generation: 1, runtimeCount: 1, + state: 'ready', sessionStatus: 'waiting', activeMatches: true, + rootAgent: initial.rootAgent, + }) + expect(restored.runtimeId).not.toBe(initial.runtimeId) + await expect(desktop.page.locator(`[data-session-id="${initial.sessionId}"]`)).toHaveCount(1) + await expectHistory(desktop.page, 1) + + await send(desktop.page, afterUser) + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation).toContainText(afterAssistant, { timeout: 20_000 }) + await ready(desktop.page) + await expectHistory(desktop.page, 2) + expect(await runtimeState(desktop.page)).toMatchObject({ + sessionId: restored.sessionId, runtimeId: restored.runtimeId, + generation: restored.generation, runtimeCount: 1, state: 'ready', + sessionStatus: 'waiting', activeMatches: true, + }) + + const detail = await activeDetail(desktop.page) + const dialogue = detail.conversation.filter((entry) => ( + entry.role === 'user' || entry.role === 'assistant' + )) + expect(dialogue.map(({ role, text }) => ({ role, text }))).toEqual([ + { role: 'user', text: beforeUser }, + { role: 'assistant', text: beforeAssistant }, + { role: 'user', text: afterUser }, + { role: 'assistant', text: afterAssistant }, + ]) + expect(new Set(dialogue.map(({ id }) => id)).size).toBe(dialogue.length) + + expect(await desktop.llm!.requests()).toEqual([ + expect.objectContaining({ + sequence: 1, protocol: 'anthropic', messageCount: 1, + lastUserText: beforeUser, matched: true, + }), + expect.objectContaining({ + sequence: 2, protocol: 'anthropic', messageCount: 3, + lastUserText: afterUser, assistantBlockTypes: ['text'], matched: true, + }), + ]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, requestCount: 2, remaining: 0, unmatchedRequests: 0, verified: true, + }) + await expect(desktop.page.getByLabel('Message Loopal')).toBeEnabled() + await expect(desktop.page.getByLabel('Message Loopal')).toHaveValue('') + } finally { + await closeDesktop(desktop) + } +}) + +async function runtimeState(page: Page) { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const detail = await window.loopalDesktop.openSession(sessionId) + const runtimes = bootstrap.runtimes.filter((runtime) => runtime.sessionId === sessionId) + const runtime = runtimes.find(({ id }) => id === detail.session.activeRuntimeId)! + return { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + rootAgent: runtime.rootAgent, state: runtime.state, runtimeCount: runtimes.length, + sessionStatus: detail.session.status, + activeMatches: detail.session.activeRuntimeId === runtime.id, + } + }) +} + +async function expectHistory(page: Page, turns: number): Promise { + const conversation = page.getByTestId('conversation') + await expect(conversation.locator('[data-message-role="user"]')).toHaveCount(turns) + await expect(conversation.locator('[data-message-role="assistant"]')).toHaveCount(turns) + await expect(conversation.locator('[data-message-role="user"]', { + hasText: beforeUser, + })).toHaveCount(1) + await expect(conversation.locator('[data-message-role="assistant"]', { + hasText: beforeAssistant, + })).toHaveCount(1) + if (turns === 2) { + await expect(conversation.locator('[data-message-role="user"]', { + hasText: afterUser, + })).toHaveCount(1) + await expect(conversation.locator('[data-message-role="assistant"]', { + hasText: afterAssistant, + })).toHaveCount(1) + } +} diff --git a/apps/desktop/e2e/real/provider/host-provider-global-skills.spec.ts b/apps/desktop/e2e/real/provider/host-provider-global-skills.spec.ts new file mode 100644 index 00000000..b3778085 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-global-skills.spec.ts @@ -0,0 +1,136 @@ +import { expect, test, type Page } from '@playwright/test' +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { + closeDesktop, launchDesktop, relaunchDesktop, type DesktopFixture, +} from '../../support/electron/electron-fixture' +import { seedPlugin } from '../../support/fixtures/fixture-loader' +import { activeDetail, ready } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +const name = '/global-e2e' +const command = `${name} alpha beta` +const initialDescription = 'Run the initial global Skill contract' +const editedDescription = 'Run the edited global Skill contract' +const initialBody = 'GLOBAL_SKILL_INITIAL_BODY_MARKER\nUse $ARGUMENTS.' +const editedBody = 'GLOBAL_SKILL_EDITED_BODY_MARKER\nUse $ARGUMENTS.' + +test('manages, invokes, and restores global Skills and plugin contributions', async () => { + let desktop = await launchDesktop('real', 'provider-global-skills') + const skillPath = join(desktop.home, '.loopal', 'skills', 'global-e2e.md') + try { + await ready(desktop.page) + await expectHidden(desktop) + await openSkills(desktop.page) + const section = desktop.page.getByTestId('skills-plugin-settings') + + await section.getByTestId('skill-create').click() + await section.getByTestId('skill-name').fill(name) + await section.getByTestId('skill-description').fill(initialDescription) + await section.getByTestId('skill-body').fill(initialBody) + await section.getByTestId('skill-save').click() + await expect(section.getByRole('status')).toContainText(`Saved ${name}`) + await expect(section.getByTestId('skill-description')).toHaveValue(initialDescription) + await expectFile(skillPath, initialDescription, initialBody) + + await section.getByTestId('skill-description').fill(editedDescription) + await section.getByTestId('skill-body').fill(editedBody) + await section.getByTestId('skill-save').click() + await expectFile(skillPath, editedDescription, editedBody) + await expect.poll(() => readFile(skillPath, 'utf8')).not.toContain(initialBody) + await section.getByRole('button', { name: 'Cancel' }).click() + await expect(section.getByTestId('global-skill-global-e2e')) + .toContainText(editedDescription) + + await desktop.page.getByRole('button', { name: 'Close settings' }).click() + await desktop.page.getByLabel('Message Loopal').fill(command) + await desktop.page.getByRole('button', { name: 'Send' }).click() + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation.getByText(`Skill · ${name}`)).toHaveCount(1, { + timeout: 20_000, + }) + await expect(conversation).toContainText('alpha beta') + await expect(conversation).not.toContainText('GLOBAL_SKILL_EDITED_BODY_MARKER') + await expect(conversation).toContainText( + 'Global Skill invocation reached the production runtime.', { timeout: 20_000 }, + ) + await ready(desktop.page) + const detail = await activeDetail(desktop.page) + expect(detail.conversation.find((entry) => entry.skill?.name === name)).toMatchObject({ + role: 'user', skill: { name, userArgs: 'alpha beta' }, + }) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + protocol: 'anthropic', messageCount: 1, matched: true, + }) + expect(requests[0]!.lastUserText).toContain(editedBody.split('\n')[0]) + expect(requests[0]!.lastUserText).toContain('alpha beta') + + await seedPlugin(desktop.home, 'global-skills') + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + await expectHidden(desktop) + await openSkills(desktop.page) + const restored = desktop.page.getByTestId('skills-plugin-settings') + const global = restored.getByTestId('global-skill-global-e2e') + await expect(global).toContainText(editedDescription) + await expect(restored.getByTestId('effective-skill-list')).toContainText('/plugin-check') + await expect(restored.getByTestId('effective-skill-list')).toContainText(name) + const pluginCard = restored.getByTestId('plugin-global-skills') + await expect(pluginCard).toContainText('/plugin-check') + await expect(pluginCard).toContainText('fixture-mcp') + await expect(pluginCard).toContainText('LOOPAL.md') + await expectPluginContract(desktop.page) + await global.getByRole('button', { name: `Edit ${name}` }).click() + await expect(restored.getByTestId('skill-description')).toHaveValue(editedDescription) + await expect(restored.getByTestId('skill-body')).toHaveValue(editedBody) + + await restored.getByTestId('skill-delete').click() + const confirmation = restored.getByRole('alertdialog') + await confirmation.getByRole('button', { name: 'Delete permanently' }).click() + await expect(restored.getByTestId('global-skill-global-e2e')).toHaveCount(0) + await expect.poll(() => exists(skillPath)).toBe(false) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, requestCount: 1, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function openSkills(page: Page): Promise { + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'skills') + await expect(page.getByTestId('skills-plugin-settings')).toBeVisible() +} + +async function expectFile(path: string, ...parts: string[]): Promise { + for (const part of parts) { + await expect.poll(() => readFile(path, 'utf8')).toContain(part) + } +} + +async function exists(path: string): Promise { + try { await stat(path); return true } catch { return false } +} + +async function expectHidden(desktop: DesktopFixture): Promise { + expect(await desktop.app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + return { visible: window?.isVisible(), focused: window?.isFocused() } + })).toEqual({ visible: false, focused: false }) +} + +async function expectPluginContract(page: Page): Promise { + const response = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.listPlugins(bootstrap.workspaces[0]!.id) + }) + expect(response.plugins).toContainEqual({ + name: 'global-skills', source: 'plugin:global-skills', skills: ['/plugin-check'], + mcpServers: ['fixture-mcp'], hookCount: 0, + hasSettings: true, hasInstructions: true, hasMemory: false, + }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-governance.spec.ts b/apps/desktop/e2e/real/provider/host-provider-governance.spec.ts new file mode 100644 index 00000000..95dd1fd7 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-governance.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' + +test('projects degeneration and continuation-gate close and reopen events', async () => { + const desktop = await launchDesktop('real', 'provider-governance') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + for (const word of ['one', 'two', 'three', 'four', 'five']) { + await send(page, `Degeneration sample ${word}`) + await expect(conversation).toContainText('IDENTICAL DEGENERATION OUTPUT') + await ready(page) + } + + await expect(conversation).toContainText( + 'Degeneration detected: repeated text (5).', { timeout: 20_000 }, + ) + await expect(conversation).toContainText( + /Automatic continuation paused: degeneration until /, + ) + let detail = await activeDetail(page) + expect(detail.conversation.filter((entry) => ( + entry.eventNotice && entry.text.includes('Degeneration detected') + ))).toHaveLength(1) + + await send(page, 'Resume after degeneration') + await expect(conversation).toContainText('Automatic continuation resumed.', { + timeout: 20_000, + }) + await expect(conversation).toContainText( + 'Fresh human progress reopened governance.', { timeout: 20_000 }, + ) + await ready(page) + detail = await activeDetail(page) + expect(detail.agents.find((agent) => agent.id === 'main')?.telemetry).toMatchObject({ + turnCount: 6, + }) + expect(await desktop.llm!.requests()).toHaveLength(7) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 7, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-image-matrix.spec.ts b/apps/desktop/e2e/real/provider/host-provider-image-matrix.spec.ts new file mode 100644 index 00000000..e616886a --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-image-matrix.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider, providerModel } from '../../support/providers/provider-e2e-fixture' + +const providers: readonly E2eProvider[] = [ + 'anthropic', 'openai', 'openai_compat', 'google', +] + +for (const provider of providers) { + test(`${provider} sends a user image through its production adapter`, async () => { + const desktop = await launchDesktop('real', 'provider-image-matrix', {}, provider) + try { + await desktop.app.evaluate(async ({ dialog }, path) => { + dialog.showOpenDialog = async () => ({ + canceled: false, filePaths: [path], bookmarks: [], + }) + }, join(desktop.project, 'pixel.png')) + await ready(desktop.page) + await desktop.page.getByRole('button', { name: 'Attach images' }).click() + await expect(desktop.page.getByTestId('pending-image-attachments')) + .toContainText('pixel.png') + await send(desktop.page, 'Exercise provider image input') + await expect(desktop.page.getByTestId('conversation')).toContainText( + 'Provider image adapter completed its model turn.', { timeout: 20_000 }, + ) + await ready(desktop.page) + expect((await desktop.llm!.requests())[0]).toMatchObject({ + model: providerModel(provider), imageBlockCount: 1, + }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } + }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-images.spec.ts b/apps/desktop/e2e/real/provider/host-provider-images.spec.ts new file mode 100644 index 00000000..f7973ed4 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-images.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' + +test('selects, removes, and sends captioned and image-only model turns', async () => { + const desktop = await launchDesktop('real', 'provider-user-images') + try { + const page = desktop.page + await desktop.app.evaluate(async ({ dialog }, path) => { + dialog.showOpenDialog = async () => ({ + canceled: false, filePaths: [path], bookmarks: [], + }) + }, join(desktop.project, 'pixel.png')) + await ready(page) + + await page.getByRole('button', { name: 'Attach images' }).click() + let pending = page.getByTestId('pending-image-attachments') + await expect(pending).toContainText('pixel.png') + await page.getByRole('button', { name: 'Remove pixel.png' }).click() + await expect(pending).toHaveCount(0) + + await page.getByRole('button', { name: 'Attach images' }).click() + pending = page.getByTestId('pending-image-attachments') + await expect(pending).toContainText('pixel.png') + await send(page, 'Describe the attached pixel') + await expect(pending).toHaveCount(0) + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText('1 image attachment(s)') + await expect(conversation).toContainText( + 'The caption and image reached the production provider.', { timeout: 20_000 }, + ) + + await ready(page) + await page.getByRole('button', { name: 'Attach images' }).click() + await expect(page.getByTestId('pending-image-attachments')).toContainText('pixel.png') + await page.getByRole('button', { name: 'Send' }).click() + await expect(conversation).toContainText( + 'The image-only turn also reached the production provider.', { timeout: 20_000 }, + ) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ lastUserText: 'Describe the attached pixel', + imageBlockCount: 1 }) + expect(requests[1]).toMatchObject({ lastUserText: '', imageBlockCount: 2 }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-matrix.spec.ts b/apps/desktop/e2e/real/provider/host-provider-matrix.spec.ts new file mode 100644 index 00000000..0b492e3f --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-matrix.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider, providerModel } from '../../support/providers/provider-e2e-fixture' + +const providers: readonly [E2eProvider, string][] = [ + ['anthropic', 'anthropic'], + ['openai', 'openai_responses'], + ['openai_compat', 'openai_compat'], + ['google', 'google'], +] + +for (const [provider, protocol] of providers) { + test(`${provider} completes a production model and tool round trip`, async () => { + const desktop = await launchDesktop('real', 'provider-matrix', {}, provider) + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await send(page, 'Exercise provider wire') + await expect(conversation).toContainText( + 'Provider wire completed through the production runtime.', { timeout: 20_000 }, + ) + const tool = conversation.getByTestId('tool-invocation').filter({ hasText: 'README.md' }) + await expect(tool.getByLabel('Completed')).toBeVisible() + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests.map((request) => request.protocol)).toEqual([protocol, protocol]) + expect(requests[0]!.model).toBe(providerModel(provider)) + if (provider !== 'google') { + expect(requests[1]!.toolResultIds).toContain('provider-readme') + } + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } + }) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-mcp-rich.spec.ts b/apps/desktop/e2e/real/provider/host-provider-mcp-rich.spec.ts new file mode 100644 index 00000000..5ef0eb93 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-mcp-rich.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { fixturePath } from '../../support/fixtures/fixture-loader' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('preserves rich MCP, error, reconnect, and cancellation semantics', async () => { + const desktop = await launchDesktop('real', 'provider-mcp-rich') + try { + const page = desktop.page + const initial = await runtimeTarget(page) + await ready(page) + await page.evaluate(async ({ command, script }) => window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', + server: { + type: 'stdio', name: 'fixture_echo_server', command, args: [script], + enabled: true, timeoutMs: 10_000, sharing: 'spawn-tree', + cwdIsolation: null, secretPatches: [], + }, + }), { command: process.execPath, script: fixturePath('mcp/fixture-echo.mjs') }) + await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), initial.sessionId, + ) + await expect.poll( + async () => (await runtimeTarget(page)).generation, { timeout: 30_000 }, + ).toBe(initial.generation + 1) + await ready(page) + await expect.poll(async () => mcpSnapshot(page), { timeout: 30_000 }).toMatchObject({ + status: 'connected', toolCount: 5, resourceCount: 1, promptCount: 1, errors: [], + }) + await page.getByRole('tab', { name: 'MCP', exact: true }).click() + await expect(page.getByTestId('mcp-runtime-pane')).toContainText( + '5 tools · 1 resources · 1 prompts', + ) + await page.waitForTimeout(2_000) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + + await send(page, 'Exercise rich MCP content') + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText('Rich MCP content reached the model.', { + timeout: 20_000, + }) + const rich = conversation.getByTestId('tool-invocation').filter({ hasText: 'fixture_rich' }) + await expect(rich.getByLabel('Completed')).toBeVisible() + await rich.locator(':scope > summary').click() + const output = rich.locator('.tool-output') + await expect(output).toContainText('fixture rich text') + await expect(output).toContainText('data:image/png;base64,iVBORw0KGgo=') + await expect(output).toContainText('[resource fixture://embedded]') + await expect(output).toContainText('[resource: fixture://linked]') + await ready(page) + + await send(page, 'Exercise MCP error result') + await expect(conversation).toContainText('MCP error result was preserved for the model.', { + timeout: 20_000, + }) + const failed = conversation.getByTestId('tool-invocation').filter({ hasText: 'fixture_error' }) + await expect(failed.getByLabel('Failed')).toBeVisible() + await expect(failed.locator('.tool-detail')).toContainText('fixture MCP error result') + await ready(page) + + await send(page, 'Exercise MCP transport reconnect') + await expect(conversation).toContainText( + 'MCP transport reconnected and retried exactly once.', { timeout: 20_000 }, + ) + await expect(readFile(join(desktop.root, 'mcp-reconnect-marker.txt'), 'utf8')) + .resolves.toBe('closed once\n') + const reconnected = conversation.getByTestId('tool-invocation') + .filter({ hasText: 'fixture_reconnect' }) + await expect(reconnected.getByLabel('Completed')).toBeVisible() + await ready(page) + + await send(page, 'Start cancellable MCP tool') + const slow = conversation.getByTestId('tool-invocation').filter({ hasText: 'fixture_slow' }) + await expect(slow.getByLabel('Running')).toBeVisible({ timeout: 15_000 }) + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Interrupt' }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(slow.getByLabel('Cancelled')).toBeVisible({ timeout: 15_000 }) + await page.waitForTimeout(4_500) + await expect(conversation).not.toContainText('fixture slow late result') + await send(page, 'Recover after cancelling MCP tool') + await expect(conversation).toContainText( + 'Session remained reusable after MCP cancellation.', { timeout: 20_000 }, + ) + await ready(page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(8) + expect(requests[0]!.toolNames).toEqual(expect.arrayContaining([ + 'fixture_rich', 'fixture_error', 'fixture_reconnect', 'fixture_slow', + ])) + expect(requests[3]!.toolResultErrorIds).toContain('mcp-error-call') + expect(requests[7]!.toolResultErrorIds).toContain('mcp-slow-call') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 8, remaining: 0, unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function mcpSnapshot(page: import('@playwright/test').Page) { + const detail = await activeDetail(page) + return detail.view?.mcpServers.find((server) => server.name === 'fixture_echo_server') +} diff --git a/apps/desktop/e2e/real/provider/host-provider-mcp.spec.ts b/apps/desktop/e2e/real/provider/host-provider-mcp.spec.ts new file mode 100644 index 00000000..98cb6f6f --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-mcp.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { fixturePath } from '../../support/fixtures/fixture-loader' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +test('discovers and calls a real repo-owned stdio MCP tool', async () => { + const desktop = await launchDesktop('real', 'provider-mcp') + try { + const page = desktop.page + await ready(page) + const initial = await runtimeTarget(page) + const definitions = await page.evaluate(async ({ command, script }) => ( + window.loopalDesktop.upsertMcpServer({ + workspaceId: 'local-workspace', + server: { + type: 'stdio', name: 'fixture_echo_server', command, args: [script], + enabled: true, timeoutMs: 10_000, sharing: 'spawn-tree', + cwdIsolation: null, secretPatches: [], + }, + }) + ), { command: process.execPath, script: fixturePath('mcp/fixture-echo.mjs') }) + expect(definitions.servers).toContainEqual(expect.objectContaining({ + type: 'stdio', name: 'fixture_echo_server', enabled: true, + })) + + await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), initial.sessionId, + ) + await expect.poll( + async () => (await runtimeTarget(page)).generation, { timeout: 30_000 }, + ).toBe(initial.generation + 1) + await ready(page) + await expect.poll(async () => { + const detail = await activeDetail(page) + return detail.view?.mcpServers.find((server) => server.name === 'fixture_echo_server') + }, { timeout: 30_000 }).toMatchObject({ + status: 'connected', toolCount: 5, resourceCount: 1, promptCount: 1, errors: [], + }) + + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await send(page, 'Exercise real stdio MCP echo') + + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText( + 'Real stdio MCP tool completed through the production model loop.', + { timeout: 20_000 }, + ) + const invocation = conversation.getByTestId('tool-invocation') + .filter({ hasText: 'fixture_echo' }).last() + await expect(invocation.getByLabel('Completed')).toBeVisible() + await invocation.locator(':scope > summary').click() + await expect(invocation.locator('.tool-output')) + .toHaveText('fixture_echo result: desktop-mcp-roundtrip') + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[0]!.toolNames).toContain('fixture_echo') + expect(requests[1]!.toolResultIds).toContain('fixture-echo-call') + expect(requests[1]!.toolResultErrorIds).not.toContain('fixture-echo-call') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'provider-mcp', served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-memory.spec.ts b/apps/desktop/e2e/real/provider/host-provider-memory.spec.ts new file mode 100644 index 00000000..8c80fe18 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-memory.spec.ts @@ -0,0 +1,175 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { queueSessionDirectories } from '../../support/fixtures/session-directory-fixture' +import { createFromDirectory } from '../../support/fixtures/session-directory-ui' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('runs observation, recall, and importance through isolated project memory', async () => { + const desktop = await launchDesktop( + 'real', 'provider-memory', { + LOOPAL_DESKTOP_E2E_USER_SETTINGS: JSON.stringify({ memory: { enabled: false } }), + }, 'anthropic', 'memory', + ) + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const original = await runtimeTarget(page) + + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'loopal') + const pane = page.getByTestId('loopal-default-settings') + const enabled = pane.getByLabel('Enable project memory') + await expect(enabled).not.toBeChecked() + await enabled.check() + await pane.getByRole('button', { name: 'Save Loopal defaults' }).click() + await expect(pane.getByRole('status')).toContainText('new or restarted Sessions') + await page.getByRole('button', { name: 'Close settings' }).click() + + await send(page, 'ACTIVE_SESSION_MEMORY_DISABLED') + await expect(conversation).toContainText( + 'The already-running Session kept memory disabled.', { timeout: 20_000 }, + ) + const disabled = invocation(conversation, 'ACTIVE_SESSION_MEMORY_DISABLED') + await expect(disabled.getByLabel('Failed')).toBeVisible() + await expect(disabled).toContainText('Memory is not enabled') + await page.waitForTimeout(700) + expect(await desktop.llm!.state()).toMatchObject({ served: 2 }) + await expect(readFile(join( + desktop.project, '.loopal/memory/disabled-observation.md', + ), 'utf8')).rejects.toThrow() + + await queueSessionDirectories(desktop, [desktop.project]) + await createFromDirectory(page, 'directory', true) + await expect(page.locator('[data-session-id]')).toHaveCount(2, { timeout: 30_000 }) + await ready(page) + const created = await runtimeTarget(page) + expect(created.sessionId).not.toBe(original.sessionId) + + await send(page, 'NEW_SESSION_MEMORY_OBSERVATION') + await expect(conversation).toContainText( + 'The new Session queued the memory observation.', { timeout: 20_000 }, + ) + await expectSuccessfulOutput( + invocation(conversation, 'NEW_SESSION_MEMORY_OBSERVATION'), 'Noted.', + ) + await expect.poll(() => desktop.llm!.state(), { timeout: 40_000 }).toMatchObject({ served: 7 }) + const memoryPath = join( + desktop.project, '.loopal/memory/memory-observer-roundtrip.md', + ) + await expect.poll(() => readFile(memoryPath, 'utf8'), { timeout: 20_000 }) + .toContain('OBSERVER PERSISTED CONTRACT') + const firstMemoryAgents = await waitForMemoryAgent(page, new Set()) + + await restart(page, created.generation) + await send(page, 'RESTARTED_SESSION_MEMORY_OBSERVATION') + await expect(conversation).toContainText( + 'The restarted Session queued another observation.', { timeout: 20_000 }, + ) + await expectSuccessfulOutput( + invocation(conversation, 'RESTARTED_SESSION_MEMORY_OBSERVATION'), 'Noted.', + ) + await expect.poll(() => desktop.llm!.state(), { timeout: 40_000 }) + .toMatchObject({ served: 10 }) + await waitForMemoryAgent(page, new Set(firstMemoryAgents)) + + await send(page, 'RECALL_OBSERVER_MEMORY_BEFORE_IMPORTANCE') + await expect(conversation).toContainText( + 'The model received the recalled observer memory.', { timeout: 20_000 }, + ) + const before = await toolOutput(invocation(conversation, 'memory_recall').last()) + expect(before).toContain('OBSERVER PERSISTED CONTRACT') + expect(before.indexOf('memory-observer-roundtrip')) + .toBeLessThan(before.indexOf('stable-desktop-contract')) + + await send(page, 'TAG_BASELINE_MEMORY_IMPORTANCE') + await expect(conversation).toContainText( + 'The importance result reached the model.', { timeout: 20_000 }, + ) + await expectSuccessfulOutput( + invocation(conversation, 'memory_set_importance').last(), + 'Tagged `stable-desktop-contract` with importance=10', + ) + await expect.poll(async () => (await memoryEvents(desktop.project)).some((event) => ( + event.type === 'importance_tag' && event.node === 'stable-desktop-contract' + && event.importance === 10 + ))).toBe(true) + + const afterImportance = await runtimeTarget(page) + await restart(page, afterImportance.generation) + await send(page, 'RECALL_MEMORY_AFTER_RESTART') + await expect(conversation).toContainText( + 'Restarted recall preserved memory and importance ordering.', { timeout: 20_000 }, + ) + const after = await toolOutput(invocation(conversation, 'memory_recall').last()) + expect(after).toContain('OBSERVER PERSISTED CONTRACT') + expect(after.indexOf('stable-desktop-contract')) + .toBeLessThan(after.indexOf('memory-observer-roundtrip')) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(16) + expect(requests[0]!.toolNames).toEqual(expect.arrayContaining([ + 'Memory', 'memory_recall', 'memory_set_importance', + ])) + expect(requests[1]!.toolResultErrorIds).toContain('memory-disabled') + expect(requests[3]!.toolResultIds).toContain('memory-new-session') + expect(requests[8]!.toolResultIds).toContain('memory-restarted-session') + expect(requests[11]!.toolResultIds).toContain('main-recall-before') + expect(requests[13]!.toolResultIds).toContain('main-importance') + expect(requests[15]!.toolResultIds).toContain('main-recall-after') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 16, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +function invocation(conversation: Locator, text: string): Locator { + return conversation.getByTestId('tool-invocation').filter({ hasText: text }).last() +} + +async function expectSuccessfulOutput(tool: Locator, output: string): Promise { + await expect(tool.getByLabel('Completed')).toBeVisible({ timeout: 20_000 }) + if (!(await tool.evaluate((node) => (node as HTMLDetailsElement).open))) { + await tool.locator(':scope > summary').click() + } + await expect(tool.locator('.tool-output')).toContainText(output) +} + +async function toolOutput(tool: Locator): Promise { + await expect(tool.getByLabel('Completed')).toBeVisible({ timeout: 20_000 }) + if (!(await tool.evaluate((node) => (node as HTMLDetailsElement).open))) { + await tool.locator(':scope > summary').click() + } + return await tool.locator('.tool-output').innerText() +} + +async function restart(page: Page, generation: number): Promise { + await page.getByRole('button', { name: 'Restart session', exact: true }).click() + await expect.poll(() => runtimeTarget(page).then((target) => target.generation), { + timeout: 30_000, + }).toBe(generation + 1) + await ready(page) +} + +async function waitForMemoryAgent(page: Page, previous: Set): Promise { + const completed = async () => (await activeDetail(page)).agents.filter((agent) => ( + agent.name.startsWith('memory-') && agent.status === 'completed' + )).map((agent) => agent.name) + await expect.poll(async () => (await completed()).some((name) => !previous.has(name)), { + timeout: 30_000, + }).toBe(true) + return completed() +} + +async function memoryEvents(project: string): Promise[]> { + const directory = join(project, '.loopal/memory-events') + const files = (await readdir(directory)).filter((name) => name.endsWith('.jsonl')) + const contents = await Promise.all(files.map((name) => readFile(join(directory, name), 'utf8'))) + return contents.flatMap((content) => content.trim().split('\n')) + .filter(Boolean).map((line) => JSON.parse(line) as Record) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-plan-approval.spec.ts b/apps/desktop/e2e/real/provider/host-provider-plan-approval.spec.ts new file mode 100644 index 00000000..c4484b38 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-plan-approval.spec.ts @@ -0,0 +1,81 @@ +import { expect, test, type Locator } from '@playwright/test' +import { readFile, writeFile } from 'node:fs/promises' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +test('returns reject, edited approval, and direct approval to the real model loop', async () => { + const desktop = await launchDesktop('real', 'provider-plan-approval') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await expect.poll(async () => { + const detail = await activeDetail(page) + return detail.agents.find((agent) => agent.id === target.agentId)?.permissionMode + }).toBe('bypass') + await send(page, 'Enter deterministic plan mode') + const permissions = page.getByTestId('permissions-pane') + await expect(permissions).toContainText('Allow EnterPlanMode') + await permissions.getByRole('button', { name: 'Allow for session' }).click() + await expect(conversation).toContainText('Plan mode is ready for fixture seeding.', { + timeout: 20_000, + }) + const planPath = await planFilePath(conversation, 'EnterPlanMode') + await writeFile(planPath, '# ORIGINAL PLAN MARKER\n') + + await send(page, 'Request a rejected plan review') + let card = page.getByTestId('plan-approval-card') + await expect(card).toContainText('# ORIGINAL PLAN MARKER', { timeout: 20_000 }) + await card.getByTestId('plan-approval-reject').click() + await expect(conversation).toContainText( + 'The rejected plan returned to the model for revision.', { timeout: 20_000 }, + ) + await expect(card).toHaveCount(0) + expect(await readFile(planPath, 'utf8')).toBe('# ORIGINAL PLAN MARKER\n') + + await send(page, 'Request an edited plan approval') + card = page.getByTestId('plan-approval-card') + await expect(card).toBeVisible({ timeout: 20_000 }) + await card.getByTestId('plan-approval-editor').fill('# EDITED PLAN MARKER\n') + await card.getByTestId('plan-approval-approve-edits').click() + await expect(conversation).toContainText( + 'The edited approval returned to the model and restored Act mode.', { timeout: 20_000 }, + ) + await expect(card).toHaveCount(0) + expect(await readFile(planPath, 'utf8')).toBe('# EDITED PLAN MARKER\n') + expect((await activeDetail(page)).agents.find((agent) => agent.id === 'main')?.mode).toBe('act') + + await send(page, 'Enter plan mode for direct approval') + await expect(conversation).toContainText('Second plan review is ready.', { timeout: 20_000 }) + const secondPath = await planFilePath(conversation, 'EnterPlanMode') + await writeFile(secondPath, '# DIRECT PLAN MARKER\n') + await send(page, 'Request direct plan approval') + card = page.getByTestId('plan-approval-card') + await expect(card).toContainText('# DIRECT PLAN MARKER', { timeout: 20_000 }) + await card.getByTestId('plan-approval-approve').click() + await expect(conversation).toContainText( + 'The direct approval returned to the model.', { timeout: 20_000 }, + ) + await expect(card).toHaveCount(0) + expect(await readFile(secondPath, 'utf8')).toBe('# DIRECT PLAN MARKER\n') + expect((await activeDetail(page)).agents.find((agent) => agent.id === 'main')?.mode).toBe('act') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 10, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function planFilePath(conversation: Locator, toolName: string): Promise { + const tool = conversation.getByTestId('tool-invocation').filter({ hasText: toolName }).last() + await expect(tool.getByLabel('Completed')).toBeVisible({ timeout: 20_000 }) + await tool.locator(':scope > summary').click() + const match = (await tool.textContent())?.match(/(\/\S+\/\.loopal\/plans\/\S+\.md)/u) + if (!match?.[1]) throw new Error('EnterPlanMode did not expose its plan file path') + return match[1] +} diff --git a/apps/desktop/e2e/real/provider/host-provider-recovery.spec.ts b/apps/desktop/e2e/real/provider/host-provider-recovery.spec.ts new file mode 100644 index 00000000..159eeca7 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-recovery.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' + +test('renders retry, fatal, recovery, and retry-exhaustion HTTP states', async () => { + const desktop = await launchDesktop('real', 'provider-http-errors') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + + await send(page, 'Recover one server error') + await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 }) + await expect(conversation).toContainText('Recovered after one HTTP 503.', { + timeout: 20_000, + }) + await expect(conversation).not.toContainText('Retrying in 2.0s') + await ready(page) + + await send(page, 'Exercise fatal authentication') + await expect(conversation.locator('[data-message-role="error"]')).toContainText( + 'status=401', { timeout: 20_000 }, + ) + await expect(page.getByTestId('runtime-status')).toContainText('Failed') + await expect(page.getByLabel('Message Loopal')).toBeEnabled() + const diagnosticsTab = page.getByRole('tab', { name: 'Diagnostics', exact: true }) + await diagnosticsTab.click() + await expect(page.getByTestId('diagnostics-pane')).toContainText('status=401') + + await send(page, 'Recover after fatal provider error') + await expect(conversation).toContainText( + 'Session recovered after a fatal provider turn.', { timeout: 20_000 }, + ) + await ready(page) + + await send(page, 'Exhaust provider retries') + await expect(conversation.locator('[data-message-role="error"]').last()).toContainText( + 'retry after 0ms', { timeout: 20_000 }, + ) + await expect(page.getByTestId('runtime-status')).toContainText('Failed') + expect(await desktop.llm!.requests()).toHaveLength(11) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 11, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('keeps malformed, empty, and thinking-only stream failures recoverable', async () => { + const desktop = await launchDesktop('real', 'provider-stream-faults') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + + await send(page, 'Recover malformed partial stream') + await expect(conversation).toContainText('SSE parse error', { timeout: 20_000 }) + await expect(conversation).toContainText( + 'Malformed SSE recovered through continuation.', { timeout: 20_000 }, + ) + await expect(conversation).toContainText( + 'Response stream ended unexpectedly. Auto-continuing (1/3)', + ) + await ready(page) + + await send(page, 'Handle empty stream disconnect') + await expect(conversation).toContainText( + 'possible network interruption', { timeout: 20_000 }, + ) + await ready(page) + await send(page, 'Continue after empty disconnect') + await expect(conversation).toContainText( + 'Session remained usable after an empty disconnect.', { timeout: 20_000 }, + ) + await ready(page) + + await send(page, 'Recover thinking-only disconnect') + await expect(conversation).toContainText('THINKING BEFORE DISCONNECT', { timeout: 20_000 }) + await expect(conversation).toContainText( + 'Thinking-only disconnect recovered.', { timeout: 20_000 }, + ) + await ready(page) + await send(page, 'Recover server-only disconnect') + await expect(conversation).toContainText('PARTIAL SERVER RESULT', { timeout: 20_000 }) + await expect(conversation).toContainText( + 'Server-only disconnect recovered.', { timeout: 20_000 }, + ) + await ready(page) + const detail = await activeDetail(page) + expect(detail.session.attention).not.toBe('failure') + expect(detail.conversation.some((entry) => ( + entry.role === 'thinking' && entry.text.includes('THINKING BEFORE DISCONNECT') + ))).toBe(true) + expect(await desktop.llm!.requests()).toHaveLength(8) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 8, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('force-compacts and retries a real context overflow', async () => { + const desktop = await launchDesktop('real', 'provider-context-recovery') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + for (const message of ['Seed recovery context one', 'Seed recovery context two']) { + await send(page, message) + await ready(page) + } + + await send(page, 'Trigger context overflow recovery') + await expect(page.getByTestId('runtime-status')).toContainText('Compacting', { + timeout: 20_000, + }) + await expect(conversation).toContainText( + 'Context overflow — compacting and retrying...', { timeout: 20_000 }, + ) + await expect(conversation).toContainText( + 'Context overflow recovered after real compaction.', { timeout: 30_000 }, + ) + await expect(conversation).toContainText('Context compacted (context_overflow)') + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(5) + expect(requests[3]!.model).toContain('claude') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 5, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('condenses rejected server blocks before retrying', async () => { + const desktop = await launchDesktop('real', 'provider-server-block-recovery') + try { + const page = desktop.page + await ready(page) + await send(page, 'Trigger server block recovery') + await expect(page.getByTestId('conversation')).toContainText( + 'Server blocks condensed and the request recovered.', { timeout: 20_000 }, + ) + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(3) + expect(requests[1]!.serverBlockCount).toBeGreaterThanOrEqual(2) + expect(requests[2]!.serverBlockCount).toBe(0) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 3, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-runtime.spec.ts b/apps/desktop/e2e/real/provider/host-provider-runtime.spec.ts new file mode 100644 index 00000000..aee20881 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-runtime.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('retries a real Anthropic HTTP 429 and clears the retry state', async () => { + const desktop = await launchDesktop('real', 'provider-retry') + try { + const page = desktop.page + await ready(page) + await send(page, 'Recover from a scripted rate limit') + await expect(page.getByTestId('conversation')).toContainText( + 'Retrying in 2.0s', { timeout: 10_000 }, + ) + await expect(page.getByTestId('conversation')).toContainText( + 'Recovered through the real provider retry loop.', { timeout: 15_000 }, + ) + await expect(page.getByTestId('conversation')).not.toContainText('Retrying in 2.0s') + expect(await desktop.llm!.requests()).toHaveLength(2) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('continues a real max_tokens response without duplicating the user turn', async () => { + const desktop = await launchDesktop('real', 'provider-max-tokens') + try { + const page = desktop.page + await ready(page) + await send(page, 'Exercise max token continuation') + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText('PARTIAL MAX TOKEN OUTPUT') + await expect(conversation).toContainText('Output truncated (max_tokens). Auto-continuing') + await expect(conversation).toContainText( + 'Continuation completed over a second HTTP request.', { timeout: 15_000 }, + ) + await expect(conversation.locator('[data-message-role="user"]')).toHaveCount(1) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[1]!.lastUserText).toBe('[Continue from where you left off]') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ verified: true }) + } finally { + await closeDesktop(desktop) + } +}) + +test('interrupts a live HTTP stream and keeps the Session reusable', async () => { + const desktop = await launchDesktop('real', 'provider-interrupt') + try { + const page = desktop.page + await ready(page) + await send(page, 'Hold this provider stream') + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText('STREAM START BEFORE INTERRUPT') + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Interrupt' }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(conversation).toContainText('Turn cancelled', { timeout: 15_000 }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ clientDisconnects: 1 }) + await expect(conversation).not.toContainText('LATE CHUNK MUST NOT RENDER') + await send(page, 'Recover after interrupt') + await expect(conversation).toContainText( + 'Recovered after cancelling the real HTTP stream.', { timeout: 15_000 }, + ) + await expect(page.getByLabel('Message Loopal')).toBeEnabled() + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('retries a connection closed before HTTP headers', async () => { + const desktop = await launchDesktop('real', 'provider-preheader-retry') + try { + const conversation = desktop.page.getByTestId('conversation') + await ready(desktop.page) + await send(desktop.page, 'Recover from a pre-header disconnect') + await expect(conversation).toContainText('Retrying in 2.0s', { timeout: 10_000 }) + await expect(conversation).toContainText( + 'Recovered after the transport disappeared before headers.', { timeout: 15_000 }, + ) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, scriptedDisconnects: 1, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('interrupts a retry wait and accepts the next turn immediately', async () => { + const desktop = await launchDesktop('real', 'provider-retry-interrupt') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Wait on a long provider retry') + await expect(conversation).toContainText('Retrying in 30.0s', { timeout: 10_000 }) + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Interrupt' }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(conversation).not.toContainText('Retrying in 30.0s', { timeout: 10_000 }) + await expect(page.getByLabel('Message Loopal')).toBeEnabled({ timeout: 10_000 }) + await send(page, 'Recover after interrupting retry wait') + await expect(conversation).toContainText( + 'Recovered without waiting for the cancelled backoff.', { timeout: 15_000 }, + ) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('cancels a running model tool and suppresses late process output', async () => { + const desktop = await launchDesktop('real', 'provider-tool-interrupt') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const target = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent } + }) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await send(page, 'Start an interruptible model tool') + const tool = conversation.getByTestId('tool-invocation').filter({ hasText: 'TOOL EARLY' }) + await expect(tool.getByLabel('Running')).toBeVisible({ timeout: 15_000 }) + const progress = tool.locator('.tool-progress') + await expect(progress).toContainText('TOOL EARLY') + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await page.getByRole('group', { name: 'Agent controls' }) + .getByRole('button', { name: 'Interrupt' }).click() + await page.getByRole('button', { name: 'Close settings' }).click() + await expect(tool.getByLabel('Cancelled')).toBeVisible({ timeout: 15_000 }) + await expect(progress).toHaveCount(0) + await page.waitForTimeout(3_500) + await expect(readFile(join(desktop.project, 'tool-late-marker.txt'), 'utf8')) + .rejects.toThrow() + await send(page, 'Recover after cancelling a model tool') + await expect(conversation).toContainText( + 'The Session remained reusable after tool cancellation.', { timeout: 20_000 }, + ) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests[1]!.toolResultIds).toContain('interrupt-bash') + expect(requests[1]!.toolResultErrorIds).toContain('interrupt-bash') + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-secret-vault.spec.ts b/apps/desktop/e2e/real/provider/host-provider-secret-vault.spec.ts new file mode 100644 index 00000000..aa9ba0d3 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-secret-vault.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test' +import { execFile, spawn } from 'node:child_process' +import { mkdir, readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { + closeDesktop, launchDesktop, loopalBinaryPath, relaunchDesktop, + shutdownDesktop, type DesktopFixture, +} from '../../support/electron/electron-fixture' +import { isolatedTestEnvironment } from '../../support/providers/mock-llm-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +const canary = 'LOOPAL_DESKTOP_E2E_SECRET_7c19f2a84d' +const placeholder = '' +const execFileAsync = promisify(execFile) + +test('resolves a vault secret only inside a real Bash subprocess', async () => { + let desktop = await launchDesktop('real', 'provider-secret-vault') + let stopped = false + try { + await ready(desktop.page) + await initializeVault(desktop) + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + + const target = await runtimeTarget(desktop.page) + await desktop.page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + await send(desktop.page, 'Exercise the production secret boundary') + + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation).toContainText( + 'The production secret boundary stayed redacted.', { timeout: 20_000 }, + ) + const tool = conversation.getByTestId('tool-invocation').filter({ hasText: 'printf' }).last() + await expect(tool.getByLabel('Completed')).toBeVisible() + await tool.locator(':scope > summary').click() + await expect(tool.locator('.tool-output')).toContainText(`observed=${placeholder}`) + await expect(conversation).not.toContainText(canary) + const liveDetail = JSON.stringify(await activeDetail(desktop.page)) + expect(liveDetail).toContain(`observed=${placeholder}`) + expect(liveDetail).not.toContain(canary) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[1]).toMatchObject({ + toolResultIds: ['secret-bash'], toolResultErrorIds: [], matched: true, + }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, unmatchedRequests: 0, verified: true, + }) + + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + await expect(desktop.page.getByTestId('conversation')).toContainText(`observed=${placeholder}`) + await expect(desktop.page.getByTestId('conversation')).not.toContainText(canary) + expect(JSON.stringify(await activeDetail(desktop.page))).not.toContain(canary) + await assertPublicSettings(desktop) + await shutdownDesktop(desktop) + stopped = true + await assertTreesExclude(canary, [desktop.home, desktop.project, join(desktop.root, 'user-data')]) + } finally { + if (stopped) await desktop.cleanup() + else await closeDesktop(desktop) + } +}) + +async function initializeVault(desktop: DesktopFixture): Promise { + const ssh = join(desktop.home, '.ssh') + await mkdir(ssh) + await execFileAsync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-C', 'loopal-desktop-e2e', + '-f', join(ssh, 'id_ed25519'), + ], { env: isolatedTestEnvironment({ HOME: desktop.home }) }) + await runLoopal(desktop, ['vaults', 'init']) + await runLoopal(desktop, ['vault', 'set', 'desktop_canary'], `${canary}\n`) +} + +async function runLoopal( + desktop: DesktopFixture, args: string[], stdin?: string, +): Promise { + const child = spawn(loopalBinaryPath(), args, { + cwd: desktop.project, + env: isolatedTestEnvironment({ HOME: desktop.home }), + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + child.stdout.resume() + child.stdin.end(stdin) + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`loopal ${args.join(' ')} timed out`)) + }, 15_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('close', (value) => { clearTimeout(timer); resolve(value) }) + }) + if (code !== 0) throw new Error(`loopal ${args.join(' ')} failed (${code}): ${stderr}`) +} + +async function assertPublicSettings(desktop: DesktopFixture): Promise { + const projections = await desktop.page.evaluate(async () => Promise.all([ + window.loopalDesktop.getLoopalSettings('local-workspace'), + window.loopalDesktop.listMcpServers('local-workspace'), + window.loopalDesktop.getMetaHubSettings(), + ])) + expect(JSON.stringify(projections)).not.toContain(canary) + await desktop.page.getByRole('button', { name: 'Settings' }).click() + await expect(desktop.page.getByTestId('settings-pane')).toBeVisible() + expect(await desktop.page.getByTestId('settings-pane').textContent()).not.toContain(canary) +} + +async function assertTreesExclude(secret: string, roots: string[]): Promise { + const needle = Buffer.from(secret) + for (const root of roots) { + for (const path of await filesBelow(root)) { + expect((await readFile(path)).includes(needle), `plaintext secret persisted at ${path}`) + .toBe(false) + } + } +} + +async function filesBelow(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }) + const nested = await Promise.all(entries.map(async (entry) => { + const path = join(root, entry.name) + if (entry.isDirectory()) return filesBelow(path) + return entry.isFile() ? [path] : [] + })) + return nested.flat() +} diff --git a/apps/desktop/e2e/real/provider/host-provider-server-search.spec.ts b/apps/desktop/e2e/real/provider/host-provider-server-search.spec.ts new file mode 100644 index 00000000..54841a99 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-server-search.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider, providerModel } from '../../support/providers/provider-e2e-fixture' + +const cases: readonly SearchCase[] = [{ + provider: 'openai', protocol: 'openai_responses', scenario: 'provider-server-search-openai', + prompt: 'Use native OpenAI server search', + answer: 'OpenAI native search rendered.', followUpAnswer: 'OpenAI search history remained ordered.', + tool: 'web_search', result: '"status":"completed"', declaration: 'web_search', + blocks: ['reasoning', 'web_search_call', 'message'], serverBlockCount: 1, +}, { + provider: 'google', protocol: 'google', scenario: 'provider-server-search-google', + prompt: 'Use native Google server search', + answer: 'Google native search rendered.', followUpAnswer: 'Google search history remained ordered.', + tool: 'google_search', result: 'Fixture Google source', declaration: 'WebSearch', + blocks: ['server_tool_use', 'server_tool_result', 'text'], serverBlockCount: 2, +}] + +for (const item of cases) { + test(`${item.provider} renders and replays native server search`, async () => { + const desktop = await launchDesktop('real', item.scenario, {}, item.provider) + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, item.prompt) + await expect(conversation).toContainText(item.answer, { timeout: 20_000 }) + const search = conversation.getByTestId('tool-invocation') + .filter({ hasText: item.tool }).last() + await expect(search.getByLabel('Completed')).toBeVisible() + await search.locator(':scope > summary').click() + await expect(search).toContainText(item.result) + await expect(conversation).toContainText('Output truncated (max_tokens). Auto-continuing') + await expect(conversation).toContainText(item.followUpAnswer, { timeout: 20_000 }) + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ + protocol: item.protocol, model: providerModel(item.provider), + serverBlockCount: 0, apiKeyPresent: true, matched: true, + }) + expect(requests[0]!.toolNames).toContain(item.declaration) + expect(requests[1]).toMatchObject({ + protocol: item.protocol, model: providerModel(item.provider), messageCount: 2, + lastUserText: item.prompt, assistantBlockTypes: item.blocks, + serverBlockCount: item.serverBlockCount, matched: true, + }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } + }) +} + +interface SearchCase { + readonly provider: Extract + readonly protocol: string + readonly scenario: string + readonly prompt: string + readonly answer: string + readonly followUpAnswer: string + readonly tool: string + readonly result: string + readonly declaration: string + readonly blocks: readonly string[] + readonly serverBlockCount: number +} diff --git a/apps/desktop/e2e/real/provider/host-provider-settings-activation.spec.ts b/apps/desktop/e2e/real/provider/host-provider-settings-activation.spec.ts new file mode 100644 index 00000000..7a71ce26 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-settings-activation.spec.ts @@ -0,0 +1,169 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider } from '../../support/providers/provider-e2e-fixture' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +interface SettingsCase { + provider: 'anthropic' | 'google' + initialProvider: E2eProvider + scenario: string + label: 'Anthropic' | 'Google' + model: string + protocol: string + prompt: string + response: string +} + +const cases: readonly SettingsCase[] = [ + { + provider: 'anthropic', initialProvider: 'openai', scenario: 'provider-settings-anthropic', + label: 'Anthropic', model: 'claude-opus-4-8', protocol: 'anthropic', + prompt: 'Use Anthropic saved in Desktop Settings', + response: 'Desktop Settings activated the authenticated Anthropic provider.', + }, + { + provider: 'google', initialProvider: 'anthropic', scenario: 'provider-settings-google', + label: 'Google', model: 'gemini-2.0-flash', protocol: 'google', + prompt: 'Use Google saved in Desktop Settings', + response: 'Desktop Settings activated the authenticated Google provider.', + }, +] + +for (const item of cases) { + test(`Settings activates, redacts, and clears ${item.label}`, async () => { + const desktop = await launchDesktop('real', item.scenario, {}, item.initialProvider) + try { + const page = desktop.page + await ready(page) + const pane = await openSettings(page) + await pane.getByLabel('Default model').fill(item.model) + await selectSettingsSection(page, 'providers') + const card = pane.getByTestId(`provider-${item.provider}`) + await card.getByLabel(`Enable ${item.label}`).check() + await card.getByLabel(`${item.label} base URL`).fill(desktop.llm!.baseUrl) + await card.getByLabel(`${item.label} API key environment`).fill('') + await card.getByLabel(`${item.label} API key`, { exact: true }).fill(desktop.llm!.apiKey) + await pane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(pane.getByRole('status')).toContainText('new or restarted Sessions') + + const saved = await settings(page) + expect(saved.settings.model).toBe(item.model) + expect(saved.configuredProviders).toContain(item.provider) + expect(saved.providers[item.provider]).toMatchObject({ + enabled: true, baseUrl: desktop.llm!.baseUrl, apiKeyEnv: '', apiKeyConfigured: true, + }) + expect(saved.resolvedEntries).toContainEqual({ + key: `providers.${item.provider}.api_key`, value: '********', + }) + expect(JSON.stringify(saved)).not.toContain(desktop.llm!.apiKey) + + await pane.getByRole('button', { name: 'Restart current Session' }).click() + await expect(pane.getByRole('status')).toContainText('restarted with the saved') + await page.getByRole('button', { name: 'Close settings' }).click() + await ready(page) + await send(page, item.prompt) + await expect(page.getByTestId('conversation')).toContainText(item.response, { + timeout: 20_000, + }) + await ready(page) + + expect(await desktop.llm!.requests()).toEqual([expect.objectContaining({ + protocol: item.protocol, model: item.model, apiKeyPresent: true, matched: true, + })]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, remaining: 0, unmatchedRequests: 0, verified: true, + }) + + const clearPane = await openSettings(page) + await selectSettingsSection(page, 'providers') + const clearCard = clearPane.getByTestId(`provider-${item.provider}`) + await clearCard.getByRole('button', { name: 'Clear API key' }).click() + await clearPane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(clearPane.getByRole('status')).toContainText('new or restarted Sessions') + const cleared = await settings(page) + expect(cleared.providers[item.provider].apiKeyConfigured).toBe(false) + expect(JSON.stringify(cleared)).not.toContain(desktop.llm!.apiKey) + const global = await readFile(join(desktop.home, '.loopal', 'settings.json'), 'utf8') + expect(global).not.toContain(desktop.llm!.apiKey) + } finally { + await closeDesktop(desktop) + } + }) +} + +test('Settings activates, redacts, and clears an OpenAI-compatible endpoint', async () => { + const desktop = await launchDesktop( + 'real', 'provider-settings-openai-compatible', {}, 'anthropic', + ) + try { + const page = desktop.page + await ready(page) + const pane = await openSettings(page) + await pane.getByLabel('Default model').fill('compat-e2e/deepseek-reasoner') + await selectSettingsSection(page, 'providers') + await pane.getByRole('button', { name: 'Add endpoint' }).click() + const card = pane.getByTestId('provider-openai-compatible') + await expect(card.getByLabel('Compatible provider name')).toHaveValue('compatible-1') + await card.getByLabel('Compatible base URL').fill(`${desktop.llm!.baseUrl}/v1`) + await card.getByLabel('Compatible model prefix').fill('compat-e2e/') + await card.getByLabel('Compatible API key environment').fill('') + await card.getByLabel('Compatible API key', { exact: true }).fill(desktop.llm!.apiKey) + await pane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(pane.getByRole('status')).toContainText('new or restarted Sessions') + + const saved = await settings(page) + expect(saved.configuredProviders).toContain('openai-compatible: compatible-1') + expect(saved.openaiCompatible).toContainEqual({ + name: 'compatible-1', baseUrl: `${desktop.llm!.baseUrl}/v1`, + modelPrefix: 'compat-e2e/', apiKeyEnv: '', apiKeyConfigured: true, + }) + expect(JSON.stringify(saved)).not.toContain(desktop.llm!.apiKey) + + await pane.getByRole('button', { name: 'Restart current Session' }).click() + await expect(pane.getByRole('status')).toContainText('restarted with the saved') + await page.getByRole('button', { name: 'Close settings' }).click() + await ready(page) + await send(page, 'Use compatible provider saved in Desktop Settings') + await expect(page.getByTestId('conversation')).toContainText( + 'Desktop Settings activated the authenticated compatible provider.', { timeout: 20_000 }, + ) + await ready(page) + expect(await desktop.llm!.requests()).toEqual([expect.objectContaining({ + protocol: 'openai_compat', model: 'compat-e2e/deepseek-reasoner', + apiKeyPresent: true, matched: true, + })]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, remaining: 0, unmatchedRequests: 0, verified: true, + }) + + const clearPane = await openSettings(page) + await selectSettingsSection(page, 'providers') + const clearCard = clearPane.getByTestId('provider-openai-compatible') + await clearCard.getByRole('button', { name: 'Clear API key' }).click() + await clearPane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(clearPane.getByRole('status')).toContainText('new or restarted Sessions') + const cleared = await settings(page) + expect(cleared.openaiCompatible[0]!.apiKeyConfigured).toBe(false) + expect(JSON.stringify(cleared)).not.toContain(desktop.llm!.apiKey) + const global = await readFile(join(desktop.home, '.loopal', 'settings.json'), 'utf8') + expect(global).not.toContain(desktop.llm!.apiKey) + } finally { + await closeDesktop(desktop) + } +}) + +async function openSettings(page: Page): Promise { + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'loopal') + const pane = page.getByTestId('loopal-default-settings') + await expect(pane).toBeVisible() + await expect(pane.getByLabel('Default model')).toBeVisible() + return pane +} + +async function settings(page: Page) { + return page.evaluate(() => window.loopalDesktop.getLoopalSettings('local-workspace')) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-settings-openai.spec.ts b/apps/desktop/e2e/real/provider/host-provider-settings-openai.spec.ts new file mode 100644 index 00000000..b10823a8 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-settings-openai.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +const model = 'gpt-4.1' +const prompt = 'Use the provider saved in Desktop Settings' + +test('saves an OpenAI provider in Settings and uses it after restart', async () => { + const desktop = await launchDesktop('real', 'provider-settings-openai') + try { + const page = desktop.page + await ready(page) + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'loopal') + const pane = page.getByTestId('loopal-default-settings') + await pane.getByLabel('Default model').fill(model) + await selectSettingsSection(page, 'providers') + await pane.getByLabel('Enable OpenAI').check() + await pane.getByLabel('OpenAI base URL').fill(`${desktop.llm!.baseUrl}/v1`) + await pane.getByLabel('OpenAI API key environment').fill('') + await pane.getByLabel('OpenAI API key', { exact: true }).fill(desktop.llm!.apiKey) + await pane.getByRole('button', { name: 'Save provider settings' }).click() + await expect(pane.getByRole('status')).toContainText('new or restarted Sessions') + + const settings = await page.evaluate( + () => window.loopalDesktop.getLoopalSettings('local-workspace'), + ) + expect(settings.settings.model).toBe(model) + expect(settings.providers.openai).toMatchObject({ + enabled: true, baseUrl: `${desktop.llm!.baseUrl}/v1`, + apiKeyEnv: '', apiKeyConfigured: true, + }) + expect(JSON.stringify(settings)).not.toContain(desktop.llm!.apiKey) + + const restart = pane.getByRole('button', { name: 'Restart current Session' }) + await expect(restart).toBeEnabled() + await restart.click() + await expect(pane.getByRole('status')).toContainText('restarted with the saved') + await page.getByRole('button', { name: 'Close settings' }).click() + await ready(page) + await send(page, prompt) + await expect(page.getByTestId('conversation')).toContainText( + 'Desktop Settings selected the OpenAI model.', { timeout: 20_000 }, + ) + await ready(page) + + expect(await desktop.llm!.requests()).toEqual([expect.objectContaining({ + sequence: 1, protocol: 'openai_responses', model, + lastUserText: prompt, messageCount: 1, apiKeyPresent: true, + protocolVersionPresent: true, stream: true, matched: true, + })]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, requestCount: 1, remaining: 0, + unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-skill.spec.ts b/apps/desktop/e2e/real/provider/host-provider-skill.spec.ts new file mode 100644 index 00000000..21a74e4f --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-skill.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test' +import { + closeDesktop, launchDesktop, relaunchDesktop, +} from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' + +const command = '/desktop-check alpha beta' +const marker = 'SKILL_E2E_BODY_MARKER' + +test('routes and restores a real project Skill invocation', async () => { + let desktop = await launchDesktop( + 'real', 'provider-skill', {}, 'anthropic', 'skill', + ) + try { + await ready(desktop.page) + const initialElectronPid = desktop.app.process().pid + await desktop.page.getByLabel('Message Loopal').fill(command) + await desktop.page.getByRole('button', { name: 'Send' }).click() + + const conversation = desktop.page.getByTestId('conversation') + await expect(conversation.getByText('Skill · /desktop-check')).toHaveCount(1, { + timeout: 20_000, + }) + await expect(conversation).toContainText('alpha beta') + await expect(conversation).not.toContainText(marker) + await expect(conversation).toContainText( + 'Skill invocation reached the production runtime.', { timeout: 20_000 }, + ) + await ready(desktop.page) + await expectSkillDetail(desktop.page) + + desktop = await relaunchDesktop(desktop) + await ready(desktop.page) + expect(desktop.app.process().pid).not.toBe(initialElectronPid) + const restored = desktop.page.getByTestId('conversation') + await expect(restored.getByText('Skill · /desktop-check')).toHaveCount(1) + await expect(restored).toContainText('alpha beta') + await expect(restored).not.toContainText(marker) + await expectSkillDetail(desktop.page) + + await send(desktop.page, 'SKILL_E2E_AFTER_RELAUNCH') + await expect(restored).toContainText( + 'Skill history survived the Electron relaunch.', { timeout: 20_000 }, + ) + await ready(desktop.page) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests[0]).toMatchObject({ + protocol: 'anthropic', messageCount: 1, matched: true, + }) + expect(requests[0]!.lastUserText).toContain(marker) + expect(requests[0]!.lastUserText).toContain('alpha beta') + expect(requests[1]).toMatchObject({ + protocol: 'anthropic', messageCount: 3, + lastUserText: 'SKILL_E2E_AFTER_RELAUNCH', matched: true, + }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, requestCount: 2, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function expectSkillDetail(page: import('@playwright/test').Page): Promise { + const detail = await activeDetail(page) + const skill = detail.conversation.find((entry) => entry.skill?.name === '/desktop-check') + expect(skill).toMatchObject({ + role: 'user', + skill: { name: '/desktop-check', userArgs: 'alpha beta' }, + }) + expect(skill?.text).toContain(marker) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-state-matrix.spec.ts b/apps/desktop/e2e/real/provider/host-provider-state-matrix.spec.ts new file mode 100644 index 00000000..af09de5c --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-state-matrix.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider } from '../../support/providers/provider-e2e-fixture' + +interface StateCase { + provider: E2eProvider + scenario: string + model: string + prompt: string + thinking: string + streaming: string + cacheCreation: number + cacheRead: number + thinkingTokens: number + requestCount?: number +} + +const cases: readonly StateCase[] = [ + { provider: 'anthropic', scenario: 'provider-state-anthropic', model: 'claude-opus-4-8', + prompt: 'Observe Anthropic provider state', thinking: 'ANTHROPIC THINKING STATE MARKER', + streaming: 'ANTHROPIC STREAMING STATE MARKER', cacheCreation: 3, cacheRead: 5, + thinkingTokens: 13 }, + { provider: 'openai', scenario: 'provider-state-openai', model: 'o3', + prompt: 'Observe OpenAI provider state', thinking: 'OPENAI THINKING STATE MARKER', + streaming: 'OPENAI STREAMING STATE MARKER', cacheCreation: 0, cacheRead: 5, + thinkingTokens: 11 }, + { provider: 'openai_compat', scenario: 'provider-state-openai-compat', + model: 'deepseek-reasoner', prompt: 'Observe compatible provider state', + thinking: 'COMPAT THINKING STATE MARKER', streaming: 'COMPAT STREAMING STATE MARKER', + cacheCreation: 0, cacheRead: 0, thinkingTokens: 11 }, + { provider: 'google', scenario: 'provider-state-google', + model: 'gemini-2.5-flash-preview-04-17', prompt: 'Observe Google provider state', + thinking: 'GOOGLE THINKING STATE MARKER', streaming: 'GOOGLE STREAMING STATE MARKER', + cacheCreation: 0, cacheRead: 0, thinkingTokens: 11, requestCount: 2 }, +] + +for (const state of cases) { + test(`${state.provider} projects thinking, streaming, and usage telemetry`, async () => { + const desktop = await launchDesktop('real', state.scenario, {}, state.provider) + try { + const page = desktop.page + await ready(page) + await selectThinkingModel(page, state.model) + await send(page, state.prompt) + const conversation = page.getByTestId('conversation') + await expect(conversation).toContainText(state.thinking, { timeout: 15_000 }) + await expect(page.getByTestId('runtime-status')).toContainText('Thinking') + await expect(conversation).toContainText(state.streaming, { timeout: 15_000 }) + await expect(page.getByTestId('runtime-status')).toContainText('Streaming') + await ready(page) + + const detail = await activeDetail(page) + const root = detail.agents.find((agent) => agent.id === 'main')! + expect(root.view).toMatchObject({ thinkingActive: false, streamingThinking: '', + streamingText: '', retryBanner: null }) + expect(detail.session.attention).not.toBe('failure') + expect(root.telemetry).toMatchObject({ + turnCount: 1, inputTokens: 37, outputTokens: 13, + cacheCreationTokens: state.cacheCreation, cacheReadTokens: state.cacheRead, + }) + expect(root.telemetry!.thinkingTokens).toBe(state.thinkingTokens) + const thought = root.conversation!.find((entry) => ( + entry.role === 'thinking' && entry.text.includes(state.thinking) + ))! + expect(thought.thinkingTokens).toBe(state.thinkingTokens) + expect(root.conversation!.filter((entry) => entry.role === 'thinking')).toHaveLength(1) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(state.requestCount ?? 1) + expect(requests[0]).toEqual(expect.objectContaining({ + protocol: protocol(state.provider), model: state.model, + thinkingEnabled: true, matched: true, + })) + if (state.provider === 'google') { + expect(requests[1]!.assistantBlockTypes).toEqual(['thinking', 'text', 'tool_use']) + expect(root.conversation).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: 'GOOGLE SIGNATURE REPLAY MARKER' }), + ])) + } + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: state.requestCount ?? 1, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } + }) +} + +async function selectThinkingModel(page: Page, model: string): Promise { + const target = await runtimeTarget(page) + for (const command of [ + { type: 'model' as const, model }, + { type: 'thinking' as const, config: { type: 'effort' as const, level: 'high' as const } }, + ]) await page.evaluate(async ([value, control]) => { + await window.loopalDesktop.controlAgent({ target: value, command: control }) + }, [target, command] as const) +} + +function protocol(provider: E2eProvider): string { + if (provider === 'openai') return 'openai_responses' + return provider +} diff --git a/apps/desktop/e2e/real/provider/host-provider-subagent-process-lifecycle.spec.ts b/apps/desktop/e2e/real/provider/host-provider-subagent-process-lifecycle.spec.ts new file mode 100644 index 00000000..bc25e710 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-subagent-process-lifecycle.spec.ts @@ -0,0 +1,164 @@ +import { expect, test, type Page } from '@playwright/test' +import { execFile as execFileCallback } from 'node:child_process' +import { basename } from 'node:path' +import { promisify } from 'node:util' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +const execFile = promisify(execFileCallback) + +test('isolates child processes and cleans active children across lifecycle controls', async () => { + test.skip(process.platform === 'win32', 'safe process-tree probe currently uses ps') + const desktop = await launchDesktop('real', 'subagent-process-lifecycle') + try { + const page = desktop.page + const electronPid = desktop.app.process().pid! + await ready(page) + let target = await runtimeTarget(page) + await bypass(page, target) + const initial = await loopalTree(electronPid) + const host = initial.find((row) => row.ppid === electronPid) + expect(host).toBeDefined() + expect(initial.some((row) => row.ppid === host!.pid)).toBe(true) + + await send(page, 'Spawn the interruptible process child') + await expectAgent(page, 'interrupt-child', 'INTERRUPT CHILD STREAM ACTIVE') + const interrupted = await newChildProcess(electronPid, initial, host!.pid) + await page.evaluate(async ({ root, agentId }) => { + await window.loopalDesktop.interruptAgent({ ...root, agentId }) + }, { root: target, agentId: 'interrupt-child' }) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ clientDisconnects: 1 }) + await expect.poll(() => agentStatus(page, 'interrupt-child')).toBe('completed') + await expect.poll(() => agentText(page, 'interrupt-child')).toContain('Turn cancelled') + await expect.poll(() => rootText(page)).toContain('Root observed interrupted child completion.') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ served: 4, inFlight: 0 }) + expect([host!.pid, undefined]).toContain(await processParent(interrupted.pid)) + + await send(page, 'Spawn the stop cleanup child') + await expectAgent(page, 'stop-child', 'STOP CHILD STREAM ACTIVE') + await expect.poll(() => rootText(page)).toContain('Stop child is running.') + const stopTree = await loopalTree(electronPid) + expect(stopTree.length).toBeGreaterThan(initial.length) + await page.evaluate((sessionId) => window.loopalDesktop.stopSession(sessionId), target.sessionId) + await expect.poll(() => livePids(stopTree)).toEqual([]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ clientDisconnects: 2, inFlight: 0 }) + + const resumed = await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), target.sessionId, + ) + expect(resumed.generation).toBeGreaterThan(target.generation) + await ready(page) + target = await runtimeTarget(page) + const resumedTree = await loopalTree(electronPid) + expect(resumedTree.length).toBeGreaterThanOrEqual(initial.length) + expect(resumedTree.some((row) => stopTree.some((old) => old.pid === row.pid))).toBe(false) + await send(page, 'Recover after stopping the Session') + await expect.poll(() => rootText(page)).toContain('Session recovered after stop cleanup.') + + await send(page, 'Spawn the restart cleanup child') + await expectAgent(page, 'restart-child', 'RESTART CHILD STREAM ACTIVE') + await expect.poll(() => rootText(page)).toContain('Restart child is running.') + const restartTree = await loopalTree(electronPid) + const restarted = await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), target.sessionId, + ) + expect(restarted.generation).toBeGreaterThan(target.generation) + await expect.poll(() => livePids(restartTree)).toEqual([]) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ clientDisconnects: 3, inFlight: 0 }) + await ready(page) + const finalTree = await loopalTree(electronPid) + expect(finalTree.length).toBeGreaterThanOrEqual(initial.length) + expect(finalTree.some((row) => restartTree.some((old) => old.pid === row.pid))).toBe(false) + await send(page, 'Recover after restarting the Session') + await expect.poll(() => rootText(page)).toContain('Session recovered after restart cleanup.') + await expect.poll(() => agentText(page, 'interrupt-child')) + .not.toContain('INTERRUPT CHILD LATE OUTPUT') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 12, remaining: 0, unmatchedRequests: 0, inFlight: 0, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests.every((request) => request.matched)).toBe(true) + expect(JSON.stringify(requests)).not.toContain('LATE OUTPUT') + } finally { + await closeDesktop(desktop) + } +}) + +interface ProcessRow { readonly pid: number; readonly ppid: number; readonly command: string } + +async function loopalTree(root: number): Promise { + const { stdout } = await execFile('ps', ['-axo', 'pid=,ppid=,comm=']) + const rows = stdout.split('\n').flatMap((line): ProcessRow[] => { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/) + return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3]!.trim() }] : [] + }) + const descendants = new Set([root]) + for (let changed = true; changed;) { + changed = false + for (const row of rows) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { + descendants.add(row.pid); changed = true + } + } + return rows.filter((row) => descendants.has(row.pid) && basename(row.command) === 'loopal') +} + +async function newChildProcess( + root: number, before: readonly ProcessRow[], parentPid: number, +): Promise { + const known = new Set(before.map((row) => row.pid)) + await expect.poll(async () => (await loopalTree(root)).filter((row) => !known.has(row.pid)).length) + .toBe(1) + const child = (await loopalTree(root)).find((row) => !known.has(row.pid))! + expect(child.ppid).toBe(parentPid) + return child +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true } catch { return false } +} + +function livePids(rows: readonly ProcessRow[]): number[] { + return rows.filter((row) => isAlive(row.pid)).map((row) => row.pid) +} + +async function processParent(pid: number): Promise { + try { + const { stdout } = await execFile('ps', ['-o', 'ppid=', '-p', String(pid)]) + const parent = Number(stdout.trim()) + return Number.isSafeInteger(parent) && parent > 0 ? parent : undefined + } catch { return undefined } +} + +async function expectAgent(page: Page, agentId: string, marker: string): Promise { + await expect.poll(async () => { + const agent = await agentSnapshot(page, agentId) + return { status: agent?.status, text: agent?.conversation?.map((entry) => entry.text).join('\n') } + }).toEqual({ status: 'running', text: expect.stringContaining(marker) }) +} + +async function agentSnapshot(page: Page, agentId: string) { + return page.evaluate(async (id) => { + const bootstrap = await window.loopalDesktop.bootstrap() + return (await window.loopalDesktop.openSession(bootstrap.activeSessionId!)).agents + .find((agent) => agent.id === id) + }, agentId) +} + +async function agentStatus(page: Page, agentId: string): Promise { + return (await agentSnapshot(page, agentId))?.status +} + +async function agentText(page: Page, agentId: string): Promise { + const agent = await agentSnapshot(page, agentId) + return agent?.conversation?.map((entry) => entry.text).join('\n') ?? '' +} + +async function rootText(page: Page): Promise { + return agentText(page, 'main') +} + +async function bypass(page: Page, target: Awaited>): Promise { + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-subagent-ui-control.spec.ts b/apps/desktop/e2e/real/provider/host-provider-subagent-ui-control.spec.ts new file mode 100644 index 00000000..6d633aa4 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-subagent-ui-control.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, send } from '../../support/runtime/llm-e2e-helpers' +import { selectSettingsSection } from '../../support/settings/settings-helpers' + +test('interrupts a real background child through the selected Agent UI', async () => { + const desktop = await launchDesktop('real', 'subagent-ui-interrupt') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + const rootControls = page.getByRole('group', { name: 'Agent controls' }) + await rootControls.getByLabel('Permission mode').selectOption('bypass') + await expect(rootControls.getByLabel('Permission mode')).toHaveValue('bypass') + await page.getByRole('button', { name: 'Close settings' }).click() + + await send(page, 'Spawn the UI interrupt child') + await expect(conversation).toContainText('UI child is running in background.', { + timeout: 30_000, + }) + const agentsTab = page.getByRole('tab', { name: 'Agents', exact: true }) + await agentsTab.click() + const agents = page.getByTestId('agents-pane') + const child = agents.locator('[data-agent-id="ui-interrupt-child"]') + await expect(child).toContainText('running', { timeout: 30_000 }) + await child.click() + await expect(child).toHaveAttribute('aria-selected', 'true') + await expect(conversation).toContainText('Viewing ui-interrupt-child · running') + await expect(conversation).toContainText('UI CHILD STREAM ACTIVE', { timeout: 20_000 }) + + await page.getByRole('button', { name: 'Settings' }).click() + await selectSettingsSection(page, 'agent') + await expect(page.getByLabel('Settings agent')).toHaveValue('ui-interrupt-child') + const childControls = page.getByRole('group', { name: 'Agent controls' }) + await expect(childControls).toContainText('ui-interrupt-child') + const interrupt = childControls.getByRole('button', { name: 'Interrupt' }) + await expect(interrupt).toBeEnabled() + await interrupt.click() + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + clientDisconnects: 1, inFlight: 0, + }) + await page.getByRole('button', { name: 'Close settings' }).click() + + await expect(conversation).toContainText('Viewing ui-interrupt-child · completed', { + timeout: 30_000, + }) + await expect(conversation).toContainText('UI CHILD STREAM ACTIVE') + await expect(conversation).not.toContainText('UI CHILD LATE OUTPUT') + await expect(child).toContainText('completed') + + await agents.locator('[data-agent-id="main"]').click() + await expect(page.getByLabel('Message Loopal')).toBeEnabled() + await expect(conversation).toContainText('Root observed the UI child interrupt.', { + timeout: 30_000, + }) + await send(page, 'Continue after the UI child interrupt') + await expect(conversation).toContainText('Root remained usable after the UI interrupt.', { + timeout: 20_000, + }) + await assertJournal(desktop) + } finally { + await closeDesktop(desktop) + } +}) + +async function assertJournal(desktop: Awaited>): Promise { + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'subagent-ui-interrupt', served: 5, requestCount: 5, + remaining: 0, unmatchedRequests: 0, inFlight: 0, + clientDisconnects: 1, verified: true, + }) + const requests = await desktop.llm!.requests() + expect(requests.map(({ sequence, matched }) => ({ sequence, matched }))).toEqual([ + { sequence: 1, matched: true }, { sequence: 2, matched: true }, + { sequence: 3, matched: true }, { sequence: 4, matched: true }, + { sequence: 5, matched: true }, + ]) + expect(requests.filter((request) => ( + request.lastUserText === 'Spawn the UI interrupt child' + ))).toHaveLength(1) + expect(matching(requests, 'Hold the UI interrupt child stream')).toHaveLength(1) + expect(requests.filter((request) => ( + request.lastUserText === '' + && request.toolResultIds.includes('spawn-ui-interrupt') + ))).toHaveLength(1) + expect(matching(requests, 'UI CHILD STREAM ACTIVE')).toHaveLength(1) + expect(requests.filter((request) => ( + request.lastUserText === 'Continue after the UI child interrupt' + ))).toHaveLength(1) + expect(JSON.stringify(requests)).not.toContain('UI CHILD LATE OUTPUT') +} + +function matching( + requests: Awaited>['llm']>['requests']>>, + marker: string, +) { + return requests.filter((request) => request.lastUserText.includes(marker)) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-task-lifecycle.spec.ts b/apps/desktop/e2e/real/provider/host-provider-task-lifecycle.spec.ts new file mode 100644 index 00000000..cd1f9827 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-task-lifecycle.spec.ts @@ -0,0 +1,119 @@ +import { expect, test, type Locator, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +test('persists model-driven Task dependencies and status transitions across restart', async () => { + const desktop = await launchDesktop('real', 'provider-task-lifecycle') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Build a persisted task dependency lifecycle') + await expect(conversation).toContainText( + 'Task lifecycle is ready for a Session restart.', { timeout: 30_000 }, + ) + await ready(page) + await assertActiveTasks(page) + await expect(conversation.getByTestId('tool-invocation').filter({ + hasText: 'Foundation task', + }).filter({ hasText: '"status": "completed"' })).toHaveCount(1) + + const initial = await runtimeTarget(page) + const taskFile = join( + desktop.home, '.loopal', 'sessions', initial.sessionId, 'tasks', 'tasks.json', + ) + await expect.poll(() => persistedTasks(taskFile)).toEqual([ + { id: '1', status: 'completed' }, { id: '2', status: 'in_progress' }, + ]) + await page.evaluate( + (sessionId) => window.loopalDesktop.restartSession(sessionId), initial.sessionId, + ) + await expect.poll(async () => (await runtimeTarget(page)).generation, { + timeout: 30_000, + }).toBe(initial.generation + 1) + await ready(page) + expect(await persistedTasks(taskFile)).toEqual([ + { id: '1', status: 'completed' }, { id: '2', status: 'in_progress' }, + ]) + await expect.poll(() => activeTasks(page), { timeout: 30_000 }).toEqual([ + { id: '2', status: 'in_progress', blockedBy: ['1'] }, + ]) + await assertActiveTasks(page) + + await send(page, 'Complete the persisted dependent task') + await expect(conversation).toContainText( + 'Persisted task lifecycle completed after restart.', { timeout: 30_000 }, + ) + await ready(page) + await expect(page.getByRole('tab', { name: 'Tasks', exact: true })).toHaveCount(0) + const detail = await activeDetail(page) + expect(detail.view?.tasks).toEqual([]) + const finalList = conversation.getByTestId('tool-invocation').last() + await expect(finalList.locator('summary strong')).toContainText('TaskList') + const finalOutput = finalList.locator('.tool-output') + await expect(finalOutput).toContainText('"status": "completed"') + const output = JSON.parse((await finalOutput.textContent()) ?? '') as Array<{ + id: string + status: string + }> + expect(output.map(({ id, status }) => ({ id, status }))).toEqual([ + { id: '1', status: 'completed' }, { id: '2', status: 'completed' }, + ]) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(14) + expect(requests[5]!.toolResultIds).toContain('task-get-dependent') + expect(requests[8]!.toolResultIds).toContain('task-dependent-start') + expect(requests[10]!.toolResultIds).toContain('task-list-after-restart') + expect(requests[11]!.toolResultIds).toContain('task-get-after-restart') + expect(requests[13]!.toolResultIds).toContain('task-list-final') + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'provider-task-lifecycle', served: 14, remaining: 0, + unmatchedRequests: 0, inFlight: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function assertActiveTasks(page: Page): Promise { + const tab = page.getByRole('tab', { name: 'Tasks', exact: true }) + const pane = page.getByTestId('tasks-pane') + await expect(tab).toBeVisible() + await expect(pane).toBeHidden() + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') + await expect(pane).toBeVisible() + await expect(pane.getByRole('button')).toHaveCount(0) + await expect(pane.getByRole('textbox')).toHaveCount(0) + const foundation = taskRow(page, pane, 'Foundation task') + const dependent = taskRow(page, pane, 'Dependent task') + await expect(foundation).toHaveCount(0) + await expect(dependent).toHaveClass(/task-in_progress/u) + await dependent.locator(':scope > summary').click() + await expect(dependent).toContainText('Blocked by 1') + await expect(dependent).toContainText('Running dependent lifecycle') + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') + await expect(pane).toBeHidden() +} + +function taskRow(page: Page, pane: Locator, subject: string): Locator { + return pane.locator('.task-row').filter({ has: page.getByText(subject, { exact: true }) }) +} + +async function activeTasks(page: Page) { + return (await activeDetail(page)).view?.tasks.map((task) => ({ + id: task.id, status: task.status, blockedBy: task.blockedBy, + })) +} + +async function persistedTasks(path: string) { + const tasks = JSON.parse(await readFile(path, 'utf8')) as Array<{ + id: string + status: string + }> + return tasks.map(({ id, status }) => ({ id, status })) +} diff --git a/apps/desktop/e2e/real/provider/host-provider-terminal-semantics.spec.ts b/apps/desktop/e2e/real/provider/host-provider-terminal-semantics.spec.ts new file mode 100644 index 00000000..8aa09027 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-terminal-semantics.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, send } from '../../support/runtime/llm-e2e-helpers' +import { type E2eProvider } from '../../support/providers/provider-e2e-fixture' + +const terminals: readonly [E2eProvider, string][] = [ + ['anthropic', 'anthropic'], ['openai', 'openai_responses'], + ['openai_compat', 'openai_compat'], ['google', 'google'], +] + +for (const [provider, protocol] of terminals) { + test(`${provider} maps its max-token terminal into one continuation`, async () => { + const desktop = await launchDesktop('real', 'provider-terminal-max', {}, provider) + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Exercise provider max token terminal') + await expect(conversation).toContainText( + 'Provider-specific max token continuation completed.', { timeout: 20_000 }, + ) + await expect(conversation).toContainText( + 'Output truncated (max_tokens). Auto-continuing (1/3)', + ) + await expect(conversation.locator('[data-message-role="user"]')).toHaveCount(1) + await ready(page) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(2) + expect(requests.map((request) => request.protocol)).toEqual([protocol, protocol]) + expect(requests[1]!.lastUserText).toBe(provider === 'anthropic' + ? '[Continue from where you left off]' : 'Exercise provider max token terminal') + expect((await activeDetail(page)).agents.find((agent) => agent.id === 'main') + ?.telemetry?.turnCount).toBe(1) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } + }) +} + +test('OpenAI response.failed is fatal, redacted, and recoverable next turn', async () => { + const desktop = await launchDesktop('real', 'provider-openai-failed', {}, 'openai') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + await send(page, 'Trigger OpenAI failed') + await expect(conversation).toContainText('openai API request failed', { timeout: 20_000 }) + await expect(conversation).not.toContainText('sensitive upstream detail') + await expect(conversation).not.toContainText('Auto-continuing') + await send(page, 'Recover from OpenAI failed') + await expect(conversation).toContainText( + 'OpenAI Session recovered after a structured terminal failure.', { timeout: 20_000 }, + ) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 2, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('Google SAFETY terminates normally without a truncation warning', async () => { + const desktop = await launchDesktop('real', 'provider-google-safety', {}, 'google') + try { + const conversation = desktop.page.getByTestId('conversation') + await ready(desktop.page) + await send(desktop.page, 'Exercise Google safety terminal') + await expect(conversation).toContainText('Google safety terminal completed.', { + timeout: 20_000, + }) + await expect(conversation).not.toContainText('network interruption') + await expect(conversation).not.toContainText('Auto-continuing') + await ready(desktop.page) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 1, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/real/provider/host-provider-tools.spec.ts b/apps/desktop/e2e/real/provider/host-provider-tools.spec.ts new file mode 100644 index 00000000..ac7752c6 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-provider-tools.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Locator } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { activeDetail, ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +test('renders server, failed, parallel, and progressive tools from the real model loop', async () => { + const desktop = await launchDesktop('real', 'provider-tools') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + + await send(page, 'Exercise server tools') + await expect(conversation).toContainText('Server-side search blocks rendered.', { + timeout: 20_000, + }) + const serverTool = tool(conversation, 'web_search') + await expect(serverTool.getByLabel('Completed')).toBeVisible() + await serverTool.locator(':scope > summary').click() + await expect(serverTool).toContainText('SERVER RESULT BODY') + const serverDetail = await activeDetail(page) + const serverAgent = serverDetail.agents.find((agent) => agent.id === 'main') + expect(serverAgent?.telemetry).toMatchObject({ cacheCreationTokens: 5, cacheReadTokens: 7 }) + await ready(page) + + await send(page, 'Exercise failed tools') + await expect(conversation).toContainText( + 'Both tool failures returned to the model.', { timeout: 20_000 }, + ) + const missing = tool(conversation, 'missing-file.txt') + const unknown = tool(conversation, 'NoSuchDesktopTool') + await expect(missing.getByLabel('Failed')).toBeVisible() + await expect(unknown.getByLabel('Failed')).toBeVisible() + await expect(missing).toContainText(/not found|No such file/i) + await expect(unknown).toContainText(/unknown|not found|NoSuchDesktopTool/i) + await ready(page) + + await send(page, 'Exercise parallel tools') + await expect(conversation).toContainText('Parallel tool batch completed.', { timeout: 20_000 }) + await expect(tool(conversation, 'README.md').getByLabel('Completed')).toBeVisible() + await expect(tool(conversation, 'src/main.rs').getByLabel('Completed')).toBeVisible() + await ready(page) + + await send(page, 'Exercise tool progress') + const bash = tool(conversation, 'MODEL TOOL START') + await expect(bash.getByLabel('Running')).toBeVisible({ timeout: 15_000 }) + await expect(bash).toContainText('MODEL TOOL START', { timeout: 15_000 }) + await expect(bash.getByLabel('Completed')).toBeVisible({ timeout: 20_000 }) + await expect(bash).toContainText('MODEL TOOL END') + await expect(conversation).toContainText('Long-running tool progress completed.', { + timeout: 20_000, + }) + await ready(page) + + await send(page, 'Exercise image tool result') + const imageTool = tool(conversation, 'pixel.png') + await expect(imageTool.getByLabel('Completed')).toBeVisible({ timeout: 20_000 }) + await expect(imageTool).toContainText(/Loaded image\/png \(1×1, \d+ bytes\)/) + await expect(conversation).toContainText( + 'Image tool output reached the model request.', { timeout: 20_000 }, + ) + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(9) + expect(requests[0]!.serverBlockCount).toBe(0) + expect(requests[1]!.serverBlockCount).toBe(0) + expect(requests[2]!.toolResultIds).toEqual(expect.arrayContaining([ + 'missing-read', 'unknown-tool', + ])) + expect(requests[2]!.toolResultErrorIds).toEqual(expect.arrayContaining([ + 'missing-read', 'unknown-tool', + ])) + expect(requests[4]!.toolResultIds).toEqual(expect.arrayContaining([ + 'parallel-readme', 'parallel-source', + ])) + expect(requests[6]!.toolResultIds).toContain('progress-bash') + expect(requests[8]!.toolResultIds).toContain('read-image-result') + expect(requests[8]!.imageBlockCount).toBe(1) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 9, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +function tool(conversation: Locator, text: string): Locator { + return conversation.getByTestId('tool-invocation').filter({ hasText: text }).last() +} diff --git a/apps/desktop/e2e/real/provider/host-runtime-resources.spec.ts b/apps/desktop/e2e/real/provider/host-runtime-resources.spec.ts new file mode 100644 index 00000000..64a15c96 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-runtime-resources.spec.ts @@ -0,0 +1,112 @@ +import { expect, test, type Page } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { closeDesktop, launchDesktop, waitForHostStatus } from '../../support/electron/electron-fixture' +import { activeDetail, ready } from '../../support/runtime/llm-e2e-helpers' + +test('projects production Goal, Task, Background, Cron, and Artifact lifecycles', async () => { + const desktop = await launchDesktop('real', 'runtime-resources') + try { + const page = desktop.page + await waitForHostStatus(page, 'ready') + await setPermissionBypass(page) + await page.getByLabel('Message Loopal').fill('Create deterministic runtime resources') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText( + 'All deterministic runtime resources are ready.', { timeout: 30_000 }, + ) + + await activate(page, 'Tasks') + const tasks = page.getByTestId('tasks-pane') + await expect(tasks).toContainText('Verify Desktop runtime resource panels') + await expect(tasks).toContainText('Inspect runtime resource panels') + await expect(tasks).toContainText('Complete') + + await activate(page, 'Background') + const background = page.getByTestId('background-tasks-pane') + await expect(background).toContainText('Desktop fixture background process') + await background.getByRole('button', { name: /Kill background task/ }).click() + await expect(page.getByRole('tab', { name: 'Background', exact: true })).toHaveCount(0) + + await activate(page, 'Scheduled') + const scheduled = page.getByTestId('scheduled-work-pane') + await expect(scheduled).toContainText('Run the deterministic Desktop fixture check') + await scheduled.getByRole('button', { name: /Delete scheduled work/ }).click() + await expect(page.getByRole('tab', { name: 'Scheduled', exact: true })).toHaveCount(0) + + await activate(page, 'Artifacts') + await expect(page.getByTestId('artifacts-pane')).toContainText('runtime-fixture.txt') + await expect.poll(() => readFile(join(desktop.project, 'runtime-fixture.txt'), 'utf8')) + .toBe('runtime fixture artifact\n') + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(7) + expect(requests[0]!.toolNames).toEqual(expect.arrayContaining([ + 'create_goal', 'update_goal', 'TaskCreate', 'Bash', 'CronCreate', 'Write', + ])) + expect(requests.every((request) => request.matched)).toBe(true) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + name: 'runtime-resources', remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +test('auto-continues an active Goal without adding a human message', async () => { + const desktop = await launchDesktop('real', 'provider-goal-loop') + try { + const page = desktop.page + const conversation = page.getByTestId('conversation') + await waitForHostStatus(page, 'ready') + await setPermissionBypass(page) + await page.getByLabel('Message Loopal').fill('Start a deterministic persistent goal') + await page.getByRole('button', { name: 'Send' }).click() + await expect(conversation).toContainText( + 'Goal continuation completed without another human turn.', { timeout: 30_000 }, + ) + await expect(conversation.locator('[data-message-role="user"]')).toHaveCount(1) + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(4) + expect(requests[2]!.lastUserText).toContain('Continue working toward the active thread goal') + expect(requests[2]!.lastUserText).toContain('Exercise the autonomous Desktop goal loop') + const detail = await activeDetail(page) + expect(detail.view?.goal?.status).toBe('complete') + expect(detail.agents.find((agent) => agent.id === 'main')?.telemetry?.turnCount).toBe(2) + await expect(page.getByRole('tab', { name: 'Tasks', exact: true })).toHaveCount(0) + await ready(page) + await page.waitForTimeout(300) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 4, requestCount: 4, remaining: 0, unmatchedRequests: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) + +async function setPermissionBypass(page: Page): Promise { + const target = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + }) + await page.evaluate(async (value) => { + await window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }) + }, target) + await expect.poll(async () => page.evaluate(async (value) => { + const detail = await window.loopalDesktop.openSession(value.sessionId) + return detail.agents.find((agent) => agent.id === value.agentId)?.permissionMode + }, target)).toBe('bypass') +} + +async function activate(page: Page, name: string): Promise { + const tab = page.getByRole('tab', { name, exact: true }) + await tab.click() + await expect(tab).toHaveAttribute('aria-selected', 'true') +} diff --git a/apps/desktop/e2e/real/provider/host-topology-model-lifecycle.spec.ts b/apps/desktop/e2e/real/provider/host-topology-model-lifecycle.spec.ts new file mode 100644 index 00000000..af303502 --- /dev/null +++ b/apps/desktop/e2e/real/provider/host-topology-model-lifecycle.spec.ts @@ -0,0 +1,71 @@ +import { expect, test, type Page } from '@playwright/test' +import { closeDesktop, launchDesktop } from '../../support/electron/electron-fixture' +import { ready, runtimeTarget, send } from '../../support/runtime/llm-e2e-helpers' + +async function ensureAgentsPanelOpen(page: Page) { + const agents = page.getByTestId('agents-pane') + if (!await agents.isVisible()) { + await page.getByRole('tab', { name: 'Agents', exact: true }).click() + } + await expect(agents).toBeVisible() + return agents +} + +test('retains parallel, failed, and nested model-driven Agent topology', async () => { + const desktop = await launchDesktop('real', 'topology-model-lifecycle') + try { + const page = desktop.page + await ready(page) + const target = await runtimeTarget(page) + await page.evaluate(async (value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) + const conversation = page.getByTestId('conversation') + + await send(page, 'Spawn parallel model children') + await expect(conversation).toContainText( + 'Parallel child results returned to root.', { timeout: 50_000 }, + ) + await ready(page) + await send(page, 'Spawn a failing model child') + await expect(conversation).toContainText( + 'Failed child result returned to root.', { timeout: 40_000 }, + ) + await ready(page) + await send(page, 'Spawn a nested model child') + await expect(conversation).toContainText( + 'Nested child result returned to root.', { timeout: 60_000 }, + ) + await ready(page) + + const agents = await ensureAgentsPanelOpen(page) + await agents.getByRole('button', { name: 'All', exact: true }).click() + for (const id of [ + 'parallel-alpha', 'parallel-beta', 'failed-child', 'parent-child', 'nested-grandchild', + ]) { + await expect(agents.locator(`[data-agent-id="${id}"]`)).toBeVisible() + } + await expect(agents.locator('[data-agent-id="parallel-alpha"]')).toContainText('completed') + await expect(agents.locator('[data-agent-id="parallel-beta"]')).toContainText('completed') + await expect(agents.locator('[data-agent-id="failed-child"]')).toContainText('failed') + await expect(agents.locator('[data-agent-id="nested-grandchild"]')) + .toHaveAttribute('data-parent-id', 'parent-child') + + await agents.locator('[data-agent-id="nested-grandchild"]').click() + await expect(conversation).toContainText('GRANDCHILD RESULT') + await expect(page.getByLabel('Message nested-grandchild')).toBeDisabled() + await ensureAgentsPanelOpen(page) + await agents.getByRole('button', { name: 'All', exact: true }).click() + await agents.locator('[data-agent-id="failed-child"]').click() + await expect(conversation).toContainText('scripted child failure') + + const requests = await desktop.llm!.requests() + expect(requests).toHaveLength(12) + expect(requests.every((request) => request.matched)).toBe(true) + await expect.poll(() => desktop.llm!.state()).toMatchObject({ + served: 12, remaining: 0, verified: true, + }) + } finally { + await closeDesktop(desktop) + } +}) diff --git a/apps/desktop/e2e/support/electron/electron-fixture.ts b/apps/desktop/e2e/support/electron/electron-fixture.ts new file mode 100644 index 00000000..d3419cec --- /dev/null +++ b/apps/desktop/e2e/support/electron/electron-fixture.ts @@ -0,0 +1,200 @@ +import { _electron, type ElectronApplication, type Page } from '@playwright/test' +import { execFile as execFileCallback } from 'node:child_process' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { + isolatedTestEnvironment, startMockLlm, type MockLlmFixture, +} from '../providers/mock-llm-fixture' +import { loadLlmScenario, seedWorkspace } from '../fixtures/fixture-loader' +import { + configureProvider, type E2eProvider, persistedDesktopEnvironment, providerEnvironment, +} from '../providers/provider-e2e-fixture' +export { waitForHostStatus } from '../runtime/host-status' +const execFile = promisify(execFileCallback) + +export interface DesktopFixture { + readonly app: ElectronApplication + readonly page: Page + readonly root: string + readonly home: string + readonly project: string + readonly backend: 'fake' | 'real' + readonly provider: E2eProvider + readonly llm?: MockLlmFixture + cleanup(): Promise +} + +export async function launchDesktop( + backend: 'fake' | 'real', scenario?: string, + environment: Readonly> = {}, + provider: E2eProvider = 'anthropic', + workspaceFixture = 'basic', +): Promise { + const root = await mkdtemp(join(tmpdir(), 'loopal-desktop-e2e-')) + const home = join(root, 'home') + const userData = join(root, 'user-data') + const project = join(root, 'project') + let app: ElectronApplication | undefined + let llm: MockLlmFixture | undefined + try { + await Promise.all([mkdir(home), mkdir(userData)]) + const env = isolatedTestEnvironment({ + ...environment, + HOME: home, + LOOPAL_DESKTOP_CWD: project, + LOOPAL_DESKTOP_E2E_HIDDEN: '1', + }) + if (backend === 'fake') { + await mkdir(project) + env.LOOPAL_DESKTOP_BACKEND = 'fake' + } else { + await seedWorkspace(project, root, workspaceFixture) + await seedProject(project, root) + delete env.LOOPAL_DESKTOP_BACKEND + delete env.LOOPAL_DESKTOP_BINARY + delete env.ELECTRON_RENDERER_URL + const calls = await loadLlmScenario(scenario ?? 'default-ok', { + PROJECT: project, HOME: home, ROOT: root, + }) + llm = await startMockLlm(root, calls) + await configureProvider(home, llm, provider, environment.LOOPAL_DESKTOP_E2E_USER_SETTINGS) + Object.assign(env, providerEnvironment(llm, provider)) + env.LOOPAL_DESKTOP_BINARY_RUNFILE = loopalBinaryRunfile() + env.LOOPAL_MCP_STARTUP_WAIT_SECS = '1' + } + app = await _electron.launch({ + args: [runfile('apps/desktop/out/main/index.cjs'), `--user-data-dir=${userData}`], + env, + timeout: 30_000, + }) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.getByTestId('workbench').waitFor({ timeout: 10_000 }) + await stabilizeSystemLanguage(page) + return { + app, + page, + root, + home, + project, + backend, + provider, + ...(llm ? { llm } : {}), + cleanup: async () => { + await llm?.stop() + await rm(root, { recursive: true, force: true }) + }, + } + } catch (error) { + if (app) await stopApp(app) + await llm?.stop() + await rm(root, { recursive: true, force: true }) + throw error + } +} + +export async function relaunchDesktop(fixture: DesktopFixture): Promise { + await shutdownDesktop(fixture) + const env = persistedDesktopEnvironment( + fixture.backend, fixture.home, fixture.project, loopalBinaryRunfile(), + fixture.llm, fixture.provider, + ) + let app: ElectronApplication | undefined + try { + app = await _electron.launch({ + args: [runfile('apps/desktop/out/main/index.cjs'), `--user-data-dir=${join(fixture.root, 'user-data')}`], + env, + timeout: 30_000, + }) + const page = await app.firstWindow({ timeout: 30_000 }) + await page.getByTestId('workbench').waitFor({ timeout: 10_000 }) + await stabilizeSystemLanguage(page) + return { ...fixture, app, page } + } catch (error) { + if (app) await stopApp(app) + throw error + } +} + +async function seedProject(project: string, root: string): Promise { + if (process.platform !== 'win32') { + const { symlink } = await import('node:fs/promises') + await symlink(join(root, 'outside.txt'), join(project, 'escape-link')) + } + const options = { cwd: project, env: isolatedTestEnvironment({ + HOME: join(root, 'home'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_AUTHOR_NAME: 'Loopal Desktop E2E', GIT_COMMITTER_NAME: 'Loopal Desktop E2E', + GIT_AUTHOR_EMAIL: 'desktop-e2e@loopal.local', GIT_COMMITTER_EMAIL: 'desktop-e2e@loopal.local', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }) } + await execFile('git', ['init', '-q', '--initial-branch=main'], options) + await execFile('git', ['add', 'README.md', 'src/main.rs'], options) + await execFile('git', ['commit', '-qm', 'fixture'], options) +} + +export async function closeDesktop(fixture: DesktopFixture): Promise { + try { + await shutdownDesktop(fixture) + } finally { + await fixture.cleanup() + } +} + +export async function shutdownDesktop(fixture: DesktopFixture): Promise { + await stopApp(fixture.app) +} + +async function stopApp(app: ElectronApplication): Promise { + const child = app.process() + let timer: NodeJS.Timeout | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, 5_000) + }) + try { + await Promise.race([app.close().catch(() => undefined), deadline]) + } finally { + clearTimeout(timer) + if (child.exitCode !== null) return + if (process.platform === 'win32' || !child.pid) { + child.kill('SIGKILL') + return + } + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + child.kill('SIGKILL') + } + } +} + +function runfile(relative: string): string { + const testSrcDir = process.env.TEST_SRCDIR + const workspace = process.env.TEST_WORKSPACE + if (testSrcDir && workspace) return join(testSrcDir, workspace, relative) + const here = dirname(fileURLToPath(import.meta.url)) + return resolve(here, '../../../../..', relative) +} + +function loopalBinaryRunfile(): string { + const executable = process.platform === 'win32' ? 'loopal.exe' : 'loopal' + return `${process.env.TEST_WORKSPACE ?? '_main'}/${executable}` +} + +export function loopalBinaryPath(): string { + const executable = process.platform === 'win32' ? 'loopal.exe' : 'loopal' + return runfile(executable) +} +async function stabilizeSystemLanguage(page: Page): Promise { + await page.evaluate(() => { + Object.defineProperties(navigator, { + language: { configurable: true, get: () => 'en-US' }, + languages: { configurable: true, get: () => ['en-US'] }, + }) + window.dispatchEvent(new Event('languagechange')) + }) + const preferences = await page.evaluate(() => window.loopalDesktop.getDesktopPreferences()) + if (preferences.locale === 'system') + await page.waitForFunction(() => document.documentElement.lang === 'en') +} diff --git a/apps/desktop/e2e/support/federation/metahub-data-plane-fixture.ts b/apps/desktop/e2e/support/federation/metahub-data-plane-fixture.ts new file mode 100644 index 00000000..fc5b2b5a --- /dev/null +++ b/apps/desktop/e2e/support/federation/metahub-data-plane-fixture.ts @@ -0,0 +1,199 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { createConnection, type Socket } from 'node:net' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { loopalBinaryPath, type DesktopFixture } from '../electron/electron-fixture' +import { isolatedTestEnvironment } from '../providers/mock-llm-fixture' + +export interface MetaHubProcess { + readonly child: ChildProcess + readonly address: string + readonly token: string +} + +export interface RemoteHubProcess { + readonly child: ChildProcess + readonly probe: HubProbe +} + +export async function startMetaHub(): Promise { + const child = spawn(loopalBinaryPath(), [ + '--meta-hub', '127.0.0.1:0', '--meta-hub-parent-pid', String(process.pid), + ], { env: isolatedTestEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] }) + child.stderr?.resume() + try { + const value = await waitForPrefixedJson(child, 'LOOPAL_METAHUB ', 10_000) + return { child, address: String(value.address), token: String(value.token) } + } catch (error) { + await stopProcess(child) + throw error + } +} + +export async function startRemoteHub( + desktop: DesktopFixture, meta: MetaHubProcess, name = 'hub-b', +): Promise { + const home = join(desktop.root, `${name}-home`) + await mkdir(home, { recursive: true }) + const child = spawn(loopalBinaryPath(), [ + 'desktop', 'serve', '--parent-pid', String(process.pid), + '--join-hub', meta.address, '--hub-name', name, + ], { + cwd: desktop.project, + env: isolatedTestEnvironment({ + HOME: home, + LOOPAL_META_HUB_TOKEN: meta.token, + ANTHROPIC_API_KEY: desktop.llm!.apiKey, + ANTHROPIC_BASE_URL: desktop.llm!.baseUrl, + LOOPAL_OTEL_ENABLED: 'false', + LOOPAL_MCP_STARTUP_WAIT_SECS: '1', + }), + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stderr?.resume() + try { + const alive = await waitForDesktopReady(child) + const probe = await HubProbe.connect(String(alive.addr), String(alive.token)) + return { child, probe } + } catch (error) { + await stopProcess(child) + throw error + } +} + +export class HubProbe { + private readonly pending = new Map) => void>() + private readonly observed: unknown[] = [] + private buffer = '' + private nextId = 1 + + private constructor(private readonly socket: Socket) { + socket.setEncoding('utf8') + socket.on('data', (chunk) => this.accept(String(chunk))) + } + + static async connect(address: string, token: string): Promise { + const separator = address.lastIndexOf(':') + const socket = createConnection({ + host: address.slice(0, separator), port: Number(address.slice(separator + 1)), + }) + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + const probe = new HubProbe(socket) + await probe.call('hub/register', { + name: `metahub-e2e-${randomUUID()}`, token, role: 'ui_client', + }) + return probe + } + + notifications(): readonly unknown[] { + return this.observed + } + + async startModelTurn(text: string): Promise { + await this.call('hub/control', { target: 'main', command: { PermissionModeSwitch: 'bypass' } }) + await this.call('hub/route', { + id: randomUUID(), + source: 'Human', + target: { hub: [], agent: 'main' }, + content: { text, images: [] }, + timestamp: new Date().toISOString(), + }) + } + + close(): void { + this.socket.end() + this.socket.destroy() + } + + private call(method: string, params: unknown): Promise { + const id = this.nextId++ + return new Promise((resolve, reject) => { + this.pending.set(id, (message) => message.error + ? reject(new Error(`${method}: ${JSON.stringify(message.error)}`)) + : resolve(message.result)) + this.socket.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + }) + } + + private accept(chunk: string): void { + this.buffer += chunk + let boundary = this.buffer.indexOf('\n') + while (boundary >= 0) { + const line = this.buffer.slice(0, boundary).replace(/\r$/, '') + this.buffer = this.buffer.slice(boundary + 1) + if (line) this.acceptMessage(JSON.parse(line) as Record) + boundary = this.buffer.indexOf('\n') + } + } + + private acceptMessage(message: Record): void { + if (typeof message.method === 'string' && message.id === undefined) { + this.observed.push(message) + return + } + if (typeof message.id === 'number') { + const resolve = this.pending.get(message.id) + this.pending.delete(message.id) + resolve?.(message) + } + } +} + +async function waitForDesktopReady(child: ChildProcess): Promise> { + return new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity }) + let alive: Record | undefined + const timer = setTimeout(() => finish(new Error('Timed out waiting for remote Hub')), 20_000) + const finish = (error?: Error): void => { + clearTimeout(timer); lines.close(); child.off('exit', exited) + error ? reject(error) : resolve(alive!) + } + const exited = (): void => finish(new Error('Remote Hub exited before ready')) + child.once('exit', exited) + lines.on('line', (line) => { + if (!line.startsWith('LOOPAL_DESKTOP ')) return + try { + const value = JSON.parse(line.slice(15)) as Record + if (value.phase === 'alive') alive = value + if (value.phase === 'ready' && alive) finish() + } catch { finish(new Error('Invalid remote Hub handshake')) } + }) + }) +} + +async function waitForPrefixedJson( + child: ChildProcess, prefix: string, timeout: number, + accept: (value: Record) => boolean = () => true, +): Promise> { + return new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity }) + const timer = setTimeout(() => finish(new Error(`Timed out waiting for ${prefix.trim()}`)), timeout) + const finish = (error?: Error, value?: Record): void => { + clearTimeout(timer); lines.close(); child.off('exit', exited) + error ? reject(error) : resolve(value!) + } + const exited = (): void => finish(new Error('Loopal fixture exited before handshake')) + child.once('exit', exited) + lines.on('line', (line) => { + if (!line.startsWith(prefix)) return + try { + const value = JSON.parse(line.slice(prefix.length)) as Record + if (accept(value)) finish(undefined, value) + } catch { finish(new Error('Invalid Loopal fixture handshake')) } + }) + }) +} + +export async function stopProcess(child: ChildProcess): Promise { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + await new Promise((resolve) => { + const timer = setTimeout(() => { child.kill('SIGKILL'); resolve() }, 3_000) + child.once('exit', () => { clearTimeout(timer); resolve() }) + }) +} diff --git a/apps/desktop/e2e/support/fixtures/fixture-loader.ts b/apps/desktop/e2e/support/fixtures/fixture-loader.ts new file mode 100644 index 00000000..aa0ee914 --- /dev/null +++ b/apps/desktop/e2e/support/fixtures/fixture-loader.ts @@ -0,0 +1,73 @@ +import { chmod, cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export async function loadLlmScenario( + name: string, + variables: Readonly>, +): Promise { + if (!/^[a-z0-9-]+$/.test(name)) throw new Error(`Invalid fixture name: ${name}`) + const content = await readFile(fixturePath(`llm/${name}.json`), 'utf8') + return interpolate(JSON.parse(content) as unknown, variables) +} + +export async function seedWorkspace( + project: string, root: string, fixture = 'basic', +): Promise { + if (!/^[a-z0-9-]+$/.test(fixture)) throw new Error(`Invalid workspace fixture: ${fixture}`) + await cp(fixturePath(`workspaces/${fixture}`), project, { recursive: true, dereference: true }) + await cp(fixturePath('outside.txt'), join(root, 'outside.txt'), { dereference: true }) + const encodedImage = join(project, 'pixel.png.base64') + const image = Buffer.from((await readFile(encodedImage, 'utf8')).trim(), 'base64') + await writeFile(join(project, 'pixel.png'), image) + await rm(encodedImage) + await makeWritable(project) + await chmod(join(root, 'outside.txt'), 0o644) +} + +export async function seedPlugin(home: string, fixture: string): Promise { + if (!/^[a-z0-9-]+$/.test(fixture)) throw new Error(`Invalid plugin fixture: ${fixture}`) + const target = join(home, '.loopal', 'plugins', fixture) + await mkdir(dirname(target), { recursive: true }) + await cp(fixturePath(`plugins/${fixture}`), target, { recursive: true, dereference: true }) + await makeWritable(target) + return target +} + +async function makeWritable(path: string): Promise { + await chmod(path, 0o755) + const entries = await readdir(path, { withFileTypes: true }) + await Promise.all(entries.map(async (entry) => { + const child = join(path, entry.name) + if (entry.isDirectory()) await makeWritable(child) + else if (entry.isFile()) await chmod(child, 0o644) + })) +} + +export function fixturePath(relative: string): string { + const testSrcDir = process.env.TEST_SRCDIR + const workspace = process.env.TEST_WORKSPACE + if (testSrcDir && workspace) { + return join(testSrcDir, workspace, 'apps/desktop/e2e/fixtures', relative) + } + return resolve(dirname(fileURLToPath(import.meta.url)), '../../fixtures', relative) +} + +function interpolate(value: unknown, variables: Readonly>): unknown { + if (typeof value === 'string') { + const resolved = value.replace(/\$\{([A-Z_]+)\}/g, (_, name: string) => { + const replacement = variables[name] + if (replacement === undefined) throw new Error(`Unknown fixture variable: ${name}`) + return replacement + }) + if (resolved.includes('${')) throw new Error(`Unresolved fixture variable in: ${resolved}`) + return resolved + } + if (Array.isArray(value)) return value.map((item) => interpolate(item, variables)) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => ( + [key, interpolate(item, variables)] + ))) + } + return value +} diff --git a/apps/desktop/e2e/support/fixtures/session-directory-fixture.ts b/apps/desktop/e2e/support/fixtures/session-directory-fixture.ts new file mode 100644 index 00000000..839bc1f3 --- /dev/null +++ b/apps/desktop/e2e/support/fixtures/session-directory-fixture.ts @@ -0,0 +1,78 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { type DesktopFixture } from '../electron/electron-fixture' +import { seedWorkspace } from './fixture-loader' + +const execFile = promisify(execFileCallback) + +export interface SessionDirectoryFixture { + readonly path: string + readonly git: boolean + readonly branch?: string +} + +export async function createSessionDirectory( + desktop: DesktopFixture, + name: string, + git: boolean, +): Promise { + const parent = join(desktop.root, 'session-directories') + const path = join(parent, name) + await mkdir(parent, { recursive: true }) + await seedWorkspace(path, desktop.root) + await writeFile(join(path, 'session-fixture.txt'), `${name}\n`) + if (!git) return { path, git } + await runGit(desktop, path, ['init', '-q', '--initial-branch=main']) + await runGit(desktop, path, ['add', '.']) + await runGit(desktop, path, ['commit', '-qm', 'session fixture']) + return { path, git, branch: 'main' } +} + +export async function queueSessionDirectories( + desktop: DesktopFixture, + paths: readonly (string | null)[], +): Promise { + await desktop.app.evaluate(({ dialog }, queue) => { + const pending = [...queue] + dialog.showOpenDialog = (async () => { + const selected = pending.shift() + return selected + ? { canceled: false, filePaths: [selected] } + : { canceled: true, filePaths: [] } + }) as typeof dialog.showOpenDialog + }, paths) +} + +export async function gitOutput( + desktop: DesktopFixture, + cwd: string, + args: readonly string[], +): Promise { + const result = await execFile('git', args, { cwd, env: gitEnvironment(desktop) }) + return result.stdout.trim() +} + +async function runGit( + desktop: DesktopFixture, + cwd: string, + args: readonly string[], +): Promise { + await execFile('git', args, { cwd, env: gitEnvironment(desktop) }) +} + +function gitEnvironment(desktop: DesktopFixture): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: desktop.home, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_AUTHOR_NAME: 'Loopal Desktop E2E', + GIT_COMMITTER_NAME: 'Loopal Desktop E2E', + GIT_AUTHOR_EMAIL: 'desktop-e2e@loopal.local', + GIT_COMMITTER_EMAIL: 'desktop-e2e@loopal.local', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + } +} diff --git a/apps/desktop/e2e/support/fixtures/session-directory-ui.ts b/apps/desktop/e2e/support/fixtures/session-directory-ui.ts new file mode 100644 index 00000000..a30fe306 --- /dev/null +++ b/apps/desktop/e2e/support/fixtures/session-directory-ui.ts @@ -0,0 +1,88 @@ +import { expect, type Page } from '@playwright/test' +import { realpath } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { type DesktopFixture } from '../electron/electron-fixture' +import { activeDetail, ready, runtimeTarget } from '../runtime/llm-e2e-helpers' + +export async function createFromDirectory( + page: Page, + mode: 'directory' | 'worktree', + git = false, + name?: string, +): Promise { + await openWithSelectedDirectory(page) + const dialog = page.getByTestId('new-session-dialog') + await expect(dialog.getByTestId('launch-direct')).toBeChecked() + if (git) await expect(dialog.getByTestId('launch-worktree')).toBeVisible() + else await expect(dialog.getByTestId('launch-worktree')).toHaveCount(0) + if (mode === 'worktree') { + await dialog.getByTestId('launch-worktree').check() + await expect(dialog).toContainText(/uncommitted changes are not copied/i) + await dialog.getByTestId('worktree-name').fill(name!) + } + await dialog.getByTestId('create-session-confirm').click() + await expect(dialog).toHaveCount(0, { timeout: 30_000 }) + await ready(page) +} + +export async function openWithSelectedDirectory(page: Page): Promise { + await page.locator('.new-session').click() + const dialog = page.getByTestId('new-session-dialog') + await expect(dialog).toBeVisible() + await dialog.getByTestId('session-directory').click() + await expect(dialog.getByTestId('create-session-confirm')).toBeEnabled() +} + +export async function expectActiveDirectory( + page: Page, + expected: string, + kind: 'folder' | 'git_worktree', +): Promise { + const state = await page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const session = bootstrap.sessions.find((item) => item.id === bootstrap.activeSessionId)! + return { + session, + workspace: bootstrap.workspaces.find((item) => item.id === session.workspaceId)!, + } + }) + const actual = state.workspace.rootUri.startsWith('file:') + ? fileURLToPath(state.workspace.rootUri) : state.workspace.rootUri + expect(await realpath(actual)).toBe(await realpath(expected)) + expect(state.workspace.kind).toBe(kind) + expect(state.session.workspaceId).toBe(state.workspace.id) +} + +export async function enableTools(page: Page): Promise { + const target = await runtimeTarget(page) + await page.evaluate((value) => window.loopalDesktop.controlAgent({ + target: value, command: { type: 'permission', mode: 'bypass' }, + }), target) +} + +export async function stopActive(page: Page): Promise { + const detail = await activeDetail(page) + await page.evaluate((id) => window.loopalDesktop.stopSession(id), detail.session.id) +} + +export async function sessionCount(page: Page): Promise { + return page.evaluate(async () => (await window.loopalDesktop.bootstrap()).sessions.length) +} + +export async function stopAllLiveSessions(page: Page): Promise { + const ids = await page.evaluate(async () => (await window.loopalDesktop.bootstrap()).runtimes + .filter((runtime) => !['stopped', 'crashed'].includes(runtime.state)) + .map((runtime) => runtime.sessionId)) + for (const id of ids) await page.evaluate((sessionId) => ( + window.loopalDesktop.stopSession(sessionId) + ), id) + await expect.poll(() => page.evaluate(async () => (await window.loopalDesktop.bootstrap()) + .runtimes.filter((runtime) => !['stopped', 'crashed'].includes(runtime.state)).length)).toBe(0) +} + +export async function expectHidden(desktop: DesktopFixture): Promise { + await expect.poll(() => desktop.app.evaluate(({ BrowserWindow }) => { + const current = BrowserWindow.getAllWindows()[0] + return { visible: current?.isVisible(), focused: current?.isFocused() } + })).toEqual({ visible: false, focused: false }) +} diff --git a/apps/desktop/e2e/support/providers/mock-llm-fixture.ts b/apps/desktop/e2e/support/providers/mock-llm-fixture.ts new file mode 100644 index 00000000..86d8a659 --- /dev/null +++ b/apps/desktop/e2e/support/providers/mock-llm-fixture.ts @@ -0,0 +1,161 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' + +const apiKey = 'loopal-desktop-e2e' +const handshakePrefix = 'LOOPAL_MOCK_LLM ' + +export interface MockLlmRequest { + readonly sequence: number + readonly protocol: string + readonly model: string + readonly messageCount: number + readonly toolCount: number + readonly toolNames: readonly string[] + readonly toolResultIds: readonly string[] + readonly toolResultErrorIds: readonly string[] + readonly assistantBlockTypes: readonly string[] + readonly serverBlockCount: number + readonly imageBlockCount: number + readonly lastUserText: string + readonly hasSystem: boolean + readonly thinkingEnabled: boolean + readonly stream: boolean + readonly maxTokens: number + readonly apiKeyPresent: boolean + readonly protocolVersionPresent: boolean + readonly matched: boolean +} + +export interface MockLlmState { + readonly name: string + readonly served: number + readonly remaining: number + readonly requestCount: number + readonly unmatchedRequests: number + readonly inFlight: number + readonly clientDisconnects: number + readonly scriptedDisconnects: number + readonly verified: boolean +} + +export interface MockLlmFixture { + readonly child: ChildProcess + readonly baseUrl: string + readonly apiKey: string + requests(): Promise + state(): Promise + stop(): Promise +} + +export async function startMockLlm(root: string, scenario: unknown): Promise { + const path = join(root, 'mock-llm-scenario.json') + await writeFile(path, JSON.stringify(scenario)) + const child = spawn(mockLlmBinary(), [ + '--scenario', path, '--api-key', apiKey, + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + child.stderr?.resume() + let ready: Record + try { + ready = await waitForReady(child) + } catch (error) { + await stop(child) + throw error + } + const baseUrl = String(ready.baseUrl) + return { + child, + baseUrl, + apiKey, + requests: () => getJson(`${baseUrl}/__mock/requests`), + state: () => getJson(`${baseUrl}/__mock/state`), + stop: () => stop(child), + } +} + +export function mockProviderEnvironment(llm: MockLlmFixture): Record { + return { + ANTHROPIC_API_KEY: llm.apiKey, + ANTHROPIC_BASE_URL: llm.baseUrl, + LOOPAL_OTEL_ENABLED: 'false', + } +} + +export function isolatedTestEnvironment( + overrides: Readonly> = {}, +): Record { + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + const exact = new Set([ + 'ELECTRON_RENDERER_URL', 'ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', + 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', + ]) + for (const name of Object.keys(env)) { + if (exact.has(name) || ['LOOPAL_', 'ANTHROPIC_', 'OPENAI_', 'GOOGLE_', 'OTEL_'] + .some((prefix) => name.startsWith(prefix))) delete env[name] + } + return { ...env, ...overrides } +} + +function mockLlmBinary(): string { + const configured = process.env.LOOPAL_MOCK_LLM_BINARY + ?? 'crates/loopal-mock-llm/loopal-mock-llm' + const testSrcDir = process.env.TEST_SRCDIR + const workspace = process.env.TEST_WORKSPACE + if (testSrcDir && workspace) return join(testSrcDir, workspace, configured) + const here = dirname(fileURLToPath(import.meta.url)) + return resolve(here, '../../../../..', 'bazel-bin', configured) +} + +function waitForReady(child: ChildProcess): Promise> { + return new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout!, crlfDelay: Infinity }) + let settled = false + const timer = setTimeout(() => done(new Error('Mock LLM did not become ready')), 10_000) + const done = (error?: Error, value?: Record): void => { + if (settled) return + settled = true + clearTimeout(timer); lines.close(); child.off('exit', exited); child.off('error', failed) + error ? reject(error) : resolve(value!) + } + const exited = (): void => done(new Error('Mock LLM exited before ready')) + const failed = (error: Error): void => done(error) + child.once('exit', exited) + child.once('error', failed) + lines.on('line', (line) => { + if (!line.startsWith(handshakePrefix)) return + try { done(undefined, JSON.parse(line.slice(handshakePrefix.length)) as Record) } + catch { done(new Error('Mock LLM emitted an invalid handshake')) } + }) + }) +} + +async function getJson(url: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`Mock LLM control request failed: ${response.status}`) + return response.json() as Promise +} + +async function stop(child: ChildProcess): Promise { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGTERM') + if (await waitForExit(child, 3_000)) return + child.kill('SIGKILL') + await waitForExit(child, 1_000) +} + +function waitForExit(child: ChildProcess, timeout: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { child.off('exit', exited); resolve(false) }, timeout) + const exited = (): void => { + clearTimeout(timer) + resolve(true) + } + child.once('exit', exited) + }) +} diff --git a/apps/desktop/e2e/support/providers/provider-e2e-fixture.ts b/apps/desktop/e2e/support/providers/provider-e2e-fixture.ts new file mode 100644 index 00000000..ce29e4d8 --- /dev/null +++ b/apps/desktop/e2e/support/providers/provider-e2e-fixture.ts @@ -0,0 +1,65 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + isolatedTestEnvironment, type MockLlmFixture, +} from './mock-llm-fixture' + +export type E2eProvider = 'anthropic' | 'openai' | 'openai_compat' | 'google' + +export function providerEnvironment( + llm: MockLlmFixture, provider: E2eProvider, +): Record { + const common = { LOOPAL_OTEL_ENABLED: 'false' } + if (provider === 'anthropic') return { + ...common, ANTHROPIC_API_KEY: llm.apiKey, ANTHROPIC_BASE_URL: llm.baseUrl, + } + if (provider === 'openai') return { + ...common, OPENAI_API_KEY: llm.apiKey, OPENAI_BASE_URL: `${llm.baseUrl}/v1`, + } + if (provider === 'google') return { + ...common, GOOGLE_API_KEY: llm.apiKey, GOOGLE_BASE_URL: llm.baseUrl, + } + return { ...common, LOOPAL_MOCK_LLM_API_KEY: llm.apiKey } +} + +export async function configureProvider( + home: string, llm: MockLlmFixture, provider: E2eProvider, + initialSettings?: string, +): Promise { + const settings = initialSettings + ? JSON.parse(initialSettings) as Record : {} + if (provider !== 'anthropic') settings.model = provider === 'openai' ? 'gpt-4.1' + : provider === 'google' ? 'gemini-2.0-flash' : 'deepseek-reasoner' + if (provider === 'openai_compat') settings.providers = { + openai_compat: [{ + name: 'openai_compat', base_url: `${llm.baseUrl}/v1`, + api_key_env: 'LOOPAL_MOCK_LLM_API_KEY', + }], + } + if (!Object.keys(settings).length) return + const directory = join(home, '.loopal') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'settings.json'), JSON.stringify(settings)) +} + +export function persistedDesktopEnvironment( + backend: 'fake' | 'real', home: string, project: string, binary: string, + llm?: MockLlmFixture, provider: E2eProvider = 'anthropic', +): Record { + const env = isolatedTestEnvironment({ + HOME: home, LOOPAL_DESKTOP_CWD: project, LOOPAL_DESKTOP_E2E_HIDDEN: '1', + }) + if (backend === 'fake') return { ...env, LOOPAL_DESKTOP_BACKEND: 'fake' } + if (!llm) throw new Error('Real Desktop fixture requires Mock LLM') + return { + ...env, ...providerEnvironment(llm, provider), + LOOPAL_DESKTOP_BINARY_RUNFILE: binary, LOOPAL_MCP_STARTUP_WAIT_SECS: '1', + } +} + +export function providerModel(provider: E2eProvider): string { + if (provider === 'openai') return 'gpt-4.1' + if (provider === 'google') return 'gemini-2.0-flash' + if (provider === 'openai_compat') return 'deepseek-reasoner' + return 'claude-opus-4-8' +} diff --git a/apps/desktop/e2e/support/runtime/host-process.ts b/apps/desktop/e2e/support/runtime/host-process.ts new file mode 100644 index 00000000..0b9e4ce6 --- /dev/null +++ b/apps/desktop/e2e/support/runtime/host-process.ts @@ -0,0 +1,93 @@ +import { expect, type Page } from '@playwright/test' +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { shutdownDesktop, type DesktopFixture } from '../electron/electron-fixture' + +export interface HostRecord { + readonly pid: number + readonly root_session_id: string +} + +export async function sendMarker(page: Page, marker: string): Promise { + const composer = page.getByLabel('Message Loopal') + await composer.fill(marker) + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText(marker) + await expect(page.getByTestId('conversation')).toContainText('ok', { timeout: 30_000 }) +} + +export async function activeSessionId(page: Page): Promise { + const value = await page.locator('.session-card.selected').getAttribute('data-session-id') + if (!value) throw new Error('No active Loopal Desktop Session') + return value +} + +export function sessionCard(page: Page, sessionId: string) { + return page.locator(`[data-session-id="${sessionId}"]`) +} + +export async function waitForHosts(home: string, count: number): Promise { + let hosts: readonly HostRecord[] = [] + await expect.poll(async () => { + hosts = await listHosts(home) + return hosts.length + }, { timeout: 30_000 }).toBe(count) + return hosts +} + +export async function waitForSessionHost( + home: string, + sessionId: string, + excludedPid: number, +): Promise { + let result: HostRecord | undefined + await expect.poll(async () => { + result = (await listHosts(home)).find((item) => ( + item.root_session_id === sessionId && item.pid !== excludedPid + )) + return result?.pid ?? 0 + }, { timeout: 30_000 }).toBeGreaterThan(0) + return result! +} + +export async function listHosts(home: string): Promise { + const directory = join(home, '.loopal', 'run') + const files = await readdir(directory).catch(() => []) + const records = await Promise.all(files.filter((file) => /^\d+\.json$/.test(file)).map( + async (file) => { + try { + return JSON.parse(await readFile(join(directory, file), 'utf8')) as HostRecord + } catch { + return undefined + } + }, + )) + return records.filter((item): item is HostRecord => ( + item !== undefined && Number.isSafeInteger(item.pid) && Boolean(item.root_session_id) + )) +} + +export async function shutdownAndAssertClean( + desktop: DesktopFixture, + pids: ReadonlySet, +): Promise { + await shutdownDesktop(desktop) + await expect.poll(() => [...pids].some(processIsRunning), { timeout: 10_000 }).toBe(false) + await expect.poll(() => listHosts(desktop.home).then((items) => items.length)).toBe(0) +} + +export function processIsRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export async function storedSession( + home: string, + id: string, +): Promise<{ id: string; cwd: string }> { + return JSON.parse(await readFile(join(home, '.loopal', 'sessions', id, 'session.json'), 'utf8')) +} diff --git a/apps/desktop/e2e/support/runtime/host-status.ts b/apps/desktop/e2e/support/runtime/host-status.ts new file mode 100644 index 00000000..b6fcbe96 --- /dev/null +++ b/apps/desktop/e2e/support/runtime/host-status.ts @@ -0,0 +1,9 @@ +import { expect, type Page } from '@playwright/test' + +export async function waitForHostStatus( + page: Page, status: string, timeout = 30_000, +): Promise { + await expect.poll(() => page.evaluate(async () => ( + await window.loopalDesktop.bootstrap() + ).hostStatus), { timeout }).toBe(status) +} diff --git a/apps/desktop/e2e/support/runtime/llm-e2e-helpers.ts b/apps/desktop/e2e/support/runtime/llm-e2e-helpers.ts new file mode 100644 index 00000000..c57976a5 --- /dev/null +++ b/apps/desktop/e2e/support/runtime/llm-e2e-helpers.ts @@ -0,0 +1,43 @@ +import { expect, type Page } from '@playwright/test' +import { type SessionDetail } from '../../../src/shared/contracts' +import { waitForHostStatus } from './host-status' + +export interface RuntimeTarget { + readonly sessionId: string + readonly runtimeId: string + readonly generation: number + readonly agentId: string +} + +export async function ready(page: Page): Promise { + await waitForHostStatus(page, 'ready') + await expect(page.getByTestId('runtime-status')).toContainText( + 'Ready for input', { timeout: 30_000 }, + ) + await expect(page.getByLabel('Message Loopal')).toBeEnabled({ timeout: 30_000 }) +} + +export async function send(page: Page, message: string): Promise { + await page.getByLabel('Message Loopal').fill(message) + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('conversation')).toContainText(message) +} + +export async function activeDetail(page: Page): Promise { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + return window.loopalDesktop.openSession(bootstrap.activeSessionId!) + }) +} + +export async function runtimeTarget(page: Page): Promise { + return page.evaluate(async () => { + const bootstrap = await window.loopalDesktop.bootstrap() + const sessionId = bootstrap.activeSessionId! + const runtime = bootstrap.runtimes.find((item) => item.sessionId === sessionId)! + return { + sessionId, runtimeId: runtime.id, generation: runtime.generation, + agentId: runtime.rootAgent, + } + }) +} diff --git a/apps/desktop/e2e/support/settings/settings-helpers.ts b/apps/desktop/e2e/support/settings/settings-helpers.ts new file mode 100644 index 00000000..e3433f31 --- /dev/null +++ b/apps/desktop/e2e/support/settings/settings-helpers.ts @@ -0,0 +1,23 @@ +import { expect, type Page } from '@playwright/test' + +export type SettingsSection = + | 'appearance' + | 'loopal' + | 'providers' + | 'mcp' + | 'agent' + | 'runtime' + | 'federation' + | 'skills' + +export async function selectSettingsSection( + page: Page, + section: SettingsSection, +): Promise { + const navigation = page.getByTestId('settings-navigation') + await expect(navigation).toBeVisible() + const target = navigation.locator(`[role="tab"][data-section="${section}"]`) + await target.click() + await expect(target).toHaveAttribute('aria-selected', 'true') + await expect(page.getByTestId('settings-section-content')).toBeVisible() +} diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 00000000..e6dbc117 --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,49 @@ +appId: dev.loopal.desktop +productName: Loopal Desktop +copyright: Copyright © 2026 Loopal +electronVersion: 43.1.0 +electronDist: ../../../node_modules/electron/dist + +directories: + output: ../dist + +files: + - out/**/* + - package.json + - "!**/*.{map,ts,tsx}" + +extraResources: + - from: runtime/loopal + to: bin/loopal + +asar: true +npmRebuild: true +beforeBuild: ./dist_staging/builder-before-build.cjs +afterExtract: ./dist_staging/builder-after-extract.cjs + +mac: + category: public.app-category.developer-tools + target: + - dmg + - zip + hardenedRuntime: true + gatekeeperAssess: false + binaries: + - Contents/Resources/bin/loopal + +win: + target: + - nsis + extraResources: + - from: runtime/loopal + to: bin/loopal.exe + +linux: + target: + - AppImage + - deb + category: Development + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 00000000..517dc87d --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + build: { + sourcemap: true, + }, + }, + preload: { + plugins: [externalizeDepsPlugin()], + build: { + sourcemap: true, + }, + }, + renderer: { + plugins: [react()], + build: { + sourcemap: true, + }, + }, +}) diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 00000000..d388f866 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' +import { join } from 'node:path' + +const artifacts = process.env.TEST_UNDECLARED_OUTPUTS_DIR + ? join(process.env.TEST_UNDECLARED_OUTPUTS_DIR, 'playwright') + : 'test-results' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 120_000, + expect: { timeout: 10_000 }, + retries: process.env.CI ? 1 : 0, + outputDir: artifacts, + reporter: [['line']], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, +}) diff --git a/apps/desktop/src/architecture-boundaries.test.ts b/apps/desktop/src/architecture-boundaries.test.ts new file mode 100644 index 00000000..2ef1c3e4 --- /dev/null +++ b/apps/desktop/src/architecture-boundaries.test.ts @@ -0,0 +1,117 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, extname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceRoot = dirname(fileURLToPath(import.meta.url)) +const modulePattern = /\b(?:from|import)\s*(?:\(\s*)?['"](\.\.?\/[^'"\n]+)['"]/g + +function filesBelow(root: string): string[] { + return readdirSync(root).flatMap((name) => { + const path = join(root, name) + return statSync(path).isDirectory() ? filesBelow(path) : [path] + }) +} + +function sourcePath(path: string): string { + return relative(sourceRoot, path).split(sep).join('/') +} + +function lineCount(path: string): number { + const source = readFileSync(path, 'utf8') + return source.split('\n').length - Number(source.endsWith('\n')) +} + +function resolveModule(importer: string, specifier: string): string | undefined { + const base = resolve(dirname(importer), specifier) + return [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.css`, + join(base, 'index.ts'), + join(base, 'index.tsx'), + ].find((candidate) => { + try { + return statSync(candidate).isFile() + } catch { + return false + } + }) +} + +function relativeDependencies(path: string): string[] { + const source = readFileSync(path, 'utf8') + return [...source.matchAll(modulePattern)] + .map((match) => match[1] ? resolveModule(path, match[1]) : undefined) + .filter((target): target is string => Boolean(target)) +} + +function forbiddenDependency(importer: string, target: string): boolean { + if (importer.startsWith('base/')) return !target.startsWith('base/') + if (importer.startsWith('shared/')) return !target.startsWith('shared/') + if (importer.startsWith('workbench/')) { + return target.startsWith('main/') + || target.startsWith('preload/') + || /^platform\/[^/]+\/node\//.test(target) + } + if (importer.startsWith('preload/')) { + return target.startsWith('main/') + || target.startsWith('renderer/') + || target.startsWith('workbench/') + || /^platform\/[^/]+\/node\//.test(target) + } + if (/^platform\/[^/]+\/common\//.test(importer)) { + return /^platform\/[^/]+\/node\//.test(target) + || target.startsWith('main/') + || target.startsWith('workbench/') + } + return false +} + +describe('Desktop source architecture', () => { + const sources = filesBelow(sourceRoot) + + it('keeps imports inside the intended dependency direction', () => { + const violations = sources + .filter((path) => ['.ts', '.tsx'].includes(extname(path))) + .flatMap((path) => relativeDependencies(path).map((target) => [path, target] as const)) + .filter(([, target]) => target.startsWith(sourceRoot)) + .map(([path, target]) => [sourcePath(path), sourcePath(target)] as const) + .filter(([importer, target]) => forbiddenDependency(importer, target)) + .map(([importer, target]) => `${importer} -> ${target}`) + + expect(violations).toEqual([]) + }) + + it('keeps platform implementation roots grouped by responsibility', () => { + const roots = [ + 'platform/desktop-host/node', + 'platform/loopal-backend/node', + ] + const flatFiles = roots.flatMap((root) => readdirSync(join(sourceRoot, root)) + .filter((name) => statSync(join(sourceRoot, root, name)).isFile()) + .map((name) => `${root}/${name}`)) + + expect(flatFiles).toEqual([]) + expect(readdirSync(join(sourceRoot, 'shared')).sort()).toEqual([ + 'contracts', + 'global.d.ts', + 'i18n', + 'protocol', + ]) + }) + + it('keeps handwritten Desktop source files within 200 lines', () => { + const oversized = sources + .filter((path) => ['.ts', '.tsx', '.css'].includes(extname(path))) + .map((path) => [ + sourcePath(path), + lineCount(path), + ] as const) + .filter(([, lines]) => lines > 200) + .map(([path, lines]) => `${path}: ${lines}`) + + expect(oversized).toEqual([]) + }) +}) diff --git a/apps/desktop/src/base/common/async.test.ts b/apps/desktop/src/base/common/async.test.ts new file mode 100644 index 00000000..dcdb669c --- /dev/null +++ b/apps/desktop/src/base/common/async.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationTokenSource } from './cancellation' +import { DeferredPromise, timeout } from './async' + +describe('async helpers', () => { + it('resolves and rejects deferred promises once', async () => { + const resolved = new DeferredPromise() + resolved.resolve(4) + resolved.resolve(5) + resolved.reject(new Error('ignored')) + await expect(resolved.promise).resolves.toBe(4) + expect(resolved.isSettled).toBe(true) + + const rejected = new DeferredPromise() + const error = new Error('failed') + rejected.reject(error) + rejected.resolve(1) + await expect(rejected.promise).rejects.toBe(error) + }) + + it('waits and cancels a timeout', async () => { + vi.useFakeTimers() + const completed = timeout(20) + await vi.advanceTimersByTimeAsync(20) + await expect(completed).resolves.toBeUndefined() + + const source = new CancellationTokenSource() + const cancelled = timeout(50, source.token) + source.cancel() + await expect(cancelled).rejects.toThrow('Operation cancelled') + + await expect(timeout(10, source.token)).rejects.toThrow('Operation cancelled') + vi.useRealTimers() + }) +}) diff --git a/apps/desktop/src/base/common/async.ts b/apps/desktop/src/base/common/async.ts new file mode 100644 index 00000000..5c616858 --- /dev/null +++ b/apps/desktop/src/base/common/async.ts @@ -0,0 +1,59 @@ +import { + CancellationError, + CancellationToken, + type CancellationToken as CancellationTokenType, +} from './cancellation' + +export class DeferredPromise { + readonly promise: Promise + private resolvePromise!: (value: T | PromiseLike) => void + private rejectPromise!: (reason?: unknown) => void + private settled = false + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve + this.rejectPromise = reject + }) + } + + get isSettled(): boolean { + return this.settled + } + + resolve(value: T | PromiseLike): void { + if (this.settled) { + return + } + this.settled = true + this.resolvePromise(value) + } + + reject(reason?: unknown): void { + if (this.settled) { + return + } + this.settled = true + this.rejectPromise(reason) + } +} + +export function timeout( + milliseconds: number, + token: CancellationTokenType = CancellationToken.None, +): Promise { + if (token.isCancellationRequested) { + return Promise.reject(new CancellationError()) + } + return new Promise((resolve, reject) => { + const handle = setTimeout(() => { + subscription.dispose() + resolve() + }, milliseconds) + const subscription = token.onCancellationRequested(() => { + clearTimeout(handle) + subscription.dispose() + reject(new CancellationError()) + }) + }) +} diff --git a/apps/desktop/src/base/common/cancellation.test.ts b/apps/desktop/src/base/common/cancellation.test.ts new file mode 100644 index 00000000..475516d7 --- /dev/null +++ b/apps/desktop/src/base/common/cancellation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CancellationError, + CancellationToken, + CancellationTokenSource, + throwIfCancelled, +} from './cancellation' + +describe('cancellation', () => { + it('exposes none and cancelled tokens', async () => { + expect(CancellationToken.None.isCancellationRequested).toBe(false) + const none = vi.fn() + CancellationToken.None.onCancellationRequested(none).dispose() + expect(none).not.toHaveBeenCalled() + + expect(CancellationToken.Cancelled.isCancellationRequested).toBe(true) + const cancelled = vi.fn() + const cancelledSubscription = CancellationToken.Cancelled.onCancellationRequested(cancelled) + await Promise.resolve() + expect(cancelled).toHaveBeenCalledOnce() + cancelledSubscription.dispose() + }) + + it('cancels exactly once and supports late listeners', async () => { + const source = new CancellationTokenSource() + const listener = vi.fn() + source.token.onCancellationRequested(listener) + source.cancel() + source.cancel() + expect(listener).toHaveBeenCalledOnce() + const late = vi.fn() + source.token.onCancellationRequested(late) + await Promise.resolve() + expect(late).toHaveBeenCalledOnce() + source.dispose() + }) + + it('inherits parent cancellation and handles an already-cancelled parent', () => { + const parent = new CancellationTokenSource() + const child = new CancellationTokenSource(parent.token) + parent.cancel() + expect(child.token.isCancellationRequested).toBe(true) + child.dispose() + + const alreadyCancelled = new CancellationTokenSource(CancellationToken.Cancelled) + expect(alreadyCancelled.token.isCancellationRequested).toBe(true) + alreadyCancelled.dispose(true) + }) + + it('can cancel during disposal and throw typed errors', () => { + const source = new CancellationTokenSource() + source.dispose(true) + expect(source.token.isCancellationRequested).toBe(true) + expect(() => throwIfCancelled(source.token)).toThrow(CancellationError) + expect(() => throwIfCancelled(CancellationToken.None)).not.toThrow() + expect(new CancellationError('custom').message).toBe('custom') + }) +}) diff --git a/apps/desktop/src/base/common/cancellation.ts b/apps/desktop/src/base/common/cancellation.ts new file mode 100644 index 00000000..8476a86a --- /dev/null +++ b/apps/desktop/src/base/common/cancellation.ts @@ -0,0 +1,100 @@ +import { Emitter, Event, type Event as EventType } from './event' +import { type IDisposable } from './lifecycle' + +export class CancellationError extends Error { + constructor(message = 'Operation cancelled') { + super(message) + this.name = 'CancellationError' + } +} + +export interface CancellationToken { + readonly isCancellationRequested: boolean + readonly onCancellationRequested: EventType +} + +const noneToken: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: Event.none(), +} + +const cancelledToken: CancellationToken = { + isCancellationRequested: true, + onCancellationRequested: (listener) => { + queueMicrotask(listener) + return { dispose: () => undefined } + }, +} + +export const CancellationToken = { + None: noneToken, + Cancelled: cancelledToken, +} + +class MutableToken implements CancellationToken, IDisposable { + private cancelled = false + private emitter: Emitter | undefined + + get isCancellationRequested(): boolean { + return this.cancelled + } + + get onCancellationRequested(): EventType { + if (this.cancelled) { + return cancelledToken.onCancellationRequested + } + this.emitter ??= new Emitter() + return this.emitter.event + } + + cancel(): void { + if (this.cancelled) { + return + } + this.cancelled = true + this.emitter?.fire() + this.emitter?.dispose() + this.emitter = undefined + } + + dispose(): void { + this.emitter?.dispose() + this.emitter = undefined + } +} + +export class CancellationTokenSource implements IDisposable { + private readonly mutableToken = new MutableToken() + private readonly parentSubscription: IDisposable | undefined + + constructor(parent?: CancellationToken) { + if (parent?.isCancellationRequested) { + this.parentSubscription = undefined + this.mutableToken.cancel() + } else { + this.parentSubscription = parent?.onCancellationRequested(() => this.cancel()) + } + } + + get token(): CancellationToken { + return this.mutableToken + } + + cancel(): void { + this.mutableToken.cancel() + } + + dispose(cancel = false): void { + if (cancel) { + this.cancel() + } + this.parentSubscription?.dispose() + this.mutableToken.dispose() + } +} + +export function throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new CancellationError() + } +} diff --git a/apps/desktop/src/base/common/event.test.ts b/apps/desktop/src/base/common/event.test.ts new file mode 100644 index 00000000..89bcdc85 --- /dev/null +++ b/apps/desktop/src/base/common/event.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { Emitter, Event } from './event' + +describe('events', () => { + it('subscribes, fires snapshots, and unsubscribes', () => { + const first = vi.fn() + const second = vi.fn() + const emitter = new Emitter() + const firstSubscription = emitter.event(first) + const secondSubscription = emitter.event((value) => { + second(value) + firstSubscription.dispose() + }) + emitter.fire(7) + emitter.fire(8) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(2) + secondSubscription.dispose() + }) + + it('notifies listener lifecycle and disposal', () => { + const first = vi.fn() + const last = vi.fn() + const emitter = new Emitter({ onFirstListenerAdd: first, onLastListenerRemove: last }) + const left = emitter.event(() => undefined) + const right = emitter.event(() => undefined) + expect(first).toHaveBeenCalledOnce() + left.dispose() + expect(last).not.toHaveBeenCalled() + right.dispose() + expect(last).toHaveBeenCalledOnce() + emitter.event(() => undefined) + emitter.dispose() + emitter.dispose() + expect(last).toHaveBeenCalledTimes(2) + expect(emitter.event(() => undefined)).toBeDefined() + emitter.fire() + }) + + it('maps, filters, fires once, and provides a none event', () => { + const emitter = new Emitter() + const values: string[] = [] + Event.filter(Event.map(emitter.event, (value) => `v${value}`), (value) => value !== 'v2')( + (value) => values.push(value), + ) + const once = vi.fn() + Event.once(emitter.event)(once) + Event.none()(() => undefined).dispose() + emitter.fire(1) + emitter.fire(2) + expect(values).toEqual(['v1']) + expect(once).toHaveBeenCalledOnce() + }) + + it('handles a synchronously firing once source', () => { + const listener = vi.fn() + const dispose = vi.fn() + Event.once((callback) => { + callback(3) + return { dispose } + })(listener) + expect(listener).toHaveBeenCalledWith(3) + expect(dispose).toHaveBeenCalledOnce() + }) + + it('disposes an asynchronously firing once source at the first value', () => { + const source = new Emitter() + const listener = vi.fn() + Event.once(source.event)(listener) + source.fire(1) + source.fire(2) + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith(1) + }) + + it('routes listener errors to a handler or console', () => { + const handled = vi.fn() + const emitter = new Emitter({ onListenerError: handled }) + emitter.event(() => { throw new Error('handled') }) + emitter.fire() + expect(handled).toHaveBeenCalledOnce() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fallback = new Emitter() + fallback.event(() => { throw new Error('fallback') }) + fallback.fire() + expect(consoleError).toHaveBeenCalledOnce() + fallback.dispose() + fallback.event(() => undefined).dispose() + }) +}) diff --git a/apps/desktop/src/base/common/event.ts b/apps/desktop/src/base/common/event.ts new file mode 100644 index 00000000..8847d6ff --- /dev/null +++ b/apps/desktop/src/base/common/event.ts @@ -0,0 +1,103 @@ +import { type IDisposable, toDisposable } from './lifecycle' + +export type Listener = (event: T) => void +export type Event = (listener: Listener) => IDisposable + +export const Event = { + none(): Event { + return () => toDisposable(() => undefined) + }, + + once(event: Event): Event { + return (listener) => { + let subscription: IDisposable | undefined + let didFire = false + subscription = event((value) => { + if (didFire) { + return + } + didFire = true + subscription?.dispose() + listener(value) + }) + if (didFire) { + subscription.dispose() + } + return subscription + } + }, + + map(event: Event, mapper: (value: T) => U): Event { + return (listener) => event((value) => listener(mapper(value))) + }, + + filter(event: Event, predicate: (value: T) => boolean): Event { + return (listener) => + event((value) => { + if (predicate(value)) { + listener(value) + } + }) + }, +} + +export interface EmitterOptions { + onFirstListenerAdd?: () => void + onLastListenerRemove?: () => void + onListenerError?: (error: unknown) => void +} + +export class Emitter implements IDisposable { + private readonly listeners = new Set>() + private disposed = false + private readonly options: EmitterOptions + + constructor(options: EmitterOptions = {}) { + this.options = options + } + + readonly event: Event = (listener) => { + if (this.disposed) { + return toDisposable(() => undefined) + } + if (this.listeners.size === 0) { + this.options.onFirstListenerAdd?.() + } + this.listeners.add(listener) + return toDisposable(() => { + const removed = this.listeners.delete(listener) + if (removed && this.listeners.size === 0) { + this.options.onLastListenerRemove?.() + } + }) + } + + fire(value: T): void { + if (this.disposed) { + return + } + for (const listener of [...this.listeners]) { + try { + listener(value) + } catch (error) { + if (this.options.onListenerError) { + this.options.onListenerError(error) + } else { + console.error('Unhandled event listener error', error) + } + } + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + const hadListeners = this.listeners.size > 0 + this.listeners.clear() + if (hadListeners) { + this.options.onLastListenerRemove?.() + } + } +} diff --git a/apps/desktop/src/base/common/lifecycle.test.ts b/apps/desktop/src/base/common/lifecycle.test.ts new file mode 100644 index 00000000..336cb4d0 --- /dev/null +++ b/apps/desktop/src/base/common/lifecycle.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { + Disposable, + DisposableStore, + MutableDisposable, + combinedDisposable, + disposeAll, + isDisposable, + toDisposable, +} from './lifecycle' + +describe('lifecycle', () => { + it('recognizes disposables', () => { + expect(isDisposable({ dispose() {} })).toBe(true) + expect(isDisposable({ dispose: 1 })).toBe(false) + expect(isDisposable(null)).toBe(false) + expect(isDisposable('dispose')).toBe(false) + }) + + it('creates idempotent disposables', () => { + const callback = vi.fn() + const disposable = toDisposable(callback) + disposable.dispose() + disposable.dispose() + expect(callback).toHaveBeenCalledOnce() + }) + + it('disposes collections and reports one or many failures', () => { + const first = vi.fn() + disposeAll([toDisposable(first)]) + expect(first).toHaveBeenCalledOnce() + + const single = new Error('single') + expect(() => disposeAll([{ dispose: () => { throw single } }])).toThrow(single) + + const failures = [new Error('one'), new Error('two')] + expect(() => + disposeAll(failures.map((error) => ({ dispose: () => { throw error } }))), + ).toThrow(AggregateError) + }) + + it('combines disposable resources', () => { + const left = vi.fn() + const right = vi.fn() + combinedDisposable(toDisposable(left), toDisposable(right)).dispose() + expect(left).toHaveBeenCalledOnce() + expect(right).toHaveBeenCalledOnce() + }) + + it('owns, deletes, clears, and immediately disposes late resources', () => { + const store = new DisposableStore() + const deleted = vi.fn() + const cleared = vi.fn() + const deletedDisposable = store.add(toDisposable(deleted)) + store.add(toDisposable(cleared)) + store.delete(deletedDisposable) + store.delete(deletedDisposable) + expect(deleted).toHaveBeenCalledOnce() + store.clear() + expect(cleared).toHaveBeenCalledOnce() + store.dispose() + store.dispose() + expect(store.isDisposed).toBe(true) + const late = vi.fn() + store.add(toDisposable(late)) + expect(late).toHaveBeenCalledOnce() + }) + + it('supports disposable subclasses', () => { + const callback = vi.fn() + class Resource extends Disposable { + constructor() { + super() + this.register(toDisposable(callback)) + } + } + const resource = new Resource() + resource.dispose() + resource.dispose() + expect(callback).toHaveBeenCalledOnce() + }) + + it('replaces and clears mutable disposables', () => { + const holder = new MutableDisposable<{ dispose(): void }>() + const first = { dispose: vi.fn() } + const second = { dispose: vi.fn() } + holder.value = first + holder.value = first + expect(holder.value).toBe(first) + holder.value = second + expect(first.dispose).toHaveBeenCalledOnce() + holder.clear() + expect(second.dispose).toHaveBeenCalledOnce() + expect(holder.value).toBeUndefined() + holder.dispose() + holder.dispose() + const late = { dispose: vi.fn() } + holder.value = late + expect(late.dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/base/common/lifecycle.ts b/apps/desktop/src/base/common/lifecycle.ts new file mode 100644 index 00000000..57975f54 --- /dev/null +++ b/apps/desktop/src/base/common/lifecycle.ts @@ -0,0 +1,130 @@ +export interface IDisposable { + dispose(): void +} + +export function isDisposable(value: unknown): value is IDisposable { + return ( + typeof value === 'object' && + value !== null && + 'dispose' in value && + typeof value.dispose === 'function' + ) +} + +export function toDisposable(callback: () => void): IDisposable { + let disposed = false + return { + dispose(): void { + if (disposed) { + return + } + disposed = true + callback() + }, + } +} + +export function disposeAll(disposables: Iterable): void { + const errors: unknown[] = [] + for (const disposable of disposables) { + try { + disposable.dispose() + } catch (error) { + errors.push(error) + } + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Multiple errors occurred while disposing resources') + } +} + +export function combinedDisposable(...disposables: IDisposable[]): IDisposable { + return toDisposable(() => disposeAll(disposables)) +} + +export class DisposableStore implements IDisposable { + private readonly items = new Set() + private disposed = false + + get isDisposed(): boolean { + return this.disposed + } + + add(disposable: T): T { + if (this.disposed) { + disposable.dispose() + return disposable + } + this.items.add(disposable) + return disposable + } + + delete(disposable: IDisposable): void { + if (this.items.delete(disposable)) { + disposable.dispose() + } + } + + clear(): void { + const current = [...this.items] + this.items.clear() + disposeAll(current) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.clear() + } +} + +export abstract class Disposable implements IDisposable { + private readonly store = new DisposableStore() + + protected register(disposable: T): T { + return this.store.add(disposable) + } + + dispose(): void { + this.store.dispose() + } +} + +export class MutableDisposable implements IDisposable { + private current: T | undefined + private disposed = false + + get value(): T | undefined { + return this.current + } + + set value(next: T | undefined) { + if (this.disposed) { + next?.dispose() + return + } + if (next === this.current) { + return + } + this.current?.dispose() + this.current = next + } + + clear(): void { + this.value = undefined + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.current?.dispose() + this.current = undefined + } +} diff --git a/apps/desktop/src/main/app/parent-liveness.test.ts b/apps/desktop/src/main/app/parent-liveness.test.ts new file mode 100644 index 00000000..d1761f2c --- /dev/null +++ b/apps/desktop/src/main/app/parent-liveness.test.ts @@ -0,0 +1,26 @@ +import { monitorParent } from './parent-liveness' + +describe('E2E parent liveness', () => { + afterEach(() => vi.useRealTimers()) + + it('quits once when the owning test process disappears', async () => { + vi.useFakeTimers() + const onMissing = vi.fn() + const probe = vi.fn(() => { throw new Error('gone') }) + const monitor = monitorParent(42, onMissing, probe, 10) + await vi.advanceTimersByTimeAsync(30) + expect(probe).toHaveBeenCalledTimes(1) + expect(onMissing).toHaveBeenCalledTimes(1) + monitor.dispose() + }) + + it('stops probing when disposed', async () => { + vi.useFakeTimers() + const probe = vi.fn() + const monitor = monitorParent(42, vi.fn(), probe, 10) + await vi.advanceTimersByTimeAsync(10) + monitor.dispose() + await vi.advanceTimersByTimeAsync(30) + expect(probe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/main/app/parent-liveness.ts b/apps/desktop/src/main/app/parent-liveness.ts new file mode 100644 index 00000000..addb0ebe --- /dev/null +++ b/apps/desktop/src/main/app/parent-liveness.ts @@ -0,0 +1,25 @@ +import { type IDisposable, toDisposable } from '../../base/common/lifecycle' + +export function monitorParent( + parentPid: number, + onMissing: () => void, + probe: (pid: number) => void = defaultProbe, + interval = 1_000, +): IDisposable { + let missing = false + const timer = setInterval(() => { + if (missing) return + try { + probe(parentPid) + } catch { + missing = true + onMissing() + } + }, interval) + timer.unref() + return toDisposable(() => clearInterval(timer)) +} + +function defaultProbe(pid: number): void { + process.kill(pid, 0) +} diff --git a/apps/desktop/src/main/app/renderer-server.test.ts b/apps/desktop/src/main/app/renderer-server.test.ts new file mode 100644 index 00000000..58ca8b4b --- /dev/null +++ b/apps/desktop/src/main/app/renderer-server.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type DesktopBackend } from '../../platform/loopal-backend/common/backend' +import { Emitter } from '../../base/common/event' + +const electron = vi.hoisted(() => { + const listeners = new Map void>>() + const ipcMain = { + on: vi.fn((channel: string, listener: (event: unknown) => void) => { + const values = listeners.get(channel) ?? new Set() + values.add(listener) + listeners.set(channel, values) + }), + removeListener: vi.fn((channel: string, listener: (event: unknown) => void) => { + listeners.get(channel)?.delete(listener) + }), + emit(channel: string, event: unknown) { + for (const listener of listeners.get(channel) ?? []) { + listener(event) + } + }, + reset() { + listeners.clear() + ipcMain.on.mockClear() + ipcMain.removeListener.mockClear() + }, + } + + function port() { + const messageListeners = new Set<(event: { data: unknown }) => void>() + const closeListeners = new Set<() => void>() + return { + postMessage: vi.fn(), + start: vi.fn(), + close: vi.fn(), + on: vi.fn((type: string, listener: (event: { data: unknown }) => void) => { + if (type === 'message') messageListeners.add(listener) + }), + off: vi.fn((type: string, listener: (event: { data: unknown }) => void) => { + if (type === 'message') messageListeners.delete(listener) + }), + once: vi.fn((type: string, listener: () => void) => { + if (type === 'close') closeListeners.add(listener) + }), + emitMessage(data: unknown) { + for (const listener of messageListeners) listener({ data }) + }, + emitClose() { + for (const listener of closeListeners) listener() + closeListeners.clear() + }, + } + } + + const channels: Array<{ port1: ReturnType; port2: ReturnType }> = [] + class MessageChannelMain { + readonly port1 = port() + readonly port2 = port() + constructor() { + channels.push({ port1: this.port1, port2: this.port2 }) + } + } + return { ipcMain, MessageChannelMain, channels } +}) + +vi.mock('electron', () => ({ + ipcMain: electron.ipcMain, + MessageChannelMain: electron.MessageChannelMain, +})) + +import { + mainWindowPolicy, + registerRendererServer, +} from './renderer-server' +import { createBackendStub } from '../../../test/support/backend/backend-stub' +import { RENDERER_CONNECT_CHANNEL } from '../../shared/protocol/renderer-protocol' +import { type DesktopEvent } from '../../shared/contracts' + +function backend(): DesktopBackend { + const emitter = new Emitter() + return createBackendStub({ onEvent: emitter.event }) +} + +describe('renderer channel server', () => { + beforeEach(() => { + electron.ipcMain.reset() + electron.channels.length = 0 + }) + + it('accepts only the owning main frame and serves the explicit backend channel', async () => { + const frame = { url: 'file:///renderer/index.html', postMessage: vi.fn() } + const webContents = { id: 7, mainFrame: frame } + const policy = mainWindowPolicy(webContents as never) + const service = backend() + const registration = registerRendererServer(service, policy) + const connect = electron.ipcMain.on.mock.calls.at(-1)?.[1] + expect(connect).toBeTypeOf('function') + + const rejected = { sender: { id: 9 }, senderFrame: frame } + connect!(rejected) + expect(electron.channels).toHaveLength(0) + + const event = { sender: webContents, senderFrame: frame } + connect!(event) + expect(electron.channels).toHaveLength(1) + expect(frame.postMessage).toHaveBeenCalledWith( + 'loopal-desktop:port', + { protocolVersion: 2 }, + [electron.channels[0]?.port2], + ) + + const port = electron.channels[0]!.port1 + port.emitMessage({ + type: 'request', + id: 1, + channel: 'desktopBackend', + command: 'bootstrap', + }) + await Promise.resolve() + await Promise.resolve() + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'response', id: 1, ok: true }), + ) + + port.emitClose() + registration.dispose() + expect(electron.ipcMain.removeListener).toHaveBeenCalled() + }) + + it('requires both the expected web contents and its main frame', () => { + const mainFrame = { url: 'file:///main', postMessage: vi.fn() } + const webContents = { id: 1, mainFrame } + const policy = mainWindowPolicy(webContents as never) + expect(policy.isAllowed({ sender: webContents, senderFrame: mainFrame } as never)).toBe(true) + expect(policy.isAllowed({ sender: webContents, senderFrame: {} } as never)).toBe(false) + expect(policy.isAllowed({ sender: {}, senderFrame: mainFrame } as never)).toBe(false) + }) + + it('disposes all open renderer connections', () => { + const registration = registerRendererServer(backend(), { isAllowed: () => true }) + const frame = { url: 'file:///renderer', postMessage: vi.fn() } + const connect = electron.ipcMain.on.mock.calls.at(-1)?.[1] + expect(connect).toBeTypeOf('function') + connect!({ + sender: { id: 1 }, + senderFrame: frame, + }) + expect(electron.channels).toHaveLength(1) + registration.dispose() + registration.dispose() + expect(electron.channels[0]?.port1.close).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/main/app/renderer-server.ts b/apps/desktop/src/main/app/renderer-server.ts new file mode 100644 index 00000000..5f047a32 --- /dev/null +++ b/apps/desktop/src/main/app/renderer-server.ts @@ -0,0 +1,78 @@ +import { ipcMain, MessageChannelMain, type IpcMainEvent, type WebContents } from 'electron' +import { DisposableStore, type IDisposable, toDisposable } from '../../base/common/lifecycle' +import { ChannelServer } from '../../platform/ipc/common/channel' +import { MessagePortTransport } from '../../platform/ipc/common/transport' +import { DesktopBackendChannel } from '../../platform/loopal-backend/common/channels/backend-channel' +import { type DesktopBackend } from '../../platform/loopal-backend/common/backend' +import { + RENDERER_CONNECT_CHANNEL, + RENDERER_PORT_CHANNEL, + RENDERER_PROTOCOL_VERSION, +} from '../../shared/protocol/renderer-protocol' + +export interface RendererContext { + readonly webContentsId: number + readonly frameUrl: string + readonly principal: 'main-window' +} + +export interface RendererConnectionPolicy { + isAllowed(event: IpcMainEvent): boolean +} + +export function mainWindowPolicy(webContents: WebContents): RendererConnectionPolicy { + return { + isAllowed: (event) => + event.sender === webContents && event.senderFrame === webContents.mainFrame, + } +} + +export function isSafeExternalUrl(value: string): boolean { + try { return new URL(value).protocol === 'https:' } + catch { return false } +} + +export function registerRendererServer( + backend: DesktopBackend, + policy: RendererConnectionPolicy, +): IDisposable { + const store = new DisposableStore() + const servers = new Set>() + + const listener = (event: IpcMainEvent): void => { + const frame = event.senderFrame + if (!frame || !policy.isAllowed(event)) { + return + } + const { port1, port2 } = new MessageChannelMain() + const context: RendererContext = { + webContentsId: event.sender.id, + frameUrl: frame.url, + principal: 'main-window', + } + const server = new ChannelServer(new MessagePortTransport(port1), context) + server.registerChannel('desktopBackend', new DesktopBackendChannel(backend)) + servers.add(server) + port1.once('close', () => { + servers.delete(server) + server.dispose() + }) + frame.postMessage( + RENDERER_PORT_CHANNEL, + { protocolVersion: RENDERER_PROTOCOL_VERSION }, + [port2], + ) + } + + ipcMain.on(RENDERER_CONNECT_CHANNEL, listener) + store.add(toDisposable(() => ipcMain.removeListener(RENDERER_CONNECT_CHANNEL, listener))) + store.add( + toDisposable(() => { + for (const server of servers) { + server.dispose() + } + servers.clear() + }), + ) + return store +} diff --git a/apps/desktop/src/main/app/runtime-mode.test.ts b/apps/desktop/src/main/app/runtime-mode.test.ts new file mode 100644 index 00000000..b25b5895 --- /dev/null +++ b/apps/desktop/src/main/app/runtime-mode.test.ts @@ -0,0 +1,153 @@ +import { + keepE2eWindowHidden, + resolveDesktopCwd, + resolveLoopalBinary, + resolveRendererUrl, + useFakeBackend, +} from './runtime-mode' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 } from 'node:path' + +describe('packaged runtime boundaries', () => { + const temporaryDirectories: string[] = [] + const env = { + LOOPAL_DESKTOP_BACKEND: 'fake', + LOOPAL_DESKTOP_BINARY: '/tmp/other-loopal', + LOOPAL_DESKTOP_CWD: '/tmp/other-workspace', + LOOPAL_DESKTOP_E2E_HIDDEN: '1', + ELECTRON_RENDERER_URL: 'https://attacker.invalid', + } + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('allows explicit development overrides', () => { + expect(useFakeBackend(false, env)).toBe(true) + expect(keepE2eWindowHidden(false, env)).toBe(true) + expect(resolveDesktopCwd(false, env, '/cwd')).toBe('/tmp/other-workspace') + expect(resolveRendererUrl(false, env)).toBe('https://attacker.invalid') + expect(resolveLoopalBinary({ + isPackaged: false, + env, + resourcesPath: '/resources', + platform: 'darwin', + cwd: '/cwd', + })).toBe('/tmp/other-loopal') + }) + + it('ignores all development overrides in a packaged application', () => { + expect(useFakeBackend(true, env)).toBe(false) + expect(keepE2eWindowHidden(true, env)).toBe(false) + expect(resolveDesktopCwd(true, env, '/cwd')).toBeUndefined() + expect(resolveRendererUrl(true, env)).toBeUndefined() + expect(resolveLoopalBinary({ + isPackaged: true, + env, + resourcesPath: '/resources', + platform: 'darwin', + cwd: '/cwd', + })).toBe('/resources/bin/loopal') + expect(resolveLoopalBinary({ + isPackaged: true, + env: {}, + resourcesPath: 'C:/resources', + platform: 'win32', + cwd: 'C:/cwd', + })).toBe(win32.join('C:/resources', 'bin', 'loopal.exe')) + }) + + it('falls back when development overrides are absent', () => { + expect(useFakeBackend(false, {})).toBe(false) + expect(keepE2eWindowHidden(false, {})).toBe(false) + expect(resolveDesktopCwd(false, {}, '/cwd')).toBe('/cwd') + expect(resolveRendererUrl(false, {})).toBeUndefined() + expect(resolveLoopalBinary({ + isPackaged: false, + env: {}, + resourcesPath: '/resources', + platform: 'linux', + cwd: '/cwd', + })).toBeUndefined() + }) + + it('resolves a Bazel sidecar independently of the workspace cwd', () => { + const runfiles = temporaryDirectory() + const binary = join(runfiles, '_main', 'loopal') + mkdirSync(join(runfiles, '_main')) + writeFileSync(binary, '') + expect(resolveLoopalBinary({ + isPackaged: false, + env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/loopal', + JS_BINARY__RUNFILES: runfiles, + }, + resourcesPath: '/resources', + platform: 'darwin', + cwd: '/unrelated/workspace', + })).toBe(binary) + }) + + it('resolves manifest-only runfiles and relative explicit overrides', () => { + const directory = temporaryDirectory() + const manifest = join(directory, 'MANIFEST') + const binary = join(directory, 'physical loopal') + writeFileSync(manifest, ` _main/loopal ${binary.replaceAll(' ', '\\s')}\n`) + expect(resolveLoopalBinary({ + isPackaged: false, + env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/loopal', + RUNFILES_MANIFEST_FILE: manifest, + }, + resourcesPath: '/resources', platform: 'linux', cwd: '/workspace', + })).toBe(binary) + expect(resolveLoopalBinary({ + isPackaged: false, + env: { LOOPAL_DESKTOP_BINARY: './bin/loopal' }, + resourcesPath: '/resources', platform: 'linux', cwd: '/workspace', + })).toBe('/workspace/bin/loopal') + }) + + it('handles absolute, missing, malformed, and unreadable runfiles metadata', () => { + const directory = temporaryDirectory() + const manifest = join(directory, 'MANIFEST') + const binary = join(directory, 'loopal') + writeFileSync(manifest, `malformed\n_main/loopal ${binary}\n`) + const base = { + isPackaged: false, resourcesPath: '/resources', platform: 'linux' as const, cwd: '/cwd', + } + expect(resolveLoopalBinary({ + ...base, env: { LOOPAL_DESKTOP_BINARY_RUNFILE: binary }, + })).toBe(binary) + expect(resolveLoopalBinary({ + ...base, env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/loopal', RUNFILES_MANIFEST_FILE: manifest, + }, + })).toBe(binary) + expect(resolveLoopalBinary({ + ...base, env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/other', RUNFILES_MANIFEST_FILE: manifest, + }, + })).toBeUndefined() + expect(resolveLoopalBinary({ + ...base, env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/missing', RUNFILES_DIR: directory, + }, + })).toBe(join(directory, '_main/missing')) + expect(resolveLoopalBinary({ + ...base, env: { + LOOPAL_DESKTOP_BINARY_RUNFILE: '_main/missing', TEST_SRCDIR: directory, + RUNFILES_MANIFEST_FILE: join(directory, 'absent'), + }, + })).toBe(join(directory, '_main/missing')) + }) + + function temporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'loopal-runtime-mode-')) + temporaryDirectories.push(directory) + return directory + } +}) diff --git a/apps/desktop/src/main/app/runtime-mode.ts b/apps/desktop/src/main/app/runtime-mode.ts new file mode 100644 index 00000000..d0b24591 --- /dev/null +++ b/apps/desktop/src/main/app/runtime-mode.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, join, posix, win32 } from 'node:path' + +export function useFakeBackend(isPackaged: boolean, env: NodeJS.ProcessEnv): boolean { + return !isPackaged && env.LOOPAL_DESKTOP_BACKEND === 'fake' +} + +export function keepE2eWindowHidden(isPackaged: boolean, env: NodeJS.ProcessEnv): boolean { + return !isPackaged && env.LOOPAL_DESKTOP_E2E_HIDDEN === '1' +} + +export function resolveDesktopCwd( + isPackaged: boolean, + env: NodeJS.ProcessEnv, + cwd: string, +): string | undefined { + if (isPackaged) return undefined + return env.LOOPAL_DESKTOP_CWD || cwd +} + +export function resolveRendererUrl( + isPackaged: boolean, + env: NodeJS.ProcessEnv, +): string | undefined { + return isPackaged ? undefined : env.ELECTRON_RENDERER_URL +} + +export function resolveLoopalBinary(options: { + readonly isPackaged: boolean + readonly env: NodeJS.ProcessEnv + readonly resourcesPath: string + readonly platform: NodeJS.Platform + readonly cwd: string +}): string | undefined { + const platformPath = options.platform === 'win32' ? win32 : posix + if (!options.isPackaged) { + const override = options.env.LOOPAL_DESKTOP_BINARY + if (override) { + return platformPath.isAbsolute(override) + ? override + : platformPath.resolve(options.cwd, override) + } + return resolveRunfile(options.env.LOOPAL_DESKTOP_BINARY_RUNFILE, options.env) + } + const executable = options.platform === 'win32' ? 'loopal.exe' : 'loopal' + return platformPath.join(options.resourcesPath, 'bin', executable) +} + +function resolveRunfile( + runfile: string | undefined, + env: NodeJS.ProcessEnv, +): string | undefined { + if (!runfile) return undefined + if (isAbsolute(runfile)) return runfile + const root = env.JS_BINARY__RUNFILES || env.RUNFILES_DIR || env.TEST_SRCDIR + const candidate = root ? join(root, runfile) : undefined + if (candidate && existsSync(candidate)) return candidate + const manifest = env.RUNFILES_MANIFEST_FILE + if (!manifest) return candidate + try { + for (const line of readFileSync(manifest, 'utf8').split(/\r?\n/u)) { + const entry = parseManifestEntry(line) + if (entry?.[0] === runfile) return entry[1] + } + } catch { + return candidate + } + return candidate +} + +function parseManifestEntry(line: string): readonly [string, string] | undefined { + const escaped = line.startsWith(' ') + const start = escaped ? 1 : 0 + const separator = line.indexOf(' ', start) + if (separator < 0) return undefined + const decode = (value: string): string => escaped + ? value.replace(/\\([snb])/gu, (_, code: string) => ({ s: ' ', n: '\n', b: '\\' })[code]!) + : value + return [decode(line.slice(start, separator)), decode(line.slice(separator + 1))] +} diff --git a/apps/desktop/src/main/backend-loader.ts b/apps/desktop/src/main/backend-loader.ts new file mode 100644 index 00000000..7a518851 --- /dev/null +++ b/apps/desktop/src/main/backend-loader.ts @@ -0,0 +1,62 @@ +import { app, dialog } from 'electron' +import { dirname, join } from 'node:path' +import { type DesktopBackend } from '../platform/loopal-backend/common/backend' +import { type SessionDirectorySelection } from '../shared/contracts' +import { FakeDesktopBackend } from '../platform/loopal-backend/node/fake/fake-backend' +import { LoopalDesktopBackend } from '../platform/loopal-backend/node/backend/loopal-backend' +import { UnavailableDesktopBackend } from '../platform/loopal-backend/node/unavailable/unavailable-backend' +import { + resolveDesktopCwd, resolveLoopalBinary, useFakeBackend, +} from './app/runtime-mode' +import { authorizePackagedWorkspace } from './sessions/workspace-authorization' + +export interface AppBackend extends DesktopBackend { + dispose(): void + shutdown?(): Promise + authorizeSessionDirectory(path: string): Promise +} + +export async function loadDesktopBackend(): Promise { + const preferencesPath = join(app.getPath('userData'), 'desktop-preferences.json') + if (useFakeBackend(app.isPackaged, process.env)) { + return new FakeDesktopBackend(undefined, preferencesPath) + } + const binaryPath = resolveLoopalBinary({ + isPackaged: app.isPackaged, + env: process.env, + resourcesPath: process.resourcesPath, + platform: process.platform, + cwd: process.cwd(), + }) + if (!binaryPath) { + return new UnavailableDesktopBackend( + 'Loopal Desktop Host is unavailable. Build //:loopal and launch the Bazel desktop target.', + ) + } + let cwd = app.isPackaged + ? undefined + : resolveDesktopCwd(false, process.env, process.cwd()) + if (!cwd) { + const authorization = await authorizePackagedWorkspace({ + userDataPath: app.getPath('userData'), + homePath: app.getPath('home'), + applicationPaths: [app.getAppPath(), process.resourcesPath, dirname(app.getPath('exe'))], + selectDirectory: async () => { + const result = await dialog.showOpenDialog({ + title: 'Open a Loopal workspace', properties: ['openDirectory'], + }) + return result.canceled ? undefined : result.filePaths[0] + }, + }) + if (!authorization.ok) return new UnavailableDesktopBackend(authorization.reason) + cwd = authorization.path + } + return new LoopalDesktopBackend({ + binaryPath, + cwd, + parentPid: process.pid, + sessionStatePath: join(app.getPath('userData'), 'session-lifecycle.json'), + metaHubSettingsPath: join(app.getPath('userData'), 'metahub-settings.json'), + desktopPreferencesPath: preferencesPath, + }) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 00000000..fc1d899b --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,151 @@ +import { app, BrowserWindow, session, shell } from 'electron' +import { dirname, join } from 'node:path' +import { DisposableStore } from '../base/common/lifecycle' +import { monitorParent } from './app/parent-liveness' +import { isSafeExternalUrl, mainWindowPolicy, registerRendererServer } from './app/renderer-server' +import { + keepE2eWindowHidden, + resolveRendererUrl, +} from './app/runtime-mode' +import { registerImagePicker } from './media/image-picker' +import { registerSessionDirectoryPicker } from './sessions/session-directory-picker' +import { validateWorkspaceSelection } from './sessions/workspace-authorization' +import { loadDesktopBackend, type AppBackend } from './backend-loader' +const appDisposables = new DisposableStore() +const windowDisposables = new Map() +let mainWindow: BrowserWindow | undefined +let appBackend: AppBackend | undefined +let quitInProgress: Promise | undefined +let readyToQuit = false +const keepWindowsHidden = keepE2eWindowHidden(app.isPackaged, process.env) +if (keepWindowsHidden && process.platform === 'darwin') app.setActivationPolicy('accessory') +if (!app.requestSingleInstanceLock()) { + app.quit() +} else { + if (keepWindowsHidden) appDisposables.add(monitorParent(process.ppid, () => app.quit())) + app.on('second-instance', () => { + if (keepWindowsHidden) return + if (!mainWindow) { + if (appBackend) { + void createWindow(appBackend) + } + return + } + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.focus() + }) + + void app.whenReady().then(async () => { + appBackend = await loadDesktopBackend() + appDisposables.add(appBackend) + configureSessionSecurity() + await createWindow(appBackend) + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + void createWindow(appBackend!) + } + }) + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + } + }) + + app.on('before-quit', (event) => { + if (readyToQuit) { + return + } + event.preventDefault() + if (quitInProgress) { + return + } + for (const store of windowDisposables.values()) { + store.dispose() + } + windowDisposables.clear() + quitInProgress = shutdownApplication().finally(() => { + readyToQuit = true + app.quit() + }) + }) +} +async function shutdownApplication(): Promise { + try { + if (appBackend?.shutdown) { + await Promise.race([ + appBackend.shutdown(), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]) + } + } finally { + appDisposables.dispose() + } +} +async function createWindow(backend: AppBackend): Promise { + const window = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 900, + minHeight: 620, + show: false, + skipTaskbar: keepWindowsHidden, + title: 'Loopal Desktop', + backgroundColor: '#0b0d12', + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + webPreferences: { + preload: join(__dirname, '../preload/index.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + webSecurity: true, + backgroundThrottling: !keepWindowsHidden, + }, + }) + mainWindow = window + const store = new DisposableStore() + windowDisposables.set(window.id, store) + store.add(registerRendererServer(backend, mainWindowPolicy(window.webContents))) + store.add(registerImagePicker(window)) + store.add(registerSessionDirectoryPicker(window, backend, { + validateDirectory: (path) => validateWorkspaceSelection(path, { + userDataPath: app.getPath('userData'), homePath: app.getPath('home'), + applicationPaths: [app.getAppPath(), process.resourcesPath, dirname(app.getPath('exe'))], + }), + })) + window.webContents.setWindowOpenHandler(({ url }) => { + if (isSafeExternalUrl(url)) { + void shell.openExternal(url) + } + return { action: 'deny' } + }) + window.webContents.on('will-navigate', (event, url) => { + if (url !== window.webContents.getURL()) { + event.preventDefault() + } + }) + if (!keepWindowsHidden) window.once('ready-to-show', () => window.show()) + window.once('closed', () => { + store.dispose() + windowDisposables.delete(window.id) + if (mainWindow === window) { + mainWindow = undefined + } + }) + const rendererUrl = resolveRendererUrl(app.isPackaged, process.env) + if (rendererUrl) { + await window.loadURL(rendererUrl) + } else { + await window.loadFile(join(__dirname, '../renderer/index.html')) + } +} +function configureSessionSecurity(): void { + session.defaultSession.setPermissionRequestHandler( + (_webContents, _permission, callback) => callback(false), + ) + session.defaultSession.setPermissionCheckHandler(() => false) +} diff --git a/apps/desktop/src/main/media/image-loader.ts b/apps/desktop/src/main/media/image-loader.ts new file mode 100644 index 00000000..0289f6d3 --- /dev/null +++ b/apps/desktop/src/main/media/image-loader.ts @@ -0,0 +1,65 @@ +import { open } from 'node:fs/promises' +import { basename } from 'node:path' +import { + DESKTOP_IMAGE_MAX_BYTES, + DESKTOP_IMAGE_MAX_COUNT, + DESKTOP_IMAGE_MAX_TOTAL_BYTES, + DesktopImageAttachmentListSchema, + type DesktopImageAttachment, +} from '../../shared/contracts' + +export async function loadSelectedImages( + paths: readonly string[], +): Promise { + if (paths.length > DESKTOP_IMAGE_MAX_COUNT) { + throw new Error(`Attach at most ${DESKTOP_IMAGE_MAX_COUNT} images`) + } + const images: DesktopImageAttachment[] = [] + let total = 0 + for (const path of paths) { + const data = await readBoundedFile(path) + total += data.length + if (total > DESKTOP_IMAGE_MAX_TOTAL_BYTES) { + throw new Error('Image attachments exceed the total size limit') + } + const mediaType = detectImageType(data) + if (!mediaType) throw new Error(`${basename(path)} is not a supported image`) + images.push({ + name: basename(path).slice(0, 255), mediaType, + data: data.toString('base64'), sizeBytes: data.length, + }) + } + return DesktopImageAttachmentListSchema.parse(images) +} + +async function readBoundedFile(path: string): Promise { + const file = await open(path, 'r') + try { + const stat = await file.stat() + if (!stat.isFile() || stat.size <= 0 || stat.size > DESKTOP_IMAGE_MAX_BYTES) { + throw new Error(`${basename(path)} must be a non-empty image under 10 MiB`) + } + const data = Buffer.alloc(stat.size) + let offset = 0 + while (offset < data.length) { + const { bytesRead } = await file.read(data, offset, data.length - offset, offset) + if (bytesRead === 0) throw new Error(`${basename(path)} changed while it was being read`) + offset += bytesRead + } + return data + } finally { + await file.close() + } +} + +function detectImageType(data: Buffer): DesktopImageAttachment['mediaType'] | undefined { + if (data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return 'image/png' + } + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg' + const prefix = data.subarray(0, 6).toString('ascii') + if (prefix === 'GIF87a' || prefix === 'GIF89a') return 'image/gif' + if (data.subarray(0, 4).toString('ascii') === 'RIFF' + && data.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp' + return undefined +} diff --git a/apps/desktop/src/main/media/image-picker-loader.test.ts b/apps/desktop/src/main/media/image-picker-loader.test.ts new file mode 100644 index 00000000..e8533981 --- /dev/null +++ b/apps/desktop/src/main/media/image-picker-loader.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { DESKTOP_IMAGE_MAX_BYTES, DESKTOP_IMAGE_MAX_COUNT } from '../../shared/contracts' +import { loadSelectedImages } from './image-loader' + +let directory: string | undefined + +afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }) + directory = undefined +}) + +async function fixture(name: string, bytes: readonly number[]): Promise { + directory ??= await mkdtemp(join(tmpdir(), 'loopal-images-')) + const path = join(directory, name) + await writeFile(path, Buffer.from(bytes)) + return path +} + +describe('selected image loading', () => { + it('detects image content and emits bounded base64 data', async () => { + const png = await fixture('pixel.png', [137, 80, 78, 71, 13, 10, 26, 10]) + const gif = await fixture('animation.gif', [...Buffer.from('GIF89a'), 0, 0]) + await expect(loadSelectedImages([png, gif])).resolves.toEqual([ + { + name: 'pixel.png', mediaType: 'image/png', + data: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString('base64'), sizeBytes: 8, + }, + { + name: 'animation.gif', mediaType: 'image/gif', + data: Buffer.from([...Buffer.from('GIF89a'), 0, 0]).toString('base64'), sizeBytes: 8, + }, + ]) + }) + + it('rejects spoofed, oversized, and excessive selections before routing', async () => { + const fake = await fixture('fake.png', [...Buffer.from('not an image')]) + await expect(loadSelectedImages([fake])).rejects.toThrow('not a supported image') + + const huge = await fixture('huge.png', [137, 80, 78, 71, 13, 10, 26, 10]) + await truncate(huge, DESKTOP_IMAGE_MAX_BYTES + 1) + await expect(loadSelectedImages([huge])).rejects.toThrow('under 10 MiB') + await expect(loadSelectedImages( + Array.from({ length: DESKTOP_IMAGE_MAX_COUNT + 1 }, () => fake), + )).rejects.toThrow('Attach at most') + }) +}) diff --git a/apps/desktop/src/main/media/image-picker.test.ts b/apps/desktop/src/main/media/image-picker.test.ts new file mode 100644 index 00000000..318e688f --- /dev/null +++ b/apps/desktop/src/main/media/image-picker.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SELECT_IMAGES_CHANNEL } from '../../shared/protocol/renderer-protocol' + +const electron = vi.hoisted(() => { + const state: { + handler: ((...args: unknown[]) => Promise) | undefined + } = { handler: undefined } + return { + state, + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: vi.fn((_channel: string, handler: (...args: unknown[]) => Promise) => { + state.handler = handler + }), + removeHandler: vi.fn(), + }, + } +}) + +vi.mock('electron', () => ({ + dialog: electron.dialog, + ipcMain: electron.ipcMain, +})) + +import { registerImagePicker } from './image-picker' + +describe('main-process image picker', () => { + beforeEach(() => { + electron.state.handler = undefined + electron.dialog.showOpenDialog.mockReset() + electron.ipcMain.handle.mockClear() + electron.ipcMain.removeHandler.mockClear() + }) + + it('accepts only owner-frame selections and never accepts a renderer path', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const window = { webContents } + const image = { + name: 'pixel.png', mediaType: 'image/png' as const, data: 'iVBORw==', sizeBytes: 4, + } + electron.dialog.showOpenDialog.mockResolvedValue({ + canceled: false, filePaths: ['/chosen/pixel.png'], + }) + const load = vi.fn(async () => [image]) + const registration = registerImagePicker(window as never, load) + expect(electron.ipcMain.handle).toHaveBeenCalledWith( + SELECT_IMAGES_CHANNEL, expect.any(Function), + ) + + await expect(electron.state.handler!({ sender: {}, senderFrame: mainFrame })) + .rejects.toThrow('restricted to the main window') + await expect(electron.state.handler!( + { sender: webContents, senderFrame: mainFrame }, '/renderer/path.png', + )).rejects.toThrow('does not accept file paths') + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toEqual([image]) + expect(load).toHaveBeenCalledWith(['/chosen/pixel.png']) + + registration.dispose() + expect(electron.ipcMain.removeHandler).toHaveBeenCalledWith(SELECT_IMAGES_CHANNEL) + }) + + it('returns no data when the native picker is cancelled', async () => { + const mainFrame = {} + const webContents = { mainFrame } + electron.dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }) + const load = vi.fn() + registerImagePicker({ webContents } as never, load) + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toEqual([]) + expect(load).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/media/image-picker.ts b/apps/desktop/src/main/media/image-picker.ts new file mode 100644 index 00000000..56c536b4 --- /dev/null +++ b/apps/desktop/src/main/media/image-picker.ts @@ -0,0 +1,31 @@ +import { dialog, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron' +import { toDisposable, type IDisposable } from '../../base/common/lifecycle' +import { + DesktopImageAttachmentListSchema, + type DesktopImageAttachment, +} from '../../shared/contracts' +import { SELECT_IMAGES_CHANNEL } from '../../shared/protocol/renderer-protocol' +import { loadSelectedImages } from './image-loader' + +type ImageLoader = (paths: readonly string[]) => Promise + +export function registerImagePicker( + window: BrowserWindow, + load: ImageLoader = loadSelectedImages, +): IDisposable { + const handler = async (event: IpcMainInvokeEvent, ...args: unknown[]): Promise => { + if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) { + throw new Error('Image selection is restricted to the main window') + } + if (args.length !== 0) throw new Error('Image selection does not accept file paths') + const result = await dialog.showOpenDialog(window, { + title: 'Attach images', + properties: ['openFile', 'multiSelections'], + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }], + }) + if (result.canceled) return [] + return DesktopImageAttachmentListSchema.parse(await load(result.filePaths)) + } + ipcMain.handle(SELECT_IMAGES_CHANNEL, handler) + return toDisposable(() => ipcMain.removeHandler(SELECT_IMAGES_CHANNEL)) +} diff --git a/apps/desktop/src/main/sessions/session-directory-picker.test.ts b/apps/desktop/src/main/sessions/session-directory-picker.test.ts new file mode 100644 index 00000000..24a33af9 --- /dev/null +++ b/apps/desktop/src/main/sessions/session-directory-picker.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SELECT_SESSION_DIRECTORY_CHANNEL } from '../../shared/protocol/renderer-protocol' + +const electron = vi.hoisted(() => { + const state: { handler?: (...args: unknown[]) => Promise } = {} + return { + state, + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: vi.fn((_channel: string, handler: (...args: unknown[]) => Promise) => { + state.handler = handler + }), + removeHandler: vi.fn(), + }, + } +}) +vi.mock('electron', () => ({ dialog: electron.dialog, ipcMain: electron.ipcMain })) + +import { registerSessionDirectoryPicker } from './session-directory-picker' + +const selection = { + authorizationId: 'd10f67f2-f471-44ea-b6d1-e1b963e11228', + path: '/work/loopal', name: 'loopal', suggestedWorktreeName: 'loopal-task', + git: { root: '/work/loopal', branch: 'main', dirty: false }, +} + +describe('main-process session directory picker', () => { + beforeEach(() => { + delete process.env.LOOPAL_DESKTOP_E2E_DIRECTORY_QUEUE + delete process.env.LOOPAL_DESKTOP_E2E_HIDDEN + delete electron.state.handler + electron.ipcMain.handle.mockClear() + electron.ipcMain.removeHandler.mockClear() + }) + afterEach(() => { + delete process.env.LOOPAL_DESKTOP_E2E_DIRECTORY_QUEUE + delete process.env.LOOPAL_DESKTOP_E2E_HIDDEN + }) + + it('authorizes only a main-frame native selection', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const authorizeSessionDirectory = vi.fn(async () => selection) + const registration = registerSessionDirectoryPicker( + { webContents } as never, + { authorizeSessionDirectory }, + { validateDirectory: async (path) => path, pickDirectory: async () => '/work/loopal' }, + ) + expect(electron.ipcMain.handle).toHaveBeenCalledWith( + SELECT_SESSION_DIRECTORY_CHANNEL, expect.any(Function), + ) + + await expect(electron.state.handler!({ sender: {}, senderFrame: mainFrame })) + .rejects.toThrow('restricted to the main window') + await expect(electron.state.handler!( + { sender: webContents, senderFrame: mainFrame }, '/forged/path', + )).rejects.toThrow('does not accept renderer paths') + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toEqual(selection) + expect(authorizeSessionDirectory).toHaveBeenCalledWith('/work/loopal') + + registration.dispose() + expect(electron.ipcMain.removeHandler).toHaveBeenCalledWith( + SELECT_SESSION_DIRECTORY_CHANNEL, + ) + }) + + it('does not authorize a cancelled selection', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const authorizeSessionDirectory = vi.fn() + registerSessionDirectoryPicker( + { webContents } as never, { authorizeSessionDirectory }, + { validateDirectory: async (path) => path, pickDirectory: async () => undefined }, + ) + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toBeUndefined() + expect(authorizeSessionDirectory).not.toHaveBeenCalled() + }) + + it('rejects unsafe native selections before backend authorization', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const authorizeSessionDirectory = vi.fn() + registerSessionDirectoryPicker( + { webContents } as never, { authorizeSessionDirectory }, { + validateDirectory: async () => undefined, + pickDirectory: async () => '/private/application-state', + }, + ) + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .rejects.toThrow('unavailable or unsafe') + expect(authorizeSessionDirectory).not.toHaveBeenCalled() + }) + + it('rejects a safe subdirectory when its Git root is reserved', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const unsafeRoot = { ...selection, git: { ...selection.git, root: '/reserved/home' } } + const authorizeSessionDirectory = vi.fn(async () => unsafeRoot) + registerSessionDirectoryPicker( + { webContents } as never, { authorizeSessionDirectory }, { + validateDirectory: async (path) => path === '/work/loopal' ? path : undefined, + pickDirectory: async () => '/work/loopal', + }, + ) + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .rejects.toThrow('Git repository is unavailable or unsafe') + expect(authorizeSessionDirectory).toHaveBeenCalledWith('/work/loopal') + }) + + it('rejects a directory that changes while the backend inspects it', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const changed = { ...selection, path: '/reserved/replaced' } + registerSessionDirectoryPicker( + { webContents } as never, + { authorizeSessionDirectory: async () => changed }, + { + validateDirectory: async (path) => path, + pickDirectory: async () => '/work/loopal', + }, + ) + + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .rejects.toThrow('changed during authorization') + }) + + it('returns the validated canonical Git root when path spelling differs', async () => { + const mainFrame = {} + const webContents = { mainFrame } + const differentlySpelled = { + ...selection, git: { ...selection.git, root: '/WORK/LOOPAL' }, + } + registerSessionDirectoryPicker( + { webContents } as never, + { authorizeSessionDirectory: async () => differentlySpelled }, + { + validateDirectory: async (path) => path.toLocaleLowerCase(), + pickDirectory: async () => '/WORK/LOOPAL', + }, + ) + + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toEqual(selection) + }) + + it('uses an explicit hidden-E2E queue without opening a native dialog', async () => { + process.env.LOOPAL_DESKTOP_E2E_HIDDEN = '1' + process.env.LOOPAL_DESKTOP_E2E_DIRECTORY_QUEUE = JSON.stringify(['/work/loopal', null]) + const mainFrame = {} + const webContents = { mainFrame } + const authorizeSessionDirectory = vi.fn(async () => selection) + const native = vi.fn(async () => '/native') + registerSessionDirectoryPicker( + { webContents } as never, { authorizeSessionDirectory }, + { validateDirectory: async (path) => path, pickDirectory: native }, + ) + + await electron.state.handler!({ sender: webContents, senderFrame: mainFrame }) + await expect(electron.state.handler!({ sender: webContents, senderFrame: mainFrame })) + .resolves.toBeUndefined() + expect(authorizeSessionDirectory).toHaveBeenCalledWith('/work/loopal') + expect(native).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/main/sessions/session-directory-picker.ts b/apps/desktop/src/main/sessions/session-directory-picker.ts new file mode 100644 index 00000000..4962f32b --- /dev/null +++ b/apps/desktop/src/main/sessions/session-directory-picker.ts @@ -0,0 +1,76 @@ +import { dialog, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron' +import { isAbsolute } from 'node:path' +import { toDisposable, type IDisposable } from '../../base/common/lifecycle' +import { + SessionDirectorySelectionSchema, type SessionDirectorySelection, +} from '../../shared/contracts' +import { SELECT_SESSION_DIRECTORY_CHANNEL } from '../../shared/protocol/renderer-protocol' + +export interface SessionDirectoryAuthorizer { + authorizeSessionDirectory(path: string): Promise +} + +type DirectoryPicker = () => Promise +interface SessionDirectoryPickerOptions { + readonly validateDirectory: (path: string) => Promise + readonly pickDirectory?: DirectoryPicker +} + +export function registerSessionDirectoryPicker( + window: BrowserWindow, + authorizer: SessionDirectoryAuthorizer, + options: SessionDirectoryPickerOptions, +): IDisposable { + const queued = e2eDirectoryPicker(process.env) + const picker = queued ?? options.pickDirectory ?? nativePicker(window) + const handler = async (event: IpcMainInvokeEvent, ...args: unknown[]): Promise => { + if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) { + throw new Error('Directory selection is restricted to the main window') + } + if (args.length !== 0) throw new Error('Directory selection does not accept renderer paths') + const path = await picker() + if (!path) return undefined + const validated = await options.validateDirectory(path) + if (!validated) throw new Error('The selected session directory is unavailable or unsafe') + const selection = SessionDirectorySelectionSchema.parse( + await authorizer.authorizeSessionDirectory(validated), + ) + const selectedPath = await options.validateDirectory(selection.path) + if (!selectedPath || selectedPath !== validated) { + throw new Error('The selected session directory changed during authorization') + } + if (selection.git) { + const gitRoot = await options.validateDirectory(selection.git.root) + if (!gitRoot) { + throw new Error('The selected Git repository is unavailable or unsafe') + } + return SessionDirectorySelectionSchema.parse({ + ...selection, path: selectedPath, git: { ...selection.git, root: gitRoot }, + }) + } + return SessionDirectorySelectionSchema.parse({ ...selection, path: selectedPath }) + } + ipcMain.handle(SELECT_SESSION_DIRECTORY_CHANNEL, handler) + return toDisposable(() => ipcMain.removeHandler(SELECT_SESSION_DIRECTORY_CHANNEL)) +} + +function nativePicker(window: BrowserWindow): DirectoryPicker { + return async () => { + const result = await dialog.showOpenDialog(window, { + properties: ['openDirectory', 'createDirectory'], + }) + return result.canceled ? undefined : result.filePaths[0] + } +} + +function e2eDirectoryPicker(env: NodeJS.ProcessEnv): DirectoryPicker | undefined { + const raw = env.LOOPAL_DESKTOP_E2E_DIRECTORY_QUEUE + if (env.LOOPAL_DESKTOP_E2E_HIDDEN !== '1' || !raw) return undefined + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed) || parsed.some((value) => value !== null + && (typeof value !== 'string' || !isAbsolute(value)))) { + throw new Error('LOOPAL_DESKTOP_E2E_DIRECTORY_QUEUE must contain absolute paths or null') + } + const queue = [...parsed] as (string | null)[] + return async () => queue.shift() ?? undefined +} diff --git a/apps/desktop/src/main/sessions/workspace-authorization.test.ts b/apps/desktop/src/main/sessions/workspace-authorization.test.ts new file mode 100644 index 00000000..56b3ac84 --- /dev/null +++ b/apps/desktop/src/main/sessions/workspace-authorization.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, mkdir, readFile, realpath, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import { + authorizePackagedWorkspace, + workspaceRecordName, + type WorkspaceAuthorizationOptions, +} from './workspace-authorization' + +describe('packaged workspace authorization', () => { + let root: string + let home: string + let userData: string + let application: string + let workspace: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-workspace-')) + home = join(root, 'homes', 'stone') + userData = join(root, 'state') + application = join(root, 'install', 'Loopal.app') + workspace = join(home, 'projects', 'loopal') + await Promise.all([ + mkdir(userData, { recursive: true }), + mkdir(join(application, 'internal'), { recursive: true }), + mkdir(workspace, { recursive: true }), + ]) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('uses a valid stored canonical workspace without opening the chooser', async () => { + await writeRecord(workspace) + const selectDirectory = vi.fn<() => Promise>() + + await expect(authorize({ selectDirectory })).resolves.toEqual({ + ok: true, + path: await realpath(workspace), + }) + expect(selectDirectory).not.toHaveBeenCalled() + }) + + it('reselects when a stored record is invalid or unsafe', async () => { + const records = [ + 'not json', + 'null', + '[]', + JSON.stringify({ version: 2, path: workspace }), + JSON.stringify({ version: 1, path: 42 }), + JSON.stringify({ version: 1, path: join(root, 'missing') }), + JSON.stringify({ version: 1, path: application }), + ] + for (const record of records) { + await writeFile(join(userData, workspaceRecordName), record) + const selectDirectory = vi.fn(async () => workspace) + const result = await authorize({ selectDirectory }) + expect(result).toEqual({ ok: true, path: await realpath(workspace) }) + expect(selectDirectory).toHaveBeenCalledOnce() + } + }) + + it('fails closed when selection is cancelled or throws', async () => { + const cancelled = await authorize({ selectDirectory: async () => undefined }) + expect(cancelled).toEqual({ ok: false, reason: 'Workspace selection was cancelled.' }) + + const failed = await authorize({ selectDirectory: async () => { + throw new Error('dialog unavailable') + } }) + expect(failed).toEqual({ ok: false, reason: 'Workspace selection failed.' }) + await expect(readFile(join(userData, workspaceRecordName))).rejects.toThrow() + }) + + it('rejects roots, app-related paths, internal state, and files', async () => { + const file = join(home, 'file.txt') + await writeFile(file, 'not a directory') + const dangerous = [ + parse(root).root, + join(root, 'homes'), + home, + join(root, 'install'), + application, + join(application, 'internal'), + userData, + join(userData, 'nested'), + file, + ] + await mkdir(join(userData, 'nested')) + for (const path of dangerous) { + const result = await authorize({ selectDirectory: async () => path }) + expect(result).toEqual({ + ok: false, + reason: 'The selected workspace is unavailable or unsafe.', + }) + } + }) + + it('atomically persists a successful explicit selection', async () => { + const result = await authorize({ selectDirectory: async () => workspace }) + const canonical = await realpath(workspace) + + expect(result).toEqual({ ok: true, path: canonical }) + expect(JSON.parse(await readFile(join(userData, workspaceRecordName), 'utf8'))).toEqual({ + version: 1, + path: canonical, + }) + expect((await readdir(userData)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('fails closed when the authorization record cannot be persisted', async () => { + await mkdir(join(userData, workspaceRecordName)) + + await expect(authorize({ + selectDirectory: async () => workspace, + })).resolves.toEqual({ + ok: false, + reason: 'The workspace authorization could not be saved.', + }) + expect((await readdir(userData)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + function authorize( + overrides: Partial, + ) { + return authorizePackagedWorkspace({ + userDataPath: userData, + homePath: home, + applicationPaths: [application, join(root, 'missing-app')], + selectDirectory: async () => undefined, + ...overrides, + }) + } + + async function writeRecord(path: string): Promise { + await writeFile( + join(userData, workspaceRecordName), + JSON.stringify({ version: 1, path }), + ) + } +}) diff --git a/apps/desktop/src/main/sessions/workspace-authorization.ts b/apps/desktop/src/main/sessions/workspace-authorization.ts new file mode 100644 index 00000000..12c0f8dc --- /dev/null +++ b/apps/desktop/src/main/sessions/workspace-authorization.ts @@ -0,0 +1,124 @@ +import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path' + +export const workspaceRecordName = 'workspace.json' + +export interface WorkspaceAuthorizationOptions { + readonly userDataPath: string + readonly homePath: string + readonly applicationPaths: readonly string[] + readonly selectDirectory: () => Promise +} + +export type WorkspaceAuthorization = + | { readonly ok: true; readonly path: string } + | { readonly ok: false; readonly reason: string } + +export async function authorizePackagedWorkspace( + options: WorkspaceAuthorizationOptions, +): Promise { + const recordPath = join(options.userDataPath, workspaceRecordName) + const storedPath = await readRecord(recordPath) + if (storedPath) { + const stored = await validateWorkspaceSelection(storedPath, options) + if (stored) return { ok: true, path: stored } + } + + let selected: string | undefined + try { + selected = await options.selectDirectory() + } catch { + return failure('Workspace selection failed.') + } + if (!selected) return failure('Workspace selection was cancelled.') + + const workspace = await validateWorkspaceSelection(selected, options) + if (!workspace) return failure('The selected workspace is unavailable or unsafe.') + try { + await persistRecord(recordPath, workspace) + } catch { + return failure('The workspace authorization could not be saved.') + } + return { ok: true, path: workspace } +} + +async function readRecord(path: string): Promise { + try { + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + if (!value || typeof value !== 'object') return undefined + const record = value as Record + return record.version === 1 && typeof record.path === 'string' + ? record.path + : undefined + } catch { + return undefined + } +} + +export async function validateWorkspaceSelection( + candidate: string, + options: Omit, +): Promise { + let canonical: string + try { + canonical = await realpath(candidate) + if (!(await stat(canonical)).isDirectory()) return undefined + } catch { + return undefined + } + if (samePath(canonical, parse(canonical).root)) return undefined + const home = await canonicalReference(options.homePath) + if (containsPath(canonical, home)) return undefined + const reserved = [options.userDataPath, ...options.applicationPaths] + for (const path of reserved) { + const reference = await canonicalReference(path) + if (containsPath(canonical, reference) || containsPath(reference, canonical)) { + return undefined + } + } + return canonical +} + +async function canonicalReference(path: string): Promise { + try { + return await realpath(path) + } catch { + return resolve(path) + } +} + +function containsPath(parent: string, child: string): boolean { + const value = relative(normalizePath(parent), normalizePath(child)) + return value === '' || (value !== '..' && !value.startsWith(`..${sep}`) && !isAbsolute(value)) +} + +function samePath(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right) +} + +function normalizePath(path: string): string { + return resolve(path) +} + +async function persistRecord(recordPath: string, workspace: string): Promise { + const directory = resolve(recordPath, '..') + const temporary = join(directory, `.workspace-${randomUUID()}.tmp`) + await mkdir(directory, { recursive: true }) + let committed = false + try { + await writeFile(temporary, `${JSON.stringify({ version: 1, path: workspace })}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }) + await rename(temporary, recordPath) + committed = true + } finally { + if (!committed) await rm(temporary, { force: true }) + } +} + +function failure(reason: string): WorkspaceAuthorization { + return { ok: false, reason } +} diff --git a/apps/desktop/src/platform/desktop-host/common/desktop-handshake.test.ts b/apps/desktop/src/platform/desktop-host/common/desktop-handshake.test.ts new file mode 100644 index 00000000..be1ac7ce --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/common/desktop-handshake.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + DESKTOP_CAPABILITY_HUB_UI, + DESKTOP_CAPABILITY_WORKSPACE, + DESKTOP_EVENT_PREFIX, + DESKTOP_HANDSHAKE_PREFIX, + DESKTOP_PROTOCOL_VERSION, + DESKTOP_TRANSPORT, + DesktopHandshakeSchema, + DesktopSessionCreatedHandshakeSchema, + parseDesktopHandshakeLine, +} from './desktop-handshake' + +const common = { + protocol_version: DESKTOP_PROTOCOL_VERSION, + server_version: '0.6.3', + pid: 1200, + parent_pid: 1100, +} + +function line(payload: unknown, ending = '\n'): string { + return `${DESKTOP_HANDSHAKE_PREFIX}${JSON.stringify(payload)}${ending}` +} + +describe('Desktop Host handshake', () => { + it('parses alive, ready, and error records with LF or CRLF framing', () => { + const alive = { + ...common, + phase: 'alive' as const, + addr: '127.0.0.1:49978', + token: 'secret-token', + transport: DESKTOP_TRANSPORT, + capabilities: [ + DESKTOP_CAPABILITY_HUB_UI, + DESKTOP_CAPABILITY_WORKSPACE, + 'future_surface_v2', + ], + } + const ready = { + protocol_version: DESKTOP_PROTOCOL_VERSION, + server_version: '0.6.3', + pid: 1200, + phase: 'ready' as const, + session_id: 'session-1', + } + const error = { + ...common, + phase: 'error' as const, + code: 'startup_failed', + message: 'could not start', + } + + expect(parseDesktopHandshakeLine(line(alive))).toEqual(alive) + expect(parseDesktopHandshakeLine(line(ready, '\r\n'))).toEqual(ready) + expect(parseDesktopHandshakeLine(line(error, '\n\n'))).toEqual(error) + expect(DesktopHandshakeSchema.parse(alive)).toEqual(alive) + }) + + it('ignores ordinary stdout without attempting JSON parsing', () => { + expect(parseDesktopHandshakeLine('Loopal is starting\n')).toBeUndefined() + expect(parseDesktopHandshakeLine('')).toBeUndefined() + }) + + it('parses session-created only on its backward-compatible optional prefix', () => { + const created = { ...common, phase: 'session_created' as const, session_id: 'session-1' } + expect(parseDesktopHandshakeLine( + `${DESKTOP_EVENT_PREFIX}${JSON.stringify(created)}\n`, + )).toEqual(created) + expect(DesktopSessionCreatedHandshakeSchema.parse(created)).toEqual(created) + expect(() => parseDesktopHandshakeLine(line(created))).toThrow() + expect(() => parseDesktopHandshakeLine( + `${DESKTOP_EVENT_PREFIX}${JSON.stringify({ + ...common, phase: 'ready', session_id: 'session-1', + })}\n`, + )).toThrow() + }) + + it.each([ + ['malformed JSON', `${DESKTOP_HANDSHAKE_PREFIX}{not-json}`], + ['wrong protocol', line({ ...common, protocol_version: 2, phase: 'ready', session_id: 's' })], + ['non-loopback address', line({ + ...common, + phase: 'alive', + addr: '0.0.0.0:9000', + token: 'token', + transport: DESKTOP_TRANSPORT, + capabilities: [DESKTOP_CAPABILITY_HUB_UI], + })], + ['missing capability', line({ + ...common, + phase: 'alive', + addr: '127.0.0.1:9000', + token: 'token', + transport: DESKTOP_TRANSPORT, + capabilities: [], + })], + ['unknown field', line({ ...common, phase: 'ready', session_id: 's', extra: true })], + ['empty error message', line({ ...common, phase: 'error', code: 'failed', message: '' })], + ['invalid pid', line({ ...common, pid: 0, phase: 'ready', session_id: 's' })], + ])('rejects %s', (_name, value) => { + expect(() => parseDesktopHandshakeLine(value)).toThrow() + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/common/desktop-handshake.ts b/apps/desktop/src/platform/desktop-host/common/desktop-handshake.ts new file mode 100644 index 00000000..9170166d --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/common/desktop-handshake.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +export const DESKTOP_HANDSHAKE_PREFIX = 'LOOPAL_DESKTOP ' as const +export const DESKTOP_EVENT_PREFIX = 'LOOPAL_DESKTOP_EVENT ' as const +export const DESKTOP_PROTOCOL_VERSION = 1 as const +export const DESKTOP_TRANSPORT = 'tcp_jsonrpc_ndjson' as const +export const DESKTOP_CAPABILITY_HUB_UI = 'hub_ui_v1' as const +export const DESKTOP_CAPABILITY_WORKSPACE = 'workspace_v1' as const +export const DESKTOP_REQUIRED_CAPABILITIES = [ + DESKTOP_CAPABILITY_HUB_UI, + DESKTOP_CAPABILITY_WORKSPACE, +] as const + +const capabilities = z.array(z.string().min(1)).refine( + (items) => DESKTOP_REQUIRED_CAPABILITIES.every((item) => items.includes(item)), + 'required desktop capabilities are missing', +) + +const common = z.object({ + protocol_version: z.literal(DESKTOP_PROTOCOL_VERSION), + server_version: z.string().min(1), + pid: z.number().int().positive(), + parent_pid: z.number().int().positive().optional(), +}) + +export const DesktopHandshakeSchema = z.discriminatedUnion('phase', [ + common.extend({ + phase: z.literal('alive'), + addr: z.string().regex(/^127\.0\.0\.1:\d+$/), + token: z.string().min(1), + transport: z.literal(DESKTOP_TRANSPORT), + capabilities, + }).strict(), + common.extend({ + phase: z.literal('ready'), + session_id: z.string().min(1), + }).strict(), + common.extend({ + phase: z.literal('error'), + code: z.string().min(1), + message: z.string().min(1), + }).strict(), +]) + +export const DesktopSessionCreatedHandshakeSchema = common.extend({ + phase: z.literal('session_created'), + session_id: z.string().min(1), +}).strict() + +export type DesktopCoreHandshake = z.infer +export type DesktopSessionCreatedHandshake = z.infer< + typeof DesktopSessionCreatedHandshakeSchema +> +export type DesktopHandshake = DesktopCoreHandshake | DesktopSessionCreatedHandshake +export type DesktopAliveHandshake = Extract +export type DesktopReadyHandshake = Extract +export type DesktopActivationHandshake = DesktopSessionCreatedHandshake | DesktopReadyHandshake + +/** Parse one physical stdout line. Non-protocol logging is deliberately ignored. */ +export function parseDesktopHandshakeLine(line: string): DesktopHandshake | undefined { + const normalized = line.replace(/[\r\n]+$/, '') + if (normalized.startsWith(DESKTOP_EVENT_PREFIX)) { + return DesktopSessionCreatedHandshakeSchema.parse( + JSON.parse(normalized.slice(DESKTOP_EVENT_PREFIX.length)), + ) + } + if (!normalized.startsWith(DESKTOP_HANDSHAKE_PREFIX)) return undefined + return DesktopHandshakeSchema.parse( + JSON.parse(normalized.slice(DESKTOP_HANDSHAKE_PREFIX.length)), + ) +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-generation.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-generation.ts new file mode 100644 index 00000000..432246b6 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-generation.ts @@ -0,0 +1,91 @@ +import { DisposableStore } from '../../../../base/common/lifecycle' +import { + DesktopProcess, DesktopProcessTerminationError, withTimeout, +} from '../process/desktop-process' +import { type DesktopHostSession } from './desktop-host-types' +import { type JsonRpcClient } from '../rpc/jsonrpc-client' + +export interface DesktopHostGeneration { + readonly command: number + readonly process: DesktopProcess + readonly subscriptions: DisposableStore + rpc?: JsonRpcClient + session?: DesktopHostSession + cleanup?: Promise + closing: boolean + exited: boolean +} + +export function createGeneration( + command: number, + process: DesktopProcess, +): DesktopHostGeneration { + return { + command, + process, + subscriptions: new DisposableStore(), + closing: false, + exited: false, + } +} + +export function terminateGeneration( + generation: DesktopHostGeneration, + graceful: boolean, + timeoutMs: number, +): Promise { + generation.cleanup ??= terminateInternal(generation, graceful, timeoutMs) + return generation.cleanup +} + +async function terminateInternal( + generation: DesktopHostGeneration, + graceful: boolean, + timeoutMs: number, +): Promise { + generation.closing = true + delete generation.session + generation.subscriptions.dispose() + try { + let sentTerm = !graceful || !generation.rpc + if (!sentTerm) { + try { + await withTimeout( + generation.rpc!.call('hub/shutdown', {}), + timeoutMs, + 'Loopal Hub shutdown request timed out', + ) + } catch { + sentTerm = true + } + } + if (sentTerm && !generation.exited) generation.process.kill('SIGTERM') + const exit = generation.process.waitForExit() + try { + await withTimeout(exit, timeoutMs, 'Loopal Desktop Host did not exit after shutdown') + generation.exited = true + } catch { + generation.process.kill('SIGKILL') + try { + await withTimeout( + exit, + Math.max(timeoutMs, 100), + 'Loopal Desktop Host stdout did not close after SIGKILL', + ) + generation.exited = true + } catch { + if (!generation.process.forceFinalizeProtocol()) { + throw new DesktopProcessTerminationError( + 'desktop_process_termination_unconfirmed: Host did not exit after SIGKILL', + ) + } + const forced = await exit + generation.exited = true + throw forced.error + } + } + } finally { + generation.rpc?.dispose() + delete generation.rpc + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-methods.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-methods.ts new file mode 100644 index 00000000..f71feebc --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-methods.ts @@ -0,0 +1,38 @@ +export const DESKTOP_HUB_METHODS = new Set([ + 'desktop/listSessions', + 'desktop/getSettings', + 'desktop/updateSettings', + 'desktop/listMcpServers', + 'desktop/upsertMcpServer', + 'desktop/deleteMcpServer', + 'desktop/listSkills', + 'desktop/getSkill', + 'desktop/upsertSkill', + 'desktop/deleteSkill', + 'desktop/listPlugins', + 'view/snapshot', + 'hub/list_agents', + 'hub/topology', + 'hub/status', + 'hub/join_meta', + 'hub/leave_meta', + 'meta/list_hubs', + 'meta/topology', + 'hub/route', + 'hub/interrupt', + 'hub/control', + 'hub/permission_response', + 'hub/question_response', + 'hub/plan_approval_response', + 'workspace/listDirectory', + 'workspace/readFile', + 'workspace/writeFile', + 'workspace/search', + 'workspace/gitStatus', + 'workspace/gitDiff', + 'workspace/gitStage', + 'workspace/gitUnstage', + 'workspace/listWorktrees', + 'workspace/createWorktree', + 'workspace/removeWorktree', +]) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-operation.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-operation.ts new file mode 100644 index 00000000..5f8653b0 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-operation.ts @@ -0,0 +1,24 @@ +import { DeferredPromise } from '../../../../base/common/async' + +export interface DesktopHostOperation { + readonly command: number + readonly promise: Promise +} + +export interface PendingDesktopHostOperation { + readonly operation: DesktopHostOperation + complete(task: Promise): void +} + +export function createOperation(command: number): PendingDesktopHostOperation { + const result = new DeferredPromise() + return { + operation: { command, promise: result.promise }, + complete(task): void { + void task.then( + (value) => result.resolve(value), + (error) => result.reject(error), + ) + }, + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup-errors.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup-errors.ts new file mode 100644 index 00000000..2925884d --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup-errors.ts @@ -0,0 +1,36 @@ +export class DesktopSessionCreationStateUnknownError extends Error { + readonly code = 'desktop_session_creation_state_unknown' + + constructor(cause: unknown) { + const detail = cause instanceof Error ? cause.message : String(cause) + super( + 'desktop_session_creation_state_unknown: Host failed after ALIVE before Session identity; ' + + `original failure (${detail})`, + { cause }, + ) + } +} + +export function cleanupFailure(startup: unknown, cleanup: unknown): Error { + return combinedFailure(startup, cleanup, 'cleanup failed') +} + +export function activationFailure(startup: unknown, activation: unknown): Error { + return combinedFailure(startup, activation, 'activation failed') +} + +export function isTerminationUncertain(value: unknown): boolean { + if (value instanceof AggregateError) return value.errors.some(isTerminationUncertain) + if (!(value instanceof Error)) return false + return value.message.includes('desktop_protocol_drain_incomplete') + || value.message.includes('desktop_process_termination_unconfirmed') + || isTerminationUncertain(value.cause) +} + +function combinedFailure(primary: unknown, related: unknown, label: string): Error { + const primaryMessage = primary instanceof Error ? primary.message : String(primary) + const relatedMessage = related instanceof Error ? related.message : String(related) + return new Error(`${primaryMessage}; ${label} (${relatedMessage})`, { + cause: new AggregateError([primary, related]), + }) +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup.ts new file mode 100644 index 00000000..0046a1ac --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-startup.ts @@ -0,0 +1,179 @@ +import { randomUUID } from 'node:crypto' +import { type HostStatus } from '../../../../shared/contracts' +import { withTimeout } from '../process/desktop-process' +import { type DesktopHostGeneration } from './desktop-host-generation' +import { + activationFailure, + cleanupFailure, + DesktopSessionCreationStateUnknownError, + isTerminationUncertain, +} from './desktop-host-startup-errors' +import { + type DesktopHostActivation, type DesktopHostOptions, type DesktopHostSession, +} from './desktop-host-types' +import { JsonRpcClient, type JsonRpcNotification } from '../rpc/jsonrpc-client' + +export interface DesktopHostStartupHooks { + readonly owns: () => boolean + readonly assertOwned: () => void + readonly setStatus: (status: HostStatus) => void + readonly notify: (event: JsonRpcNotification) => void + readonly fail: () => void +} + +interface ActivatedGeneration { + readonly created: Awaited + readonly session: DesktopHostSession +} + +export interface DesktopHostRunHooks extends DesktopHostStartupHooks { + readonly cleanup: () => Promise + readonly crash: () => void +} + +export async function runGeneration( + generation: DesktopHostGeneration, + options: DesktopHostOptions, + activate: DesktopHostActivation | undefined, + hooks: DesktopHostRunHooks, +): Promise { + const activation = activateGeneration( + generation, activate, options.startupTimeoutMs ?? 20_000, + ) + void activation.catch(() => undefined) + try { + return await initializeGeneration(generation, options, hooks, activation) + } catch (error) { + const settledActivation = withTimeout( + activation, + options.startupTimeoutMs ?? 20_000, + 'Loopal Desktop Session activation did not settle after cleanup', + ) + const [cleanup, activated] = await Promise.allSettled([ + hooks.cleanup(), settledActivation, + ]) + if (generation.process.didReportSession) await activation.catch(() => undefined) + hooks.crash() + let failure = cleanup.status === 'rejected' ? cleanupFailure(error, cleanup.reason) : error + if (activated.status === 'rejected' && isTerminationUncertain(activated.reason)) { + failure = activationFailure(failure, activated.reason) + } + if (!options.resumeSessionId && generation.process.creationMayHaveCommitted) { + failure = new DesktopSessionCreationStateUnknownError(failure) + } + throw withDiagnostics(failure, generation.process.diagnostics) + } +} + +export async function initializeGeneration( + generation: DesktopHostGeneration, + options: DesktopHostOptions, + hooks: DesktopHostStartupHooks, + activation: Promise, +): Promise { + const timeout = options.startupTimeoutMs ?? 20_000 + const alive = await withTimeout( + generation.process.alive, + timeout, + 'Loopal Desktop Host did not emit alive in time', + ) + hooks.assertOwned() + hooks.setStatus('alive') + hooks.setStatus('registering') + const connectRpc = options.connectRpc ?? JsonRpcClient.connect + const connectionState = { claimed: false, disposed: false, abandoned: false } + const disposeOnce = (rpc: JsonRpcClient): void => { + if (connectionState.disposed) return + connectionState.disposed = true + rpc.dispose() + } + const connecting = connectRpc(alive.addr) + void connecting.then( + (lateRpc) => { + if (!connectionState.claimed && (connectionState.abandoned || !hooks.owns())) { + disposeOnce(lateRpc) + } + }, + () => undefined, + ) + let rpc: JsonRpcClient + try { + rpc = await withTimeout( + connecting, + timeout, + 'Loopal Desktop Host did not connect to the Hub in time', + ) + } catch (error) { + connectionState.abandoned = true + throw error + } + connectionState.claimed = true + if (!hooks.owns()) { + disposeOnce(rpc) + hooks.assertOwned() + } + generation.rpc = rpc + generation.subscriptions.add(rpc.onNotification(hooks.notify)) + generation.subscriptions.add(rpc.onClose(hooks.fail)) + const registration = await withTimeout( + rpc.call('hub/register', { + name: options.clientName ?? `loopal-desktop-${randomUUID()}`, + token: alive.token, + role: 'ui_client', + }), + timeout, + 'Loopal Desktop Host did not register with the Hub in time', + ) + if (!isRecord(registration) || registration.ok !== true) { + throw new Error('Loopal Hub rejected the Desktop UI registration') + } + const { created, session } = await withTimeout( + activation, timeout, 'Loopal Desktop Host did not create or report a Session in time', + ) + hooks.assertOwned() + const ready = created.phase === 'ready' ? created : await withTimeout( + generation.process.ready, + timeout, + 'Loopal Desktop Host did not emit ready in time', + ) + if (ready.server_version !== alive.server_version + || session.serverVersion !== alive.server_version) { + throw new Error('Loopal Desktop Host changed server version during startup') + } + if (ready.session_id !== session.sessionId) { + throw new Error('Loopal Desktop Host changed Session during startup') + } + hooks.setStatus('ready') + return session +} + +export async function activateGeneration( + generation: DesktopHostGeneration, + activate: DesktopHostActivation | undefined, + timeout: number, +): Promise { + const created = await generation.process.sessionCreated + const session = { + sessionId: created.session_id, + serverVersion: created.server_version, + pid: created.pid, + } + generation.session = session + if (activate) { + await withTimeout( + activate(session), timeout, 'Loopal Desktop Session activation did not complete in time', + ) + } + return { created, session } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function withDiagnostics(error: unknown, diagnostics: readonly string[]): Error { + const message = error instanceof Error ? error.message : String(error) + const suffix = diagnostics.length > 0 + ? `\nHost emitted ${diagnostics.length} diagnostic lines.` : '' + return new Error(`${message}${suffix}`, { cause: error }) +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host-types.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-types.ts new file mode 100644 index 00000000..74ef5809 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host-types.ts @@ -0,0 +1,29 @@ +import { type SpawnDesktopProcess } from '../process/desktop-process' +import { type JsonRpcClient } from '../rpc/jsonrpc-client' + +export interface DesktopHostSession { + readonly sessionId: string + readonly serverVersion: string + readonly pid: number +} +export type DesktopHostActivation = (session: DesktopHostSession) => Promise + +export interface MetaHubStartupOptions { + readonly address: string + readonly hubName: string + readonly token: string +} + +export interface DesktopHostOptions { + readonly binaryPath: string + readonly cwd: string + readonly resumeSessionId?: string + readonly parentPid?: number + readonly env?: NodeJS.ProcessEnv + readonly startupTimeoutMs?: number + readonly shutdownTimeoutMs?: number + readonly clientName?: string + readonly spawnProcess?: SpawnDesktopProcess + readonly connectRpc?: typeof JsonRpcClient.connect + readonly metaHub?: MetaHubStartupOptions +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.activation.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.activation.test.ts new file mode 100644 index 00000000..94a147b8 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.activation.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest' +import { DeferredPromise } from '../../../../base/common/async' +import { LoopalDesktopHost } from './desktop-host' +import { + FakeChild, alive, createHost, fakeRpc, parentPid, ready, sessionCreated, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost session activation', () => { + it('awaits activation before accepting READY', async () => { + const fixture = createHost() + const gate = new DeferredPromise() + const activate = vi.fn(async () => gate.promise) + const start = fixture.host.start(activate) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(sessionCreated()) + fixture.child.stdout.write(ready()) + + await vi.waitFor(() => expect(activate).toHaveBeenCalledOnce()) + expect(fixture.host.currentStatus).not.toBe('ready') + gate.resolve(undefined) + await expect(start).resolves.toMatchObject({ sessionId: 'session-1' }) + expect(fixture.host.currentStatus).toBe('ready') + }) + + it('uses READY as an exactly-once fallback for an old Host', async () => { + const fixture = createHost() + const activate = vi.fn(async () => undefined) + const start = fixture.host.start(activate) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(ready()) + + await expect(start).resolves.toMatchObject({ sessionId: 'session-1' }) + expect(activate).toHaveBeenCalledOnce() + }) + + it('settles an observed marker before reporting registration failure', async () => { + const fixture = createHost(new FakeChild(), fakeRpc(async () => ({ ok: false }))) + const activate = vi.fn(async () => undefined) + const start = fixture.host.start(activate) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(sessionCreated()) + + await expect(start).rejects.toThrow('rejected') + expect(activate).toHaveBeenCalledOnce() + }) + + it('marks a fresh ALIVE-without-identity failure as creation-state unknown', async () => { + const fixture = createHost(new FakeChild(), fakeRpc(async () => ({ ok: false }))) + const start = fixture.host.start() + fixture.child.stdout.write(alive()) + + await expect(start).rejects.toThrow('desktop_session_creation_state_unknown') + }) + + it('drains a marker written after child exit and activation timeout', async () => { + const child = new FakeChild() + child.endStreamsOnExit = false + const fixture = createHost(child, fakeRpc(), { + startupTimeoutMs: 10, shutdownTimeoutMs: 5, + }) + const activate = vi.fn(async () => undefined) + const start = fixture.host.start(activate) + child.stdout.write(alive()) + child.exit(7) + setTimeout(() => { + child.stdout.write(sessionCreated()) + child.stdout.end() + child.stderr.end() + child.emit('close', 7, null) + }, 30) + + await expect(start).rejects.toThrow() + expect(activate).toHaveBeenCalledOnce() + }) + + it('rejects conflicting marker IDs while preserving the first activation', async () => { + const fixture = createHost() + const activate = vi.fn(async () => undefined) + const start = fixture.host.start(activate) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(sessionCreated()) + fixture.child.stdout.write(sessionCreated({ session_id: 'session-2' })) + fixture.child.stdout.write(ready()) + + await expect(start).rejects.toThrow('changed Session') + expect(activate).toHaveBeenCalledOnce() + expect(activate).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'session-1' })) + }) + + it('bounds a post-SIGKILL stdio drain when a descendant holds the pipe', async () => { + const child = new FakeChild() + child.autoExitOnKill = false + child.endStreamsOnExit = false + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL') child.exit(null, signal) + return true + }) + const fixture = createHost(child) + const start = fixture.host.start() + child.stdout.write(alive()) + child.stdout.write(ready()) + await start + + await expect(fixture.host.stop()).rejects.toThrow('desktop_protocol_drain_incomplete') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.destroyed).toBe(true) + expect(child.stderr.destroyed).toBe(true) + }) + + it('does not start a replacement while process termination is unconfirmed', async () => { + const child = new FakeChild() + child.autoExitOnKill = false + let emittedKillError = false + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL' && !emittedKillError) { + emittedKillError = true + child.emit('error', new Error('kill failed')) + } + return true + }) + const fixture = createHost(child) + const start = fixture.host.start() + child.stdout.write(alive()) + child.stdout.write(ready()) + await start + + await expect(fixture.host.stop()).rejects.toThrow( + 'desktop_process_termination_unconfirmed', + ) + await expect(fixture.host.start()).rejects.toThrow( + 'desktop_process_termination_unconfirmed', + ) + expect(fixture.spawnProcess).toHaveBeenCalledOnce() + child.exit(null, 'SIGKILL') + await expect(fixture.host.stop()).resolves.toBeUndefined() + }) + + it('preserves a late drain error after the activation timeout settled', async () => { + const child = new FakeChild() + child.autoExitOnKill = false + child.endStreamsOnExit = false + child.kill.mockImplementation((signal) => { + if (signal === 'SIGKILL') child.exit(null, signal) + return true + }) + const fixture = createHost(child, fakeRpc(), { + startupTimeoutMs: 10, shutdownTimeoutMs: 5, + }) + const start = fixture.host.start(vi.fn(async () => undefined)) + child.stdout.write(alive()) + + await expect(start).rejects.toThrow('desktop_protocol_drain_incomplete') + expect(child.stdout.destroyed).toBe(true) + }) + + it('settles activation despite cleanup failure and permits a new generation', async () => { + const children = [new FakeChild(), new FakeChild()] + const firstRpc = fakeRpc(async () => ({ ok: false })) + firstRpc.dispose.mockImplementationOnce(() => { throw new Error('dispose failed') }) + const secondRpc = fakeRpc() + let childIndex = 0 + let rpcIndex = 0 + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', cwd: '/workspace', parentPid, + startupTimeoutMs: 1_000, shutdownTimeoutMs: 5, + spawnProcess: vi.fn(() => children[childIndex++]!) as never, + connectRpc: vi.fn(async () => [firstRpc.rpc, secondRpc.rpc][rpcIndex++]!) as never, + }) + const gate = new DeferredPromise() + const activate = vi.fn(async () => gate.promise) + const first = host.start(activate) + children[0]!.stdout.write(alive()) + children[0]!.stdout.write(sessionCreated()) + await vi.waitFor(() => expect(activate).toHaveBeenCalledOnce()) + let rejected = false + void first.catch(() => { rejected = true }) + await Promise.resolve() + expect(rejected).toBe(false) + gate.resolve(undefined) + await expect(first).rejects.toThrow('cleanup failed (dispose failed)') + + const second = host.start() + children[1]!.stdout.write(alive()) + children[1]!.stdout.write(ready({ session_id: 'session-2' })) + await expect(second).resolves.toMatchObject({ sessionId: 'session-2' }) + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.creation-state.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.creation-state.test.ts new file mode 100644 index 00000000..150065c6 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.creation-state.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { + FakeChild, alive, childPid, createHost, fakeRpc, parentPid, sessionCreated, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost creation-state safety', () => { + it('keeps unknown state when a marker arrives after a terminal handshake', async () => { + const fixture = createHost() + const activate = vi.fn(async () => undefined) + const start = fixture.host.start(activate) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(startupError()) + fixture.child.stdout.write(sessionCreated()) + + await expect(start).rejects.toThrow('desktop_session_creation_state_unknown') + expect(activate).not.toHaveBeenCalled() + }) + + it('keeps a pre-ALIVE failure eligible for ordinary rollback', async () => { + const fixture = createHost() + const start = fixture.host.start() + fixture.child.stdout.write(startupError()) + const error = await rejectedError(start) + + expect(error.message).toContain('startup_failed') + expect(error.message).not.toContain('desktop_session_creation_state_unknown') + }) + + it('does not classify a resumed Host failure as fresh creation uncertainty', async () => { + const fixture = createHost(new FakeChild(), fakeRpc(), { + resumeSessionId: 'session-existing', + }) + const start = fixture.host.start() + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(startupError()) + const error = await rejectedError(start) + + expect(error.message).toContain('startup_failed') + expect(error.message).not.toContain('desktop_session_creation_state_unknown') + }) +}) + +function startupError(): string { + return `LOOPAL_DESKTOP ${JSON.stringify({ + protocol_version: 1, + server_version: '0.6.3', + pid: childPid, + parent_pid: parentPid, + phase: 'error', + code: 'startup_failed', + message: 'agent start failed', + })}\n` +} + +async function rejectedError(promise: Promise): Promise { + try { + await promise + } catch (error) { + if (error instanceof Error) return error + throw new Error('expected an Error rejection') + } + throw new Error('expected rejection') +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.defaults.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.defaults.test.ts new file mode 100644 index 00000000..cb4b473d --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.defaults.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' +import { LoopalDesktopHost } from './desktop-host' +import { JsonRpcClient } from '../rpc/jsonrpc-client' +import { + FakeChild, + alive, + createHost, + fakeRpc, + ready, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost defaults and operation cleanup', () => { + it('uses the default JSON-RPC connector', async () => { + const child = new FakeChild() + const rpcFixture = fakeRpc() + const connect = vi.spyOn(JsonRpcClient, 'connect').mockResolvedValue(rpcFixture.rpc) + const fixture = createHost(child, rpcFixture, { connectRpc: undefined }) + try { + const start = fixture.host.start() + child.stdout.write(alive()) + child.stdout.write(ready()) + await start + expect(connect).toHaveBeenCalledWith('127.0.0.1:4567') + await fixture.host.stop() + } finally { + connect.mockRestore() + } + }) + + it('uses the shell-free default process spawner', async () => { + const host = new LoopalDesktopHost({ + binaryPath: process.execPath, + cwd: process.cwd(), + startupTimeoutMs: 5_000, + shutdownTimeoutMs: 100, + }) + await expect(host.start()).rejects.toThrow('exited before shutdown') + expect(host.currentStatus).toBe('crashed') + await host.stop() + }) + + it('clears a rejected stop operation before the next lifecycle command', async () => { + const fixture = createHost() + const start = fixture.host.start() + const internals = fixture.host as unknown as { + release: () => Promise + } + const release = vi.spyOn(internals, 'release').mockRejectedValueOnce(new Error('cleanup failed')) + + await expect(fixture.host.stop()).rejects.toThrow('cleanup failed') + release.mockRestore() + fixture.child.exit(1) + await expect(start).rejects.toThrow() + await fixture.host.stop() + }) + + it('forwards a valid resume ID and rejects invalid IDs before spawning', async () => { + const resumed = createHost(new FakeChild(), fakeRpc(), { resumeSessionId: 'session-1' }) + const start = resumed.host.start() + expect(resumed.spawnProcess).toHaveBeenCalledWith( + '/bin/loopal', '/workspace', 321, undefined, 'session-1', + ) + resumed.child.exit(1) + await expect(start).rejects.toThrow() + + expect(() => createHost(new FakeChild(), fakeRpc(), { resumeSessionId: '--parent-pid' })) + .toThrow('Invalid Loopal Desktop resume session ID') + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.failures.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.failures.test.ts new file mode 100644 index 00000000..4f54b701 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.failures.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import { LoopalDesktopHost, spawnDesktopProcess } from './desktop-host' +import { + FakeChild, + alive, + childPid, + createHost, + fakeRpc, + parentPid, + ready, + startReady, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost failures and process invocation', () => { + it.each([ + ['malformed JSON', 'LOOPAL_DESKTOP {broken}\n', 'JSON'], + ['wrong pid', alive({ pid: 999 }), 'PID metadata'], + ['wrong parent pid', alive({ parent_pid: 999 }), 'PID metadata'], + [ + 'structured error', + `LOOPAL_DESKTOP ${JSON.stringify({ + protocol_version: 1, + server_version: '0.6.3', + pid: childPid, + parent_pid: parentPid, + phase: 'error', + code: 'startup_failed', + message: 'could not start', + })}\n`, + 'startup_failed', + ], + ])('rejects a %s handshake', async (_name, line, expected) => { + const fixture = createHost() + const start = fixture.host.start() + fixture.child.stderr.write('diagnostic line\n') + fixture.child.stdout.write(line) + await expect(start).rejects.toThrow(expected) + await expect(start).rejects.toThrow('diagnostic line') + expect(fixture.child.kill).toHaveBeenCalledWith('SIGTERM') + }) + + it('rejects registration failure, early process exit, and startup timeout', async () => { + const rejected = createHost(new FakeChild(), fakeRpc(async () => ({ ok: false }))) + const registration = rejected.host.start() + rejected.child.stdout.write(alive()) + await expect(registration).rejects.toThrow('rejected') + + const malformedRegistration = createHost(new FakeChild(), fakeRpc(async () => 'invalid')) + const malformed = malformedRegistration.host.start() + malformedRegistration.child.stdout.write(alive()) + await expect(malformed).rejects.toThrow('rejected') + + for (const registrationValue of [null, []]) { + const invalid = createHost(new FakeChild(), fakeRpc(async () => registrationValue)) + const invalidStart = invalid.host.start() + invalid.child.stdout.write(alive()) + await expect(invalidStart).rejects.toThrow('rejected') + } + + const nonError = createHost(new FakeChild(), fakeRpc(), { + connectRpc: vi.fn(async () => Promise.reject('connect failed')), + }) + const nonErrorStart = nonError.host.start() + nonError.child.stdout.write(alive()) + await expect(nonErrorStart).rejects.toThrow('connect failed') + + const early = createHost() + const start = early.host.start() + early.child.exit(7, null) + await expect(start).rejects.toThrow('code=7') + expect(early.host.currentStatus).toBe('crashed') + + const timedOut = createHost(new FakeChild(), fakeRpc(), { startupTimeoutMs: 1 }) + await expect(timedOut.host.start()).rejects.toThrow('did not emit alive') + + const readyTimedOut = createHost(new FakeChild(), fakeRpc(), { startupTimeoutMs: 1 }) + const waitingForReady = readyTimedOut.host.start() + readyTimedOut.child.stdout.write(alive()) + await expect(waitingForReady).rejects.toThrow('did not create or report a Session') + }) + + it('rejects a server version change between alive and ready', async () => { + const fixture = createHost() + const start = fixture.host.start() + fixture.child.stdout.write(alive()) + await vi.waitFor(() => expect(fixture.connectRpc).toHaveBeenCalled()) + fixture.child.stdout.write(ready({ server_version: '9.9.9' })) + await expect(start).rejects.toThrow('changed server version') + }) + + it('reports a child-process spawn error and ignores its later exit', async () => { + const fixture = createHost() + const start = fixture.host.start() + fixture.child.fail(new Error('spawn failed')) + await expect(start).rejects.toThrow('spawn failed') + await Promise.resolve() + expect(fixture.host.currentStatus).toBe('crashed') + }) + + it('uses process defaults and stops a child before the RPC connection exists', async () => { + const child = new FakeChild() + const spawnProcess = vi.fn(() => child) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', + cwd: '/workspace', + spawnProcess: spawnProcess as never, + }) + const start = host.start() + const stop = host.stop() + await expect(stop).resolves.toBeUndefined() + await expect(start).rejects.toThrow('exited before shutdown') + expect(spawnProcess).toHaveBeenCalledWith( + '/bin/loopal', '/workspace', process.pid, undefined, undefined, + ) + }) + + it('generates a unique default UI client name', async () => { + const fixture = createHost(new FakeChild(), fakeRpc(), { clientName: undefined }) + const start = fixture.host.start() + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(ready()) + await start + expect(fixture.rpcFixture.call).toHaveBeenCalledWith( + 'hub/register', + expect.objectContaining({ name: expect.stringMatching(/^loopal-desktop-[0-9a-f-]{36}$/) }), + ) + }) + + it('stages the exact shell-free child-process invocation', () => { + const child = new FakeChild() + const spawnFn = vi.fn(() => child) + expect( + spawnDesktopProcess( + '/runtime/loopal', '/project', 88, { CUSTOM: 'yes' }, undefined, spawnFn as never, + ), + ).toBe(child) + expect(spawnFn).toHaveBeenCalledWith( + '/runtime/loopal', + ['desktop', 'serve', '--parent-pid', '88'], + expect.objectContaining({ + cwd: '/project', + env: expect.objectContaining({ CUSTOM: 'yes' }), + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }), + ) + }) + + it('passes a validated resume ID as one shell-free argv value', () => { + const child = new FakeChild() + const spawnFn = vi.fn(() => child) + expect(spawnDesktopProcess( + '/runtime/loopal', '/project', 88, undefined, 'session-1', spawnFn as never, + )).toBe(child) + expect(spawnFn).toHaveBeenCalledWith( + '/runtime/loopal', + ['desktop', 'serve', '--parent-pid', '88', '--resume', 'session-1'], + expect.objectContaining({ shell: false }), + ) + }) + + it.each(['--parent-pid', 'session;touch-pwned', 'session/name', 'session\nnext'])( + 'rejects resume argv injection %j before spawning', + (sessionId) => { + const spawnFn = vi.fn(() => new FakeChild()) + expect(() => spawnDesktopProcess( + '/runtime/loopal', '/project', 88, undefined, sessionId, spawnFn as never, + )).toThrow('Invalid Loopal Desktop resume session ID') + expect(spawnFn).not.toHaveBeenCalled() + }, + ) + + it('marks an established connection crash and caps diagnostic history', async () => { + const fixture = await startReady() + for (let index = 0; index < 110; index += 1) { + fixture.child.stderr.write(`${index}-${'x'.repeat(2_100)}\n`) + } + await vi.waitFor(() => expect(fixture.host.diagnostics).toHaveLength(100)) + expect(fixture.host.diagnostics[0]).toMatch(/^10-/) + expect(fixture.host.diagnostics.at(-1)?.length).toBe(2_000) + fixture.rpcFixture.closed.fire(new Error('connection lost')) + expect(fixture.host.currentStatus).toBe('crashed') + fixture.host.dispose() + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.generations.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.generations.test.ts new file mode 100644 index 00000000..be8ddd4d --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.generations.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import { LoopalDesktopHost } from './desktop-host' +import { + FakeChild, + alive, + fakeRpc, + parentPid, + ready, +} from './desktop-host.test-fixtures.ts' + +function createSequence( + children: FakeChild[], + rpcFixtures: ReturnType[], + shutdownTimeoutMs = 5, +) { + let childIndex = 0 + let rpcIndex = 0 + const spawnProcess = vi.fn(() => children[childIndex++]!) + const connectRpc = vi.fn(async () => rpcFixtures[rpcIndex++]!.rpc) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', + cwd: '/workspace', + parentPid, + startupTimeoutMs: 50, + shutdownTimeoutMs, + clientName: 'desktop-test', + spawnProcess: spawnProcess as never, + connectRpc: connectRpc as never, + }) + return { host, spawnProcess, connectRpc } +} + +async function finishStart( + child: FakeChild, + start: Promise, + sessionId = 'session-1', +): Promise { + child.stdout.write(alive()) + child.stdout.write(ready({ session_id: sessionId })) + await start +} + +describe('LoopalDesktopHost generations', () => { + it('restarts after reaping RPC-loss generation and detaches its callbacks', async () => { + const firstChild = new FakeChild() + firstChild.autoExitOnKill = false + const secondChild = new FakeChild() + const firstRpc = fakeRpc() + const secondRpc = fakeRpc() + const fixture = createSequence([firstChild, secondChild], [firstRpc, secondRpc]) + const notification = vi.fn() + fixture.host.onNotification(notification) + await finishStart(firstChild, fixture.host.start()) + const firstGeneration = (fixture.host as unknown as { active: unknown }).active + + firstRpc.closed.fire(new Error('socket lost')) + expect(fixture.host.currentStatus).toBe('crashed') + await expect(fixture.host.request('view/snapshot')).rejects.toThrow('not ready') + expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM') + + const restart = fixture.host.start() + await vi.waitFor(() => expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')) + expect(fixture.spawnProcess).toHaveBeenCalledOnce() + expect(firstRpc.dispose).not.toHaveBeenCalled() + firstChild.exit(null, 'SIGKILL') + await vi.waitFor(() => expect(fixture.spawnProcess).toHaveBeenCalledTimes(2)) + await finishStart(secondChild, restart, 'session-2') + firstRpc.notifications.fire({ method: 'old/event', params: {} }) + firstRpc.closed.fire(new Error('late close')) + const internals = fixture.host as unknown as { + failGeneration: (generation: unknown, error: Error) => void + } + internals.failGeneration(firstGeneration, new Error('late callback')) + + expect(notification).not.toHaveBeenCalled() + expect(fixture.host.currentStatus).toBe('ready') + await expect(fixture.host.request('hub/list_agents')).resolves.toEqual({ ok: true }) + expect(secondRpc.call).toHaveBeenCalledWith('hub/list_agents', {}, undefined) + expect(firstRpc.dispose).toHaveBeenCalledOnce() + await fixture.host.stop() + }) + + it('waits for TERM exit and disposes RPC after startup failure', async () => { + const child = new FakeChild() + child.autoExitOnKill = false + const rpcFixture = fakeRpc(async () => ({ ok: false })) + const fixture = createSequence([child], [rpcFixture], 500) + const start = fixture.host.start() + let settled = false + void start.then( + () => { settled = true }, + () => { settled = true }, + ) + child.stdout.write(alive()) + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGTERM')) + await Promise.resolve() + expect(settled).toBe(false) + expect(rpcFixture.dispose).not.toHaveBeenCalled() + child.exit(null, 'SIGTERM') + await expect(start).rejects.toThrow('rejected') + expect(rpcFixture.dispose).toHaveBeenCalledOnce() + expect(child.kill).not.toHaveBeenCalledWith('SIGKILL') + expect(fixture.host.currentStatus).toBe('crashed') + }) + + it('serializes stop against startup and coalesces the queued restart', async () => { + const firstChild = new FakeChild() + const secondChild = new FakeChild() + const fixture = createSequence( + [firstChild, secondChild], + [fakeRpc(), fakeRpc()], + ) + const firstStart = fixture.host.start() + const firstStop = fixture.host.stop() + const secondStop = fixture.host.stop() + expect(secondStop).toBe(firstStop) + const firstRestart = fixture.host.start() + const secondRestart = fixture.host.start() + expect(secondRestart).toBe(firstRestart) + + await firstStop + await expect(firstStart).rejects.toThrow('exited before shutdown') + await vi.waitFor(() => expect(fixture.spawnProcess).toHaveBeenCalledTimes(2)) + await finishStart(secondChild, firstRestart, 'session-2') + expect(fixture.host.currentStatus).toBe('ready') + await fixture.host.stop() + }) + + it('disposes a stale RPC connection without touching its replacement', async () => { + const firstChild = new FakeChild() + const secondChild = new FakeChild() + const firstRpc = fakeRpc() + const secondRpc = fakeRpc() + let resolveFirst!: () => void + const firstConnection = new Promise((resolve) => { resolveFirst = resolve }) + let calls = 0 + const spawnProcess = vi.fn(() => [firstChild, secondChild][calls]!) + const connectRpc = vi.fn(async () => { + const call = ++calls + if (call === 1) await firstConnection + return call === 1 ? firstRpc.rpc : secondRpc.rpc + }) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', cwd: '/workspace', parentPid, + startupTimeoutMs: 50, shutdownTimeoutMs: 5, + spawnProcess: spawnProcess as never, connectRpc: connectRpc as never, + }) + const staleStart = host.start() + firstChild.stdout.write(alive()) + await vi.waitFor(() => expect(connectRpc).toHaveBeenCalledOnce()) + await host.stop() + const currentStart = host.start() + await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2)) + secondChild.stdout.write(alive()) + secondChild.stdout.write(ready({ session_id: 'session-2' })) + await currentStart + resolveFirst() + await expect(staleStart).rejects.toThrow('superseded') + expect(firstRpc.dispose).toHaveBeenCalledOnce() + expect(host.currentStatus).toBe('ready') + await host.stop() + }) + + it('cancels a restart superseded while crash cleanup is pending', async () => { + const child = new FakeChild() + child.autoExitOnKill = false + const rpcFixture = fakeRpc() + const fixture = createSequence([child], [rpcFixture]) + await finishStart(child, fixture.host.start()) + rpcFixture.closed.fire(new Error('socket lost')) + + const restart = fixture.host.start() + const stop = fixture.host.stop() + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.exit(null, 'SIGKILL') + await expect(restart).rejects.toThrow('superseded') + await stop + expect(fixture.spawnProcess).toHaveBeenCalledOnce() + expect(fixture.host.currentStatus).toBe('stopped') + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.lifecycle.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.lifecycle.test.ts new file mode 100644 index 00000000..b6ec0276 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.lifecycle.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest' +import { LoopalDesktopHost } from './desktop-host' +import { + FakeChild, + alive, + childPid, + createHost, + fakeRpc, + parentPid, + ready, + startReady, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost lifecycle', () => { + it('performs the alive/register/ready state machine and forwards notifications', async () => { + const fixture = await startReady() + expect(fixture.session).toEqual({ sessionId: 'session-1', serverVersion: '0.6.3', pid: childPid }) + expect(fixture.statuses).toEqual(['spawning', 'alive', 'registering', 'ready']) + expect(fixture.spawnProcess).toHaveBeenCalledWith( + '/bin/loopal', '/workspace', 321, undefined, undefined, + ) + expect(fixture.rpcFixture.call).toHaveBeenCalledWith('hub/register', { + name: 'desktop-test', + token: 'secret', + role: 'ui_client', + }) + + const listener = vi.fn() + fixture.host.onNotification(listener) + fixture.rpcFixture.notifications.fire({ method: 'agent/event', params: { payload: 'Running' } }) + expect(listener).toHaveBeenCalledOnce() + await expect(fixture.host.request('view/snapshot', { agent: 'main' })).resolves.toEqual({ ok: true }) + await expect(fixture.host.request('hub/control', { + target: 'main', command: 'Clear', + })).resolves.toEqual({ ok: true }) + expect(fixture.rpcFixture.call).toHaveBeenCalledWith('hub/control', { + target: 'main', command: 'Clear', + }, undefined) + await expect(fixture.host.request('agent/control')).rejects.toThrow('not allowlisted') + await expect(fixture.host.request('agent/interrupt')).rejects.toThrow('not allowlisted') + await expect(fixture.host.request('hub/secret/get')).rejects.toThrow('not allowlisted') + expect(await fixture.host.start()).toBe(fixture.session) + }) + + it('reuses one in-flight startup promise', async () => { + const fixture = createHost() + const first = fixture.host.start() + const second = fixture.host.start() + expect(second).toBe(first) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(ready()) + await expect(first).resolves.toMatchObject({ sessionId: 'session-1' }) + }) + + it('rejects requests before ready and stops without having started', async () => { + const { host } = createHost() + await expect(host.request('hub/list_agents')).rejects.toThrow('not ready') + await expect(host.stop()).resolves.toBeUndefined() + expect(host.currentStatus).toBe('stopped') + }) + + it('shuts down the Hub and observes the child exit', async () => { + const child = new FakeChild() + const rpcFixture = fakeRpc(async (method) => { + if (method === 'hub/register') return { ok: true } + if (method === 'hub/shutdown') { + queueMicrotask(() => child.exit(0)) + return { ok: true } + } + return {} + }) + const fixture = await startReady(createHost(child, rpcFixture)) + await fixture.host.stop() + expect(rpcFixture.call).toHaveBeenCalledWith('hub/shutdown', {}) + expect(rpcFixture.dispose).toHaveBeenCalled() + expect(fixture.host.currentStatus).toBe('stopped') + }) + + it('coalesces concurrent shutdown and ignores close events during intentional stop', async () => { + const child = new FakeChild() + let finishShutdown!: () => void + const shutdown = new Promise((resolve) => { + finishShutdown = resolve + }) + const rpcFixture = fakeRpc(async (method) => { + if (method === 'hub/register') return { ok: true } + if (method === 'hub/shutdown') { + await shutdown + queueMicrotask(() => child.exit(0)) + return { ok: true } + } + return {} + }) + const fixture = await startReady(createHost(child, rpcFixture)) + const first = fixture.host.stop() + const second = fixture.host.stop() + expect(second).toBe(first) + expect(fixture.host.currentStatus).toBe('stopping') + rpcFixture.closed.fire(undefined) + expect(fixture.host.currentStatus).toBe('stopping') + finishShutdown() + await first + }) + + it('falls back to TERM/KILL when graceful shutdown fails or stalls', async () => { + const failingChild = new FakeChild() + const failure = fakeRpc(async (method) => { + if (method === 'hub/register') return { ok: true } + throw new Error('socket failed') + }) + const first = await startReady(createHost(failingChild, failure)) + await first.host.stop() + expect(failingChild.kill).toHaveBeenCalledWith('SIGTERM') + + const stalledChild = new FakeChild() + stalledChild.autoExitOnKill = false + const second = await startReady(createHost(stalledChild)) + stalledChild.stderr.write('stalled diagnostic\n') + await vi.waitFor(() => expect(second.host.diagnostics).toContain('stalled diagnostic')) + const stop = second.host.stop() + await vi.waitFor(() => expect(stalledChild.kill).toHaveBeenCalledWith('SIGKILL')) + expect(second.rpcFixture.dispose).not.toHaveBeenCalled() + stalledChild.exit(null, 'SIGKILL') + await stop + expect(stalledChild.kill).toHaveBeenCalledWith('SIGKILL') + expect(second.host.diagnostics).toContain('stalled diagnostic') + }) + + it('keeps restart blocked until the killed generation is reaped', async () => { + const firstChild = new FakeChild() + firstChild.autoExitOnKill = false + const secondChild = new FakeChild() + const firstRpc = fakeRpc() + const secondRpc = fakeRpc() + let childIndex = 0 + let rpcIndex = 0 + const spawnProcess = vi.fn(() => [firstChild, secondChild][childIndex++]!) + const connectRpc = vi.fn(async () => [firstRpc.rpc, secondRpc.rpc][rpcIndex++]!) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', cwd: '/workspace', parentPid, + startupTimeoutMs: 50, shutdownTimeoutMs: 5, + spawnProcess: spawnProcess as never, connectRpc: connectRpc as never, + }) + const firstStart = host.start() + firstChild.stdout.write(alive()) + firstChild.stdout.write(ready()) + await firstStart + firstRpc.closed.fire(new Error('socket lost')) + + const restart = host.start() + await vi.waitFor(() => expect(firstChild.kill).toHaveBeenCalledWith('SIGKILL')) + expect(spawnProcess).toHaveBeenCalledOnce() + expect(firstRpc.dispose).not.toHaveBeenCalled() + firstChild.exit(null, 'SIGKILL') + await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2)) + secondChild.stdout.write(alive()) + secondChild.stdout.write(ready({ session_id: 'session-2' })) + await restart + expect(firstRpc.dispose).toHaveBeenCalledOnce() + await host.stop() + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.reentrancy.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.reentrancy.test.ts new file mode 100644 index 00000000..1d5a220d --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.reentrancy.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { LoopalDesktopHost } from './desktop-host' +import { + FakeChild, + alive, + createHost, + fakeRpc, + parentPid, + ready, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost lifecycle reentrancy', () => { + it('publishes the startup operation before spawning status fires', async () => { + const fixture = createHost() + let nestedStart: Promise | undefined + fixture.host.onStatus((status) => { + if (status === 'spawning') nestedStart = fixture.host.start() + }) + const start = fixture.host.start() + expect(nestedStart).toBe(start) + fixture.child.stdout.write(alive()) + fixture.child.stdout.write(ready()) + await start + expect(fixture.spawnProcess).toHaveBeenCalledOnce() + await fixture.host.stop() + }) + + it('publishes stop before a status listener requests restart', async () => { + const firstChild = new FakeChild() + const secondChild = new FakeChild() + const firstRpc = fakeRpc(async (method) => { + if (method === 'hub/shutdown') { + queueMicrotask(() => firstChild.exit(0)) + } + return { ok: true } + }) + const secondRpc = fakeRpc() + let spawnIndex = 0 + let rpcIndex = 0 + const spawnProcess = vi.fn(() => [firstChild, secondChild][spawnIndex++]!) + const connectRpc = vi.fn(async () => [firstRpc.rpc, secondRpc.rpc][rpcIndex++]!) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', cwd: '/workspace', parentPid, + startupTimeoutMs: 50, shutdownTimeoutMs: 5, + spawnProcess: spawnProcess as never, connectRpc: connectRpc as never, + }) + const firstStart = host.start() + firstChild.stdout.write(alive()) + firstChild.stdout.write(ready()) + await firstStart + let restart: Promise | undefined + host.onStatus((status) => { + if (status === 'stopping') restart = host.start() + }) + + const stop = host.stop() + expect(restart).toBeDefined() + await stop + await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2)) + secondChild.stdout.write(alive()) + secondChild.stdout.write(ready({ session_id: 'session-2' })) + await restart + expect(host.currentStatus).toBe('ready') + await host.stop() + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.test-fixtures.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.test-fixtures.ts new file mode 100644 index 00000000..af9fe2af --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.test-fixtures.ts @@ -0,0 +1,135 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { expect, vi } from 'vitest' +import { Emitter } from '../../../../base/common/event' +import { LoopalDesktopHost } from './desktop-host' +import { type JsonRpcClient, type JsonRpcNotification } from '../rpc/jsonrpc-client' +import { DESKTOP_REQUIRED_CAPABILITIES } from '../../common/desktop-handshake' + +export const parentPid = 321 +export const childPid = 654 + +export class FakeChild extends EventEmitter { + readonly pid = childPid + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + autoExitOnKill = true + endStreamsOnExit = true + readonly kill = vi.fn((signal: NodeJS.Signals = 'SIGTERM') => { + if (this.autoExitOnKill) { + queueMicrotask(() => this.exit(null, signal)) + } + return true + }) + + exit(code: number | null, signal: NodeJS.Signals | null = null): void { + this.exitCode = code + this.signalCode = signal + this.emit('exit', code, signal) + if (this.endStreamsOnExit) { + this.stdout.end() + this.stderr.end() + queueMicrotask(() => this.emit('close', code, signal)) + } + } + + fail(error: Error): void { + this.emit('error', error) + this.stdout.end() + this.stderr.end() + queueMicrotask(() => this.emit('close', null, null)) + } +} + +export function alive(overrides: Record = {}): string { + return `LOOPAL_DESKTOP ${JSON.stringify({ + protocol_version: 1, + server_version: '0.6.3', + pid: childPid, + parent_pid: parentPid, + phase: 'alive', + addr: '127.0.0.1:4567', + token: 'secret', + transport: 'tcp_jsonrpc_ndjson', + capabilities: [...DESKTOP_REQUIRED_CAPABILITIES], + ...overrides, + })}\n` +} + +export function ready(overrides: Record = {}): string { + return `LOOPAL_DESKTOP ${JSON.stringify({ + protocol_version: 1, + server_version: '0.6.3', + pid: childPid, + parent_pid: parentPid, + phase: 'ready', + session_id: 'session-1', + ...overrides, + })}\n` +} + +export function sessionCreated(overrides: Record = {}): string { + return `LOOPAL_DESKTOP_EVENT ${JSON.stringify({ + protocol_version: 1, + server_version: '0.6.3', + pid: childPid, + parent_pid: parentPid, + phase: 'session_created', + session_id: 'session-1', + ...overrides, + })}\n` +} + +export function fakeRpc( + callImplementation?: (method: string, params: unknown) => Promise, +) { + const notifications = new Emitter() + const closed = new Emitter() + const call = vi.fn( + callImplementation ?? + (async (method: string) => (method === 'hub/register' ? { ok: true } : { ok: true })), + ) + const dispose = vi.fn() + const rpc = { + call, + dispose, + onNotification: notifications.event, + onClose: closed.event, + } as unknown as JsonRpcClient + return { rpc, call, dispose, notifications, closed } +} + +export function createHost( + child = new FakeChild(), + rpcFixture = fakeRpc(), + overrides: Record = {}, +) { + const spawnProcess = vi.fn(() => child) + const connectRpc = vi.fn(async () => rpcFixture.rpc) + const host = new LoopalDesktopHost({ + binaryPath: '/bin/loopal', + cwd: '/workspace', + parentPid, + startupTimeoutMs: 1_000, + shutdownTimeoutMs: 5, + clientName: 'desktop-test', + spawnProcess: spawnProcess as never, + connectRpc: connectRpc as never, + ...overrides, + }) + return { host, child, rpcFixture, spawnProcess, connectRpc } +} + +export async function startReady(fixture = createHost()) { + const statuses: string[] = [] + fixture.host.onStatus((status) => statuses.push(status)) + const start = fixture.host.start() + fixture.child.stdout.write('ordinary diagnostic output\n') + fixture.child.stdout.write(alive()) + await vi.waitFor(() => expect(fixture.connectRpc).toHaveBeenCalledWith('127.0.0.1:4567')) + fixture.child.stdout.write(ready()) + const session = await start + return { ...fixture, statuses, session } +} diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.timeouts.test.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.timeouts.test.ts new file mode 100644 index 00000000..6d90cae9 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.timeouts.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import { DisposableStore } from '../../../../base/common/lifecycle' +import { parseDesktopHandshakeLine } from '../../common/desktop-handshake' +import { LoopalDesktopHost } from './desktop-host' +import { type DesktopHostGeneration } from './desktop-host-generation' +import { initializeGeneration } from './desktop-host-startup' +import { + FakeChild, + alive, + createHost, + fakeRpc, +} from './desktop-host.test-fixtures.ts' + +describe('LoopalDesktopHost startup timeouts', () => { + it('disposes an RPC connection that resolves after connect timeout', async () => { + const child = new FakeChild() + const rpcFixture = fakeRpc() + let resolveConnection!: (rpc: typeof rpcFixture.rpc) => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + const fixture = createHost(child, rpcFixture, { + startupTimeoutMs: 5, + connectRpc: vi.fn(() => connection), + }) + const start = fixture.host.start() + child.stdout.write(alive()) + + await expect(start).rejects.toThrow('did not connect') + expect(rpcFixture.dispose).not.toHaveBeenCalled() + resolveConnection(rpcFixture.rpc) + await vi.waitFor(() => expect(rpcFixture.dispose).toHaveBeenCalledOnce()) + expect(fixture.host.currentStatus).toBe('crashed') + }) + + it('times out a pending Hub registration and releases its generation', async () => { + const child = new FakeChild() + const rpcFixture = fakeRpc(async (method) => { + if (method === 'hub/register') return new Promise(() => undefined) + return { ok: true } + }) + const fixture = createHost(child, rpcFixture, { startupTimeoutMs: 5 }) + const start = fixture.host.start() + child.stdout.write(alive()) + + await expect(start).rejects.toThrow('did not register') + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(rpcFixture.dispose).toHaveBeenCalledOnce() + expect(fixture.host.currentStatus).toBe('crashed') + }) + + it('abandons a timed-out connection before outer Host cleanup', async () => { + const handshake = parseDesktopHandshakeLine(alive().trim()) + if (!handshake || handshake.phase !== 'alive') throw new Error('invalid fixture') + const rpcFixture = fakeRpc() + let resolveConnection!: (rpc: typeof rpcFixture.rpc) => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + const generation = { + command: 1, + process: { + alive: Promise.resolve(handshake), + ready: new Promise(() => undefined), + }, + subscriptions: new DisposableStore(), + closing: false, + exited: false, + } as unknown as DesktopHostGeneration + const owns = vi.fn(() => true) + const initialization = initializeGeneration( + generation, + { binaryPath: '/bin/loopal', cwd: '/workspace', startupTimeoutMs: 5, + connectRpc: vi.fn(() => connection) as never }, + { owns, assertOwned: vi.fn(), setStatus: vi.fn(), notify: vi.fn(), fail: vi.fn() }, + new Promise(() => undefined), + ) + + await expect(initialization).rejects.toThrow('did not connect') + resolveConnection(rpcFixture.rpc) + await vi.waitFor(() => expect(rpcFixture.dispose).toHaveBeenCalledOnce()) + expect(owns()).toBe(true) + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/host/desktop-host.ts b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.ts new file mode 100644 index 00000000..909d2f38 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/host/desktop-host.ts @@ -0,0 +1,200 @@ +import { Emitter, type Event } from '../../../../base/common/event' +import { Disposable } from '../../../../base/common/lifecycle' +import { type HostStatus } from '../../../../shared/contracts' +import { DesktopProcess, spawnDesktopProcess, validateResumeSessionId } from '../process/desktop-process' +import { + createGeneration, + terminateGeneration, + type DesktopHostGeneration, +} from './desktop-host-generation' +import { + type DesktopHostActivation, type DesktopHostOptions, type DesktopHostSession, +} from './desktop-host-types' +import { createOperation, type DesktopHostOperation } from './desktop-host-operation' +import { runGeneration } from './desktop-host-startup' +import { type JsonRpcNotification } from '../rpc/jsonrpc-client' +import { DESKTOP_HUB_METHODS } from './desktop-host-methods' + +export { spawnDesktopProcess } from '../process/desktop-process' +export type { + DesktopHostActivation, DesktopHostOptions, DesktopHostSession, MetaHubStartupOptions, +} from './desktop-host-types' +export class LoopalDesktopHost extends Disposable { + private readonly statusEmitter = this.register(new Emitter()) + private readonly notificationEmitter = this.register(new Emitter()) + private status: HostStatus = 'stopped' + private active: DesktopHostGeneration | undefined + private starting: DesktopHostOperation | undefined + private stopping: DesktopHostOperation | undefined + private cleanup: Promise | undefined + private command = 0 + private lastDiagnostics: readonly string[] = [] + readonly onStatus: Event = this.statusEmitter.event + readonly onNotification: Event = this.notificationEmitter.event + constructor(private readonly options: DesktopHostOptions) { + super() + validateResumeSessionId(options.resumeSessionId) + } + + get currentStatus(): HostStatus { + return this.status + } + get diagnostics(): readonly string[] { + return this.active?.process.diagnostics ?? this.lastDiagnostics + } + + start(activate?: DesktopHostActivation): Promise { + const session = this.active?.session + if (session && this.status === 'ready') { + return activate ? activate(session).then(() => session) : Promise.resolve(session) + } + if (this.starting?.command === this.command) return this.starting.promise + const command = ++this.command + const pending = createOperation(command) + this.starting = pending.operation + pending.complete(this.startInternal(command, activate, this.stopping?.promise)) + void pending.operation.promise.then( + () => this.clearStarting(pending.operation), + () => this.clearStarting(pending.operation), + ) + return pending.operation.promise + } + + request(method: string, params: unknown = {}, signal?: AbortSignal): Promise { + const rpc = this.active?.rpc + if (!rpc || this.status !== 'ready') { + return Promise.reject(new Error('Loopal Desktop Host is not ready')) + } + if (!DESKTOP_HUB_METHODS.has(method)) { + return Promise.reject(new Error(`Loopal Desktop Host method is not allowlisted: ${method}`)) + } + return rpc.call(method, params, signal) + } + + stop(): Promise { + if (this.stopping?.command === this.command) return this.stopping.promise + const command = ++this.command + const pending = createOperation(command) + this.stopping = pending.operation + pending.complete(this.stopInternal(command)) + void pending.operation.promise.then( + () => this.clearStopping(pending.operation), + () => this.clearStopping(pending.operation), + ) + return pending.operation.promise + } + + override dispose(): void { + void this.stop() + super.dispose() + } + + private async startInternal( + command: number, activate?: DesktopHostActivation, blocked?: Promise, + ): Promise { + if (blocked) await blocked + if (this.cleanup) await this.cleanup + if (this.active) await this.release(this.active, false) + this.assertCommand(command) + this.setStatus('spawning') + const generation = this.spawnGeneration(command) + return runGeneration(generation, this.options, activate, { + owns: () => this.owns(generation), + assertOwned: () => this.assertOwned(generation), + setStatus: (status) => this.setStatus(status), + notify: (event) => this.notificationEmitter.fire(event), + fail: () => this.failGeneration(generation), + cleanup: () => this.release(generation, false), + crash: () => { + if (this.command === command) this.setStatus('crashed') + }, + }) + } + + private spawnGeneration(command: number): DesktopHostGeneration { + const parentPid = this.options.parentPid ?? globalThis.process.pid + const metaHub = this.options.metaHub + const process = new DesktopProcess({ + binaryPath: this.options.binaryPath, + cwd: this.options.cwd, + parentPid, + env: this.options.env, + resumeSessionId: this.options.resumeSessionId, + spawnProcess: this.options.spawnProcess ?? spawnDesktopProcess, + ...(metaHub ? { metaHub } : {}), + }) + const generation = createGeneration(command, process) + this.active = generation + generation.subscriptions.add(process.onExit((exit) => { + generation.exited = true + this.failGeneration(generation, exit.error) + })) + return generation + } + + private failGeneration(generation: DesktopHostGeneration, error?: Error): void { + if (!this.owns(generation)) return + delete generation.session + this.lastDiagnostics = generation.process.diagnostics + if (this.command === generation.command) this.command += 1 + const cleanup = this.release(generation, false) + this.setStatus('crashed') + void cleanup + } + + private async stopInternal(command: number): Promise { + const generation = this.active + if (generation) { + this.setStatus('stopping') + await this.release(generation, true) + } else { + await this.cleanup + } + if (this.command === command) this.setStatus('stopped') + } + + private release(generation: DesktopHostGeneration, graceful: boolean): Promise { + const cleanup = terminateGeneration( + generation, + graceful, + this.options.shutdownTimeoutMs ?? 4_000, + ) + this.cleanup = cleanup + const finish = (failed: boolean): void => { + this.lastDiagnostics = generation.process.diagnostics + if (!failed || generation.exited) { + if (this.active === generation) this.active = undefined + } else { + delete generation.cleanup + } + if (this.cleanup === cleanup) this.cleanup = undefined + } + void cleanup.then(() => finish(false), () => finish(true)) + return cleanup + } + + private owns(generation: DesktopHostGeneration): boolean { + return this.active === generation && !generation.closing + } + private assertOwned(generation: DesktopHostGeneration): void { + if (!this.owns(generation) || this.command !== generation.command) { + throw new Error('Loopal Desktop Host startup was superseded') + } + } + + private assertCommand(command: number): void { + if (this.command !== command) throw new Error('Loopal Desktop Host startup was superseded') + } + private clearStarting(operation: DesktopHostOperation): void { + if (this.starting === operation) this.starting = undefined + } + private clearStopping(operation: DesktopHostOperation): void { + if (this.stopping === operation) this.stopping = undefined + } + + private setStatus(status: HostStatus): void { + if (status === this.status) return + this.status = status + this.statusEmitter.fire(status) + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/process/desktop-process-launch.ts b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-launch.ts new file mode 100644 index 00000000..adb0db64 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-launch.ts @@ -0,0 +1,62 @@ +import { spawn, type ChildProcessByStdio } from 'node:child_process' +import { type Readable } from 'node:stream' +import { DeferredPromise } from '../../../../base/common/async' +import { type MetaHubStartupOptions } from '../host/desktop-host-types' + +export type DesktopChild = ChildProcessByStdio +export type SpawnDesktopProcess = ( + binaryPath: string, + cwd: string, + parentPid: number, + env?: NodeJS.ProcessEnv, + resumeSessionId?: string, + metaHub?: MetaHubStartupOptions, +) => DesktopChild + +export function spawnDesktopProcess( + binaryPath: string, + cwd: string, + parentPid: number, + env?: NodeJS.ProcessEnv, + resumeSessionId?: string, + metaHubOrSpawn?: MetaHubStartupOptions | typeof spawn, + spawnFn: typeof spawn = spawn, +): DesktopChild { + const metaHub = typeof metaHubOrSpawn === 'function' ? undefined : metaHubOrSpawn + const spawnProcess = typeof metaHubOrSpawn === 'function' ? metaHubOrSpawn : spawnFn + const args = ['desktop', 'serve', '--parent-pid', String(parentPid)] + const sessionId = validateResumeSessionId(resumeSessionId) + if (sessionId) args.push('--resume', sessionId) + if (metaHub) args.push('--join-hub', metaHub.address, '--hub-name', metaHub.hubName) + return spawnProcess(binaryPath, args, { + cwd, + env: { + ...process.env, + ...env, + ...(metaHub ? { LOOPAL_META_HUB_TOKEN: metaHub.token } : {}), + }, + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +export function validateResumeSessionId(value: string | undefined): string | undefined { + if (value === undefined) return undefined + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) { + throw new Error('Invalid Loopal Desktop resume session ID') + } + return value +} + +export async function withTimeout( + promise: Promise, milliseconds: number, message: string, +): Promise { + const timeout = new DeferredPromise() + const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds) + try { + return await Promise.race([promise, timeout.promise]) + } finally { + clearTimeout(timer) + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/process/desktop-process-metahub.test.ts b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-metahub.test.ts new file mode 100644 index 00000000..33abf5da --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-metahub.test.ts @@ -0,0 +1,26 @@ +import { vi } from 'vitest' +import { FakeChild } from '../host/desktop-host.test-fixtures.ts' +import { spawnDesktopProcess } from './desktop-process' + +describe('DesktopProcess MetaHub startup', () => { + it('passes address and Hub name as shell-free argv while keeping token in env', () => { + const child = new FakeChild() + const spawn = vi.fn((_command: string, _args: readonly string[], _options: unknown) => child) + const result = spawnDesktopProcess( + '/bin/loopal', '/workspace', 42, { CUSTOM: 'yes' }, undefined, + { address: '127.0.0.1:9000', hubName: 'desktop-a', token: 'private-token' }, + spawn as never, + ) + expect(result).toBe(child) + const [, args, options] = spawn.mock.calls[0]! + expect(args).toEqual([ + 'desktop', 'serve', '--parent-pid', '42', + '--join-hub', '127.0.0.1:9000', '--hub-name', 'desktop-a', + ]) + expect(args).not.toContain('private-token') + expect(options).toMatchObject({ + cwd: '/workspace', shell: false, + env: expect.objectContaining({ CUSTOM: 'yes', LOOPAL_META_HUB_TOKEN: 'private-token' }), + }) + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/process/desktop-process-phases.ts b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-phases.ts new file mode 100644 index 00000000..df6592d2 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/process/desktop-process-phases.ts @@ -0,0 +1,82 @@ +import { DeferredPromise } from '../../../../base/common/async' +import { + type DesktopActivationHandshake, + type DesktopAliveHandshake, + type DesktopHandshake, + type DesktopReadyHandshake, +} from '../../common/desktop-handshake' + +export class DesktopProcessPhases { + private readonly alivePhase = new DeferredPromise() + private readonly createdPhase = new DeferredPromise() + private readonly readyPhase = new DeferredPromise() + readonly alive = this.alivePhase.promise + readonly sessionCreated = this.createdPhase.promise + readonly ready = this.readyPhase.promise + private aliveSeen = false + private createdId?: string + private readyId?: string + private failed = false + + constructor() { + void this.alive.catch(() => undefined) + void this.sessionCreated.catch(() => undefined) + void this.ready.catch(() => undefined) + } + + accept(handshake: DesktopHandshake): boolean { + if (this.failed) return false + if (handshake.phase === 'alive') { + if (this.aliveSeen) { + this.reject(new Error('Duplicate Desktop alive handshake')) + return false + } + this.aliveSeen = true + this.alivePhase.resolve(handshake) + return true + } + if (handshake.phase === 'error') { + this.reject(new Error(`${handshake.code}: ${handshake.message}`)) + return false + } + if (!this.aliveSeen) { + this.reject(new Error(`Desktop ${handshake.phase} handshake preceded alive`)) + return false + } + if (handshake.phase === 'session_created') { + if (this.readyId) { + this.reject(new Error('Desktop created Session after ready')) + return false + } + if (this.createdId && this.createdId !== handshake.session_id) { + this.reject(new Error('Desktop Host changed Session during creation')) + return false + } + this.createdId = handshake.session_id + this.createdPhase.resolve(handshake) + return true + } + if (handshake.phase === 'ready') { + if (this.readyId && this.readyId !== handshake.session_id) { + this.reject(new Error('Desktop Host changed Session after ready')) + return false + } + if (this.createdId && this.createdId !== handshake.session_id) { + this.reject(new Error('Desktop Host changed Session during startup')) + return false + } + this.readyId = handshake.session_id + this.createdPhase.resolve(handshake) + this.readyPhase.resolve(handshake) + return true + } + return false + } + + reject(error: unknown): void { + this.failed = true + this.alivePhase.reject(error) + this.createdPhase.reject(error) + this.readyPhase.reject(error) + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/process/desktop-process.ts b/apps/desktop/src/platform/desktop-host/node/process/desktop-process.ts new file mode 100644 index 00000000..fb4c2866 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/process/desktop-process.ts @@ -0,0 +1,185 @@ +import { createInterface, type Interface } from 'node:readline' +import { Emitter, type Event } from '../../../../base/common/event' +import { parseDesktopHandshakeLine } from '../../common/desktop-handshake' +import { type MetaHubStartupOptions } from '../host/desktop-host-types' +import { + type DesktopChild, type SpawnDesktopProcess, validateResumeSessionId, +} from './desktop-process-launch' +import { DesktopProcessPhases } from './desktop-process-phases' + +export { + spawnDesktopProcess, validateResumeSessionId, withTimeout, +} from './desktop-process-launch' +export type { DesktopChild, SpawnDesktopProcess } from './desktop-process-launch' + +export interface DesktopProcessOptions { + readonly binaryPath: string + readonly cwd: string + readonly parentPid: number + readonly env?: NodeJS.ProcessEnv | undefined + readonly resumeSessionId?: string | undefined + readonly spawnProcess: SpawnDesktopProcess + readonly metaHub?: MetaHubStartupOptions +} + +export interface DesktopProcessExit { + readonly code: number | null + readonly signal: NodeJS.Signals | null + readonly error: Error +} + +export class DesktopProtocolDrainError extends Error { + readonly code = 'desktop_protocol_drain_incomplete' +} + +export class DesktopProcessTerminationError extends Error { + readonly code = 'desktop_process_termination_unconfirmed' +} + +export class DesktopProcess { + private readonly phases = new DesktopProcessPhases() + private readonly exitEmitter = new Emitter() + private readonly child: DesktopChild + private readonly stdout: Interface + private readonly diagnosticLines: string[] = [] + private exit: DesktopProcessExit | undefined + private pendingExit: DesktopProcessExit | undefined + private protocolClosed = false + private childClosed = false + private exitObserved = false + private aliveObserved = false + private sessionReported = false + + readonly alive = this.phases.alive + readonly sessionCreated = this.phases.sessionCreated + readonly ready = this.phases.ready + readonly onExit: Event = this.exitEmitter.event + + constructor(options: DesktopProcessOptions) { + const base = [ + options.binaryPath, options.cwd, options.parentPid, options.env, + validateResumeSessionId(options.resumeSessionId), + ] as const + this.child = options.metaHub + ? options.spawnProcess(...base, options.metaHub) + : options.spawnProcess(...base) + this.stdout = createInterface({ input: this.child.stdout, crlfDelay: Infinity }) + this.stdout.on('line', (line) => this.acceptLine(line, options.parentPid)) + this.stdout.once('close', () => this.finishProtocol()) + this.child.stderr.setEncoding('utf8') + this.child.stderr.on('data', (value: string | Buffer) => { + this.captureDiagnostics(value.toString()) + }) + this.child.once('error', (error) => this.observeExit(null, null, error)) + this.child.once('exit', (code, signal) => { + this.exitObserved = true + this.observeExit(code, signal) + }) + this.child.once('close', (code, signal) => this.finishChild(code, signal)) + } + + get diagnostics(): readonly string[] { + return this.diagnosticLines + } + + get creationMayHaveCommitted(): boolean { + return this.aliveObserved && !this.sessionReported + } + + get didReportSession(): boolean { + return this.sessionReported + } + + kill(signal: NodeJS.Signals): boolean { + return this.child.kill(signal) + } + + waitForExit(): Promise { + if (this.exit) return Promise.resolve(this.exit) + return new Promise((resolve) => { + const subscription = this.onExit((exit) => { + subscription.dispose() + resolve(exit) + }) + }) + } + + forceFinalizeProtocol(): boolean { + if (this.exit) return true + const previous = this.pendingExit + if (!previous || !this.exitObserved) return false + this.pendingExit = { + code: previous.code, + signal: previous.signal, + error: new DesktopProtocolDrainError( + 'desktop_protocol_drain_incomplete: stdout did not close after SIGKILL', { + cause: previous.error, + }), + } + this.childClosed = true + this.protocolClosed = true + this.stdout.close() + this.child.stdout.destroy() + this.child.stderr.destroy() + this.finishExit() + return true + } + + private acceptLine(line: string, parentPid: number): void { + try { + const handshake = parseDesktopHandshakeLine(line) + if (!handshake) return + if (handshake.pid !== this.child.pid || handshake.parent_pid !== parentPid) { + throw new Error('Loopal Desktop Host handshake PID metadata does not match its process') + } + if (this.phases.accept(handshake)) { + if (handshake.phase === 'alive') this.aliveObserved = true + if (handshake.phase === 'session_created' || handshake.phase === 'ready') { + this.sessionReported = true + } + } + } catch (error) { + this.phases.reject(error) + } + } + + private captureDiagnostics(chunk: string): void { + for (const line of chunk.split(/\r?\n/)) { + if (!line) continue + this.diagnosticLines.push(line.slice(0, 2_000)) + if (this.diagnosticLines.length > 100) this.diagnosticLines.shift() + } + } + + private observeExit( + code: number | null, + signal: NodeJS.Signals | null, + cause?: Error, + ): void { + if (this.exit || this.pendingExit) return + const error = cause ?? new Error( + `Loopal Desktop Host exited before shutdown (code=${String(code)}, signal=${String(signal)})`, + ) + this.pendingExit = { code, signal, error } + } + + private finishProtocol(): void { + this.protocolClosed = true + this.finishExit() + } + + private finishChild(code: number | null, signal: NodeJS.Signals | null): void { + this.childClosed = true + this.observeExit(code, signal) + this.finishExit() + } + + private finishExit(): void { + if (this.exit || !this.pendingExit || !this.protocolClosed || !this.childClosed) return + this.exit = this.pendingExit + this.pendingExit = undefined + const { error } = this.exit + this.phases.reject(error) + this.exitEmitter.fire(this.exit) + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.test.ts b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.test.ts new file mode 100644 index 00000000..d80bc47c --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FakeSocket, response } from '../../../../../test/support/ipc/jsonrpc' +import { JsonRpcClient } from './jsonrpc-client' + +function createClient(options: ConstructorParameters[1] = {}) { + const socket = new FakeSocket() + const client = new JsonRpcClient(socket as never, options) + return { client, socket } +} + +afterEach(() => vi.useRealTimers()) + +describe('JsonRpcClient', () => { + it('correlates fragmented and coalesced responses using default options', async () => { + const { client, socket } = createClient() + const first = client.call('hub/first') + expect(JSON.parse(socket.writes[0]!.trim())).toEqual({ + jsonrpc: '2.0', + id: 1, + method: 'hub/first', + params: {}, + }) + const encoded = response(1, { ok: true }) + socket.data(encoded.slice(0, 12)) + socket.data(`${encoded.slice(12).trimEnd()}\r\n\n`) + await expect(first).resolves.toEqual({ ok: true }) + + const second = client.call('hub/second', { value: 2 }) + const third = client.call('hub/third', null) + socket.data(`${response(3, 'third')}${response(2, 'second')}`) + await expect(second).resolves.toBe('second') + await expect(third).resolves.toBe('third') + expect(socket.setEncoding).toHaveBeenCalledWith('utf8') + + const closed = vi.fn() + client.onClose(closed) + client.dispose() + client.dispose() + expect(socket.end).toHaveBeenCalledOnce() + expect(socket.destroyReasons).toEqual([undefined]) + expect(closed).not.toHaveBeenCalled() + }) + + it('delivers notifications and rejects inbound Hub requests', () => { + const { client, socket } = createClient() + const notifications = vi.fn() + client.onNotification(notifications) + socket.data( + `${JSON.stringify({ jsonrpc: '2.0', method: 'agent/event', params: { value: 1 } })}\n`, + ) + socket.data(`${JSON.stringify({ jsonrpc: '2.0', method: 'without/params' })}\n`) + socket.data(`${JSON.stringify({ jsonrpc: '2.0', id: 'notification-id', method: 'string/id' })}\n`) + socket.data(`${JSON.stringify({ jsonrpc: '2.0', id: 41, method: 'hub/calls/desktop' })}\n`) + + expect(notifications).toHaveBeenNthCalledWith(1, { + method: 'agent/event', + params: { value: 1 }, + }) + expect(notifications).toHaveBeenNthCalledWith(2, { + method: 'without/params', + params: undefined, + }) + expect(notifications).toHaveBeenNthCalledWith(3, { + method: 'string/id', + params: undefined, + }) + expect(JSON.parse(socket.writes.at(-1)!.trim())).toEqual({ + jsonrpc: '2.0', + id: 41, + error: { + code: -32601, + message: 'Desktop does not expose Hub-callable methods', + }, + }) + client.dispose() + }) + + it('returns typed remote errors, protocol defaults, and ignores late responses', async () => { + const { client, socket } = createClient() + const detailed = client.call('hub/detailed-error') + socket.data( + `${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { code: -32001, message: 'denied', data: { scope: 'workspace' } }, + })}\n`, + ) + await expect(detailed).rejects.toMatchObject({ + name: 'JsonRpcRemoteError', + code: -32001, + message: 'denied', + data: { scope: 'workspace' }, + }) + + const fallback = client.call('hub/fallback-error') + socket.data(`${JSON.stringify({ jsonrpc: '2.0', id: 2, error: { code: 'bad' } })}\n`) + await expect(fallback).rejects.toEqual( + expect.objectContaining({ + name: 'JsonRpcRemoteError', + code: -32603, + message: 'Remote error', + }), + ) + socket.data(response(2, 'late')) + socket.data(response(999, 'unknown')) + client.dispose() + }) + + it('supports preflight abort, active abort, timeout, and write failure', async () => { + const { client, socket } = createClient({ requestTimeoutMs: 5 }) + const preflight = new AbortController() + const preflightReason = new Error('already aborted') + preflight.abort(preflightReason) + await expect(client.call('never/sent', {}, preflight.signal)).rejects.toBe(preflightReason) + + const active = new AbortController() + const pending = client.call('abort/active', {}, active.signal) + const activeReason = new Error('stop now') + active.abort(activeReason) + await expect(pending).rejects.toBe(activeReason) + + let abortListener: (() => void) | undefined + const removeAbortListener = vi.fn() + const signal = { + aborted: false, + reason: undefined, + addEventListener: (_type: string, listener: () => void) => { abortListener = listener }, + removeEventListener: removeAbortListener, + } as unknown as AbortSignal + const fallbackAbort = client.call('abort/fallback', {}, signal) + abortListener?.() + await expect(fallbackAbort).rejects.toThrow('request aborted') + expect(removeAbortListener).toHaveBeenCalledOnce() + + vi.useFakeTimers() + const timedOut = client.call('timeout/request') + const assertion = expect(timedOut).rejects.toThrow('request timed out: timeout/request') + await vi.advanceTimersByTimeAsync(5) + await assertion + + socket.throwOnWrite = new Error('write failed') + await expect(client.call('write/failure')).rejects.toThrow('write failed') + client.dispose() + }) + + it('rejects pending work on error, clean close, and disposal', async () => { + const errored = createClient() + const errorClose = vi.fn() + errored.client.onClose(errorClose) + const errorPending = errored.client.call('pending/error') + const socketError = new Error('socket failed') + errored.socket.fail(socketError) + await expect(errorPending).rejects.toBe(socketError) + expect(errorClose).toHaveBeenCalledWith(socketError) + errored.socket.close() + await expect(errored.client.call('after/error')).rejects.toThrow('connection is closed') + errored.client.dispose() + + const clean = createClient() + const cleanClose = vi.fn() + clean.client.onClose(cleanClose) + const cleanPending = clean.client.call('pending/clean-close') + clean.socket.close() + await expect(cleanPending).rejects.toThrow('connection closed') + expect(cleanClose).toHaveBeenCalledWith(undefined) + clean.client.dispose() + + const disposed = createClient() + const disposedPending = disposed.client.call('pending/dispose') + disposed.client.dispose() + await expect(disposedPending).rejects.toThrow('client disposed') + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.ts b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.ts new file mode 100644 index 00000000..8afe48d0 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-client.ts @@ -0,0 +1,199 @@ +import { createConnection, type Socket } from 'node:net' +import { Emitter, type Event } from '../../../../base/common/event' +import { Disposable, toDisposable } from '../../../../base/common/lifecycle' +import { + isRecord, + encodeJsonRpcFrame, + JsonRpcFrameDecoder, + JsonRpcRemoteError, + MAX_DESKTOP_HOST_FRAME_BYTES, + parseTcpAddress, + type JsonRpcClientOptions, + type JsonRpcNotification, + type JsonRpcPendingRequest, +} from './jsonrpc-protocol' + +export { JsonRpcRemoteError, type JsonRpcClientOptions, type JsonRpcNotification } from './jsonrpc-protocol' + +export class JsonRpcClient extends Disposable { + private readonly notifications = this.register(new Emitter()) + private readonly closed = this.register(new Emitter()) + private readonly pending = new Map() + private readonly requestTimeoutMs: number + private readonly decoder: JsonRpcFrameDecoder + private nextId = 1 + private didClose = false + + readonly onNotification: Event = this.notifications.event + readonly onClose: Event = this.closed.event + + constructor(private readonly socket: Socket, options: JsonRpcClientOptions = {}) { + super() + this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000 + this.decoder = new JsonRpcFrameDecoder(options.maxFrameBytes ?? MAX_DESKTOP_HOST_FRAME_BYTES) + socket.setEncoding('utf8') + socket.on('data', this.acceptChunk) + socket.once('close', this.acceptClose) + socket.on('error', this.acceptError) + this.register( + toDisposable(() => { + socket.off('data', this.acceptChunk) + socket.off('close', this.acceptClose) + socket.off('error', this.acceptError) + socket.end() + socket.destroy() + this.finishClose(new Error('JSON-RPC client disposed')) + }), + ) + } + + static async connect( + address: string, + options: JsonRpcClientOptions & { readonly connectTimeoutMs?: number } = {}, + ): Promise { + const { host, port } = parseTcpAddress(address) + const socket = createConnection({ host, port }) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + socket.destroy() + reject(new Error(`Timed out connecting to Loopal Hub at ${address}`)) + }, options.connectTimeoutMs ?? 5_000) + const onConnect = (): void => { + cleanup() + resolve() + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const cleanup = (): void => { + clearTimeout(timer) + socket.off('connect', onConnect) + socket.off('error', onError) + } + socket.once('connect', onConnect) + socket.once('error', onError) + }) + return new JsonRpcClient(socket, options) + } + + call(method: string, params: unknown = {}, signal?: AbortSignal): Promise { + if (this.didClose) { + return Promise.reject(new Error('Loopal Hub connection is closed')) + } + if (signal?.aborted) { + return Promise.reject(signal.reason ?? new Error('JSON-RPC request aborted')) + } + const id = this.nextId++ + return new Promise((resolve, reject) => { + const onAbort = (): void => { + this.rejectPending(id, signal?.reason ?? new Error('JSON-RPC request aborted')) + } + signal?.addEventListener('abort', onAbort, { once: true }) + const timer = setTimeout( + () => this.rejectPending(id, new Error(`Loopal Hub request timed out: ${method}`)), + this.requestTimeoutMs, + ) + this.pending.set(id, { + resolve, + reject, + timer, + removeAbortListener: () => signal?.removeEventListener('abort', onAbort), + }) + try { + const request = { jsonrpc: '2.0', id, method, params } + this.socket.write(encodeJsonRpcFrame(request, this.decoder.maxFrameBytes)) + } catch (error) { + this.rejectPending(id, error) + } + }) + } + + private readonly acceptChunk = (chunk: string | Buffer): void => { + try { + for (const line of this.decoder.accept(chunk)) { + this.acceptMessage(line) + } + } catch (error) { + this.socket.destroy(error instanceof Error ? error : new Error(String(error))) + } + } + + private acceptMessage(line: string): void { + let message: unknown + try { + message = JSON.parse(line) + } catch { + this.socket.destroy(new Error('Loopal Hub sent malformed JSON')) + return + } + if (!isRecord(message) || message.jsonrpc !== '2.0') { + this.socket.destroy(new Error('Loopal Hub sent an invalid JSON-RPC message')) + return + } + if (typeof message.method === 'string') { + if (typeof message.id === 'number') { + this.socket.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: 'Desktop does not expose Hub-callable methods' }, + })}\n`, + ) + } else { + this.notifications.fire({ method: message.method, params: message.params }) + } + return + } + if (typeof message.id !== 'number') { + this.socket.destroy(new Error('Loopal Hub response is missing a numeric id')) + return + } + const pending = this.takePending(message.id) + if (!pending) { + return + } + if (isRecord(message.error)) { + pending.reject( + new JsonRpcRemoteError( + typeof message.error.code === 'number' ? message.error.code : -32603, + typeof message.error.message === 'string' ? message.error.message : 'Remote error', + message.error.data, + ), + ) + } else { + pending.resolve(message.result) + } + } + + private rejectPending(id: number, reason: unknown): void { + this.takePending(id)?.reject(reason) + } + + private takePending(id: number): JsonRpcPendingRequest | undefined { + const pending = this.pending.get(id) + if (!pending) { + return undefined + } + this.pending.delete(id) + clearTimeout(pending.timer) + pending.removeAbortListener() + return pending + } + + private readonly acceptError = (error: Error): void => this.finishClose(error) + private readonly acceptClose = (): void => this.finishClose(undefined) + + private finishClose(reason: Error | undefined): void { + if (this.didClose) { + return + } + this.didClose = true + const error = reason ?? new Error('Loopal Hub connection closed') + for (const id of [...this.pending.keys()]) { + this.rejectPending(id, error) + } + this.closed.fire(reason) + } +} diff --git a/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.test.ts b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.test.ts new file mode 100644 index 00000000..9dfe9639 --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FakeSocket } from '../../../../../test/support/ipc/jsonrpc' + +const net = vi.hoisted(() => ({ createConnection: vi.fn() })) + +vi.mock('node:net', () => ({ + createConnection: net.createConnection, + default: { createConnection: net.createConnection }, +})) + +import { JsonRpcClient } from './jsonrpc-client' + +function createClient(options: ConstructorParameters[1] = {}) { + const socket = new FakeSocket() + const client = new JsonRpcClient(socket as never, options) + return { client, socket } +} + +afterEach(() => { + net.createConnection.mockReset() + vi.useRealTimers() +}) + +describe('JsonRpcClient protocol', () => { + it('destroys the socket for malformed, invalid, missing-id, and oversized frames', async () => { + const malformed = createClient() + malformed.socket.data('{not-json}\n') + expect(malformed.socket.destroyReasons.at(-1)?.message).toContain('malformed JSON') + malformed.client.dispose() + + for (const value of ['text', null, [], { jsonrpc: '1.0' }]) { + const invalid = createClient() + invalid.socket.data(`${JSON.stringify(value)}\n`) + expect(invalid.socket.destroyReasons.at(-1)?.message).toContain('invalid JSON-RPC') + invalid.client.dispose() + } + + const missingId = createClient() + missingId.socket.data(`${JSON.stringify({ jsonrpc: '2.0', result: true })}\n`) + expect(missingId.socket.destroyReasons.at(-1)?.message).toContain('numeric id') + missingId.client.dispose() + + const unterminated = createClient({ maxFrameBytes: 5 }) + unterminated.socket.data('123456') + expect(unterminated.socket.destroyReasons.at(-1)?.message).toContain('oversized') + unterminated.client.dispose() + + const terminated = createClient({ maxFrameBytes: 5 }) + terminated.socket.data('123456\n') + expect(terminated.socket.destroyReasons.at(-1)?.message).toContain('oversized') + terminated.client.dispose() + + const outbound = createClient({ maxFrameBytes: 5 }) + await expect(outbound.client.call('too-large')).rejects.toThrow('oversized') + expect(outbound.socket.writes).toHaveLength(0) + outbound.client.dispose() + }) + + it('connects, reports connection errors and timeouts, and validates addresses', async () => { + const connectedSocket = new FakeSocket() + net.createConnection.mockImplementationOnce(() => { + queueMicrotask(() => connectedSocket.emit('connect')) + return connectedSocket + }) + const connected = await JsonRpcClient.connect('127.0.0.1:49978') + expect(net.createConnection).toHaveBeenCalledWith({ host: '127.0.0.1', port: 49978 }) + connected.dispose() + + const failedSocket = new FakeSocket() + const connectError = new Error('connection refused') + net.createConnection.mockImplementationOnce(() => { + queueMicrotask(() => failedSocket.emit('error', connectError)) + return failedSocket + }) + await expect(JsonRpcClient.connect('localhost:49979')).rejects.toBe(connectError) + + vi.useFakeTimers() + const timedOutSocket = new FakeSocket() + net.createConnection.mockReturnValueOnce(timedOutSocket) + const timedOut = JsonRpcClient.connect('localhost:49980', { connectTimeoutMs: 5 }) + const assertion = expect(timedOut).rejects.toThrow('Timed out connecting') + await vi.advanceTimersByTimeAsync(5) + await assertion + expect(timedOutSocket.destroyReasons).toEqual([undefined]) + + for (const address of [ + 'localhost', + ':9000', + 'localhost:0', + 'localhost:65536', + 'localhost:not-a-port', + 'localhost:1.5', + ]) { + await expect(JsonRpcClient.connect(address)).rejects.toThrow('Invalid Loopal Hub TCP address') + } + }) +}) diff --git a/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.ts b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.ts new file mode 100644 index 00000000..37b5dddb --- /dev/null +++ b/apps/desktop/src/platform/desktop-host/node/rpc/jsonrpc-protocol.ts @@ -0,0 +1,79 @@ +export interface JsonRpcNotification { + readonly method: string + readonly params: unknown +} + +export interface JsonRpcClientOptions { + readonly requestTimeoutMs?: number + readonly maxFrameBytes?: number +} + +export interface JsonRpcPendingRequest { + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void + readonly timer: ReturnType + readonly removeAbortListener: () => void +} + +export const MAX_DESKTOP_HOST_FRAME_BYTES = 64 * 1024 * 1024 + +export function encodeJsonRpcFrame(value: unknown, maxFrameBytes: number): string { + const frame = JSON.stringify(value) + if (Buffer.byteLength(frame) > maxFrameBytes) { + throw new Error('Desktop attempted to send an oversized JSON-RPC frame') + } + return `${frame}\n` +} + +export class JsonRpcRemoteError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown, + ) { + super(message) + this.name = 'JsonRpcRemoteError' + } +} + +export class JsonRpcFrameDecoder { + private buffer = '' + + constructor(readonly maxFrameBytes: number) {} + + accept(chunk: string | Buffer): string[] { + this.buffer += chunk.toString() + if (Buffer.byteLength(this.buffer) > this.maxFrameBytes && !this.buffer.includes('\n')) { + throw new Error('Loopal Hub sent an oversized JSON-RPC frame') + } + + const frames: string[] = [] + let boundary = this.buffer.indexOf('\n') + while (boundary >= 0) { + const line = this.buffer.slice(0, boundary).replace(/\r$/, '') + this.buffer = this.buffer.slice(boundary + 1) + if (Buffer.byteLength(line) > this.maxFrameBytes) { + throw new Error('Loopal Hub sent an oversized JSON-RPC frame') + } + if (line.length > 0) { + frames.push(line) + } + boundary = this.buffer.indexOf('\n') + } + return frames + } +} + +export function parseTcpAddress(address: string): { host: string; port: number } { + const separator = address.lastIndexOf(':') + const host = address.slice(0, separator) + const port = Number(address.slice(separator + 1)) + if (separator <= 0 || !host || !Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error(`Invalid Loopal Hub TCP address: ${address}`) + } + return { host, port } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/desktop/src/platform/instantiation/common/scope.ts b/apps/desktop/src/platform/instantiation/common/scope.ts new file mode 100644 index 00000000..f309191b --- /dev/null +++ b/apps/desktop/src/platform/instantiation/common/scope.ts @@ -0,0 +1,11 @@ +export enum ServiceScope { + App = 'app', + Window = 'window', + Workspace = 'workspace', + Pane = 'pane', +} + +export interface ScopeDescriptor { + readonly id: string + readonly scope: ServiceScope +} diff --git a/apps/desktop/src/platform/instantiation/common/services.test.ts b/apps/desktop/src/platform/instantiation/common/services.test.ts new file mode 100644 index 00000000..bed9ee4b --- /dev/null +++ b/apps/desktop/src/platform/instantiation/common/services.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { ServiceScope } from './scope' +import { + CyclicServiceDependencyError, + InstantiationService, + MissingServiceError, + ServiceCollection, + createServiceIdentifier, +} from './services' + +describe('instantiation services', () => { + it('stores instances, factories, and service identifiers', () => { + const IValue = createServiceIdentifier('value') + const collection = new ServiceCollection() + expect(collection.has(IValue)).toBe(false) + collection.setInstance(IValue, 4) + expect(collection.has(IValue)).toBe(true) + const service = new InstantiationService(collection) + expect(service.get(IValue)).toBe(4) + expect(IValue.toString()).toBe('value') + expect(service.invokeFunction((accessor) => accessor.get(IValue) + 1)).toBe(5) + }) + + it('lazily creates and caches owned disposable services', () => { + const IResource = createServiceIdentifier<{ dispose(): void; value: number }>('resource') + const dispose = vi.fn() + const factory = vi.fn(() => ({ dispose, value: 9 })) + const collection = new ServiceCollection().setFactory(IResource, factory) + const service = new InstantiationService(collection) + expect(service.get(IResource).value).toBe(9) + expect(service.get(IResource).value).toBe(9) + expect(factory).toHaveBeenCalledOnce() + service.dispose() + service.dispose() + expect(dispose).toHaveBeenCalledOnce() + expect(() => service.get(IResource)).toThrow('disposed') + expect(() => service.invokeFunction(() => 1)).toThrow('disposed') + expect(() => service.createChild()).toThrow('disposed') + }) + + it('resolves parent services and child overrides', () => { + const IValue = createServiceIdentifier('value') + const parent = new InstantiationService(new ServiceCollection().setInstance(IValue, 1)) + const child = parent.createChild() + expect(child.get(IValue)).toBe(1) + const override = parent.createChild(new ServiceCollection().setInstance(IValue, 2)) + expect(override.get(IValue)).toBe(2) + child.dispose() + override.dispose() + parent.dispose() + }) + + it('reports missing and cyclic dependencies', () => { + const IMissing = createServiceIdentifier('missing') + const service = new InstantiationService(new ServiceCollection()) + expect(() => service.get(IMissing)).toThrow(MissingServiceError) + + const ILeft = createServiceIdentifier('left') + const IRight = createServiceIdentifier('right') + const cyclicCollection = new ServiceCollection() + .setFactory(ILeft, (accessor) => ({ right: accessor.get(IRight) })) + .setFactory(IRight, (accessor) => ({ left: accessor.get(ILeft) })) + const cyclic = new InstantiationService(cyclicCollection) + expect(() => cyclic.get(ILeft)).toThrow(CyclicServiceDependencyError) + expect(() => cyclic.get(ILeft)).toThrow('left -> right -> left') + }) + + it('defines the intended service scopes', () => { + expect(Object.values(ServiceScope)).toEqual(['app', 'window', 'workspace', 'pane']) + }) +}) diff --git a/apps/desktop/src/platform/instantiation/common/services.ts b/apps/desktop/src/platform/instantiation/common/services.ts new file mode 100644 index 00000000..92f608cc --- /dev/null +++ b/apps/desktop/src/platform/instantiation/common/services.ts @@ -0,0 +1,134 @@ +import { DisposableStore, type IDisposable, isDisposable } from '../../../base/common/lifecycle' + +export class ServiceIdentifier { + readonly _serviceBrand!: T + + constructor(readonly id: string) {} + + toString(): string { + return this.id + } +} + +export function createServiceIdentifier(id: string): ServiceIdentifier { + return new ServiceIdentifier(id) +} + +export interface ServicesAccessor { + get(identifier: ServiceIdentifier): T +} + +export type ServiceFactory = (accessor: ServicesAccessor) => T + +type ServiceEntry = + | { readonly kind: 'instance'; readonly value: T; readonly owned: boolean } + | { readonly kind: 'factory'; readonly factory: ServiceFactory } + +export class MissingServiceError extends Error { + constructor(identifier: ServiceIdentifier) { + super(`Service is not registered: ${identifier.id}`) + this.name = 'MissingServiceError' + } +} + +export class CyclicServiceDependencyError extends Error { + constructor(path: readonly string[]) { + super(`Cyclic service dependency: ${path.join(' -> ')}`) + this.name = 'CyclicServiceDependencyError' + } +} + +export class ServiceCollection { + private readonly entries = new Map, ServiceEntry>() + + setInstance(identifier: ServiceIdentifier, instance: T, owned = false): this { + this.entries.set(identifier, { kind: 'instance', value: instance, owned }) + return this + } + + setFactory(identifier: ServiceIdentifier, factory: ServiceFactory): this { + this.entries.set(identifier, { kind: 'factory', factory }) + return this + } + + has(identifier: ServiceIdentifier): boolean { + return this.entries.has(identifier) + } + + get(identifier: ServiceIdentifier): ServiceEntry | undefined { + return this.entries.get(identifier) as ServiceEntry | undefined + } +} + +export class InstantiationService implements ServicesAccessor, IDisposable { + private readonly owned = new DisposableStore() + private readonly resolving: ServiceIdentifier[] = [] + private disposed = false + + constructor( + private readonly services: ServiceCollection, + private readonly parent?: InstantiationService, + ) {} + + get(identifier: ServiceIdentifier): T { + if (this.disposed) { + throw new Error('InstantiationService is disposed') + } + const entry = this.services.get(identifier) + if (!entry) { + if (this.parent) { + return this.parent.get(identifier) + } + throw new MissingServiceError(identifier) + } + if (entry.kind === 'instance') { + return entry.value + } + return this.createFromFactory(identifier, entry.factory) + } + + invokeFunction(callback: (accessor: ServicesAccessor) => T): T { + if (this.disposed) { + throw new Error('InstantiationService is disposed') + } + return callback(this) + } + + createChild(services = new ServiceCollection()): InstantiationService { + if (this.disposed) { + throw new Error('InstantiationService is disposed') + } + return new InstantiationService(services, this) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.owned.dispose() + } + + private createFromFactory( + identifier: ServiceIdentifier, + factory: ServiceFactory, + ): T { + const cycleIndex = this.resolving.indexOf(identifier) + if (cycleIndex >= 0) { + const path = this.resolving.slice(cycleIndex).map((item) => item.id) + path.push(identifier.id) + throw new CyclicServiceDependencyError(path) + } + this.resolving.push(identifier) + try { + const instance = factory(this) + this.services.setInstance(identifier, instance, true) + if (isDisposable(instance)) { + this.owned.add(instance) + } + return instance + } finally { + this.resolving.pop() + } + } +} diff --git a/apps/desktop/src/platform/ipc/common/channel-client.test.ts b/apps/desktop/src/platform/ipc/common/channel-client.test.ts new file mode 100644 index 00000000..b9d759e7 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/channel-client.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' +import { timeout } from '../../../base/common/async' +import { CancellationTokenSource } from '../../../base/common/cancellation' +import { Emitter } from '../../../base/common/event' +import { + createChannelConnection, + type TestChannelContext, +} from '../../../../test/support/ipc/channel' +import { ChannelClientImpl, ChannelServer } from './channel' +import { MemoryTransport } from './transport' + +describe('ChannelClient', () => { + it('calls explicitly registered channel commands', async () => { + const call = vi.fn(async (context: TestChannelContext, command: string, arg: unknown) => ({ + context, + command, + arg, + })) + const { client, server } = createChannelConnection({ call }) + await expect(client.call('test', 'echo', { value: 3 })).resolves.toEqual({ + context: { user: 'stone' }, + command: 'echo', + arg: { value: 3 }, + }) + await expect(client.call('test', 'empty')).resolves.toEqual({ + context: { user: 'stone' }, + command: 'empty', + }) + client.dispose() + server.dispose() + }) + + it('cancels active calls from the client', async () => { + const observed = vi.fn() + const { client, server } = createChannelConnection({ + call: async (_context, _command, _arg, token) => { + token.onCancellationRequested(observed) + await timeout(100, token) + }, + }) + const source = new CancellationTokenSource() + const pending = client.call('test', 'slow', undefined, source.token) + await Promise.resolve() + source.cancel() + await expect(pending).rejects.toThrow('cancelled') + await Promise.resolve() + expect(observed).toHaveBeenCalledOnce() + client.dispose() + server.dispose() + }) + + it('rejects calls cancelled before sending', async () => { + const { client, server } = createChannelConnection({ call: vi.fn() }) + const source = new CancellationTokenSource() + source.cancel() + await expect(client.call('test', 'never', undefined, source.token)).rejects.toThrow('cancelled') + client.dispose() + server.dispose() + }) + + it('subscribes and unsubscribes remote events', async () => { + const emitter = new Emitter() + const listen = vi.fn(() => emitter.event) + const { client, server } = createChannelConnection({ call: vi.fn(), listen }) + const values: number[] = [] + const subscription = client.listen('test', 'changed', { scope: 1 })((value) => { + values.push(value) + }) + await Promise.resolve() + emitter.fire(4) + await Promise.resolve() + expect(values).toEqual([4]) + expect(listen).toHaveBeenCalledWith({ user: 'stone' }, 'changed', { scope: 1 }) + subscription.dispose() + subscription.dispose() + await Promise.resolve() + emitter.fire(5) + await Promise.resolve() + expect(values).toEqual([4]) + client.dispose() + server.dispose() + }) + + it('ignores malformed, duplicate, and late wire messages', async () => { + const [clientTransport, serverTransport] = MemoryTransport.pair() + const client = new ChannelClientImpl(clientTransport) + const server = new ChannelServer(serverTransport, { user: 'stone' }) + server.registerChannel('test', { call: async () => 'ok' }) + serverTransport.send({ nope: true }) + clientTransport.send({ nope: true }) + serverTransport.send({ type: 'response', id: 999, ok: true, result: 'late' }) + serverTransport.send({ type: 'event', id: 999, data: 'late' }) + await Promise.resolve() + await expect(client.call('test', 'run')).resolves.toBe('ok') + client.dispose() + server.dispose() + }) + + it('rejects pending calls when disposed', async () => { + const { client, server } = createChannelConnection({ + call: async () => new Promise(() => undefined), + }) + const pending = client.call('test', 'hang') + await Promise.resolve() + client.dispose() + await expect(pending).rejects.toThrow('disposed') + server.dispose() + }) +}) diff --git a/apps/desktop/src/platform/ipc/common/channel-client.ts b/apps/desktop/src/platform/ipc/common/channel-client.ts new file mode 100644 index 00000000..bc50b0f4 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/channel-client.ts @@ -0,0 +1,138 @@ +import { + CancellationError, + CancellationToken, + type CancellationToken as CancellationTokenType, +} from '../../../base/common/cancellation' +import { Emitter, type Event } from '../../../base/common/event' +import { DisposableStore, type IDisposable, toDisposable } from '../../../base/common/lifecycle' +import { type MessageTransport } from './transport' +import { + RemoteError, + WireMessageSchema, + type RequestMessage, + type ResponseMessage, + type SubscribeMessage, +} from './wire' + +export interface ChannelClient extends IDisposable { + call( + channel: string, + command: string, + arg?: unknown, + token?: CancellationTokenType, + ): Promise + listen(channel: string, event: string, arg?: unknown): Event +} + +interface PendingCall { + readonly resolve: (value: unknown) => void + readonly reject: (reason: unknown) => void + readonly cancellation: IDisposable +} + +export class ChannelClientImpl implements ChannelClient { + private readonly store = new DisposableStore() + private readonly pending = new Map() + private readonly subscriptions = new Map>() + private nextId = 1 + private disposed = false + + constructor(private readonly transport: MessageTransport) { + this.store.add(transport) + this.store.add(transport.onMessage((raw) => this.accept(raw))) + } + + call( + channel: string, + command: string, + arg?: unknown, + token: CancellationTokenType = CancellationToken.None, + ): Promise { + if (this.disposed) { + return Promise.reject(new Error('Channel client is disposed')) + } + if (token.isCancellationRequested) { + return Promise.reject(new CancellationError()) + } + const id = this.nextId++ + return new Promise((resolve, reject) => { + const cancellation = token.onCancellationRequested(() => { + this.transport.send({ type: 'cancel', id }) + this.pending.delete(id) + reject(new CancellationError()) + }) + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + cancellation, + }) + const message: RequestMessage = { type: 'request', id, channel, command } + this.transport.send(arg === undefined ? message : { ...message, arg }) + }) + } + + listen(channel: string, event: string, arg?: unknown): Event { + return (listener) => { + if (this.disposed) { + return toDisposable(() => undefined) + } + const id = this.nextId++ + const emitter = new Emitter() + this.subscriptions.set(id, emitter) + const local = emitter.event((value) => listener(value as T)) + const message: SubscribeMessage = { type: 'subscribe', id, channel, event } + this.transport.send(arg === undefined ? message : { ...message, arg }) + return toDisposable(() => { + local.dispose() + emitter.dispose() + if (this.subscriptions.delete(id)) { + this.transport.send({ type: 'unsubscribe', id }) + } + }) + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const pending of this.pending.values()) { + pending.cancellation.dispose() + pending.reject(new Error('Channel client disposed')) + } + this.pending.clear() + for (const emitter of this.subscriptions.values()) { + emitter.dispose() + } + this.subscriptions.clear() + this.store.dispose() + } + + private accept(raw: unknown): void { + const parsed = WireMessageSchema.safeParse(raw) + if (!parsed.success) { + return + } + const message = parsed.data + if (message.type === 'response') { + this.acceptResponse(message) + } else if (message.type === 'event') { + this.subscriptions.get(message.id)?.fire(message.data) + } + } + + private acceptResponse(message: ResponseMessage): void { + const pending = this.pending.get(message.id) + if (!pending) { + return + } + this.pending.delete(message.id) + pending.cancellation.dispose() + if (message.ok) { + pending.resolve(message.result) + } else { + pending.reject(new RemoteError(message.error.code, message.error.message, message.error.data)) + } + } +} diff --git a/apps/desktop/src/platform/ipc/common/channel-server.test.ts b/apps/desktop/src/platform/ipc/common/channel-server.test.ts new file mode 100644 index 00000000..45927c84 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/channel-server.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { Emitter } from '../../../base/common/event' +import { createChannelConnection } from '../../../../test/support/ipc/channel' +import { ChannelClientImpl, ChannelServer, type ServerChannel } from './channel' +import { MemoryTransport } from './transport' +import { RemoteError } from './wire' + +interface Context { + readonly user: string +} + +describe('ChannelServer', () => { + it('returns structured errors for unknown channels and handlers', async () => { + const { client, server } = createChannelConnection({ + call: async () => { throw new RemoteError('DENIED', 'not allowed', { scope: 'workspace' }) }, + }) + await expect(client.call('missing', 'run')).rejects.toMatchObject({ + code: 'CHANNEL_NOT_FOUND', + }) + await expect(client.call('test', 'run')).rejects.toMatchObject({ + code: 'DENIED', + data: { scope: 'workspace' }, + }) + client.dispose() + server.dispose() + }) + + it('surfaces missing event handlers as event payloads', async () => { + const { client, server } = createChannelConnection({ call: vi.fn() }) + const received = vi.fn() + const subscription = client.listen('test', 'missing')(received) + await Promise.resolve() + await Promise.resolve() + expect(received).toHaveBeenCalledWith({ + error: { + code: 'EVENT_NOT_FOUND', + message: 'Channel has no events: test', + }, + }) + subscription.dispose() + client.dispose() + server.dispose() + }) + + it('enforces registration and disposal invariants', async () => { + const [clientTransport, serverTransport] = MemoryTransport.pair() + const server = new ChannelServer(serverTransport, { user: 'stone' }) + const channel: ServerChannel = { call: async () => 'ok' } + const registration = server.registerChannel('test', channel) + expect(() => server.registerChannel('test', channel)).toThrow('already registered') + registration.dispose() + + const client = new ChannelClientImpl(clientTransport) + await expect(client.call('test', 'run')).rejects.toMatchObject({ code: 'CHANNEL_NOT_FOUND' }) + client.dispose() + client.dispose() + await expect(client.call('test', 'late')).rejects.toThrow('disposed') + client.listen('test', 'late')(() => undefined).dispose() + server.dispose() + server.dispose() + expect(() => server.registerChannel('late', channel)).toThrow('disposed') + }) + + it('disposes active client and server subscriptions', async () => { + let remoteListener: ((value: unknown) => void) | undefined + const remoteDispose = vi.fn() + const { client, server } = createChannelConnection({ + call: vi.fn(), + listen: () => (listener) => { + remoteListener = listener + return { dispose: remoteDispose } + }, + }) + const localListener = vi.fn() + const subscription = client.listen('test', 'changed')(localListener) + await Promise.resolve() + + client.dispose() + server.dispose() + remoteListener?.('late') + subscription.dispose() + + expect(remoteDispose).toHaveBeenCalledOnce() + expect(localListener).not.toHaveBeenCalled() + }) + + it('cancels active calls during disposal without sending a late response', async () => { + let release: (() => void) | undefined + const cancelled = vi.fn() + const { client, server } = createChannelConnection({ + call: async (_context, _command, _arg, token) => { + token.onCancellationRequested(cancelled) + await new Promise((resolve) => { release = resolve }) + return 'late' + }, + }) + const pending = client.call('test', 'slow') + await Promise.resolve() + await Promise.resolve() + + server.dispose() + expect(cancelled).toHaveBeenCalledOnce() + release?.() + await Promise.resolve() + client.dispose() + await expect(pending).rejects.toThrow('disposed') + }) + + it('ignores server-side no-op and opposite-direction messages', async () => { + const [clientTransport, serverTransport] = MemoryTransport.pair() + const server = new ChannelServer(serverTransport, { user: 'stone' }) + clientTransport.send({ type: 'cancel', id: 999 }) + clientTransport.send({ type: 'unsubscribe', id: 999 }) + clientTransport.send({ type: 'response', id: 999, ok: true, result: 'opposite' }) + clientTransport.send({ type: 'event', id: 999, data: 'opposite' }) + await Promise.resolve() + await Promise.resolve() + server.dispose() + clientTransport.dispose() + }) +}) diff --git a/apps/desktop/src/platform/ipc/common/channel-server.ts b/apps/desktop/src/platform/ipc/common/channel-server.ts new file mode 100644 index 00000000..b0a666cd --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/channel-server.ts @@ -0,0 +1,150 @@ +import { + CancellationTokenSource, + type CancellationToken as CancellationTokenType, +} from '../../../base/common/cancellation' +import { type Event } from '../../../base/common/event' +import { DisposableStore, type IDisposable, toDisposable } from '../../../base/common/lifecycle' +import { type MessageTransport } from './transport' +import { + RemoteError, + WireMessageSchema, + serializeError, + type RequestMessage, + type SubscribeMessage, + type WireMessage, +} from './wire' + +export interface ServerChannel { + call( + context: Context, + command: string, + arg: unknown, + token: CancellationTokenType, + ): Promise + listen?(context: Context, event: string, arg: unknown): Event +} + +export class ChannelServer implements IDisposable { + private readonly channels = new Map>() + private readonly activeCalls = new Map() + private readonly subscriptions = new Map() + private readonly store = new DisposableStore() + private disposed = false + + constructor( + private readonly transport: MessageTransport, + private readonly context: Context, + ) { + this.store.add(transport) + this.store.add(transport.onMessage((raw) => void this.accept(raw))) + } + + registerChannel(name: string, channel: ServerChannel): IDisposable { + if (this.disposed) { + throw new Error('Channel server is disposed') + } + if (this.channels.has(name)) { + throw new Error(`Channel already registered: ${name}`) + } + this.channels.set(name, channel) + return toDisposable(() => { + this.channels.delete(name) + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const source of this.activeCalls.values()) { + source.dispose(true) + } + this.activeCalls.clear() + for (const subscription of this.subscriptions.values()) { + subscription.dispose() + } + this.subscriptions.clear() + this.channels.clear() + this.store.dispose() + } + + private async accept(raw: unknown): Promise { + const parsed = WireMessageSchema.safeParse(raw) + if (!parsed.success || this.disposed) { + return + } + const message: WireMessage = parsed.data + switch (message.type) { + case 'request': + await this.acceptRequest(message) + break + case 'cancel': + this.activeCalls.get(message.id)?.cancel() + break + case 'subscribe': + this.acceptSubscription(message) + break + case 'unsubscribe': + this.subscriptions.get(message.id)?.dispose() + this.subscriptions.delete(message.id) + break + default: + break + } + } + + private async acceptRequest(message: RequestMessage): Promise { + const source = new CancellationTokenSource() + this.activeCalls.set(message.id, source) + try { + const channel = this.requireChannel(message.channel) + const result = await channel.call(this.context, message.command, message.arg, source.token) + if (!this.disposed) { + this.transport.send({ type: 'response', id: message.id, ok: true, result }) + } + } catch (error) { + if (!this.disposed) { + this.transport.send({ + type: 'response', + id: message.id, + ok: false, + error: serializeError(error), + }) + } + } finally { + this.activeCalls.delete(message.id) + source.dispose() + } + } + + private acceptSubscription(message: SubscribeMessage): void { + try { + const channel = this.requireChannel(message.channel) + if (!channel.listen) { + throw new RemoteError('EVENT_NOT_FOUND', `Channel has no events: ${message.channel}`) + } + const event = channel.listen(this.context, message.event, message.arg) + const subscription = event((data) => { + if (!this.disposed) { + this.transport.send({ type: 'event', id: message.id, data }) + } + }) + this.subscriptions.set(message.id, subscription) + } catch (error) { + this.transport.send({ + type: 'event', + id: message.id, + data: { error: serializeError(error) }, + }) + } + } + + private requireChannel(name: string): ServerChannel { + const channel = this.channels.get(name) + if (!channel) { + throw new RemoteError('CHANNEL_NOT_FOUND', `Unknown channel: ${name}`) + } + return channel + } +} diff --git a/apps/desktop/src/platform/ipc/common/channel.ts b/apps/desktop/src/platform/ipc/common/channel.ts new file mode 100644 index 00000000..88686bc0 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/channel.ts @@ -0,0 +1,2 @@ +export { ChannelClientImpl, type ChannelClient } from './channel-client' +export { ChannelServer, type ServerChannel } from './channel-server' diff --git a/apps/desktop/src/platform/ipc/common/transport.test.ts b/apps/desktop/src/platform/ipc/common/transport.test.ts new file mode 100644 index 00000000..380e99d3 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/transport.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { MemoryTransport, MessagePortTransport, type MessagePortLike } from './transport' + +describe('IPC transports', () => { + it('pairs memory transports and enforces lifecycle', async () => { + const [left, right] = MemoryTransport.pair() + const listener = vi.fn() + right.onMessage(listener) + left.send({ value: 1 }) + await Promise.resolve() + expect(listener).toHaveBeenCalledWith({ value: 1 }) + right.dispose() + right.dispose() + expect(() => left.send('late')).toThrow('unavailable') + left.dispose() + expect(() => left.send('disposed')).toThrow('disposed') + }) + + it('adapts DOM-style message ports', () => { + let listener: ((event: { data: unknown }) => void) | undefined + const port: MessagePortLike = { + postMessage: vi.fn(), + start: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn((_type, next) => { listener = next }), + removeEventListener: vi.fn(), + } + const transport = new MessagePortTransport(port) + const received = vi.fn() + transport.onMessage(received) + listener?.({ data: 'hello' }) + expect(received).toHaveBeenCalledWith('hello') + transport.send('world') + expect(port.postMessage).toHaveBeenCalledWith('world') + expect(port.start).toHaveBeenCalledOnce() + transport.dispose() + transport.dispose() + expect(port.removeEventListener).toHaveBeenCalled() + expect(port.close).toHaveBeenCalledOnce() + expect(() => transport.send('late')).toThrow('disposed') + }) + + it('adapts event-emitter-style message ports', () => { + let listener: ((event: { data: unknown }) => void) | undefined + const port: MessagePortLike = { + postMessage: vi.fn(), + on: vi.fn((_type, next) => { listener = next }), + off: vi.fn(), + } + const transport = new MessagePortTransport(port) + const received = vi.fn() + transport.onMessage(received) + listener?.({ data: 9 }) + expect(received).toHaveBeenCalledWith(9) + transport.dispose() + expect(port.off).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/platform/ipc/common/transport.ts b/apps/desktop/src/platform/ipc/common/transport.ts new file mode 100644 index 00000000..eac54fa5 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/transport.ts @@ -0,0 +1,97 @@ +import { Emitter, type Event } from '../../../base/common/event' +import { type IDisposable } from '../../../base/common/lifecycle' + +export interface MessageTransport extends IDisposable { + readonly onMessage: Event + send(message: unknown): void +} + +export class MemoryTransport implements MessageTransport { + private readonly messageEmitter = new Emitter() + private peer: MemoryTransport | undefined + private disposed = false + + readonly onMessage = this.messageEmitter.event + + static pair(): readonly [MemoryTransport, MemoryTransport] { + const left = new MemoryTransport() + const right = new MemoryTransport() + left.peer = right + right.peer = left + return [left, right] + } + + send(message: unknown): void { + if (this.disposed) { + throw new Error('Transport is disposed') + } + const peer = this.peer + if (!peer || peer.disposed) { + throw new Error('Transport peer is unavailable') + } + queueMicrotask(() => peer.messageEmitter.fire(message)) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.peer = undefined + this.messageEmitter.dispose() + } +} + +interface PortEvent { + readonly data: unknown +} + +export interface MessagePortLike { + postMessage(message: unknown): void + start?(): void + close?(): void + addEventListener?(type: 'message', listener: (event: PortEvent) => void): void + removeEventListener?(type: 'message', listener: (event: PortEvent) => void): void + on?(type: 'message', listener: (event: PortEvent) => void): void + off?(type: 'message', listener: (event: PortEvent) => void): void +} + +export class MessagePortTransport implements MessageTransport { + private readonly messageEmitter = new Emitter() + private disposed = false + private readonly handleMessage = (event: PortEvent): void => { + this.messageEmitter.fire(event.data) + } + + readonly onMessage = this.messageEmitter.event + + constructor(private readonly port: MessagePortLike) { + if (port.addEventListener) { + port.addEventListener('message', this.handleMessage) + } else { + port.on?.('message', this.handleMessage) + } + port.start?.() + } + + send(message: unknown): void { + if (this.disposed) { + throw new Error('Transport is disposed') + } + this.port.postMessage(message) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + if (this.port.removeEventListener) { + this.port.removeEventListener('message', this.handleMessage) + } else { + this.port.off?.('message', this.handleMessage) + } + this.port.close?.() + this.messageEmitter.dispose() + } +} diff --git a/apps/desktop/src/platform/ipc/common/wire.test.ts b/apps/desktop/src/platform/ipc/common/wire.test.ts new file mode 100644 index 00000000..cbeee4b4 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/wire.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + RemoteError, + WireMessageSchema, + serializeError, +} from './wire' + +describe('IPC wire protocol', () => { + it('validates every message shape', () => { + const messages = [ + { type: 'request', id: 1, channel: 'test', command: 'run', arg: { ok: true } }, + { type: 'response', id: 1, ok: true, result: 4 }, + { type: 'response', id: 1, ok: false, error: { code: 'NO', message: 'failed' } }, + { type: 'cancel', id: 1 }, + { type: 'subscribe', id: 1, channel: 'test', event: 'changed' }, + { type: 'unsubscribe', id: 1 }, + { type: 'event', id: 1, data: 'value' }, + ] + for (const message of messages) { + expect(WireMessageSchema.parse(message)).toEqual(message) + } + expect(WireMessageSchema.safeParse({ type: 'request', id: 0 }).success).toBe(false) + expect(WireMessageSchema.safeParse({ type: 'unknown' }).success).toBe(false) + }) + + it('serializes typed, native, and non-error failures', () => { + expect(serializeError(new RemoteError('DENIED', 'no', { path: '/x' }))).toEqual({ + code: 'DENIED', + message: 'no', + data: { path: '/x' }, + }) + expect(serializeError(new RemoteError('EMPTY', 'empty'))).toEqual({ + code: 'EMPTY', + message: 'empty', + }) + expect(serializeError(new TypeError('bad'))).toEqual({ code: 'TypeError', message: 'bad' }) + const unnamed = new Error('unnamed') + unnamed.name = '' + expect(serializeError(unnamed)).toEqual({ code: 'ERROR', message: 'unnamed' }) + expect(serializeError('failure')).toEqual({ code: 'ERROR', message: 'failure' }) + const error = new RemoteError('X', 'message', 2) + expect(error.name).toBe('RemoteError') + expect(error.data).toBe(2) + }) +}) diff --git a/apps/desktop/src/platform/ipc/common/wire.ts b/apps/desktop/src/platform/ipc/common/wire.ts new file mode 100644 index 00000000..c0fac3b1 --- /dev/null +++ b/apps/desktop/src/platform/ipc/common/wire.ts @@ -0,0 +1,102 @@ +import { z } from 'zod' + +const requestId = z.number().int().positive() + +export const RequestMessageSchema = z.object({ + type: z.literal('request'), + id: requestId, + channel: z.string().min(1), + command: z.string().min(1), + arg: z.unknown().optional(), +}) + +export const ResponseMessageSchema = z.discriminatedUnion('ok', [ + z.object({ + type: z.literal('response'), + id: requestId, + ok: z.literal(true), + result: z.unknown().optional(), + }), + z.object({ + type: z.literal('response'), + id: requestId, + ok: z.literal(false), + error: z.object({ + code: z.string(), + message: z.string(), + data: z.unknown().optional(), + }), + }), +]) + +export const CancelMessageSchema = z.object({ + type: z.literal('cancel'), + id: requestId, +}) + +export const SubscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: requestId, + channel: z.string().min(1), + event: z.string().min(1), + arg: z.unknown().optional(), +}) + +export const UnsubscribeMessageSchema = z.object({ + type: z.literal('unsubscribe'), + id: requestId, +}) + +export const EventMessageSchema = z.object({ + type: z.literal('event'), + id: requestId, + data: z.unknown().optional(), +}) + +export const WireMessageSchema = z.discriminatedUnion('type', [ + RequestMessageSchema, + ResponseMessageSchema, + CancelMessageSchema, + SubscribeMessageSchema, + UnsubscribeMessageSchema, + EventMessageSchema, +]) + +export type RequestMessage = z.infer +export type ResponseMessage = z.infer +export type CancelMessage = z.infer +export type SubscribeMessage = z.infer +export type UnsubscribeMessage = z.infer +export type EventMessage = z.infer +export type WireMessage = z.infer + +export interface SerializedError { + readonly code: string + readonly message: string + readonly data?: unknown +} + +export class RemoteError extends Error { + constructor( + readonly code: string, + message: string, + readonly data?: unknown, + ) { + super(message) + this.name = 'RemoteError' + } +} + +export function serializeError(error: unknown): SerializedError { + if (error instanceof RemoteError) { + const serialized: SerializedError = { + code: error.code, + message: error.message, + } + return error.data === undefined ? serialized : { ...serialized, data: error.data } + } + if (error instanceof Error) { + return { code: error.name || 'ERROR', message: error.message } + } + return { code: 'ERROR', message: String(error) } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/backend.test.ts b/apps/desktop/src/platform/loopal-backend/common/backend.test.ts new file mode 100644 index 00000000..cace6eb7 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/backend.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { IDesktopBackend } from './backend' + +describe('IDesktopBackend', () => { + it('publishes the stable runtime service identifier', () => { + expect(IDesktopBackend.id).toBe('desktopBackend') + expect(IDesktopBackend.toString()).toBe('desktopBackend') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/backend.ts b/apps/desktop/src/platform/loopal-backend/common/backend.ts new file mode 100644 index 00000000..d2f2d654 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/backend.ts @@ -0,0 +1,126 @@ +import { type Event } from '../../../base/common/event' +import { createServiceIdentifier } from '../../instantiation/common/services' +import { + type AgentControlInput, + type AgentControlTarget, + type CreateSessionInput, + type CreateWorktreeInput, + type DirectoryListing, + type DesktopEvent, + type DesktopImageAttachment, + type DesktopPreferences, + type FileDocument, + type GitDiff, + type GitStageInput, + type GitStatus, + type GitUnstageInput, + type ListDirectoryInput, + type JoinMetaHubInput, + type LocalMetaHubStatus, + type LoopalDefaultSettings, + type DeleteGlobalSkillInput, + type DeleteMcpServerInput, + type GetSkillInput, + type McpServersResponse, + type PluginsResponse, + type MetaHubRuntimeState, + type MetaHubRuntimeTarget, + type MetaHubSettings, + type PermissionResponseInput, + type PlanApprovalResponseInput, + type QuestionResponseInput, + type ReadFileInput, + type RemoveWorktreeInput, + type RuntimeSummary, + type SessionDetail, + type SkillDetail, + type SkillsResponse, + type StartLocalMetaHubInput, + type UpdateMetaHubSettingsInput, + type UpdateDesktopPreferencesInput, + type UpdateLoopalSettingsInput, + type UpsertGlobalSkillInput, + type UpsertMcpServerInput, + type WorkspaceSearchInput, + type WorkspaceSearchResult, + type WorkbenchBootstrap, + type Worktree, + type WriteFileInput, +} from '../../../shared/contracts' +import { type CancellationToken } from '../../../base/common/cancellation' + +export interface DesktopBackend { + readonly onEvent: Event + bootstrap(token: CancellationToken): Promise + openSession(sessionId: string, token: CancellationToken): Promise + createSession(input: CreateSessionInput, token: CancellationToken): Promise + stopSession(sessionId: string, token: CancellationToken): Promise + restartSession(sessionId: string, token: CancellationToken): Promise + sendMessage( + sessionId: string, text: string, token: CancellationToken, agentId?: string, + images?: readonly DesktopImageAttachment[], + ): Promise + interruptAgent(input: AgentControlTarget, token: CancellationToken): Promise + controlAgent(input: AgentControlInput, token: CancellationToken): Promise + getDesktopPreferences(token: CancellationToken): Promise + updateDesktopPreferences( + input: UpdateDesktopPreferencesInput, token: CancellationToken, + ): Promise + getLoopalSettings( + workspaceId: string, token: CancellationToken, + ): Promise + updateLoopalSettings( + input: UpdateLoopalSettingsInput, token: CancellationToken, + ): Promise + listMcpServers(workspaceId: string, token: CancellationToken): Promise + upsertMcpServer( + input: UpsertMcpServerInput, token: CancellationToken, + ): Promise + deleteMcpServer( + input: DeleteMcpServerInput, token: CancellationToken, + ): Promise + listSkills(workspaceId: string, token: CancellationToken): Promise + getSkill(input: GetSkillInput, token: CancellationToken): Promise + upsertGlobalSkill( + input: UpsertGlobalSkillInput, token: CancellationToken, + ): Promise + deleteGlobalSkill( + input: DeleteGlobalSkillInput, token: CancellationToken, + ): Promise + listPlugins(workspaceId: string, token: CancellationToken): Promise + getMetaHubSettings(token: CancellationToken): Promise + updateMetaHubSettings( + input: UpdateMetaHubSettingsInput, token: CancellationToken, + ): Promise + getMetaHubStatus( + target: MetaHubRuntimeTarget, token: CancellationToken, + ): Promise + joinMetaHub(input: JoinMetaHubInput, token: CancellationToken): Promise + disconnectMetaHub( + target: MetaHubRuntimeTarget, token: CancellationToken, + ): Promise + getLocalMetaHubStatus(token: CancellationToken): Promise + startLocalMetaHub( + input: StartLocalMetaHubInput, token: CancellationToken, + ): Promise + stopLocalMetaHub(token: CancellationToken): Promise + listDirectory(input: ListDirectoryInput, token: CancellationToken): Promise + readFile(input: ReadFileInput, token: CancellationToken): Promise + writeFile(input: WriteFileInput, token: CancellationToken): Promise + searchWorkspace( + input: WorkspaceSearchInput, + token: CancellationToken, + ): Promise + gitStatus(workspaceId: string, token: CancellationToken): Promise + gitDiff(input: ReadFileInput, token: CancellationToken): Promise + gitStage(input: GitStageInput, token: CancellationToken): Promise + gitUnstage(input: GitUnstageInput, token: CancellationToken): Promise + listWorktrees(workspaceId: string, token: CancellationToken): Promise + createWorktree(input: CreateWorktreeInput, token: CancellationToken): Promise + removeWorktree(input: RemoveWorktreeInput, token: CancellationToken): Promise + respondPermission(input: PermissionResponseInput, token: CancellationToken): Promise + respondQuestion(input: QuestionResponseInput, token: CancellationToken): Promise + respondPlanApproval(input: PlanApprovalResponseInput, token: CancellationToken): Promise +} + +export const IDesktopBackend = createServiceIdentifier('desktopBackend') diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/attention-channel.ts b/apps/desktop/src/platform/loopal-backend/common/channels/attention-channel.ts new file mode 100644 index 00000000..77643a30 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/attention-channel.ts @@ -0,0 +1,23 @@ +import { type CancellationToken } from '../../../../base/common/cancellation' +import { + PermissionResponseInputSchema, + PlanApprovalResponseInputSchema, + QuestionResponseInputSchema, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../backend' + +export async function callAttentionBackend( + backend: DesktopBackend, + command: string, + arg: unknown, + token: CancellationToken, +): Promise<{ readonly handled: boolean; readonly value?: undefined }> { + if (command === 'respondPermission') { + await backend.respondPermission(PermissionResponseInputSchema.parse(arg), token) + } else if (command === 'respondQuestion') { + await backend.respondQuestion(QuestionResponseInputSchema.parse(arg), token) + } else if (command === 'respondPlanApproval') { + await backend.respondPlanApproval(PlanApprovalResponseInputSchema.parse(arg), token) + } else return { handled: false } + return { handled: true, value: undefined } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.code.test.ts b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.code.test.ts new file mode 100644 index 00000000..46ad1837 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.code.test.ts @@ -0,0 +1,71 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { createBackendStub } from '../../../../../test/support/backend/backend-stub' +import { DesktopBackendChannel } from './backend-channel' + +const token = CancellationToken.None + +describe('DesktopBackendChannel code workbench commands', () => { + it('dispatches workspace, Git, worktree, and attention commands', async () => { + const backend = createBackendStub() + const channel = new DesktopBackendChannel(backend) + const call = (command: string, input: unknown) => channel.call({}, command, input, token) + + await expect(call('listDirectory', { workspaceId: 'workspace', path: '' })) + .resolves.toMatchObject({ workspaceId: 'workspace' }) + await expect(call('readFile', { workspaceId: 'workspace', path: 'a.ts' })) + .resolves.toMatchObject({ version: 'v1' }) + await expect(call('writeFile', { + workspaceId: 'workspace', path: 'a.ts', content: 'next', expectedVersion: 'v1', + })).resolves.toMatchObject({ content: 'next', version: 'v2' }) + await expect(call('searchWorkspace', { workspaceId: 'workspace', query: 'next' })) + .resolves.toEqual({ matches: [], truncated: false }) + await expect(call('gitStatus', { workspaceId: 'workspace' })) + .resolves.toMatchObject({ branch: 'main' }) + await expect(call('gitDiff', { workspaceId: 'workspace', path: 'a.ts' })) + .resolves.toMatchObject({ path: 'a.ts' }) + await expect(call('gitStage', { workspaceId: 'workspace', path: 'a.ts' })) + .resolves.toBeUndefined() + await expect(call('gitUnstage', { workspaceId: 'workspace', path: 'a.ts' })) + .resolves.toBeUndefined() + await expect(call('listWorktrees', { workspaceId: 'workspace' })).resolves.toEqual([]) + await expect(call('createWorktree', { workspaceId: 'workspace', name: 'feature' })) + .resolves.toMatchObject({ id: 'feature' }) + await expect(call('removeWorktree', { + workspaceId: 'workspace', name: 'feature', force: false, + })).resolves.toBeUndefined() + const scope = { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + } + await expect(call('respondPermission', { + ...scope, requestId: 'p1', decision: 'allow_once', + })) + .resolves.toBeUndefined() + await expect(call('respondQuestion', { ...scope, requestId: 'q1', answers: ['yes'] })) + .resolves.toBeUndefined() + await expect(call('respondQuestion', { ...scope, requestId: 'q2', cancelled: true })) + .resolves.toBeUndefined() + + expect(backend.searchWorkspace).toHaveBeenCalledWith( + { workspaceId: 'workspace', query: 'next', maxResults: 200 }, token, + ) + expect(backend.gitStage).toHaveBeenCalledWith( + { workspaceId: 'workspace', path: 'a.ts' }, token, + ) + expect(backend.respondQuestion).toHaveBeenLastCalledWith( + { ...scope, requestId: 'q2', cancelled: true }, token, + ) + }) + + it('rejects unsafe paths and malformed worktree or attention inputs', async () => { + const channel = new DesktopBackendChannel(createBackendStub()) + await expect(channel.call({}, 'readFile', { + workspaceId: 'workspace', path: '../secret', + }, token)).rejects.toThrow('path must stay inside its workspace') + await expect(channel.call({}, 'createWorktree', { + workspaceId: 'workspace', name: '../escape', + }, token)).rejects.toThrow() + await expect(channel.call({}, 'respondQuestion', { + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', requestId: 'q', + }, token)).rejects.toThrow('Provide answers or cancel') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.test.ts b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.test.ts new file mode 100644 index 00000000..b49d6199 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { Emitter } from '../../../../base/common/event' +import { createBackendStub } from '../../../../../test/support/backend/backend-stub' +import { DesktopBackendChannel } from './backend-channel' +import { type DesktopEvent } from '../../../../shared/contracts' + +describe('DesktopBackendChannel', () => { + it('dispatches only its explicit command allowlist', async () => { + const service = createBackendStub() + const channel = new DesktopBackendChannel(service) + await expect(channel.call({}, 'bootstrap', undefined, CancellationToken.None)).resolves.toMatchObject({ + protocolVersion: 2, + }) + await expect( + channel.call({}, 'openSession', { sessionId: 'session' }, CancellationToken.None), + ).resolves.toMatchObject({ session: { id: 'session' } }) + await expect(channel.call( + {}, 'createSession', { + authorizationId: '5d0c638c-d44c-4f47-818b-62e6b599e31c', launchMode: 'directory', + }, CancellationToken.None, + )).resolves.toMatchObject({ session: { id: 'session-new' } }) + await expect(channel.call( + {}, 'stopSession', { sessionId: 'session' }, CancellationToken.None, + )).resolves.toBeUndefined() + await expect(channel.call( + {}, 'restartSession', { sessionId: 'session' }, CancellationToken.None, + )).resolves.toMatchObject({ sessionId: 'session' }) + await expect( + channel.call({}, 'sendMessage', { + sessionId: 'session', text: 'hello', agentId: 'worker', images: [{ + name: 'pixel.png', mediaType: 'image/png', data: 'iVBORw==', sizeBytes: 4, + }], + }, CancellationToken.None), + ).resolves.toBeUndefined() + const target = { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + } + await expect(channel.call( + {}, 'interruptAgent', target, CancellationToken.None, + )).resolves.toBeUndefined() + await expect(channel.call( + {}, 'controlAgent', { target, command: { type: 'clear' } }, CancellationToken.None, + )).resolves.toBeUndefined() + await expect(channel.call( + {}, 'getDesktopPreferences', undefined, CancellationToken.None, + )).resolves.toEqual({ locale: 'system' }) + await expect(channel.call( + {}, 'updateDesktopPreferences', { locale: 'zh-CN' }, CancellationToken.None, + )).resolves.toEqual({ locale: 'zh-CN' }) + await expect(channel.call( + {}, 'getLoopalSettings', { workspaceId: 'workspace' }, CancellationToken.None, + )).resolves.toMatchObject({ settings: { model: 'gpt-5' } }) + const defaults = await service.getLoopalSettings('workspace', CancellationToken.None) + await expect(channel.call( + {}, 'updateLoopalSettings', { + workspaceId: defaults.workspaceId, settings: defaults.settings, + }, CancellationToken.None, + )).resolves.toMatchObject({ workspaceId: 'workspace' }) + expect(service.openSession).toHaveBeenCalledWith('session', CancellationToken.None) + expect(service.sendMessage).toHaveBeenCalledWith( + 'session', 'hello', CancellationToken.None, 'worker', [{ + name: 'pixel.png', mediaType: 'image/png', data: 'iVBORw==', sizeBytes: 4, + }], + ) + expect(service.interruptAgent).toHaveBeenCalledWith(target, CancellationToken.None) + await expect(channel.call({}, 'secret', {}, CancellationToken.None)).rejects.toMatchObject({ + code: 'COMMAND_NOT_FOUND', + }) + }) + + it('validates command arguments and event names', async () => { + const service = createBackendStub() + const channel = new DesktopBackendChannel(service) + await expect( + channel.call({}, 'openSession', { sessionId: '' }, CancellationToken.None), + ).rejects.toThrow() + await expect(channel.call( + {}, 'createSession', { workspaceId: 'workspace' }, CancellationToken.None, + )).rejects.toThrow() + await expect( + channel.call({}, 'sendMessage', { sessionId: 'session', text: ' ' }, CancellationToken.None), + ).rejects.toThrow() + await expect(channel.call({}, 'sendMessage', { + sessionId: 'session', text: '', images: [{ + name: 'fake.png', mediaType: 'image/png', data: 'iVBORw==', sizeBytes: 5, + }], + }, CancellationToken.None)).rejects.toThrow() + await expect(channel.call( + {}, 'sendMessage', { sessionId: 'session', text: 'ok', agentId: '' }, CancellationToken.None, + )).rejects.toThrow() + await expect(channel.call( + {}, 'controlAgent', { + target: { sessionId: 'session', runtimeId: 'runtime', generation: 0, agentId: 'main' }, + command: { type: 'resume_session', sessionId: 'other' }, + }, CancellationToken.None, + )).rejects.toThrow() + await expect(channel.call( + {}, 'getLoopalSettings', { workspaceId: '' }, CancellationToken.None, + )).rejects.toThrow() + await expect(channel.call( + {}, 'updateLoopalSettings', { workspaceId: 'workspace', settings: {} }, + CancellationToken.None, + )).rejects.toThrow() + await expect(channel.call( + {}, 'updateDesktopPreferences', { locale: 'fr' }, CancellationToken.None, + )).rejects.toThrow() + expect(() => channel.listen({}, 'missing')).toThrow('Unknown desktop backend event') + }) + + it('validates and dispatches every MetaHub command', async () => { + const service = createBackendStub() + const channel = new DesktopBackendChannel(service) + const target = { sessionId: 'session', runtimeId: 'runtime', generation: 1 } + const token = CancellationToken.None + await expect(channel.call({}, 'getMetaHubSettings', undefined, token)).resolves + .toMatchObject({ tokenConfigured: false }) + const settings = { + address: 'meta:9', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, token: 'secret', + } + await expect(channel.call({}, 'updateMetaHubSettings', settings, token)).resolves + .toMatchObject({ address: 'meta:9' }) + await expect(channel.call({}, 'getMetaHubStatus', target, token)).resolves + .toMatchObject({ state: 'disconnected' }) + await expect(channel.call({}, 'joinMetaHub', { ...target, token: 'secret' }, token)).resolves + .toMatchObject({ state: 'connected' }) + await expect(channel.call({}, 'disconnectMetaHub', target, token)).resolves + .toMatchObject({ state: 'disconnected' }) + await expect(channel.call({}, 'getLocalMetaHubStatus', undefined, token)).resolves + .toEqual({ state: 'stopped' }) + await expect(channel.call({}, 'startLocalMetaHub', { + bindAddress: '127.0.0.1:0', + }, token)).resolves.toMatchObject({ state: 'running' }) + await expect(channel.call({}, 'stopLocalMetaHub', undefined, token)).resolves + .toEqual({ state: 'stopped' }) + await expect(channel.call({}, 'joinMetaHub', { ...target, token: '' }, token)).rejects + .toThrow() + await expect(channel.call({}, 'startLocalMetaHub', { bindAddress: '' }, token)).rejects + .toThrow() + }) + + it('validates and forwards backend events until disposal', () => { + const events = new Emitter() + const service = createBackendStub({ onEvent: events.event }) + const channel = new DesktopBackendChannel(service) + const listener = vi.fn() + const subscription = channel.listen({}, 'event')(listener) + const event: DesktopEvent = { type: 'host_status', status: 'ready' } + + events.fire(event) + expect(listener).toHaveBeenCalledWith(event) + subscription.dispose() + events.fire({ type: 'host_status', status: 'stopped' }) + expect(listener).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.ts b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.ts new file mode 100644 index 00000000..2528e91a --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/backend-channel.ts @@ -0,0 +1,175 @@ +import { type CancellationToken } from '../../../../base/common/cancellation' +import { type Event } from '../../../../base/common/event' +import { RemoteError } from '../../../ipc/common/wire' +import { type ServerChannel } from '../../../ipc/common/channel' +import { + AgentControlInputSchema, + AgentControlTargetSchema, + CreateSessionInputSchema, + CreateWorktreeInputSchema, + DirectoryListingSchema, + DesktopPreferencesSchema, + DesktopEventSchema, + FileDocumentSchema, + GitDiffInputSchema, + GitDiffSchema, + GitStageInputSchema, + GitStatusSchema, + GitUnstageInputSchema, + ListDirectoryInputSchema, + LoopalDefaultSettingsSchema, + LoopalSettingsWorkspaceInputSchema, + ReadFileInputSchema, + RemoveWorktreeInputSchema, + SendMessageInputSchema, + SessionDetailSchema, + SessionOperationInputSchema, + RuntimeSummarySchema, + UpdateDesktopPreferencesInputSchema, + UpdateLoopalSettingsInputSchema, + WorkspaceOperationInputSchema, + WorkspaceSearchInputSchema, + WorkspaceSearchResultSchema, + WorkbenchBootstrapSchema, + WorktreeListSchema, + WorktreeSchema, + WriteFileInputSchema, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../backend' +import { callMetaHubBackend } from './metahub-channel' +import { callMcpSettingsBackend } from './mcp-settings-channel' +import { callAttentionBackend } from './attention-channel' +import { callSkillPluginBackend } from './skill-plugin-channel' + +export class DesktopBackendChannel implements ServerChannel { + constructor(private readonly backend: DesktopBackend) {} + + async call( + _context: Context, + command: string, + arg: unknown, + token: CancellationToken, + ): Promise { + switch (command) { + case 'bootstrap': + return WorkbenchBootstrapSchema.parse(await this.backend.bootstrap(token)) + case 'openSession': { + const { sessionId } = SessionOperationInputSchema.parse(arg) + return SessionDetailSchema.parse(await this.backend.openSession(sessionId, token)) + } + case 'createSession': { + const input = CreateSessionInputSchema.parse(arg) + return SessionDetailSchema.parse(await this.backend.createSession(input, token)) + } + case 'stopSession': { + const { sessionId } = SessionOperationInputSchema.parse(arg) + await this.backend.stopSession(sessionId, token) + return undefined + } + case 'restartSession': { + const { sessionId } = SessionOperationInputSchema.parse(arg) + return RuntimeSummarySchema.parse(await this.backend.restartSession(sessionId, token)) + } + case 'sendMessage': { + const { sessionId, text, agentId, images } = SendMessageInputSchema.parse(arg) + await this.backend.sendMessage(sessionId, text, token, agentId, images) + return undefined + } + case 'interruptAgent': { + const input = AgentControlTargetSchema.parse(arg) + await this.backend.interruptAgent(input, token) + return undefined + } + case 'controlAgent': { + const input = AgentControlInputSchema.parse(arg) + await this.backend.controlAgent(input, token) + return undefined + } + case 'getDesktopPreferences': + return DesktopPreferencesSchema.parse(await this.backend.getDesktopPreferences(token)) + case 'updateDesktopPreferences': { + const input = UpdateDesktopPreferencesInputSchema.parse(arg) + return DesktopPreferencesSchema.parse( + await this.backend.updateDesktopPreferences(input, token), + ) + } + case 'getLoopalSettings': { + const { workspaceId } = LoopalSettingsWorkspaceInputSchema.parse(arg) + return LoopalDefaultSettingsSchema.parse( + await this.backend.getLoopalSettings(workspaceId, token), + ) + } + case 'updateLoopalSettings': { + const input = UpdateLoopalSettingsInputSchema.parse(arg) + return LoopalDefaultSettingsSchema.parse( + await this.backend.updateLoopalSettings(input, token), + ) + } + case 'listDirectory': { + const input = ListDirectoryInputSchema.parse(arg) + return DirectoryListingSchema.parse(await this.backend.listDirectory(input, token)) + } + case 'readFile': { + const input = ReadFileInputSchema.parse(arg) + return FileDocumentSchema.parse(await this.backend.readFile(input, token)) + } + case 'writeFile': { + const input = WriteFileInputSchema.parse(arg) + return FileDocumentSchema.parse(await this.backend.writeFile(input, token)) + } + case 'searchWorkspace': { + const input = WorkspaceSearchInputSchema.parse(arg) + return WorkspaceSearchResultSchema.parse(await this.backend.searchWorkspace(input, token)) + } + case 'gitStatus': { + const { workspaceId } = WorkspaceOperationInputSchema.parse(arg) + return GitStatusSchema.parse(await this.backend.gitStatus(workspaceId, token)) + } + case 'gitDiff': { + const input = GitDiffInputSchema.parse(arg) + return GitDiffSchema.parse(await this.backend.gitDiff(input, token)) + } + case 'gitStage': { + const input = GitStageInputSchema.parse(arg) + await this.backend.gitStage(input, token) + return undefined + } + case 'gitUnstage': { + const input = GitUnstageInputSchema.parse(arg) + await this.backend.gitUnstage(input, token) + return undefined + } + case 'listWorktrees': { + const { workspaceId } = WorkspaceOperationInputSchema.parse(arg) + return WorktreeListSchema.parse(await this.backend.listWorktrees(workspaceId, token)) + } + case 'createWorktree': { + const input = CreateWorktreeInputSchema.parse(arg) + return WorktreeSchema.parse(await this.backend.createWorktree(input, token)) + } + case 'removeWorktree': { + const input = RemoveWorktreeInputSchema.parse(arg) + await this.backend.removeWorktree(input, token) + return undefined + } + default: + const attention = await callAttentionBackend(this.backend, command, arg, token) + if (attention.handled) return attention.value + const mcpSettings = await callMcpSettingsBackend(this.backend, command, arg, token) + if (mcpSettings.handled) return mcpSettings.value + const skillPlugins = await callSkillPluginBackend(this.backend, command, arg, token) + if (skillPlugins.handled) return skillPlugins.value + const metaHub = await callMetaHubBackend(this.backend, command, arg, token) + if (metaHub.handled) return metaHub.value + throw new RemoteError('COMMAND_NOT_FOUND', `Unknown desktop backend command: ${command}`) + } + } + + listen(_context: Context, event: string): Event { + if (event !== 'event') { + throw new RemoteError('EVENT_NOT_FOUND', `Unknown desktop backend event: ${event}`) + } + return (listener) => + this.backend.onEvent((value) => listener(DesktopEventSchema.parse(value))) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/mcp-settings-channel.ts b/apps/desktop/src/platform/loopal-backend/common/channels/mcp-settings-channel.ts new file mode 100644 index 00000000..36ae95fc --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/mcp-settings-channel.ts @@ -0,0 +1,35 @@ +import { type CancellationToken } from '../../../../base/common/cancellation' +import { + DeleteMcpServerInputSchema, + ListMcpServersInputSchema, + McpServersResponseSchema, + UpsertMcpServerInputSchema, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../backend' + +export async function callMcpSettingsBackend( + backend: DesktopBackend, + command: string, + arg: unknown, + token: CancellationToken, +): Promise<{ handled: false } | { handled: true; value: unknown }> { + switch (command) { + case 'listMcpServers': { + const { workspaceId } = ListMcpServersInputSchema.parse(arg) + const value = await backend.listMcpServers(workspaceId, token) + return { handled: true, value: McpServersResponseSchema.parse(value) } + } + case 'upsertMcpServer': { + const input = UpsertMcpServerInputSchema.parse(arg) + const value = await backend.upsertMcpServer(input, token) + return { handled: true, value: McpServersResponseSchema.parse(value) } + } + case 'deleteMcpServer': { + const input = DeleteMcpServerInputSchema.parse(arg) + const value = await backend.deleteMcpServer(input, token) + return { handled: true, value: McpServersResponseSchema.parse(value) } + } + default: + return { handled: false } + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/metahub-channel.ts b/apps/desktop/src/platform/loopal-backend/common/channels/metahub-channel.ts new file mode 100644 index 00000000..ff70d785 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/metahub-channel.ts @@ -0,0 +1,53 @@ +import { type CancellationToken } from '../../../../base/common/cancellation' +import { + JoinMetaHubInputSchema, + LocalMetaHubStatusSchema, + MetaHubRuntimeStateSchema, + MetaHubRuntimeTargetSchema, + MetaHubSettingsSchema, + StartLocalMetaHubInputSchema, + UpdateMetaHubSettingsInputSchema, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../backend' + +type MetaHubCall = { readonly handled: false } | { readonly handled: true; readonly value: unknown } + +export async function callMetaHubBackend( + backend: DesktopBackend, + command: string, + arg: unknown, + token: CancellationToken, +): Promise { + switch (command) { + case 'getMetaHubSettings': + return handled(MetaHubSettingsSchema.parse(await backend.getMetaHubSettings(token))) + case 'updateMetaHubSettings': { + const input = UpdateMetaHubSettingsInputSchema.parse(arg) + return handled(MetaHubSettingsSchema.parse(await backend.updateMetaHubSettings(input, token))) + } + case 'getMetaHubStatus': { + const input = MetaHubRuntimeTargetSchema.parse(arg) + return handled(MetaHubRuntimeStateSchema.parse(await backend.getMetaHubStatus(input, token))) + } + case 'joinMetaHub': { + const input = JoinMetaHubInputSchema.parse(arg) + return handled(MetaHubRuntimeStateSchema.parse(await backend.joinMetaHub(input, token))) + } + case 'disconnectMetaHub': { + const input = MetaHubRuntimeTargetSchema.parse(arg) + return handled(MetaHubRuntimeStateSchema.parse(await backend.disconnectMetaHub(input, token))) + } + case 'getLocalMetaHubStatus': + return handled(LocalMetaHubStatusSchema.parse(await backend.getLocalMetaHubStatus(token))) + case 'startLocalMetaHub': { + const input = StartLocalMetaHubInputSchema.parse(arg) + return handled(LocalMetaHubStatusSchema.parse(await backend.startLocalMetaHub(input, token))) + } + case 'stopLocalMetaHub': + return handled(LocalMetaHubStatusSchema.parse(await backend.stopLocalMetaHub(token))) + default: + return { handled: false } + } +} + +function handled(value: unknown): MetaHubCall { return { handled: true, value } } diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.test.ts b/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.test.ts new file mode 100644 index 00000000..f36ec1ab --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.test.ts @@ -0,0 +1,54 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { createBackendStub } from '../../../../../test/support/backend/backend-stub' +import { callSkillPluginBackend } from './skill-plugin-channel' + +const revision = 'a'.repeat(64) + +describe('Skill Plugin backend channel', () => { + it('dispatches and validates every explicit operation', async () => { + const backend = createBackendStub() + const token = CancellationToken.None + await expect(callSkillPluginBackend( + backend, 'listSkills', { workspaceId: 'workspace' }, token, + )).resolves.toMatchObject({ handled: true, value: { workspaceId: 'workspace' } }) + await callSkillPluginBackend( + backend, 'getSkill', { workspaceId: 'workspace', name: '/review' }, token, + ) + await callSkillPluginBackend(backend, 'upsertGlobalSkill', { + workspaceId: 'workspace', name: '/review', description: 'Review', body: 'Review', + }, token) + await callSkillPluginBackend(backend, 'deleteGlobalSkill', { + workspaceId: 'workspace', name: '/review', expectedRevision: revision, + }, token) + await callSkillPluginBackend( + backend, 'listPlugins', { workspaceId: 'workspace' }, token, + ) + expect(backend.getSkill).toHaveBeenCalledWith({ + workspaceId: 'workspace', name: '/review', + }, token) + expect(backend.upsertGlobalSkill).toHaveBeenCalledWith({ + workspaceId: 'workspace', name: '/review', description: 'Review', body: 'Review', + }, token) + expect(backend.deleteGlobalSkill).toHaveBeenCalledWith({ + workspaceId: 'workspace', name: '/review', expectedRevision: revision, + }, token) + }) + + it('rejects path-like names, unknown fields, and invalid revisions', async () => { + const backend = createBackendStub() + const token = CancellationToken.None + await expect(callSkillPluginBackend(backend, 'getSkill', { + workspaceId: 'workspace', name: '/../escape', + }, token)).rejects.toThrow() + await expect(callSkillPluginBackend(backend, 'upsertGlobalSkill', { + workspaceId: 'workspace', name: '/review', description: 'Review', body: 'Review', + path: '/tmp/escape', + }, token)).rejects.toThrow() + await expect(callSkillPluginBackend(backend, 'deleteGlobalSkill', { + workspaceId: 'workspace', name: '/review', expectedRevision: 'stale', + }, token)).rejects.toThrow() + await expect(callSkillPluginBackend( + backend, 'unknown', {}, token, + )).resolves.toEqual({ handled: false }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.ts b/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.ts new file mode 100644 index 00000000..57f6cc57 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/channels/skill-plugin-channel.ts @@ -0,0 +1,47 @@ +import { type CancellationToken } from '../../../../base/common/cancellation' +import { + DeleteGlobalSkillInputSchema, + GetSkillInputSchema, + ListSkillPluginsInputSchema, + PluginsResponseSchema, + SkillDetailSchema, + SkillsResponseSchema, + UpsertGlobalSkillInputSchema, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../backend' + +export async function callSkillPluginBackend( + backend: DesktopBackend, + command: string, + arg: unknown, + token: CancellationToken, +): Promise<{ handled: false } | { handled: true; value: unknown }> { + switch (command) { + case 'listSkills': { + const { workspaceId } = ListSkillPluginsInputSchema.parse(arg) + return handled(SkillsResponseSchema.parse(await backend.listSkills(workspaceId, token))) + } + case 'getSkill': { + const input = GetSkillInputSchema.parse(arg) + return handled(SkillDetailSchema.parse(await backend.getSkill(input, token))) + } + case 'upsertGlobalSkill': { + const input = UpsertGlobalSkillInputSchema.parse(arg) + return handled(SkillDetailSchema.parse(await backend.upsertGlobalSkill(input, token))) + } + case 'deleteGlobalSkill': { + const input = DeleteGlobalSkillInputSchema.parse(arg) + return handled(SkillsResponseSchema.parse(await backend.deleteGlobalSkill(input, token))) + } + case 'listPlugins': { + const { workspaceId } = ListSkillPluginsInputSchema.parse(arg) + return handled(PluginsResponseSchema.parse(await backend.listPlugins(workspaceId, token))) + } + default: + return { handled: false } + } +} + +function handled(value: unknown): { handled: true; value: unknown } { + return { handled: true, value } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/attention-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/attention-client.ts new file mode 100644 index 00000000..f6022a26 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/attention-client.ts @@ -0,0 +1,23 @@ +import { type ChannelClient } from '../../../ipc/common/channel' +import { + type PermissionResponseInput, + type PlanApprovalResponseInput, + type QuestionResponseInput, +} from '../../../../shared/contracts' + +export interface AttentionClientOperations { + respondPermission(input: PermissionResponseInput): Promise + respondQuestion(input: QuestionResponseInput): Promise + respondPlanApproval(input: PlanApprovalResponseInput): Promise +} + +export function bindAttentionClient(client: ChannelClient): AttentionClientOperations { + const call = async (command: string, input: unknown): Promise => { + await client.call('desktopBackend', command, input) + } + return { + respondPermission: (input) => call('respondPermission', input), + respondQuestion: (input) => call('respondQuestion', input), + respondPlanApproval: (input) => call('respondPlanApproval', input), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.code.test.ts b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.code.test.ts new file mode 100644 index 00000000..bd01b45e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.code.test.ts @@ -0,0 +1,111 @@ +import { Event } from '../../../../base/common/event' +import { type ChannelClient } from '../../../ipc/common/channel' +import { DesktopBackendClient } from './backend-client' + +type ChannelRequest = ( + channel: string, + command: string, + input?: unknown, +) => Promise + +class TestChannel implements ChannelClient { + readonly request = vi.fn() + + constructor(handler: ChannelRequest) { + this.request.mockImplementation(handler) + } + + async call(channel: string, command: string, input?: unknown): Promise { + return await this.request(channel, command, input) as T + } + + listen(): Event { + return Event.none() + } + + dispose(): void {} +} + +function channel(): TestChannel { + return new TestChannel(async (_channel, command, input) => { + const arg = input as Record + switch (command) { + case 'listDirectory': + return { workspaceId: arg.workspaceId, path: arg.path, entries: [] } + case 'readFile': + case 'writeFile': + return { + workspaceId: arg.workspaceId, path: arg.path, content: arg.content ?? 'text', + version: 'v1', languageId: 'typescript', readonly: false, + } + case 'searchWorkspace': return { matches: [], truncated: false } + case 'gitStatus': return { branch: 'main', ahead: 0, behind: 0, changes: [] } + case 'gitDiff': return { path: arg.path, patch: '', original: '', modified: '' } + case 'listWorktrees': return [] + case 'createWorktree': + return { + id: arg.name, path: `/tmp/${String(arg.name)}`, branch: 'branch', head: 'abc', + isMain: false, hasChanges: false, + } + default: return undefined + } + }) +} + +describe('DesktopBackendClient code workbench façade', () => { + it('maps every code workbench API onto a fixed channel command', async () => { + const client = channel() + const backend = new DesktopBackendClient(client) + + await expect(backend.listDirectory({ workspaceId: 'w', path: '' })) + .resolves.toMatchObject({ workspaceId: 'w' }) + await expect(backend.readFile({ workspaceId: 'w', path: 'a.ts' })) + .resolves.toMatchObject({ content: 'text' }) + await expect(backend.writeFile({ + workspaceId: 'w', path: 'a.ts', content: 'next', expectedVersion: 'v0', + })).resolves.toMatchObject({ content: 'next' }) + await expect(backend.searchWorkspace({ workspaceId: 'w', query: 'next' })) + .resolves.toEqual({ matches: [], truncated: false }) + await expect(backend.gitStatus('w')).resolves.toMatchObject({ branch: 'main' }) + await expect(backend.gitDiff({ workspaceId: 'w', path: 'a.ts' })) + .resolves.toMatchObject({ path: 'a.ts' }) + await backend.gitStage({ workspaceId: 'w', path: 'a.ts' }) + await backend.gitUnstage({ workspaceId: 'w', path: 'a.ts' }) + await expect(backend.listWorktrees('w')).resolves.toEqual([]) + await expect(backend.createWorktree({ workspaceId: 'w', name: 'feature' })) + .resolves.toMatchObject({ id: 'feature' }) + await backend.removeWorktree({ workspaceId: 'w', name: 'feature', force: false }) + await backend.respondPermission({ + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', + requestId: 'p', decision: 'deny', + }) + await backend.respondQuestion({ + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', + requestId: 'q', answers: ['yes'], + }) + await backend.respondQuestion({ + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', + requestId: 'cancel', cancelled: true, + }) + + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'gitStage', { + workspaceId: 'w', path: 'a.ts', + }) + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'respondQuestion', { + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', + requestId: 'cancel', cancelled: true, + }) + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'respondQuestion', { + sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'main', + requestId: 'q', answers: ['yes'], + }) + }) + + it('rejects malformed code workbench responses', async () => { + const client = channel() + client.request.mockResolvedValue({ invalid: true }) + const backend = new DesktopBackendClient(client) + await expect(backend.readFile({ workspaceId: 'w', path: 'a.ts' })).rejects.toThrow() + await expect(backend.gitStatus('w')).rejects.toThrow() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.test.ts b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.test.ts new file mode 100644 index 00000000..3c9b0291 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { Emitter, type Event } from '../../../../base/common/event' +import { + type RuntimeSummary, + type SessionDetail, + type SessionSummary, +} from '../../../../shared/contracts' +import { type ChannelClient } from '../../../ipc/common/channel' +import { DesktopBackendClient } from './backend-client' + +const now = '2026-07-11T12:00:00.000Z' +const session = { + id: 'session', workspaceId: 'workspace', title: 'Session', model: 'gpt-5', mode: 'agent', + status: 'running', createdAt: now, updatedAt: now, activeRuntimeId: 'runtime', +} satisfies SessionSummary +const runtime = { + id: 'runtime', sessionId: 'session', workspaceId: 'workspace', generation: 1, + state: 'ready', rootAgent: 'main', startedAt: now, +} satisfies RuntimeSummary +const detail = { + session, conversation: [], agents: [], artifacts: [], +} satisfies SessionDetail +const createInput = { authorizationId: '5d0c638c-d44c-4f47-818b-62e6b599e31c', launchMode: 'directory' } as const +type ChannelRequest = ( + channel: string, + command: string, + input?: unknown, +) => Promise + +class TestChannel implements ChannelClient { + readonly request = vi.fn() + + constructor( + private readonly events: Emitter, + handler: ChannelRequest, + ) { + this.request.mockImplementation(handler) + } + + async call(channel: string, command: string, input?: unknown): Promise { + return await this.request(channel, command, input) as T + } + + listen(): Event { + return (listener) => this.events.event((value) => listener(value as T)) + } + + dispose(): void { + this.events.dispose() + } +} + +describe('DesktopBackendClient', () => { + it('calls the explicit session façade and validates results', async () => { + const event = new Emitter() + const client = new TestChannel(event, async (_channel, command) => { + if (command === 'bootstrap') return { + protocolVersion: 2, hostStatus: 'ready', workspaces: [], + sessions: [session], runtimes: [runtime], activeSessionId: session.id, + } + if (command === 'openSession' || command === 'createSession') return detail + if (command === 'restartSession') return runtime + if (command === 'getDesktopPreferences') return { locale: 'system' } + if (command === 'updateDesktopPreferences') return { locale: 'zh-CN' } + if (command === 'getLoopalSettings' || command === 'updateLoopalSettings') return { + workspaceId: 'workspace', + settings: { + model: 'gpt-5', modelRouting: { + default: '', summarization: '', classification: '', refine: '', + }, + permissionMode: 'bypass', decisionMode: 'manual', + sandboxPolicy: 'default_write', thinking: { type: 'auto' }, + maxContextTokens: 0, memoryEnabled: true, microcompactIdleMinutes: 60, + telemetryEnabled: true, outputStyle: '', + }, + configuredProviders: [], + providers: { + anthropic: emptyProvider(), openai: emptyProvider(), google: emptyProvider(), + }, + openaiCompatible: [], + resolvedEntries: [{ key: 'model', value: 'gpt-5' }], + settingSources: ['defaults'], + } + return undefined + }) + const backend = new DesktopBackendClient(client) + await expect(backend.bootstrap()).resolves.toMatchObject({ protocolVersion: 2 }) + await expect(backend.openSession('session')).resolves.toMatchObject({ session }) + await expect(backend.createSession(createInput)) + .resolves.toMatchObject({ session }) + await backend.stopSession('session') + await expect(backend.restartSession('session')).resolves.toMatchObject(runtime) + await backend.sendMessage('session', 'hello') + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'sendMessage', { + sessionId: 'session', text: 'hello', + }) + await backend.sendMessage('session', 'child', 'worker') + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'sendMessage', { + sessionId: 'session', text: 'child', agentId: 'worker', + }) + const image = { + name: 'pixel.png', mediaType: 'image/png' as const, data: 'iVBORw==', sizeBytes: 4, + } + await backend.sendMessage('session', '', 'worker', [image]) + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'sendMessage', { + sessionId: 'session', text: '', agentId: 'worker', images: [image], + }) + const target = { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + } + await backend.interruptAgent(target) + await backend.controlAgent({ target, command: { type: 'mode', mode: 'plan' } }) + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'controlAgent', { + target, command: { type: 'mode', mode: 'plan' }, + }) + await expect(backend.getDesktopPreferences()).resolves.toEqual({ locale: 'system' }) + await expect(backend.updateDesktopPreferences({ locale: 'zh-CN' })).resolves + .toEqual({ locale: 'zh-CN' }) + const defaults = await backend.getLoopalSettings('workspace') + await backend.updateLoopalSettings({ + workspaceId: 'workspace', settings: defaults.settings, + }) + expect(client.request).toHaveBeenCalledWith( + 'desktopBackend', 'getLoopalSettings', { workspaceId: 'workspace' }, + ) + expect(client.request).toHaveBeenCalledWith( + 'desktopBackend', 'updateLoopalSettings', { + workspaceId: 'workspace', settings: defaults.settings, + }, + ) + const listener = vi.fn() + const unsubscribe = backend.onEvent(listener) + event.fire({ type: 'host_status', status: 'ready' }) + expect(listener).toHaveBeenCalledWith({ type: 'host_status', status: 'ready' }) + unsubscribe() + }) + + it('rejects malformed backend results and events', async () => { + const onListenerError = vi.fn() + const event = new Emitter({ onListenerError }) + const client = new TestChannel(event, async () => ({ invalid: true })) + const backend = new DesktopBackendClient(client) + await expect(backend.bootstrap()).rejects.toThrow() + await expect(backend.openSession('session')).rejects.toThrow() + await expect(backend.createSession(createInput)).rejects.toThrow() + await expect(backend.restartSession('session')).rejects.toThrow() + await expect(backend.getMetaHubSettings()).rejects.toThrow() + await expect(backend.getLoopalSettings('workspace')).rejects.toThrow() + await expect(backend.getDesktopPreferences()).rejects.toThrow() + await expect(backend.getMetaHubStatus({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, + })).rejects.toThrow() + await expect(backend.getLocalMetaHubStatus()).rejects.toThrow() + const listener = vi.fn() + const unsubscribe = backend.onEvent(listener) + expect(() => event.fire({ invalid: true })).not.toThrow() + expect(listener).not.toHaveBeenCalled() + expect(onListenerError).toHaveBeenCalledOnce() + unsubscribe() + }) + + it('validates and forwards every MetaHub operation', async () => { + const event = new Emitter() + const state = { + state: 'connected', hubs: [], topology: [], refreshedAt: now, + } + const client = new TestChannel(event, async (_channel, command) => { + if (command === 'getMetaHubSettings' || command === 'updateMetaHubSettings') return { + address: 'meta:9', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, tokenConfigured: true, + } + if (command === 'getLocalMetaHubStatus' || command === 'startLocalMetaHub') { + return { state: 'running', address: '127.0.0.1:9' } + } + if (command === 'stopLocalMetaHub') return { state: 'stopped' } + return state + }) + const backend = new DesktopBackendClient(client) + const target = { sessionId: 'session', runtimeId: 'runtime', generation: 1 } + await expect(backend.getMetaHubSettings()).resolves.toMatchObject({ tokenConfigured: true }) + await expect(backend.updateMetaHubSettings({ + address: 'meta:9', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, token: 'secret', + })).resolves.toMatchObject({ address: 'meta:9' }) + await expect(backend.getMetaHubStatus(target)).resolves.toMatchObject({ state: 'connected' }) + await expect(backend.joinMetaHub({ ...target, token: 'secret' })).resolves + .toMatchObject({ state: 'connected' }) + await expect(backend.disconnectMetaHub(target)).resolves.toMatchObject({ state: 'connected' }) + await expect(backend.getLocalMetaHubStatus()).resolves.toMatchObject({ state: 'running' }) + await expect(backend.startLocalMetaHub({ bindAddress: '127.0.0.1:0' })).resolves + .toMatchObject({ state: 'running' }) + await expect(backend.stopLocalMetaHub()).resolves.toEqual({ state: 'stopped' }) + expect(client.request).toHaveBeenCalledWith('desktopBackend', 'joinMetaHub', { + ...target, token: 'secret', + }) + }) +}) +function emptyProvider() { + return { enabled: false, baseUrl: '', apiKeyEnv: '', apiKeyConfigured: false } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.ts new file mode 100644 index 00000000..6d6c5df5 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/backend-client.ts @@ -0,0 +1,187 @@ +import { type Event } from '../../../../base/common/event' +import { type ChannelClient } from '../../../ipc/common/channel' +import { + DirectoryListingSchema, + DesktopEventSchema, + FileDocumentSchema, + GitDiffSchema, + GitStatusSchema, + LoopalDefaultSettingsSchema, + RuntimeSummarySchema, + SessionDetailSchema, + WorkspaceSearchResultSchema, + WorkbenchBootstrapSchema, + WorktreeListSchema, + WorktreeSchema, + type AgentControlInput, + type AgentControlTarget, + type CreateSessionInput, + type CreateWorktreeInput, + type DirectoryListing, + type DesktopImageAttachment, + type DesktopEvent, + type FileDocument, + type GitDiff, + type GitStageInput, + type GitStatus, + type GitUnstageInput, + type ListDirectoryInput, + type LoopalDesktopAPI, + type LoopalDefaultSettings, + type ReadFileInput, + type RemoveWorktreeInput, + type RuntimeSummary, + type SessionDirectorySelection, + type SessionDetail, + type UpdateLoopalSettingsInput, + type WorkspaceSearchInput, + type WorkspaceSearchResult, + type WorkbenchBootstrap, + type Worktree, + type WriteFileInput, +} from '../../../../shared/contracts' +import { bindMetaHubClient, type MetaHubClientOperations } from './metahub-client' +import { bindMcpSettingsClient, type McpSettingsClientOperations } from './mcp-settings-client' +import { bindSkillPluginClient, type SkillPluginClientOperations } from './skill-plugin-client' +import { bindAttentionClient, type AttentionClientOperations } from './attention-client' +import { + bindDesktopPreferencesClient, + type DesktopPreferencesClientOperations, +} from './desktop-preferences-client' + +export interface DesktopBackendClient extends + MetaHubClientOperations, McpSettingsClientOperations, AttentionClientOperations, + DesktopPreferencesClientOperations, SkillPluginClientOperations {} +export class DesktopBackendClient implements LoopalDesktopAPI { + constructor( + private readonly client: ChannelClient, + private readonly imageSelector: () => Promise = async () => [], + private readonly directorySelector: () => Promise< + SessionDirectorySelection | undefined + > = async () => undefined, + ) { + Object.assign(this, bindMetaHubClient(client)) + Object.assign(this, bindMcpSettingsClient(client)) + Object.assign(this, bindSkillPluginClient(client)) + Object.assign(this, bindAttentionClient(client)) + Object.assign(this, bindDesktopPreferencesClient(client)) + } + + async bootstrap(): Promise { + return WorkbenchBootstrapSchema.parse( + await this.client.call('desktopBackend', 'bootstrap'), + ) + } + + async openSession(sessionId: string): Promise { + return SessionDetailSchema.parse( + await this.client.call('desktopBackend', 'openSession', { sessionId }), + ) + } + + async createSession(input: CreateSessionInput): Promise { + return SessionDetailSchema.parse( + await this.client.call('desktopBackend', 'createSession', input), + ) + } + + async stopSession(sessionId: string): Promise { + await this.client.call('desktopBackend', 'stopSession', { sessionId }) + } + async restartSession(sessionId: string): Promise { + return RuntimeSummarySchema.parse( + await this.client.call('desktopBackend', 'restartSession', { sessionId }), + ) + } + + selectImages(): Promise { return this.imageSelector() } + selectSessionDirectory(): Promise { + return this.directorySelector() + } + + async sendMessage( + sessionId: string, text: string, agentId?: string, + images?: readonly DesktopImageAttachment[], + ): Promise { + await this.client.call('desktopBackend', 'sendMessage', { + sessionId, text, ...(agentId ? { agentId } : {}), ...(images?.length ? { images } : {}), + }) + } + + async interruptAgent(input: AgentControlTarget): Promise { + await this.client.call('desktopBackend', 'interruptAgent', input) + } + async controlAgent(input: AgentControlInput): Promise { + await this.client.call('desktopBackend', 'controlAgent', input) + } + + async getLoopalSettings(workspaceId: string): Promise { + return LoopalDefaultSettingsSchema.parse( + await this.client.call('desktopBackend', 'getLoopalSettings', { workspaceId }), + ) + } + + async updateLoopalSettings(input: UpdateLoopalSettingsInput): Promise { + return LoopalDefaultSettingsSchema.parse( + await this.client.call('desktopBackend', 'updateLoopalSettings', input), + ) + } + + async listDirectory(input: ListDirectoryInput): Promise { + return DirectoryListingSchema.parse( + await this.client.call('desktopBackend', 'listDirectory', input), + ) + } + + async readFile(input: ReadFileInput): Promise { + return FileDocumentSchema.parse(await this.client.call('desktopBackend', 'readFile', input)) + } + async writeFile(input: WriteFileInput): Promise { + return FileDocumentSchema.parse(await this.client.call('desktopBackend', 'writeFile', input)) + } + + async searchWorkspace(input: WorkspaceSearchInput): Promise { + return WorkspaceSearchResultSchema.parse( + await this.client.call('desktopBackend', 'searchWorkspace', input), + ) + } + + async gitStatus(workspaceId: string): Promise { + return GitStatusSchema.parse( + await this.client.call('desktopBackend', 'gitStatus', { workspaceId }), + ) + } + + async gitDiff(input: ReadFileInput): Promise { + return GitDiffSchema.parse(await this.client.call('desktopBackend', 'gitDiff', input)) + } + async gitStage(input: GitStageInput): Promise { + await this.client.call('desktopBackend', 'gitStage', input) + } + + async gitUnstage(input: GitUnstageInput): Promise { + await this.client.call('desktopBackend', 'gitUnstage', input) + } + + async listWorktrees(workspaceId: string): Promise { + return WorktreeListSchema.parse( + await this.client.call('desktopBackend', 'listWorktrees', { workspaceId }), + ) + } + + async createWorktree(input: CreateWorktreeInput): Promise { + return WorktreeSchema.parse( + await this.client.call('desktopBackend', 'createWorktree', input), + ) + } + + async removeWorktree(input: RemoveWorktreeInput): Promise { + await this.client.call('desktopBackend', 'removeWorktree', input) + } + + onEvent(listener: (event: DesktopEvent) => void): () => void { + const event: Event = this.client.listen('desktopBackend', 'event') + const subscription = event((value) => listener(DesktopEventSchema.parse(value))) + return () => subscription.dispose() + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/desktop-preferences-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/desktop-preferences-client.ts new file mode 100644 index 00000000..e9d238a8 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/desktop-preferences-client.ts @@ -0,0 +1,24 @@ +import { + DesktopPreferencesSchema, + type DesktopPreferences, + type UpdateDesktopPreferencesInput, +} from '../../../../shared/contracts' +import { type ChannelClient } from '../../../ipc/common/channel' + +export interface DesktopPreferencesClientOperations { + getDesktopPreferences(): Promise + updateDesktopPreferences(input: UpdateDesktopPreferencesInput): Promise +} + +export function bindDesktopPreferencesClient( + client: ChannelClient, +): DesktopPreferencesClientOperations { + return { + getDesktopPreferences: async () => DesktopPreferencesSchema.parse( + await client.call('desktopBackend', 'getDesktopPreferences'), + ), + updateDesktopPreferences: async (input) => DesktopPreferencesSchema.parse( + await client.call('desktopBackend', 'updateDesktopPreferences', input), + ), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/mcp-settings-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/mcp-settings-client.ts new file mode 100644 index 00000000..a9faba4f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/mcp-settings-client.ts @@ -0,0 +1,27 @@ +import { + McpServersResponseSchema, + type DeleteMcpServerInput, + type McpServersResponse, + type UpsertMcpServerInput, +} from '../../../../shared/contracts' +import { type ChannelClient } from '../../../ipc/common/channel' + +export interface McpSettingsClientOperations { + listMcpServers(workspaceId: string): Promise + upsertMcpServer(input: UpsertMcpServerInput): Promise + deleteMcpServer(input: DeleteMcpServerInput): Promise +} + +export function bindMcpSettingsClient(client: ChannelClient): McpSettingsClientOperations { + return { + listMcpServers: async (workspaceId) => McpServersResponseSchema.parse( + await client.call('desktopBackend', 'listMcpServers', { workspaceId }), + ), + upsertMcpServer: async (input) => McpServersResponseSchema.parse( + await client.call('desktopBackend', 'upsertMcpServer', input), + ), + deleteMcpServer: async (input) => McpServersResponseSchema.parse( + await client.call('desktopBackend', 'deleteMcpServer', input), + ), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/metahub-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/metahub-client.ts new file mode 100644 index 00000000..18c95d01 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/metahub-client.ts @@ -0,0 +1,56 @@ +import { + LocalMetaHubStatusSchema, + MetaHubRuntimeStateSchema, + MetaHubSettingsSchema, + type JoinMetaHubInput, + type LocalMetaHubStatus, + type MetaHubRuntimeState, + type MetaHubRuntimeTarget, + type MetaHubSettings, + type StartLocalMetaHubInput, + type UpdateMetaHubSettingsInput, +} from '../../../../shared/contracts' +import { type ChannelClient } from '../../../ipc/common/channel' + +export interface MetaHubClientOperations { + getMetaHubSettings(): Promise + updateMetaHubSettings(input: UpdateMetaHubSettingsInput): Promise + getMetaHubStatus(target: MetaHubRuntimeTarget): Promise + joinMetaHub(input: JoinMetaHubInput): Promise + disconnectMetaHub(target: MetaHubRuntimeTarget): Promise + getLocalMetaHubStatus(): Promise + startLocalMetaHub(input: StartLocalMetaHubInput): Promise + stopLocalMetaHub(): Promise +} + +export function bindMetaHubClient(client: ChannelClient): MetaHubClientOperations { + const call = (command: string, input?: unknown): Promise => ( + client.call('desktopBackend', command, input) + ) + return { + getMetaHubSettings: async () => MetaHubSettingsSchema.parse( + await call('getMetaHubSettings'), + ), + updateMetaHubSettings: async (input) => MetaHubSettingsSchema.parse( + await call('updateMetaHubSettings', input), + ), + getMetaHubStatus: async (target) => MetaHubRuntimeStateSchema.parse( + await call('getMetaHubStatus', target), + ), + joinMetaHub: async (input) => MetaHubRuntimeStateSchema.parse( + await call('joinMetaHub', input), + ), + disconnectMetaHub: async (target) => MetaHubRuntimeStateSchema.parse( + await call('disconnectMetaHub', target), + ), + getLocalMetaHubStatus: async () => LocalMetaHubStatusSchema.parse( + await call('getLocalMetaHubStatus'), + ), + startLocalMetaHub: async (input) => LocalMetaHubStatusSchema.parse( + await call('startLocalMetaHub', input), + ), + stopLocalMetaHub: async () => LocalMetaHubStatusSchema.parse( + await call('stopLocalMetaHub'), + ), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.test.ts b/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.test.ts new file mode 100644 index 00000000..c5136804 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.test.ts @@ -0,0 +1,46 @@ +import { Emitter } from '../../../../base/common/event' +import { type ChannelClient } from '../../../ipc/common/channel' +import { bindSkillPluginClient } from './skill-plugin-client' + +const revision = 'a'.repeat(64) + +describe('Skill Plugin client', () => { + it('uses the Desktop channel and validates untrusted responses', async () => { + const call = vi.fn(async (_channel: string, command: string) => { + if (command === 'listSkills' || command === 'deleteGlobalSkill') { + return { workspaceId: 'workspace', skills: [] } + } + if (command === 'listPlugins') return { workspaceId: 'workspace', plugins: [] } + return { + workspaceId: 'workspace', name: '/review', description: 'Review', body: 'Review', + hasArguments: false, source: 'global', scope: 'global', editable: true, + effective: true, revision, + } + }) + const events = new Emitter() + const client = { + call, listen: () => events.event, dispose: () => events.dispose(), + } as unknown as ChannelClient + const service = bindSkillPluginClient(client) + await service.listSkills('workspace') + await service.getSkill({ workspaceId: 'workspace', name: '/review' }) + await service.upsertGlobalSkill({ + workspaceId: 'workspace', name: '/review', description: 'Review', body: 'Review', + expectedRevision: revision, + }) + await service.deleteGlobalSkill({ + workspaceId: 'workspace', name: '/review', expectedRevision: revision, + }) + await service.listPlugins('workspace') + expect(call.mock.calls.map(([, command]) => command)).toEqual([ + 'listSkills', 'getSkill', 'upsertGlobalSkill', 'deleteGlobalSkill', 'listPlugins', + ]) + }) + + it('rejects malformed Host output before it reaches the renderer', async () => { + const client = { + call: vi.fn(async () => ({ workspaceId: 'workspace', skills: [{ path: '/secret' }] })), + } as unknown as ChannelClient + await expect(bindSkillPluginClient(client).listSkills('workspace')).rejects.toThrow() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.ts b/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.ts new file mode 100644 index 00000000..797605f5 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/common/clients/skill-plugin-client.ts @@ -0,0 +1,40 @@ +import { + PluginsResponseSchema, + SkillDetailSchema, + SkillsResponseSchema, + type DeleteGlobalSkillInput, + type GetSkillInput, + type PluginsResponse, + type SkillDetail, + type SkillsResponse, + type UpsertGlobalSkillInput, +} from '../../../../shared/contracts' +import { type ChannelClient } from '../../../ipc/common/channel' + +export interface SkillPluginClientOperations { + listSkills(workspaceId: string): Promise + getSkill(input: GetSkillInput): Promise + upsertGlobalSkill(input: UpsertGlobalSkillInput): Promise + deleteGlobalSkill(input: DeleteGlobalSkillInput): Promise + listPlugins(workspaceId: string): Promise +} + +export function bindSkillPluginClient(client: ChannelClient): SkillPluginClientOperations { + return { + listSkills: async (workspaceId) => SkillsResponseSchema.parse( + await client.call('desktopBackend', 'listSkills', { workspaceId }), + ), + getSkill: async (input) => SkillDetailSchema.parse( + await client.call('desktopBackend', 'getSkill', input), + ), + upsertGlobalSkill: async (input) => SkillDetailSchema.parse( + await client.call('desktopBackend', 'upsertGlobalSkill', input), + ), + deleteGlobalSkill: async (input) => SkillsResponseSchema.parse( + await client.call('desktopBackend', 'deleteGlobalSkill', input), + ), + listPlugins: async (workspaceId) => PluginsResponseSchema.parse( + await client.call('desktopBackend', 'listPlugins', { workspaceId }), + ), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.test.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.test.ts new file mode 100644 index 00000000..7eb68452 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { type AgentControlTarget } from '../../../../shared/contracts' +import { LoopalAgentControl, type AgentControlRouter } from './loopal-agent-control' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +const target: AgentControlTarget = { + sessionId: 'session', runtimeId: 'runtime', generation: 3, agentId: 'main', +} + +function fixture(state = 'connected') { + const request = vi.fn<( + method: string, params?: unknown, signal?: AbortSignal, + ) => Promise>(async (method) => { + if (method === 'hub/list_agents') return { agents: [{ name: 'main', state }] } + if (method === 'meta/topology') return { + hubs: [{ + hub: 'hub-b', + topology: { agents: [{ + name: 'worker', parent: 'main', children: [], lifecycle: 'running', + }] }, + }], + } + return { status: 'applied' } + }) + const runtime = { + sessionId: 'session', runtimeId: 'runtime', generation: 3, workspaceId: 'workspace', + host: { currentStatus: 'ready', request }, + } as unknown as SessionRuntimeHandle + let current: { runtime: SessionRuntimeHandle } | undefined = { runtime } + const router: AgentControlRouter = { session: () => current } + return { + service: new LoopalAgentControl(router), request, runtime, + retire: () => { current = undefined }, + } +} + +describe('LoopalAgentControl', () => { + it('validates the live agent then routes typed control and interrupt calls', async () => { + const value = fixture() + await value.service.controlAgent({ + target, command: { type: 'mode', mode: 'plan' }, + }, CancellationToken.None) + expect(value.request).toHaveBeenNthCalledWith(1, 'hub/list_agents', {}, expect.any(AbortSignal)) + expect(value.request).toHaveBeenNthCalledWith(2, 'hub/control', { + target: 'main', command: { ModeSwitch: 'Plan' }, + }, expect.any(AbortSignal)) + await value.service.interruptAgent(target, CancellationToken.None) + expect(value.request).toHaveBeenLastCalledWith( + 'hub/interrupt', { target: 'main' }, expect.any(AbortSignal), + ) + }) + + it('rejects every stale runtime scope without touching its Host', async () => { + for (const stale of [ + { ...target, sessionId: 'other' }, + { ...target, runtimeId: 'old' }, + { ...target, generation: 2 }, + ]) { + const value = fixture() + await expect(value.service.interruptAgent(stale, CancellationToken.None)) + .rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(value.request).not.toHaveBeenCalled() + } + const value = fixture() + value.retire() + await expect(value.service.controlAgent({ + target, command: { type: 'clear' }, + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(value.request).not.toHaveBeenCalled() + }) + + it('accepts local/connected agents and rejects missing or shadow agents', async () => { + const local = fixture('local') + await expect(local.service.controlAgent({ + target, command: { type: 'suspend' }, + }, CancellationToken.None)).resolves.toBeUndefined() + for (const state of ['shadow', 'finished']) { + const value = fixture(state) + await expect(value.service.controlAgent({ + target, command: { type: 'clear' }, + }, CancellationToken.None)).rejects.toMatchObject({ code: 'AGENT_GONE' }) + expect(value.request).toHaveBeenCalledOnce() + } + }) + + it('controls only live remote Agents confirmed by the current MetaHub topology', async () => { + const value = fixture() + const remote = { ...target, agentId: 'hub-b/worker' } + await expect(value.service.controlAgent({ + target: remote, command: { type: 'clear' }, + })).resolves.toBeUndefined() + expect(value.request).toHaveBeenNthCalledWith( + 2, 'meta/topology', {}, expect.any(AbortSignal), + ) + expect(value.request).toHaveBeenNthCalledWith( + 3, 'hub/control', { target: 'hub-b/worker', command: 'Clear' }, + expect.any(AbortSignal), + ) + + const missing = fixture() + await expect(missing.service.interruptAgent({ + ...target, agentId: 'hub-b/guessed', + })).rejects.toMatchObject({ code: 'AGENT_GONE' }) + expect(missing.request).toHaveBeenCalledTimes(2) + }) + + it('rechecks ownership after agent lookup and honors cancellation', async () => { + const value = fixture() + value.request.mockImplementationOnce(async () => { + value.retire() + return { agents: [{ name: 'main', state: 'connected' }] } + }) + await expect(value.service.controlAgent({ + target, command: { type: 'clear' }, + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(value.request).toHaveBeenCalledOnce() + + const cancelled = fixture() + await expect(cancelled.service.interruptAgent(target, CancellationToken.Cancelled)) + .rejects.toThrow('cancelled') + expect(cancelled.request).not.toHaveBeenCalled() + }) + + it('aborts an in-flight Host lookup when its channel call is cancelled', async () => { + const value = fixture() + const source = new CancellationTokenSource() + value.request.mockImplementationOnce(async (_method, _params, signal) => ( + await new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + )) + const pending = value.service.interruptAgent(target, source.token) + await vi.waitFor(() => expect(value.request).toHaveBeenCalledOnce()) + source.cancel() + await expect(pending).rejects.toThrow('aborted') + source.dispose() + }) + + it('requires an applied acknowledgement and classifies rejection and timeout', async () => { + for (const acknowledgement of [{ ok: true }, null, [], 'applied']) { + const invalid = fixture() + invalid.request.mockResolvedValueOnce({ agents: [{ name: 'main', state: 'connected' }] }) + .mockResolvedValueOnce(acknowledgement) + await expect(invalid.service.controlAgent({ + target, command: { type: 'clear' }, + })).rejects.toMatchObject({ code: 'CONTROL_REJECTED' }) + } + + for (const [message, code] of [ + ["control rejected: decision mode 'agent' is not implemented", 'CONTROL_REJECTED'], + ['Loopal Hub request timed out: hub/control', 'CONTROL_TIMEOUT'], + ] as const) { + const value = fixture() + value.request.mockResolvedValueOnce({ agents: [{ name: 'main', state: 'connected' }] }) + .mockRejectedValueOnce(new Error(message)) + await expect(value.service.controlAgent({ + target, command: { type: 'clear' }, + })).rejects.toMatchObject({ code, message: expect.stringContaining(message) }) + } + + const nonError = fixture() + nonError.request.mockResolvedValueOnce({ agents: [{ name: 'main', state: 'connected' }] }) + .mockRejectedValueOnce('remote rejected') + await expect(nonError.service.controlAgent({ + target, command: { type: 'clear' }, + })).rejects.toMatchObject({ + code: 'CONTROL_REJECTED', message: expect.stringContaining('remote rejected'), + }) + }) + + it('preserves cancellation while the runtime is applying a control', async () => { + const value = fixture() + const source = new CancellationTokenSource() + value.request.mockResolvedValueOnce({ agents: [{ name: 'main', state: 'connected' }] }) + .mockImplementationOnce(async (_method, _params, signal) => ( + await new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + )) + const pending = value.service.controlAgent({ + target, command: { type: 'mode', mode: 'plan' }, + }, source.token) + await vi.waitFor(() => expect(value.request).toHaveBeenCalledTimes(2)) + source.cancel() + await expect(pending).rejects.toThrow('aborted') + source.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.ts new file mode 100644 index 00000000..79fd7d7e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-agent-control.ts @@ -0,0 +1,141 @@ +import { + CancellationToken, + throwIfCancelled, +} from '../../../../base/common/cancellation' +import { + type AgentControlInput, + type AgentControlTarget, +} from '../../../../shared/contracts' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' +import { toHubControlCommand } from '../runtime/loopal-control-wire' +import { MetaHubTopologyWireSchema } from '../federation/loopal-metahub-wire' +import { AgentListSchema } from '../runtime/loopal-wire' + +export interface AgentControlOperations { + interruptAgent(target: AgentControlTarget, token?: CancellationToken): Promise + controlAgent(input: AgentControlInput, token?: CancellationToken): Promise +} + +interface ControlSession { + readonly runtime: SessionRuntimeHandle +} + +export interface AgentControlRouter { + session(sessionId: string): ControlSession | undefined +} + +export class LoopalAgentControl implements AgentControlOperations { + constructor(private readonly router: AgentControlRouter) {} + + async interruptAgent( + target: AgentControlTarget, + token = CancellationToken.None, + ): Promise { + const runtime = await this.resolve(target, token) + await this.call(runtime, 'hub/interrupt', { target: target.agentId }, token) + } + + async controlAgent( + input: AgentControlInput, + token = CancellationToken.None, + ): Promise { + const runtime = await this.resolve(input.target, token) + let acknowledgement: unknown + try { + acknowledgement = await this.call(runtime, 'hub/control', { + target: input.target.agentId, + command: toHubControlCommand(input.command), + }, token) + } catch (error) { + if (token.isCancellationRequested) throw error + const message = errorMessage(error) + const code = message.toLowerCase().includes('timed out') + ? 'CONTROL_TIMEOUT' + : 'CONTROL_REJECTED' + throw controlError(code, `Agent control was not applied: ${message}`) + } + if (!isAppliedAcknowledgement(acknowledgement)) { + throw controlError( + 'CONTROL_REJECTED', + 'Agent control returned an invalid application acknowledgement', + ) + } + } + + private async resolve( + target: AgentControlTarget, + token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const session = this.router.session(target.sessionId) + const runtime = session?.runtime + if (!runtime || runtime.sessionId !== target.sessionId + || runtime.runtimeId !== target.runtimeId + || runtime.generation !== target.generation) { + throw controlError('RUNTIME_GONE', `Session runtime is gone: ${target.sessionId}`) + } + const agents = AgentListSchema.parse(await this.call(runtime, 'hub/list_agents', {}, token)) + const remote = target.agentId.includes('/') + const localConnected = agents.agents.some((agent) => ( + agent.name === target.agentId && (agent.state === 'connected' || agent.state === 'local') + )) + const connected = remote + ? remoteConnected(MetaHubTopologyWireSchema.parse( + await this.call(runtime, 'meta/topology', {}, token), + ), target.agentId) + : localConnected + if (!connected) { + throw controlError('AGENT_GONE', `Agent is not in this runtime: ${target.agentId}`) + } + const current = this.router.session(target.sessionId)?.runtime + if (current !== runtime || current.host.currentStatus !== 'ready') { + throw controlError('RUNTIME_GONE', `Session runtime is gone: ${target.sessionId}`) + } + return runtime + } + + private async call( + runtime: SessionRuntimeHandle, + method: 'hub/control' | 'hub/interrupt' | 'hub/list_agents' | 'meta/topology', + params: unknown, + token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, params, controller.signal) + throwIfCancelled(token) + return result + } finally { + subscription.dispose() + } + } +} + +function remoteConnected( + topology: ReturnType, target: string, +): boolean { + const separator = target.indexOf('/') + if (separator <= 0 || separator === target.length - 1) return false + const hubName = target.slice(0, separator) + const agentName = target.slice(separator + 1) + return topology.hubs.some((hub) => hub.hub === hubName + && 'agents' in hub.topology + && hub.topology.agents.some((agent) => agent.name === agentName + && (agent.lifecycle === 'running' || agent.lifecycle === 'spawning'))) +} + +function controlError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }) +} + +function isAppliedAcknowledgement(value: unknown): boolean { + return typeof value === 'object' && value !== null + && !Array.isArray(value) + && (value as Record).status === 'applied' +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.test.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.test.ts new file mode 100644 index 00000000..da740d07 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.test.ts @@ -0,0 +1,117 @@ +import { projectAttentionEvent } from './loopal-attention' + +const now = () => new Date('2026-07-11T12:00:00.000Z') +const scope = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 3, +} + +describe('Loopal attention projection', () => { + it('projects permission requests with risk and readable input', () => { + const permission = projectAttentionEvent('permission_requested', { + id: 'p1', name: 'Bash', input: { command: 'rm file' }, + }, scope, 'worker', now) + expect(permission).toMatchObject({ + type: 'permission_requested', + request: { id: 'p1', agentId: 'worker', tool: 'Bash', risk: 'high' }, + }) + expect(permission?.type === 'permission_requested' && permission.request.detail) + .toContain('"command": "rm file"') + expect(projectAttentionEvent('permission_requested', { + id: 'p2', name: 'Read', input: 'README.md', + }, scope, 'worker', now)).toMatchObject({ request: { risk: 'low', detail: 'README.md' } }) + expect(projectAttentionEvent('permission_requested', { + id: 'p3', name: 'Unknown', input: 42, + }, scope, 'worker', now)).toMatchObject({ request: { risk: 'medium', detail: '42' } }) + expect(projectAttentionEvent('permission_requested', { + id: 'p4', input: null, + }, scope, 'worker', now)).toMatchObject({ request: { tool: 'tool', risk: 'medium' } }) + }) + + it('projects structured multi-question requests and resolution events', () => { + expect(projectAttentionEvent('question_requested', { + id: 'q1', classifier_running: true, + classifier_status: { kind: 'running', elapsed_ms: 1_500 }, + questions: [{ + question: 'Pick one', header: 'Choice', allow_multiple: false, + options: [ + { label: 'A', description: 'First' }, + { label: '', description: 'ignored' }, + {}, + ], + }], + }, scope, 'worker', now)).toMatchObject({ + type: 'question_requested', + request: { + id: 'q1', classifierRunning: true, + classifierStatus: { kind: 'running', elapsedMs: 1_500 }, + questions: [{ question: 'Pick one', options: [{ label: 'A' }] }], + }, + }) + expect(projectAttentionEvent('permission_resolved', { id: 'p1' }, scope, 'worker', now)) + .toEqual({ type: 'permission_resolved', ...runtimeScope(), agentId: 'worker', requestId: 'p1' }) + expect(projectAttentionEvent('question_resolved', { id: 'q1' }, scope, 'worker', now)) + .toEqual({ type: 'question_resolved', ...runtimeScope(), agentId: 'worker', requestId: 'q1' }) + expect(projectAttentionEvent('question_requested', { + id: 'q2', classifier_status: { kind: 'failed', reason: 'provider timeout' }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'failed', reason: 'provider timeout' } }, + }) + expect(projectAttentionEvent('question_requested', { + id: 'q2-empty', classifier_status: { kind: 'failed' }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'failed', reason: '' } }, + }) + expect(projectAttentionEvent('question_requested', { + id: 'q3', classifier_status: { kind: 'completed', answers: ['A', 2] }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'completed', answers: ['A', '2'] } }, + }) + expect(projectAttentionEvent('question_requested', { + id: 'q4', classifier_status: { kind: 'completed', answers: null }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'completed', answers: [] } }, + }) + expect(projectAttentionEvent('question_requested', { + id: 'q5', classifier_status: { kind: 'none' }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'none' } }, + }) + for (const elapsed_ms of [-10, Number.NaN, 'invalid']) { + expect(projectAttentionEvent('question_requested', { + id: `running-${String(elapsed_ms)}`, + classifier_status: { kind: 'running', elapsed_ms }, + questions: [{ question: 'Answer?', allow_multiple: false, options: [{ label: 'A' }] }], + }, scope, 'worker', now)).toMatchObject({ + request: { classifierStatus: { kind: 'running', elapsedMs: 0 } }, + }) + } + }) + + it('drops malformed attention events', () => { + expect(projectAttentionEvent('permission_requested', null, scope, 'worker', now)).toBeUndefined() + expect(projectAttentionEvent('permission_requested', {}, scope, 'worker', now)).toBeUndefined() + expect(projectAttentionEvent('question_requested', { + id: 'q', questions: 'invalid', + }, scope, 'worker', now)).toBeUndefined() + expect(projectAttentionEvent('question_requested', { + id: 'q', questions: [{ question: 3, options: 'invalid' }], + }, scope, 'worker', now)).toMatchObject({ type: 'question_requested' }) + }) + + it('falls back when permission input cannot be serialized', () => { + const circular: Record = {} + circular.self = circular + expect(projectAttentionEvent('permission_requested', { + id: 'p', name: 'tool', input: circular, + }, scope, 'worker', now)).toMatchObject({ request: { detail: '[object Object]' } }) + }) +}) + +function runtimeScope() { + return { sessionId: scope.sessionId, runtimeId: scope.runtimeId, generation: scope.generation } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.ts new file mode 100644 index 00000000..f7a28fcf --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-attention.ts @@ -0,0 +1,163 @@ +import { AttentionDesktopEventSchema, type DesktopEvent } from '../../../../shared/contracts' +import { type SessionRuntimeScope } from '../runtime/session-runtime-registry' + +export type AttentionEventKind = + | 'permission_requested' + | 'permission_resolved' + | 'question_requested' + | 'question_resolved' + | 'plan_approval_requested' + | 'plan_approval_resolved' +export type ProjectedAttentionEvent = Extract + +export function projectAttentionEvent( + kind: AttentionEventKind, + value: unknown, + scope: SessionRuntimeScope, + agentId: string, + now: () => Date, +): ProjectedAttentionEvent | undefined { + if (!isRecord(value) || typeof value.id !== 'string') return undefined + const event = kind === 'permission_requested' + ? permissionEvent(value, scope, agentId, now) + : kind === 'question_requested' + ? questionEvent(value, scope, agentId, now) + : kind === 'plan_approval_requested' + ? planApprovalEvent(value, scope, agentId, now) + : { type: kind, ...scopeFields(scope), agentId, requestId: value.id } + const parsed = AttentionDesktopEventSchema.safeParse(event) + return parsed.success ? parsed.data as ProjectedAttentionEvent : undefined +} + +function planApprovalEvent( + value: Record, + scope: SessionRuntimeScope, + agentId: string, + now: () => Date, +) { + return { + type: 'plan_approval_requested' as const, + request: { + id: value.id, + ...scopeFields(scope), + agentId, + planContent: String(value.plan_content ?? ''), + planPath: String(value.plan_path ?? ''), + createdAt: now().toISOString(), + }, + } +} + +export function attentionKindForPayload(kind: string): AttentionEventKind | undefined { + const kinds: Record = { + ToolPermissionRequest: 'permission_requested', + ToolPermissionResolved: 'permission_resolved', + UserQuestionRequest: 'question_requested', + UserQuestionResolved: 'question_resolved', + PlanApprovalRequest: 'plan_approval_requested', + PlanApprovalResolved: 'plan_approval_resolved', + } + return kinds[kind] +} + +function permissionEvent( + value: Record, + scope: SessionRuntimeScope, + agentId: string, + now: () => Date, +) { + const tool = typeof value.name === 'string' ? value.name : 'tool' + return { + type: 'permission_requested' as const, + request: { + id: value.id, + ...scopeFields(scope), + agentId, + tool, + title: `Allow ${tool}`, + detail: stringify(value.input), + risk: permissionRisk(tool), + createdAt: now().toISOString(), + }, + } +} + +function questionEvent( + value: Record, + scope: SessionRuntimeScope, + agentId: string, + now: () => Date, +) { + const classifier = classifierStatus(value) + const questions = Array.isArray(value.questions) + ? value.questions.filter(isRecord).map((question) => ({ + question: typeof question.question === 'string' ? question.question : 'Question', + header: typeof question.header === 'string' ? question.header : undefined, + options: Array.isArray(question.options) + ? question.options.filter(isRecord).map((option) => ({ + label: String(option.label ?? ''), + description: String(option.description ?? ''), + })).filter((option) => option.label.length > 0) + : [], + allowMultiple: question.allow_multiple === true, + })) + : [] + return { + type: 'question_requested' as const, + request: { + id: value.id, + ...scopeFields(scope), + agentId, + questions, + classifierRunning: value.classifier_running === true, + ...(classifier ? { classifierStatus: classifier } : {}), + createdAt: now().toISOString(), + }, + } +} + +function classifierStatus(value: Record) { + const status = isRecord(value.classifier_status) ? value.classifier_status : undefined + const kind = typeof status?.kind === 'string' ? status.kind : undefined + if (kind === 'running') { + return { kind, elapsedMs: number(status?.elapsed_ms) } + } + if (kind === 'failed') return { kind, reason: String(status?.reason ?? '') } + if (kind === 'completed') { + const answers = Array.isArray(status?.answers) ? status.answers.map(String) : [] + return { kind, answers } + } + if (kind === 'none') return { kind } + return value.classifier_running === true ? { kind: 'running' as const, elapsedMs: 0 } : undefined +} + +function number(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function scopeFields(scope: SessionRuntimeScope) { + return { + sessionId: scope.sessionId, + runtimeId: scope.runtimeId, + generation: scope.generation, + } +} + +function permissionRisk(tool: string): 'low' | 'medium' | 'high' { + if (/delete|write|edit|bash|process/i.test(tool)) return 'high' + if (/read|glob|grep|search/i.test(tool)) return 'low' + return 'medium' +} + +function stringify(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-code-attention.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-code-attention.ts new file mode 100644 index 00000000..bdcc7078 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-code-attention.ts @@ -0,0 +1,70 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type PermissionResponseInput, + type PlanApprovalResponseInput, + type QuestionResponseInput, +} from '../../../../shared/contracts' +import { type CodeWorkbenchRuntimeRouter } from '../workspace/loopal-code-workbench' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +type Input = PermissionResponseInput | QuestionResponseInput | PlanApprovalResponseInput + +export async function respondPermission( + router: CodeWorkbenchRuntimeRouter, input: PermissionResponseInput, token: CancellationToken, +): Promise { + await route(router, input, 'hub/permission_response', { + agent_name: input.agentId, tool_call_id: input.requestId, allow: input.decision !== 'deny', + ...(input.decision === 'allow_session' ? { remember_session: true } : {}), + }, token) +} + +export async function respondQuestion( + router: CodeWorkbenchRuntimeRouter, input: QuestionResponseInput, token: CancellationToken, +): Promise { + await route(router, input, 'hub/question_response', { + agent_name: input.agentId, question_id: input.requestId, + response: input.cancelled ? { kind: 'cancelled', question_id: input.requestId } + : { kind: 'answered', question_id: input.requestId, answers: input.answers ?? [] }, + }, token) +} + +export async function respondPlanApproval( + router: CodeWorkbenchRuntimeRouter, input: PlanApprovalResponseInput, token: CancellationToken, +): Promise { + await route(router, input, 'hub/plan_approval_response', { + agent_name: input.agentId, request_id: input.requestId, decision: input.decision, + ...(input.editedPlan !== undefined ? { edited_plan: input.editedPlan } : {}), + }, token) +} + +async function route( + router: CodeWorkbenchRuntimeRouter, + input: Input, + method: string, + params: unknown, + token: CancellationToken, +): Promise { + throwIfCancelled(token) + const runtime = await router.liveSession(input.sessionId) + throwIfCancelled(token) + assertRuntime(runtime, input) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + await runtime.host.request(method, params, controller.signal) + throwIfCancelled(token) + } finally { + subscription.dispose() + } +} + +function assertRuntime( + runtime: SessionRuntimeHandle | undefined, + input: Input, +): asserts runtime is SessionRuntimeHandle { + if (!runtime || runtime.runtimeId !== input.runtimeId || runtime.generation !== input.generation) { + throw Object.assign(new Error(`Session runtime is gone: ${input.sessionId}`), { + code: 'RUNTIME_GONE', + }) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.test.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.test.ts new file mode 100644 index 00000000..8da0adc0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.test.ts @@ -0,0 +1,97 @@ +import { type DesktopEvent } from '../../../../shared/contracts' +import { LoopalLiveAttention } from './loopal-live-attention' + +const now = () => new Date('2026-07-11T12:00:00.000Z') +const scope = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 2, +} + +describe('LoopalLiveAttention', () => { + it('routes each Agent independently and retires only unresolved requests', () => { + const events: DesktopEvent[] = [] + const attention = new LoopalLiveAttention(scope, now, (event) => events.push(event)) + attention.accept('permission_requested', { + id: 'shared', name: 'Bash', input: { command: 'pwd' }, + }, 'worker') + attention.accept('question_requested', { + id: 'shared', questions: [{ question: 'Continue?', options: [], allow_multiple: false }], + }, 'main') + attention.accept('permission_requested', {}, 'worker') + attention.accept('permission_resolved', { id: 'shared' }, 'worker') + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'permission_requested', request: expect.objectContaining({ agentId: 'worker' }), + }), + expect.objectContaining({ + type: 'question_requested', request: expect.objectContaining({ agentId: 'main' }), + }), + ])) + expect(attention.retire()).toEqual([expect.objectContaining({ + type: 'question_resolved', agentId: 'main', requestId: 'shared', + })]) + expect(attention.retire()).toEqual([]) + }) + + it('rehydrates snapshot requests and resolves entries absent from authority', () => { + const events: DesktopEvent[] = [] + const attention = new LoopalLiveAttention(scope, now, (event) => events.push(event)) + attention.accept('permission_requested', { + id: 'old', name: 'Write', input: {}, + }, 'main') + attention.reconcile([{ + kind: 'permission_requested', agentId: 'main', + value: { id: 'old', name: 'Write', input: {} }, + }]) + events.length = 0 + attention.reconcile([ + { + kind: 'permission_requested', agentId: 'worker', + value: { id: 'new', name: 'Read', input: 'README.md' }, + }, + { + kind: 'question_requested', agentId: 'worker', + value: { + id: 'question', classifier_running: true, + questions: [{ question: 'Pick', options: [], allow_multiple: false }], + }, + }, + { kind: 'permission_requested', agentId: 'worker', value: {} }, + ]) + expect(events[0]).toMatchObject({ + type: 'permission_resolved', agentId: 'main', requestId: 'old', + }) + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'permission_requested', request: expect.objectContaining({ id: 'new' }), + }), + expect.objectContaining({ + type: 'question_requested', + request: expect.objectContaining({ id: 'question', classifierRunning: true }), + }), + ])) + events.length = 0 + attention.reconcile([]) + expect(events).toHaveLength(2) + expect(events.map((event) => event.type).sort()).toEqual([ + 'permission_resolved', 'question_resolved', + ]) + }) + + it('retains remote requests until their relayed resolution event arrives', () => { + const events: DesktopEvent[] = [] + const attention = new LoopalLiveAttention(scope, now, (event) => events.push(event)) + attention.accept('question_requested', { + id: 'remote-question', + questions: [{ question: 'Remote?', options: [], allow_multiple: false }], + }, 'hub-b/worker') + events.length = 0 + + attention.reconcile([]) + expect(events).toEqual([]) + attention.accept('question_resolved', { id: 'remote-question' }, 'hub-b/worker') + expect(events).toEqual([expect.objectContaining({ + type: 'question_resolved', agentId: 'hub-b/worker', requestId: 'remote-question', + })]) + expect(attention.retire()).toEqual([]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.ts new file mode 100644 index 00000000..cfe0d2e1 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-live-attention.ts @@ -0,0 +1,128 @@ +import { type DesktopEvent } from '../../../../shared/contracts' +import { + projectAttentionEvent, + type AttentionEventKind, + type ProjectedAttentionEvent, +} from './loopal-attention' +import { type SnapshotAttention } from '../sessions/loopal-session-snapshot' +import { type SessionRuntimeScope } from '../runtime/session-runtime-registry' + +interface PendingRequest { + readonly requestId: string + readonly agentId: string +} + +export class LoopalLiveAttention { + private readonly permissions = new Map() + private readonly questions = new Map() + private readonly plans = new Map() + + constructor( + private readonly scope: SessionRuntimeScope, + private readonly now: () => Date, + private readonly fire: (event: DesktopEvent) => void, + ) {} + + accept(kind: AttentionEventKind, value: unknown, agentId: string): void { + const event = projectAttentionEvent(kind, value, this.scope, agentId, this.now) + if (!event) return + this.track(event) + this.fire(event) + } + + reconcile(pending: readonly SnapshotAttention[]): void { + const events = pending.flatMap((item) => { + const event = projectAttentionEvent( + item.kind, item.value, this.scope, item.agentId, this.now, + ) + return event ? [event] : [] + }) + const permissionKeys = new Set(events.flatMap((event) => ( + event.type === 'permission_requested' + ? [key(event.request.agentId, event.request.id)] + : [] + ))) + const questionKeys = new Set(events.flatMap((event) => ( + event.type === 'question_requested' + ? [key(event.request.agentId, event.request.id)] + : [] + ))) + const planKeys = new Set(events.flatMap((event) => ( + event.type === 'plan_approval_requested' + ? [key(event.request.agentId, event.request.id)] + : [] + ))) + this.resolveMissing(this.permissions, permissionKeys, 'permission_resolved') + this.resolveMissing(this.questions, questionKeys, 'question_resolved') + this.resolveMissing(this.plans, planKeys, 'plan_approval_resolved') + for (const event of events) { + this.track(event) + this.fire(event) + } + } + + retire(): readonly DesktopEvent[] { + const events = [ + ...this.resolvedEvents(this.permissions, 'permission_resolved'), + ...this.resolvedEvents(this.questions, 'question_resolved'), + ...this.resolvedEvents(this.plans, 'plan_approval_resolved'), + ] + this.permissions.clear() + this.questions.clear() + this.plans.clear() + return events + } + + private track(event: ProjectedAttentionEvent): void { + if (event.type === 'permission_requested') { + this.permissions.set(key(event.request.agentId, event.request.id), { + requestId: event.request.id, agentId: event.request.agentId, + }) + } else if (event.type === 'question_requested') { + this.questions.set(key(event.request.agentId, event.request.id), { + requestId: event.request.id, agentId: event.request.agentId, + }) + } else if (event.type === 'plan_approval_requested') { + this.plans.set(key(event.request.agentId, event.request.id), { + requestId: event.request.id, agentId: event.request.agentId, + }) + } else if (event.type === 'permission_resolved') { + this.permissions.delete(key(event.agentId, event.requestId)) + } else if (event.type === 'question_resolved') { + this.questions.delete(key(event.agentId, event.requestId)) + } else this.plans.delete(key(event.agentId, event.requestId)) + } + + private resolveMissing( + requests: Map, + desired: ReadonlySet, + type: 'permission_resolved' | 'question_resolved' | 'plan_approval_resolved', + ): void { + for (const [requestKey, request] of requests) { + if (desired.has(requestKey) || request.agentId.includes('/')) continue + this.fire({ type, ...this.scopeFields(), ...request }) + requests.delete(requestKey) + } + } + + private resolvedEvents( + requests: ReadonlyMap, + type: 'permission_resolved' | 'question_resolved' | 'plan_approval_resolved', + ): DesktopEvent[] { + return [...requests.values()].map((request) => ({ + type, ...this.scopeFields(), ...request, + })) + } + + private scopeFields() { + return { + sessionId: this.scope.sessionId, + runtimeId: this.scope.runtimeId, + generation: this.scope.generation, + } + } +} + +function key(agentId: string, requestId: string): string { + return `${agentId}\0${requestId}` +} diff --git a/apps/desktop/src/platform/loopal-backend/node/attention/loopal-plan-approval.test.ts b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-plan-approval.test.ts new file mode 100644 index 00000000..6ec90b5b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/attention/loopal-plan-approval.test.ts @@ -0,0 +1,54 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { PlanApprovalResponseInputSchema } from '../../../../shared/contracts' +import { projectAttentionEvent } from './loopal-attention' +import { FakeHost } from '../backend/loopal-backend.test-fixtures' +import { respondPlanApproval } from './loopal-code-attention' +import { type CodeWorkbenchRuntimeRouter } from '../workspace/loopal-code-workbench' + +const now = () => new Date('2026-07-12T12:00:00.000Z') + +describe('plan approval projection and routing', () => { + it('projects the plan body and path into a scoped desktop request', () => { + expect(projectAttentionEvent('plan_approval_requested', { + id: 'plan-1', plan_content: '# Plan', plan_path: '/tmp/plan.md', + }, { + sessionId: 'session', workspaceId: 'workspace', runtimeId: 'runtime', generation: 2, + }, 'main', now)).toEqual({ + type: 'plan_approval_requested', + request: { + id: 'plan-1', sessionId: 'session', runtimeId: 'runtime', generation: 2, + agentId: 'main', planContent: '# Plan', planPath: '/tmp/plan.md', + createdAt: '2026-07-12T12:00:00.000Z', + }, + }) + }) + + it('routes edited approval only through the matching live generation', async () => { + const host = new FakeHost('session', []) + host.request.mockResolvedValue({ resolved: true }) + const runtime = { + sessionId: 'session', workspaceId: 'workspace', runtimeId: 'runtime', generation: 2, + host, + } + const router: CodeWorkbenchRuntimeRouter = { + workspace: vi.fn(async () => runtime), + liveSession: vi.fn(async () => runtime), + } + const input = PlanApprovalResponseInputSchema.parse({ + sessionId: 'session', runtimeId: 'runtime', generation: 2, + agentId: 'main', requestId: 'plan-1', + decision: 'approve_with_edits', editedPlan: '# Edited', + }) + await respondPlanApproval(router, input, CancellationToken.None) + expect(host.request).toHaveBeenCalledWith('hub/plan_approval_response', { + agent_name: 'main', request_id: 'plan-1', + decision: 'approve_with_edits', edited_plan: '# Edited', + }, expect.any(AbortSignal)) + await expect(respondPlanApproval(router, { + ...input, generation: 3, + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(() => PlanApprovalResponseInputSchema.parse({ + ...input, editedPlan: undefined, + })).toThrow('Edited plan is required') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-lifecycle.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-lifecycle.ts new file mode 100644 index 00000000..ca52eeca --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-lifecycle.ts @@ -0,0 +1,65 @@ +import { loadSessionCatalog } from '../sessions/loopal-session-catalog' +import { LoopalSessionDirectory } from '../sessions/loopal-session-directory' +import { LoopalSessionResumeState } from '../sessions/loopal-session-resume-state' +import { + type SessionRuntimeHandle, + SessionRuntimeRegistry, +} from '../runtime/session-runtime-registry' + +interface BootstrapRuntimeInput { + readonly registry: SessionRuntimeRegistry + readonly directory: LoopalSessionDirectory + readonly resumeState: LoopalSessionResumeState + readonly workspaceId: string + readonly cwd: string + readonly resumeSessionId?: string +} + +export async function bootstrapRuntime(input: BootstrapRuntimeInput): Promise<{ + runtime: SessionRuntimeHandle + activeSessionId: string +}> { + const requested = input.resumeSessionId + const runtime = await startInitial(input, requested) + await mergeRuntimeCatalog(input.directory, runtime, false) + await input.directory.attach(runtime, false) + const active = input.resumeState.activeSessionId + const activeSessionId = active && input.directory.session(active) ? active : runtime.sessionId + await input.resumeState.normalizeRunning([runtime.sessionId], activeSessionId) + return { runtime, activeSessionId } +} + +export async function mergeRuntimeCatalog( + directory: LoopalSessionDirectory, + runtime: SessionRuntimeHandle, + emit: boolean, +): Promise { + const catalog = await loadSessionCatalog(runtime.host, runtime.workspaceId) + directory.mergeCatalog(catalog, runtime.workspaceId, emit) +} + +async function startInitial( + input: BootstrapRuntimeInput, + resumeSessionId?: string, +): Promise { + const workspace = { workspaceId: input.workspaceId, cwd: input.cwd } + if (!resumeSessionId) return input.registry.startFresh(workspace) + try { + return await input.registry.resume({ ...workspace, sessionId: resumeSessionId }) + } catch { + await input.resumeState.stopped(resumeSessionId) + return input.registry.startFresh(workspace) + } +} + +export function unknownSession(sessionId: string): Error { + return new Error(`Unknown Loopal Desktop session: ${sessionId}`) +} + +export function stoppedSession(sessionId: string): Error { + return new Error(`Session is not running; restart it first: ${sessionId}`) +} + +export function archivedSession(sessionId: string): Error { + return new Error(`Archived session cannot be restarted: ${sessionId}`) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-operations.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-operations.ts new file mode 100644 index 00000000..881603e4 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-operations.ts @@ -0,0 +1,16 @@ +import { type AgentControlOperations } from '../attention/loopal-agent-control' +import { type DesktopPreferencesOperations } from '../settings/desktop-preferences-service' +import { type CodeWorkbenchOperations } from '../workspace/loopal-code-workbench' +import { type LoopalMcpSettingsOperations } from '../settings/loopal-mcp-settings-service' +import { type MetaHubBackendOperations } from '../federation/loopal-metahub-runtime' +import { type LoopalSettingsOperations } from '../settings/loopal-settings-service' +import { type LoopalSkillPluginOperations } from '../settings/loopal-skill-plugin-service' + +export interface LoopalBackendOperations extends + CodeWorkbenchOperations, + AgentControlOperations, + MetaHubBackendOperations, + LoopalSettingsOperations, + LoopalMcpSettingsOperations, + LoopalSkillPluginOperations, + DesktopPreferencesOperations {} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.test.ts new file mode 100644 index 00000000..c9a6d16a --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.test.ts @@ -0,0 +1,103 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { createBackendRegistry } from './loopal-backend-registry' +import { FakeRuntimeHost } from '../runtime/session-runtime-registry.test-fixtures' +import { SessionRuntimeRegistry } from '../runtime/session-runtime-registry' + +const base = { binaryPath: '/bin/loopal', cwd: '/workspace', parentPid: 7 } + +describe('createBackendRegistry', () => { + it('returns an explicitly injected registry', () => { + const injected = new SessionRuntimeRegistry({ + maxLive: 1, createHost: () => new FakeRuntimeHost('session'), + }) + expect(createBackendRegistry({ ...base, runtimeRegistry: injected })).toBe(injected) + }) + + it('uses a custom Host factory and configured quota', async () => { + const registry = createBackendRegistry({ + ...base, + maxLiveRuntimes: 1, + createHost: (input) => new FakeRuntimeHost(input.resumeSessionId ?? 'fresh'), + }) + await expect(registry.resume({ + workspaceId: 'workspace', cwd: '/workspace', sessionId: 'session', + })).resolves.toMatchObject({ sessionId: 'session' }) + expect(() => registry.startFresh({ workspaceId: 'workspace', cwd: '/workspace' })) + .toThrow('quota exceeded (1)') + await registry.shutdownAll() + }) + + it('constructs the production Host with default quota and forwarded options', async () => { + const registry = createBackendRegistry({ + ...base, + env: { LOOPAL_TEST: '1' }, + startupTimeoutMs: 10, + shutdownTimeoutMs: 10, + clientName: 'coverage', + spawnProcess: () => { throw new Error('spawn failed') }, + connectRpc: async () => { throw new Error('unused') }, + }) + await expect(registry.resume({ + workspaceId: 'workspace', cwd: '/workspace', sessionId: 'resume-me', + })) + .rejects.toThrow('spawn failed') + expect(registry.liveCount).toBe(0) + }) + + it('keeps every production Host option optional', async () => { + const registry = createBackendRegistry({ + binaryPath: join(process.cwd(), `.missing-loopal-${process.pid}`), + cwd: '/workspace', + }) + await expect(registry.startFresh({ workspaceId: 'workspace', cwd: '/workspace' })) + .rejects.toThrow() + expect(registry.liveCount).toBe(0) + }) + + it('reads the MetaHub startup secret once per Host allocation', async () => { + const startup = { + address: '127.0.0.1:9000', hubName: 'desktop-a', token: 'secret', + } + const getMetaHubStartup = vi.fn(() => startup) + const spawnProcess = vi.fn(( + _binary: string, _cwd: string, _pid: number | undefined, + _env: unknown, _resume: unknown, _metaHub: { hubName: string }, + ) => { throw new Error('captured') }) + const registry = createBackendRegistry({ + ...base, getMetaHubStartup, spawnProcess: spawnProcess as never, + }) + await expect(registry.startFresh({ workspaceId: 'workspace', cwd: '/workspace' })) + .rejects.toThrow('captured') + expect(getMetaHubStartup).toHaveBeenCalledOnce() + expect(spawnProcess).toHaveBeenCalledWith( + '/bin/loopal', '/workspace', 7, undefined, undefined, + expect.objectContaining({ + address: startup.address, token: startup.token, + hubName: expect.stringMatching(/^desktop-a-.+-g1-/), + }), + ) + }) + + it('gives auto-joined runtimes distinct generation-safe Hub names', async () => { + const spawnProcess = vi.fn(( + _binary: string, _cwd: string, _pid: number | undefined, + _env: unknown, _resume: unknown, _metaHub: { hubName: string }, + ) => { throw new Error('captured') }) + const registry = createBackendRegistry({ + ...base, + getMetaHubStartup: () => ({ + address: '127.0.0.1:9000', hubName: 'desktop-a', token: 'secret', + }), + spawnProcess: spawnProcess as never, + }) + for (const sessionId of ['session-a', 'session-b']) { + await expect(registry.resume({ workspaceId: 'workspace', cwd: '/workspace', sessionId })) + .rejects.toThrow('captured') + } + const names = spawnProcess.mock.calls.map((call) => call[5]!.hubName) + expect(names[0]).toMatch(/^desktop-a-session-a-g1-/) + expect(names[1]).toMatch(/^desktop-a-session-b-g2-/) + expect(names[0]).not.toBe(names[1]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.ts new file mode 100644 index 00000000..69a34f1e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-registry.ts @@ -0,0 +1,41 @@ +import { LoopalDesktopHost } from '../../../desktop-host/node/host/desktop-host' +import { federationHubName } from '../../../../shared/contracts/metahub-identity' +import { type LoopalDesktopBackendOptions } from './loopal-backend-types' +import { SessionRuntimeRegistry } from '../runtime/session-runtime-registry' +import { WorkspaceScopedHost } from '../workspace/workspace-scoped-host' + +export function createBackendRegistry( + options: LoopalDesktopBackendOptions, +): SessionRuntimeRegistry { + if (options.runtimeRegistry) return options.runtimeRegistry + const createHost = options.createHost ?? ((input, allocation) => { + const startup = options.getMetaHubStartup?.() + const metaHub = startup ? { + ...startup, + hubName: federationHubName(startup.hubName, { + sessionId: input.resumeSessionId ?? allocation.runtimeId, + runtimeId: allocation.runtimeId, + generation: allocation.generation, + }), + } : undefined + const host = new LoopalDesktopHost({ + binaryPath: options.binaryPath, + cwd: input.cwd, + ...(input.resumeSessionId === undefined ? {} : { resumeSessionId: input.resumeSessionId }), + ...(options.parentPid === undefined ? {} : { parentPid: options.parentPid }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: options.startupTimeoutMs }), + ...(options.shutdownTimeoutMs === undefined + ? {} : { shutdownTimeoutMs: options.shutdownTimeoutMs }), + ...(options.clientName === undefined ? {} : { clientName: options.clientName }), + ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }), + ...(options.connectRpc === undefined ? {} : { connectRpc: options.connectRpc }), + ...(metaHub ? { metaHub } : {}), + }) + return new WorkspaceScopedHost(host, input.workspaceId) + }) + return new SessionRuntimeRegistry({ + maxLive: options.maxLiveRuntimes ?? 4, + createHost, + }) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.test.ts new file mode 100644 index 00000000..8ad550cd --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type DesktopEvent } from '../../../../shared/contracts' +import { type DesktopHostClient } from './loopal-backend-types' +import { LoopalBackendServices } from './loopal-backend-services' +import { type LoopalSessionDirectory } from '../sessions/loopal-session-directory' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +const request = vi.fn() +const host = { currentStatus: 'ready', request } as unknown as DesktopHostClient +const runtime: SessionRuntimeHandle = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 1, host, +} + +function notification(method: string, params: unknown) { + return { ...runtime, method, params } +} + +describe('LoopalBackendServices', () => { + it('filters nonleaders and malformed service events', () => { + let leader = false + const directory = { + leaders: { isLeader: vi.fn(() => leader) }, + } as unknown as LoopalSessionDirectory + const events: DesktopEvent[] = [] + const services = new LoopalBackendServices({ + workspace: async () => runtime, + liveSession: async () => runtime, + }, directory, (event) => events.push(event)) + expect(services.operations().gitStatus).toBeTypeOf('function') + + services.accept(notification('workspace/fileChanged', { + workspaceId: 'workspace', path: 'ignored.rs', kind: 'changed', + })) + leader = true + services.accept(notification('workspace/unknown', {})) + services.accept(notification('workspace/fileChanged', { + workspaceId: 'workspace', path: 'main.rs', kind: 'changed', + })) + expect(events).toEqual([{ + type: 'file_changed', workspaceId: 'workspace', path: 'main.rs', kind: 'changed', + }]) + }) + + it('binds exact live-runtime agent controls without resuming a session', async () => { + const directory = { + leaders: { isLeader: () => true }, + runtimeForSession: vi.fn(() => runtime), + } as unknown as LoopalSessionDirectory + const services = new LoopalBackendServices({ + workspace: async () => runtime, + liveSession: async () => runtime, + }, directory, vi.fn()) + request.mockImplementation(async (method: string) => method === 'hub/list_agents' + ? { agents: [{ name: 'main', state: 'connected' }] } + : { status: 'applied' }) + await services.operations().controlAgent({ + target: { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + }, + command: { type: 'clear' }, + }, CancellationToken.None) + expect(directory.runtimeForSession).toHaveBeenCalledWith('session') + expect(request).toHaveBeenLastCalledWith( + 'hub/control', { target: 'main', command: 'Clear' }, expect.any(AbortSignal), + ) + vi.mocked(directory.runtimeForSession).mockReturnValue(undefined) + await expect(services.operations().interruptAgent({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.ts new file mode 100644 index 00000000..f85cc077 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-services.ts @@ -0,0 +1,70 @@ +import { type DesktopEvent } from '../../../../shared/contracts' +import { + LoopalCodeWorkbench, + type CodeWorkbenchOperations, + type CodeWorkbenchRuntimeRouter, +} from '../workspace/loopal-code-workbench' +import { bindCodeWorkbench } from '../workspace/loopal-code-workbench-bind' +import { projectCodeWorkbenchEvent } from '../workspace/loopal-code-events' +import { LoopalSessionDirectory } from '../sessions/loopal-session-directory' +import { + type SessionRuntimeNotificationEvent, +} from '../runtime/session-runtime-registry' +import { + type AgentControlOperations, + LoopalAgentControl, +} from '../attention/loopal-agent-control' +import { + bindLoopalSettings, + type LoopalSettingsOperations, + LoopalSettingsService, +} from '../settings/loopal-settings-service' +import { + bindLoopalMcpSettings, LoopalMcpSettingsService, type LoopalMcpSettingsOperations, +} from '../settings/loopal-mcp-settings-service' +import { + bindLoopalSkillPlugins, LoopalSkillPluginService, type LoopalSkillPluginOperations, +} from '../settings/loopal-skill-plugin-service' + +export class LoopalBackendServices { + readonly code: LoopalCodeWorkbench + readonly agent: LoopalAgentControl + readonly settings: LoopalSettingsService + readonly mcpSettings: LoopalMcpSettingsService + readonly skillPlugins: LoopalSkillPluginService + + constructor( + router: CodeWorkbenchRuntimeRouter, + private readonly directory: LoopalSessionDirectory, + private readonly fire: (event: DesktopEvent) => void, + ) { + this.code = new LoopalCodeWorkbench(router) + this.settings = new LoopalSettingsService(router) + this.mcpSettings = new LoopalMcpSettingsService(router) + this.skillPlugins = new LoopalSkillPluginService(router) + this.agent = new LoopalAgentControl({ + session: (sessionId) => { + const runtime = this.directory.runtimeForSession(sessionId) + return runtime ? { runtime } : undefined + }, + }) + } + + operations(): CodeWorkbenchOperations & AgentControlOperations & LoopalSettingsOperations + & LoopalMcpSettingsOperations & LoopalSkillPluginOperations { + return { + ...bindCodeWorkbench(this.code), + ...bindLoopalSettings(this.settings), + ...bindLoopalMcpSettings(this.mcpSettings), + ...bindLoopalSkillPlugins(this.skillPlugins), + interruptAgent: this.agent.interruptAgent.bind(this.agent), + controlAgent: this.agent.controlAgent.bind(this.agent), + } + } + + accept(event: SessionRuntimeNotificationEvent): void { + if (!this.directory.leaders.isLeader(event.runtimeId, event.workspaceId)) return + const projected = projectCodeWorkbenchEvent(event.method, event.params) + if (projected) this.fire(projected) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-snapshot.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-snapshot.ts new file mode 100644 index 00000000..c5ad6e86 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-snapshot.ts @@ -0,0 +1,21 @@ +import { + type HostStatus, type RuntimeSummary, type SessionSummary, + type WorkbenchBootstrap, type Workspace, +} from '../../../../shared/contracts' + +export function backendSnapshot(input: { + readonly hostStatus: HostStatus + readonly workspaces: readonly Workspace[] + readonly sessions: readonly SessionSummary[] + readonly runtimes: readonly RuntimeSummary[] + readonly activeSessionId?: string +}): WorkbenchBootstrap { + return { + protocolVersion: 2, + hostStatus: input.hostStatus, + workspaces: [...input.workspaces], + sessions: [...input.sessions], + runtimes: [...input.runtimes], + ...(input.activeSessionId ? { activeSessionId: input.activeSessionId } : {}), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-types.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-types.ts new file mode 100644 index 00000000..7dc24d31 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend-types.ts @@ -0,0 +1,36 @@ +import { type Event } from '../../../../base/common/event' +import { type IDisposable } from '../../../../base/common/lifecycle' +import { type HostStatus } from '../../../../shared/contracts' +import { + type DesktopHostOptions, + type DesktopHostActivation, + type DesktopHostSession, + type MetaHubStartupOptions, +} from '../../../desktop-host/node/host/desktop-host' +import { type JsonRpcNotification } from '../../../desktop-host/node/rpc/jsonrpc-client' +import { + type SessionRuntimeHostFactory, + type SessionRuntimeRegistry, +} from '../runtime/session-runtime-registry' +import { type SessionDirectoryRequest } from '../sessions/session-directory-authority' + +export interface LoopalDesktopBackendOptions extends DesktopHostOptions { + readonly now?: () => Date + readonly createHost?: SessionRuntimeHostFactory + readonly runtimeRegistry?: SessionRuntimeRegistry + readonly maxLiveRuntimes?: number + readonly sessionStatePath?: string + readonly metaHubSettingsPath?: string + readonly desktopPreferencesPath?: string + readonly getMetaHubStartup?: () => MetaHubStartupOptions | undefined + readonly sessionDirectoryRequest?: SessionDirectoryRequest +} + +export interface DesktopHostClient extends IDisposable { + readonly currentStatus: HostStatus + readonly onStatus: Event + readonly onNotification: Event + start(activate?: DesktopHostActivation): Promise + request(method: string, params?: unknown, signal?: AbortSignal): Promise + stop(): Promise +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.bootstrap.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.bootstrap.test.ts new file mode 100644 index 00000000..0f529038 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.bootstrap.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { createBackend, timestamp } from './loopal-backend.test-fixtures' +import { nativeTestFileUri, nativeTestPath } from './loopal-backend.test-paths' + +describe('LoopalDesktopBackend bootstrap and lifecycle', () => { + it('boots protocol v2 with a live waiting session and stopped catalog sessions', async () => { + const { backend, hosts } = createBackend() + const bootstrap = await backend.bootstrap() + + expect(bootstrap).toMatchObject({ + protocolVersion: 2, + hostStatus: 'ready', + activeSessionId: 'session-1', + workspaces: [{ + id: 'local-workspace', name: 'project', + rootUri: nativeTestFileUri('/workspace/project'), kind: 'folder', + }], + }) + expect(bootstrap.sessions).toEqual([ + expect.objectContaining({ + id: 'session-1', title: 'Raw session-1', status: 'waiting', + activeRuntimeId: 'runtime-1', + }), + expect.objectContaining({ + id: 'session-2', title: 'Raw session-2', status: 'stopped', + }), + ]) + expect(bootstrap.runtimes).toEqual([{ + id: 'runtime-1', sessionId: 'session-1', workspaceId: 'local-workspace', + generation: 1, state: 'ready', rootAgent: 'main', startedAt: timestamp.toISOString(), + }]) + const detail = await backend.openSession('session-1') + expect(detail.conversation).toEqual([expect.objectContaining({ + role: 'assistant', text: 'Answer from session-1', + })]) + expect(detail.agents).toEqual([ + expect.objectContaining({ id: 'main', name: 'Loopal', status: 'waiting' }), + expect.objectContaining({ + id: 'worker', name: 'worker', status: 'waiting', parentId: 'main', + }), + ]) + expect(hosts[0]!.request).toHaveBeenCalledWith('desktop/listSessions', { + workspaceId: 'local-workspace', + }) + }) + + it('opens a catalog session read-only until an explicit restart', async () => { + const { backend, hosts, inputs } = createBackend() + await backend.bootstrap() + const stopped = await backend.openSession('session-2') + + expect(inputs).toEqual([{ + workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project'), + }]) + expect(hosts).toHaveLength(1) + expect(stopped).toMatchObject({ + session: { id: 'session-2', status: 'stopped' }, + conversation: [], agents: [], + }) + + const runtime = await backend.restartSession('session-2') + const detail = await backend.openSession('session-2') + + expect(inputs).toEqual([ + { workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project') }, + { + workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project'), + resumeSessionId: 'session-2', + }, + ]) + expect(hosts).toHaveLength(2) + expect(runtime).toMatchObject({ sessionId: 'session-2', state: 'ready' }) + expect(detail).toMatchObject({ + session: { id: 'session-2', status: 'waiting', activeRuntimeId: 'runtime-2' }, + conversation: [expect.objectContaining({ text: 'Answer from session-2' })], + }) + }) + + it('retains the final runtime, reports final host status, and restarts a new generation', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + const events: any[] = [] + backend.onEvent((event) => events.push(event)) + + await backend.stopSession('session-1') + expect(events).toContainEqual({ type: 'host_status', status: 'stopping' }) + expect(events).toContainEqual({ type: 'host_status', status: 'stopped' }) + expect(events).toContainEqual({ + type: 'runtime_updated', + runtime: expect.objectContaining({ id: 'runtime-1', state: 'stopped', startedAt: timestamp.toISOString() }), + }) + expect(events).toContainEqual({ + type: 'session_updated', + session: expect.not.objectContaining({ activeRuntimeId: expect.anything(), attention: expect.anything() }), + }) + const stoppedSnapshot = await backend.bootstrap() + expect(stoppedSnapshot.hostStatus).toBe('stopped') + expect(stoppedSnapshot.runtimes).toEqual([ + expect.objectContaining({ id: 'runtime-1', state: 'stopped' }), + ]) + const hostCount = hosts.length + const stoppedDetail = await backend.openSession('session-1') + expect(stoppedDetail).toMatchObject({ + session: { status: 'stopped' }, + conversation: [expect.objectContaining({ text: 'Answer from session-1' })], + }) + expect(stoppedDetail.session.activeRuntimeId).toBeUndefined() + expect(hosts).toHaveLength(hostCount) + + const restarted = await backend.restartSession('session-1') + expect(restarted).toMatchObject({ id: 'runtime-2', generation: 2, state: 'ready' }) + expect(hosts[1]!.sessionId).toBe('session-1') + const sessionRuntimes = events + .filter((event) => event.type === 'runtime_updated' && event.runtime.sessionId === 'session-1') + .map((event) => event.runtime.id) + expect(sessionRuntimes.at(-1)).toBe('runtime-2') + expect((await backend.bootstrap()).runtimes).toEqual([ + expect.objectContaining({ id: 'runtime-2', generation: 2, state: 'ready' }), + ]) + }) + + it('enforces the live Host quota and frees capacity after stop', async () => { + const { backend } = createBackend({ maxLive: 1 }) + await backend.bootstrap() + await expect(backend.openSession('session-2')).resolves.toMatchObject({ + session: { status: 'stopped' }, + }) + await expect(backend.restartSession('session-2')).rejects.toThrow('quota exceeded (1)') + await backend.stopSession('session-1') + await expect(backend.restartSession('session-2')).resolves.toMatchObject({ sessionId: 'session-2' }) + }) + + it('latches a crash instead of overwriting it with cleanup stop statuses', async () => { + const { backend, hosts, registry } = createBackend() + await backend.bootstrap() + const events: any[] = [] + backend.onEvent((event) => events.push(event)) + hosts[0]!.crash() + await vi.waitFor(() => expect(registry.liveCount).toBe(0)) + expect(events).toContainEqual({ type: 'host_status', status: 'crashed' }) + expect(events).toContainEqual({ + type: 'runtime_updated', + runtime: expect.objectContaining({ id: 'runtime-1', state: 'crashed' }), + }) + expect(events).toContainEqual({ + type: 'session_updated', + session: expect.objectContaining({ id: 'session-1', status: 'failed', attention: 'failure' }), + }) + expect(events).not.toContainEqual({ + type: 'runtime_updated', + runtime: expect.objectContaining({ id: 'runtime-1', state: 'stopped' }), + }) + expect(await backend.bootstrap()).toMatchObject({ + hostStatus: 'crashed', runtimes: [expect.objectContaining({ state: 'crashed' })], + }) + const hostCount = hosts.length + await expect(backend.openSession('session-1')).resolves.toMatchObject({ + session: { status: 'failed', attention: 'failure' }, + conversation: [expect.objectContaining({ text: 'Answer from session-1' })], + }) + expect(hosts).toHaveLength(hostCount) + }) + + it('rejects unknown sessions and shuts down every live Host', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + await backend.restartSession('session-2') + await expect(backend.openSession('missing')).rejects.toThrow('Unknown') + await expect(backend.sendMessage('missing', 'message')).rejects.toThrow('Unknown') + await backend.shutdown() + expect(hosts.map((host) => host.stop.mock.calls.length)).toEqual([1, 1]) + backend.dispose() + expect(hosts.map((host) => host.dispose.mock.calls.length)).toEqual([1, 1]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.directories.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.directories.test.ts new file mode 100644 index 00000000..c96526bb --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.directories.test.ts @@ -0,0 +1,116 @@ +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createBackend } from './loopal-backend.test-fixtures' +import { nativeTestFileUri, nativeTestPath } from './loopal-backend.test-paths' + +describe('LoopalDesktopBackend session directories', () => { + it('routes two directories through isolated workspace IDs and preserves cwd on restart', async () => { + const inspect = vi.fn(async (_method: string, input: unknown) => { + const path = (input as { path: string }).path + return { path, name: path.split('/').at(-1) } + }) + const { backend, inputs } = createBackend({ sessionDirectoryRequest: inspect }) + await backend.bootstrap() + const firstSelection = await backend.authorizeSessionDirectory('/projects/one') + const first = await backend.createSession({ + authorizationId: firstSelection.authorizationId, launchMode: 'directory', + }) + const secondSelection = await backend.authorizeSessionDirectory('/projects/two') + const second = await backend.createSession({ + authorizationId: secondSelection.authorizationId, launchMode: 'directory', + }) + expect(first.session.workspaceId).not.toBe(second.session.workspaceId) + expect((await backend.bootstrap()).workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ rootUri: nativeTestFileUri('/projects/one') }), + expect.objectContaining({ rootUri: nativeTestFileUri('/projects/two') }), + ])) + await backend.stopSession(first.session.id) + await backend.restartSession(first.session.id) + expect(inputs.at(-1)).toMatchObject({ + workspaceId: first.session.workspaceId, + cwd: nativeTestPath('/projects/one'), resumeSessionId: first.session.id, + }) + }) + + it('authorizes and creates after every live Session was stopped', async () => { + const { backend, inputs } = createBackend({ + sessionDirectoryRequest: async () => ({ path: '/projects/new', name: 'new' }), + }) + await backend.bootstrap() + await backend.stopSession('session-1') + const selected = await backend.authorizeSessionDirectory('/projects/new') + expect(selected.path).toBe('/projects/new') + await backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + expect(inputs.at(-1)?.cwd).toBe(selected.path) + }) + + it('restores a selected session cwd and workspace across app relaunch', async () => { + const root = await mkdtemp(join(tmpdir(), 'loopal-session-cwd-')) + const state = join(root, 'state.json') + const projectPath = join(root, 'persisted') + try { + await mkdir(projectPath) + const project = await realpath(projectPath) + const request = async () => ({ path: project, name: 'persisted' }) + const first = createBackend({ sessionStatePath: state, sessionDirectoryRequest: request }) + await first.backend.bootstrap() + const selected = await first.backend.authorizeSessionDirectory(project) + const created = await first.backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + await first.backend.shutdown() + + const second = createBackend({ sessionStatePath: state, sessionDirectoryRequest: request }) + const restored = await second.backend.bootstrap() + expect(second.inputs[0]).toMatchObject({ + cwd: project, resumeSessionId: created.session.id, + }) + expect(restored.workspaces).toContainEqual(expect.objectContaining({ + id: created.session.workspaceId, + rootUri: nativeTestFileUri(project), + })) + await second.backend.shutdown() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('keeps a missing persisted session visible without relaunching Loopal in its cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'loopal-session-missing-cwd-')) + const state = join(root, 'state.json') + const projectPath = join(root, 'moved-project') + try { + await mkdir(projectPath) + const project = await realpath(projectPath) + const request = async () => ({ path: project, name: 'moved-project' }) + const first = createBackend({ sessionStatePath: state, sessionDirectoryRequest: request }) + await first.backend.bootstrap() + await first.backend.stopSession('session-1') + const selected = await first.backend.authorizeSessionDirectory(project) + const created = await first.backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + await first.backend.shutdown() + await rm(project, { recursive: true }) + + const second = createBackend({ sessionStatePath: state, sessionDirectoryRequest: request }) + const restored = await second.backend.bootstrap() + expect(second.inputs[0]).toEqual({ + workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project'), + }) + expect(restored.sessions).toContainEqual(expect.objectContaining({ + id: created.session.id, status: 'failed', attention: 'failure', + })) + expect(JSON.parse(await readFile(state, 'utf8'))).toMatchObject({ + activeSessionId: restored.activeSessionId, + runningSessionIds: [restored.activeSessionId], + }) + await second.backend.shutdown() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.events.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.events.test.ts new file mode 100644 index 00000000..87b7f626 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.events.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type DesktopEvent } from '../../../../shared/contracts' +import { agentEvent, createBackend, timestamp } from './loopal-backend.test-fixtures' +import { nativeTestPath } from './loopal-backend.test-paths' + +describe('LoopalDesktopBackend scoped events', () => { + it('routes messages and equal revisions independently across two sessions', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + await backend.restartSession('session-2') + const events: DesktopEvent[] = [] + backend.onEvent((event) => events.push(event)) + + const image = { + name: 'pixel.png', mediaType: 'image/png' as const, data: 'iVBORw==', sizeBytes: 4, + } + await backend.sendMessage('session-1', 'message one', CancellationToken.None, 'main', [image]) + await backend.sendMessage('session-2', 'message two', CancellationToken.None, 'worker') + expect(hosts[0]!.request).toHaveBeenCalledWith('hub/route', expect.objectContaining({ + content: { + text: 'message one', images: [{ media_type: 'image/png', data: 'iVBORw==' }], + }, + })) + expect(hosts[1]!.request).toHaveBeenCalledWith('hub/route', expect.objectContaining({ + target: { hub: [], agent: 'worker' }, + content: { text: 'message two', images: [] }, + })) + expect(events).toContainEqual({ + type: 'conversation_entry', sessionId: 'session-1', + entry: expect.objectContaining({ role: 'user', imageCount: 1 }), + }) + + hosts[0]!.notification('agent/event', agentEvent({ Stream: { text: 'first' } }, 3, 3)) + hosts[0]!.notification('agent/event', agentEvent('AwaitingInput', 4, 4)) + hosts[1]!.notification('agent/event', agentEvent({ Stream: { text: 'second' } }, 3, 3)) + hosts[1]!.notification('agent/event', agentEvent({ TurnCompleted: {} }, 4, 4)) + expect(events).toContainEqual({ + type: 'conversation_entry', sessionId: 'session-1', + entry: expect.objectContaining({ text: 'first' }), + }) + expect(events).toContainEqual({ + type: 'conversation_entry', sessionId: 'session-2', + entry: expect.objectContaining({ text: 'second' }), + }) + expect(events.filter((event) => event.type === 'session_updated')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ session: expect.objectContaining({ id: 'session-1', status: 'waiting' }) }), + expect.objectContaining({ session: expect.objectContaining({ id: 'session-2', status: 'running' }) }), + ]), + ) + }) + it('scopes attention to a generation, clears it on retire, and rejects stale responses', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + const events: DesktopEvent[] = [] + backend.onEvent((event) => events.push(event)) + hosts[0]!.notification('agent/event', agentEvent({ + ToolPermissionRequest: { id: 'permission', name: 'Bash', input: { command: 'pwd' } }, + }, 3, 3)) + expect(events).toContainEqual({ + type: 'permission_requested', + request: expect.objectContaining({ + id: 'permission', sessionId: 'session-1', runtimeId: 'runtime-1', generation: 1, + }), + }) + await backend.respondPermission({ + sessionId: 'session-1', runtimeId: 'runtime-1', generation: 1, + agentId: 'main', requestId: 'permission', decision: 'allow_once', + }, CancellationToken.None) + + const restarted = await backend.restartSession('session-1') + expect(restarted).toMatchObject({ id: 'runtime-2', generation: 2 }) + expect(events).toContainEqual({ + type: 'permission_resolved', sessionId: 'session-1', + runtimeId: 'runtime-1', generation: 1, agentId: 'main', requestId: 'permission', + }) + expect(events.filter((event) => event.type === 'session_updated').at(-1)).toEqual({ + type: 'session_updated', + session: expect.not.objectContaining({ attention: expect.anything() }), + }) + const count = hosts.length + await expect(backend.respondPermission({ + sessionId: 'session-1', runtimeId: 'runtime-1', generation: 1, + agentId: 'main', requestId: 'permission', decision: 'deny', + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(hosts).toHaveLength(count) + + const before = events.length + hosts[0]!.notification('agent/event', agentEvent({ Stream: { text: 'late old host' } }, 9, 9)) + hosts[0]!.notification('agent/event', agentEvent('AwaitingInput', 10, 10)) + expect(events).toHaveLength(before) + + await backend.stopSession('session-1') + const stoppedCount = hosts.length + await expect(backend.respondPermission({ + sessionId: 'session-1', runtimeId: 'runtime-2', generation: 2, + agentId: 'main', requestId: 'stopped', decision: 'deny', + }, CancellationToken.None)).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + expect(hosts).toHaveLength(stoppedCount) + }) + it('uses one workspace leader and reopens the most recent session after zero live Hosts', async () => { + let tick = 0 + const { backend, hosts, inputs } = createBackend({ + now: () => new Date(timestamp.getTime() + tick++ * 1_000), + }) + await backend.bootstrap() + await backend.restartSession('session-2') + const events: DesktopEvent[] = [] + backend.onEvent((event) => events.push(event)) + + hosts[1]!.notification('workspace/fileChanged', { + workspaceId: 'local-workspace', path: 'ignored.rs', kind: 'changed', + }) + hosts[0]!.notification('workspace/fileChanged', { + workspaceId: 'local-workspace', path: 'leader.rs', kind: 'changed', + }) + expect(events).not.toContainEqual(expect.objectContaining({ path: 'ignored.rs' })) + expect(events).toContainEqual(expect.objectContaining({ type: 'file_changed', path: 'leader.rs' })) + + let releaseStop!: () => void + const stopGate = new Promise((resolve) => { releaseStop = resolve }) + hosts[0]!.stop.mockImplementation(async () => { + hosts[0]!.status('stopping') + await stopGate + hosts[0]!.status('stopped') + }) + const stopping = backend.stopSession('session-1') + await vi.waitFor(() => expect(hosts[0]!.currentStatus).toBe('stopping')) + hosts[0]!.notification('workspace/fileChanged', { + workspaceId: 'local-workspace', path: 'retiring.rs', kind: 'changed', + }) + hosts[1]!.notification('workspace/fileChanged', { + workspaceId: 'local-workspace', path: 'next.rs', kind: 'changed', + }) + await backend.gitStatus('local-workspace', CancellationToken.None) + expect(events).not.toContainEqual(expect.objectContaining({ path: 'retiring.rs' })) + expect(events).toContainEqual(expect.objectContaining({ type: 'file_changed', path: 'next.rs' })) + expect(hosts[1]!.request).toHaveBeenCalledWith( + 'workspace/gitStatus', { workspaceId: 'local-workspace' }, expect.any(AbortSignal), + ) + releaseStop() + await stopping + await backend.stopSession('session-2') + await expect(backend.gitStatus('local-workspace', CancellationToken.None)) + .rejects.toThrow('restart a session') + await backend.restartSession('session-2') + await expect(backend.gitStatus('local-workspace', CancellationToken.None)) + .resolves.toMatchObject({ branch: 'main' }) + expect(inputs.at(-1)).toEqual({ + workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project'), + resumeSessionId: 'session-2', + }) + }) + +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.failures.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.failures.test.ts new file mode 100644 index 00000000..aa443a08 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.failures.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type SessionSummary } from '../../../../shared/contracts' +import { createBackend } from './loopal-backend.test-fixtures' + +describe('LoopalDesktopBackend failure and concurrency boundaries', () => { + it('uses default clock/workspace name and bootstraps on the first operation', async () => { + const { backend } = createBackend({ cwd: '/', defaultClock: true }) + await backend.sendMessage('session-1', 'bootstrap implicitly') + const snapshot = await backend.bootstrap() + expect(snapshot.workspaces[0]!.name).toBe('Workspace') + expect(Date.parse(snapshot.sessions[0]!.updatedAt)).not.toBeNaN() + }) + + it('keeps concurrent open read-only and validates missing sessions and workspaces', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + const [first, second] = await Promise.all([ + backend.openSession('session-2'), backend.openSession('session-2'), + ]) + expect(first.session.id).toBe('session-2') + expect(second.session.id).toBe('session-2') + expect(hosts).toHaveLength(1) + await expect(backend.stopSession('missing')).rejects.toThrow('Unknown') + await expect(backend.restartSession('missing')).rejects.toThrow('Unknown') + await expect(backend.createSession({ + authorizationId: '5d0c638c-d44c-4f47-818b-62e6b599e31c', launchMode: 'directory', + })).rejects.toThrow('authorization') + await expect(backend.gitStatus('other', CancellationToken.None)).rejects.toThrow('Unknown workspace') + }) + + it('treats stopping an already stopped catalog session as idempotent', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + await backend.stopSession('session-2') + expect(hosts).toHaveLength(1) + await backend.stopSession('session-1') + await expect(backend.stopSession('session-1')).resolves.toBeUndefined() + expect(hosts[0]!.stop).toHaveBeenCalledOnce() + }) + + it('rejects restarting archived sessions and sending into stopped sessions', async () => { + const { backend } = createBackend() + const snapshot = await backend.bootstrap() + await backend.stopSession('session-1') + await expect(backend.sendMessage('session-1', 'must not resume')) + .rejects.toThrow('restart it first') + const internals = backend as unknown as { + directory: { sessions: Map } + } + const session = snapshot.sessions.find(({ id }) => id === 'session-2')! + internals.directory.sessions.set(session.id, { ...session, status: 'archived' }) + await expect(backend.restartSession(session.id)).rejects.toThrow('Archived session') + }) + + it('retires a newly created runtime when catalog initialization fails', async () => { + const { backend, hosts } = createBackend({ + sessionDirectoryRequest: async () => ({ path: '/workspace/project', name: 'project' }), + hostSetup: (host, index) => { + if (index !== 1) return + host.request.mockRejectedValue(new Error('catalog failed')) + host.stop.mockRejectedValue(new Error('cleanup failed')) + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/workspace/project') + await expect(backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })) + .rejects.toThrow('catalog failed') + await vi.waitFor(() => expect(hosts[1]!.dispose).toHaveBeenCalledOnce()) + }) + + it('restores a direct directory grant when Host startup fails', async () => { + const { backend, hosts } = createBackend({ + sessionDirectoryRequest: async () => ({ path: '/project', name: 'project' }), + hostSetup: (host, index) => { + if (index === 1) host.start.mockRejectedValueOnce(new Error('Host failed')) + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/project') + const input = { authorizationId: selected.authorizationId, launchMode: 'directory' as const } + await expect(backend.createSession(input)).rejects.toThrow('Host failed') + await expect(backend.createSession(input)).resolves.toMatchObject({ + session: { status: 'waiting' }, + }) + expect(hosts).toHaveLength(3) + }) + + it('rolls back a clean prepared worktree and retains an unsafe one explicitly', async () => { + let cleanupFails = false + const request = vi.fn(async (method: string) => { + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', head: 'a'.repeat(40), dirty: false }, + } + if (method.endsWith('prepareWorktree')) return { + path: '/project/.loopal/worktrees/wt', branch: 'loopal-wt-wt', name: 'wt', + } + if (cleanupFails) throw new Error('worktree_cleanup_unsafe: dirty worktree') + return { path: '/project/.loopal/worktrees/wt', removed: true } + }) + const { backend } = createBackend({ + sessionDirectoryRequest: request, + hostSetup: (host, index) => { + if (index > 0) host.start.mockRejectedValueOnce(new Error('Host failed')) + }, + }) + await backend.bootstrap() + const clean = await backend.authorizeSessionDirectory('/project') + await expect(backend.createSession({ + authorizationId: clean.authorizationId, launchMode: 'worktree', worktreeName: 'wt', + })).rejects.toThrow('Host failed') + expect((await backend.bootstrap()).workspaces.some( + ({ rootUri }) => rootUri.includes('/.loopal/worktrees/wt'), + )).toBe(false) + await expect(backend.createSession({ + authorizationId: clean.authorizationId, launchMode: 'directory', + })).rejects.toThrow('Host failed') + + cleanupFails = true + const unsafe = await backend.authorizeSessionDirectory('/project') + await expect(backend.createSession({ + authorizationId: unsafe.authorizationId, launchMode: 'worktree', worktreeName: 'wt', + })).rejects.toThrow( + 'worktree_retained: session creation failed (Host failed); cleanup failed and the worktree ' + + 'was retained at /project/.loopal/worktrees/wt', + ) + await expect(backend.createSession({ + authorizationId: unsafe.authorizationId, launchMode: 'directory', + })).rejects.toThrow('directory_authorization_invalid') + }) + + it('retains the cwd and consumes its grant after Loopal created the session', async () => { + const methods: string[] = [] + const request = vi.fn(async (method: string) => { + methods.push(method) + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', head: 'a'.repeat(40), dirty: false }, + } + if (method.endsWith('prepareWorktree')) return { + path: '/project/.loopal/worktrees/recover', + branch: 'loopal-wt-recover', name: 'recover', + } + return { path: '/project/.loopal/worktrees/recover', removed: true } + }) + const { backend } = createBackend({ + sessionDirectoryRequest: request, + hostSetup: (host, index) => { + if (index === 1) host.request.mockRejectedValue(new Error('snapshot failed')) + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/project') + await expect(backend.createSession({ + authorizationId: selected.authorizationId, + launchMode: 'worktree', worktreeName: 'recover', + })).rejects.toThrow( + 'session_created_recovery_required: Loopal session session-3 was created at ' + + '/project/.loopal/worktrees/recover; Desktop initialization failed (snapshot failed)', + ) + expect(methods).not.toContain('desktop/cleanupWorktree') + expect((await backend.bootstrap()).workspaces.some( + ({ rootUri }) => rootUri.includes('/.loopal/worktrees/recover'), + )).toBe(true) + await expect(backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).rejects.toThrow('directory_authorization_invalid') + }) + + it('does not implicitly resume a Host for workspace operations', async () => { + const { backend } = createBackend() + await backend.bootstrap() + await backend.stopSession('session-1') + await expect(backend.gitStatus('local-workspace', CancellationToken.None)) + .rejects.toThrow('no live runtime') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.leader.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.leader.test.ts new file mode 100644 index 00000000..39e16994 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.leader.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type DesktopEvent } from '../../../../shared/contracts' +import { createBackend } from './loopal-backend.test-fixtures' +import { nativeTestPath } from './loopal-backend.test-paths' + +describe('LoopalDesktopBackend workspace leader transitions', () => { + it('publishes a fresh leader attached while the last leader is stopping', async () => { + const { backend, hosts } = createBackend({ + sessionDirectoryRequest: async () => ({ path: '/workspace/project', name: 'project' }), + }) + await backend.bootstrap() + const events: DesktopEvent[] = [] + backend.onEvent((event) => events.push(event)) + let releaseStop!: () => void + const stopGate = new Promise((resolve) => { releaseStop = resolve }) + hosts[0]!.stop.mockImplementation(async () => { + hosts[0]!.status('stopping') + await stopGate + hosts[0]!.status('stopped') + }) + + const stopping = backend.stopSession('session-1') + await vi.waitFor(() => expect(hosts[0]!.currentStatus).toBe('stopping')) + const selected = await backend.authorizeSessionDirectory('/workspace/project') + const created = await backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + expect(created.session.id).toBe('session-3') + expect(events.filter((event) => event.type === 'host_status').at(-1)) + .toEqual({ type: 'host_status', status: 'ready' }) + releaseStop() + await stopping + expect((await backend.bootstrap()).hostStatus).toBe('ready') + await backend.gitStatus('local-workspace', CancellationToken.None) + expect(hosts[1]!.request).toHaveBeenCalledWith( + 'workspace/gitStatus', { workspaceId: 'local-workspace' }, expect.any(AbortSignal), + ) + }) + + it('requires restart before a zero-live workspace becomes ready', async () => { + const { backend, hosts, inputs } = createBackend() + await backend.bootstrap() + await backend.stopSession('session-1') + const events: DesktopEvent[] = [] + backend.onEvent((event) => events.push(event)) + + await expect(backend.gitStatus('local-workspace', CancellationToken.None)) + .rejects.toThrow('restart a session') + await backend.restartSession('session-1') + await backend.gitStatus('local-workspace', CancellationToken.None) + expect(inputs.at(-1)).toEqual({ + workspaceId: 'local-workspace', cwd: nativeTestPath('/workspace/project'), + resumeSessionId: 'session-1', + }) + expect(events).toContainEqual({ type: 'host_status', status: 'ready' }) + const snapshot = await backend.bootstrap() + expect(snapshot.hostStatus).toBe('ready') + expect(snapshot.runtimes).toEqual([ + expect.objectContaining({ id: 'runtime-2', sessionId: 'session-1', state: 'ready' }), + ]) + expect(hosts).toHaveLength(2) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.persistence.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.persistence.test.ts new file mode 100644 index 00000000..684eb455 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.persistence.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createBackend } from './loopal-backend.test-fixtures' +import { defaultTestWorkspacePath, nativeTestPath } from './loopal-backend.test-paths' + +describe('LoopalDesktopBackend persisted runtime intent', () => { + let root = '' + let statePath = '' + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-backend-resume-')) + statePath = join(root, 'lifecycle.json') + }) + + afterEach(async () => rm(root, { recursive: true, force: true })) + + it('explicitly resumes the last live session during bootstrap', async () => { + await seed(statePath, 'session-2') + const { backend, inputs, hosts } = createBackend({ sessionStatePath: statePath }) + + const bootstrap = await backend.bootstrap() + expect(inputs).toEqual([{ + workspaceId: 'local-workspace', cwd: nativeTestPath(defaultTestWorkspacePath), + resumeSessionId: 'session-2', + }]) + expect(bootstrap.activeSessionId).toBe('session-2') + expect(bootstrap.sessions).toContainEqual(expect.objectContaining({ + id: 'session-2', status: 'waiting', activeRuntimeId: 'runtime-1', + })) + await expect(backend.openSession('session-2')).resolves.toMatchObject({ + conversation: [expect.objectContaining({ text: 'Answer from session-2' })], + }) + expect(hosts).toHaveLength(1) + }) + + it('clears an invalid resume intent and falls back to a fresh Host', async () => { + await seed(statePath, 'missing-session') + const { backend, inputs } = createBackend({ + sessionStatePath: statePath, + hostSetup: (host, index) => { + if (index === 0) host.start.mockRejectedValueOnce(new Error('persisted session missing')) + }, + }) + + const bootstrap = await backend.bootstrap() + expect(inputs).toEqual([ + { + workspaceId: 'local-workspace', cwd: nativeTestPath(defaultTestWorkspacePath), + resumeSessionId: 'missing-session', + }, + { workspaceId: 'local-workspace', cwd: nativeTestPath(defaultTestWorkspacePath) }, + ]) + expect(bootstrap.activeSessionId).toBe('session-1') + expect(JSON.parse(await readFile(statePath, 'utf8'))).toMatchObject({ + workspace: defaultTestWorkspacePath, + activeSessionId: 'session-1', runningSessionIds: ['session-1'], + }) + }) + + it('normalizes persisted running intent to the runtime actually recovered', async () => { + await writeFile(statePath, JSON.stringify({ + version: 1, workspace: defaultTestWorkspacePath, activeSessionId: 'session-2', + runningSessionIds: ['session-2', 'session-1'], + })) + const { backend } = createBackend({ sessionStatePath: statePath }) + + await backend.bootstrap() + + expect(JSON.parse(await readFile(statePath, 'utf8'))).toMatchObject({ + workspace: defaultTestWorkspacePath, + activeSessionId: 'session-2', runningSessionIds: ['session-2'], + }) + }) +}) + +async function seed(path: string, sessionId: string): Promise { + await writeFile(path, JSON.stringify({ + version: 1, workspace: defaultTestWorkspacePath, activeSessionId: sessionId, + runningSessionIds: [sessionId], + })) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.resync.test.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.resync.test.ts new file mode 100644 index 00000000..22b06485 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.resync.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { type DesktopEvent } from '../../../../shared/contracts' +import { agentEvent, createBackend } from './loopal-backend.test-fixtures' + +describe('LoopalDesktopBackend scoped events', () => { + it('bounds a slow snapshot burst and schedules a trailing authoritative refresh', async () => { + const { backend, hosts } = createBackend() + await backend.bootstrap() + const host = hosts[0]! + const replacements: DesktopEvent[] = [] + backend.onEvent((event) => { + if (event.type === 'session_detail_replaced') replacements.push(event) + }) + const base = host.request.getMockImplementation()! + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let snapshots = 0 + host.request.mockImplementation(async (method, params, signal) => { + if (method === 'view/snapshot' && ++snapshots === 1) await gate + return base(method, params, signal) + }) + host.notification('view/resync_required', {}) + await vi.waitFor(() => expect(snapshots).toBe(1)) + for (let revision = 3; revision < 80; revision += 1) { + host.notification('agent/event', agentEvent({ Stream: { text: `${revision}` } }, revision, revision)) + } + host.snapshotRevision = 100 + host.snapshotContent = 'authoritative after overflow' + release() + await vi.waitFor(() => expect(snapshots).toBe(4)) + await vi.waitFor(() => expect(replacements.at(-1)).toMatchObject({ + detail: { conversation: [expect.objectContaining({ text: 'authoritative after overflow' })] }, + })) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-fixtures.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-fixtures.ts new file mode 100644 index 00000000..9ffea49e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-fixtures.ts @@ -0,0 +1,197 @@ +import { vi } from 'vitest' +import { Emitter } from '../../../../base/common/event' +import { type HostStatus } from '../../../../shared/contracts' +import { + type DesktopHostActivation, type DesktopHostSession, +} from '../../../desktop-host/node/host/desktop-host' +import { type JsonRpcNotification } from '../../../desktop-host/node/rpc/jsonrpc-client' +import { type DesktopHostClient, LoopalDesktopBackend } from './loopal-backend' +import { SessionRuntimeRegistry } from '../runtime/session-runtime-registry' +import { type SessionDirectoryRequest } from '../sessions/session-directory-authority' +import { defaultTestWorkspacePath } from './loopal-backend.test-paths' +export const timestamp = new Date('2026-07-11T12:00:00.000Z') +interface CatalogRow { + id: string + title: string + model: string + mode: string + createdAt: string + updatedAt: string +} +export class FakeHost implements DesktopHostClient { + private readonly statuses = new Emitter() + private readonly notifications = new Emitter() + currentStatus: HostStatus = 'stopped' + snapshotStatus = 'WaitingForInput' + snapshotRole = 'assistant' + snapshotContent: string + snapshotStreaming = '' + snapshotMessageId: string | undefined = 'message-1' + snapshotRevision = 2 + emitSessionCreated = false + failAfterSessionCreated?: Error + readonly start = vi.fn(async (activate?: DesktopHostActivation): Promise => { + this.status('spawning') + const session = { sessionId: this.sessionId, serverVersion: 'test', pid: 42 } + if (this.emitSessionCreated) { + await activate?.(session) + if (this.failAfterSessionCreated) throw this.failAfterSessionCreated + } + this.status('ready') + this.ensureCatalogRow() + return session + }) + readonly stop = vi.fn(async () => { + this.status('stopping') + this.status('stopped') + }) + readonly dispose = vi.fn() + readonly request = vi.fn(async (method, params) => { + const input = params as Record | undefined + if (method === 'desktop/listSessions') return [...this.catalog] + if (method === 'desktop/getSettings') return loopalSettings(input) + if (method === 'desktop/updateSettings') return { + ...loopalSettings(input), settings: input?.settings, + } + if (method === 'view/snapshot') return this.snapshot(String(input?.agent ?? 'main')) + if (method === 'hub/topology') { + return { agents: [ + { name: 'main', parent: null, children: ['worker'], lifecycle: 'running', model: 'model' }, + { name: 'worker', parent: 'main', children: [], lifecycle: 'running', model: 'model' }, + ] } + } + if (method === 'hub/list_agents') { + return { agents: [{ name: 'main', state: 'connected' }, { name: 'worker', state: 'connected' }] } + } + if (method === 'hub/control') return { status: 'applied' } + if (method === 'hub/status') return { agent_count: 2, uplink: null } + if (method === 'workspace/gitStatus') { + return { branch: 'main', ahead: 0, behind: 0, changes: [] } + } + if (method === 'workspace/listDirectory') { + return { workspaceId: input?.workspaceId, path: input?.path, entries: [] } + } + return { ok: true } + }) + readonly onStatus = this.statuses.event + readonly onNotification = this.notifications.event + constructor(readonly sessionId: string, private readonly catalog: CatalogRow[]) { + this.snapshotContent = `Answer from ${sessionId}` + } + status(value: HostStatus): void { + this.currentStatus = value + this.statuses.fire(value) + } + crash(): void { this.status('crashed') } + notification(method: string, params: unknown): void { + this.notifications.fire({ method, params }) + } + private ensureCatalogRow(): void { + if (this.catalog.some((row) => row.id === this.sessionId)) return + this.catalog.push(catalogRow(this.sessionId, timestamp.toISOString())) + } + private snapshot(agentName: string) { + return { + rev: this.snapshotRevision, + state: { agent: { + name: agentName, + parent: agentName === 'main' ? null : 'main', + children: agentName === 'main' ? ['worker'] : [], + observable: { status: this.snapshotStatus }, + conversation: { + streaming_text: this.snapshotStreaming, + messages: [{ + role: this.snapshotRole, + content: this.snapshotContent, + ...(this.snapshotMessageId ? { message_id: this.snapshotMessageId } : {}), + }], + }, + } }, + } + } +} +export function createBackend(options: { + cwd?: string + maxLive?: number + freshSessions?: string[] + catalog?: CatalogRow[] + now?: () => Date + defaultClock?: boolean + sessionStatePath?: string + hostSetup?: (host: FakeHost, index: number) => void + sessionDirectoryRequest?: SessionDirectoryRequest +} = {}) { + const catalog = options.catalog ?? [ + catalogRow('session-1', '2026-07-11T11:00:00.000Z'), + catalogRow('session-2', '2026-07-11T10:00:00.000Z'), + ] + const fresh = [...(options.freshSessions ?? ['session-1', 'session-3', 'session-4'])] + const hosts: FakeHost[] = [] + const inputs: Array<{ cwd: string; resumeSessionId?: string }> = [] + let runtimeIndex = 0 + const registry = new SessionRuntimeRegistry({ + maxLive: options.maxLive ?? 4, + createRuntimeId: () => `runtime-${++runtimeIndex}`, + createHost: (input) => { + inputs.push(input) + const host = new FakeHost(input.resumeSessionId ?? fresh.shift() ?? 'fresh-session', catalog) + hosts.push(host) + options.hostSetup?.(host, hosts.length - 1) + return host + }, + }) + const backend = new LoopalDesktopBackend({ + binaryPath: '/bin/loopal', + cwd: options.cwd ?? defaultTestWorkspacePath, + parentPid: 7, + runtimeRegistry: registry, + ...(options.sessionStatePath ? { sessionStatePath: options.sessionStatePath } : {}), + ...(options.defaultClock ? {} : { now: options.now ?? (() => timestamp) }), + ...(options.sessionDirectoryRequest + ? { sessionDirectoryRequest: options.sessionDirectoryRequest } : {}), + }) + return { backend, registry, hosts, inputs, catalog } +} + +export function agentEvent(payload: unknown, eventId = 10, revision?: number): unknown { + return { + agent_name: { hub: [], agent: 'main' }, + event_id: eventId, + turn_id: 1, + correlation_id: 2, + ...(revision === undefined ? {} : { rev: revision }), + payload, + } +} + +function catalogRow(id: string, updatedAt: string): CatalogRow { + return { + id, title: `Raw ${id}`, model: 'loopal-default', mode: 'agent', + createdAt: '2026-07-11T09:00:00.000Z', updatedAt, + } +} + +function loopalSettings(input?: Record) { + return { + workspaceId: input?.workspaceId ?? 'local-workspace', + settings: { + model: 'gpt-5', modelRouting: emptyRouting(), + permissionMode: 'bypass', decisionMode: 'manual', + sandboxPolicy: 'default_write', thinking: { type: 'auto' }, + maxContextTokens: 0, memoryEnabled: true, microcompactIdleMinutes: 60, + telemetryEnabled: true, outputStyle: '', + }, + configuredProviders: ['test-provider'], + providers: emptyProviders(), + openaiCompatible: [], + resolvedEntries: [{ key: 'model', value: 'gpt-5' }], + settingSources: ['project local overrides'], + } +} +function emptyRouting() { + return { default: '', summarization: '', classification: '', refine: '' } +} +function emptyProviders() { + const empty = () => ({ enabled: false, baseUrl: '', apiKeyEnv: '', apiKeyConfigured: false }) + return { anthropic: empty(), openai: empty(), google: empty() } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-paths.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-paths.ts new file mode 100644 index 00000000..8d9bb977 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.test-paths.ts @@ -0,0 +1,12 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const defaultTestWorkspacePath = '/workspace/project' + +export function nativeTestPath(path: string): string { + return resolve(path) +} + +export function nativeTestFileUri(path: string): string { + return pathToFileURL(nativeTestPath(path)).href +} diff --git a/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.ts b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.ts new file mode 100644 index 00000000..9d42d51d --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/backend/loopal-backend.ts @@ -0,0 +1,193 @@ +import { basename } from 'node:path' +import { pathToFileURL } from 'node:url' +import { type CancellationToken } from '../../../../base/common/cancellation' +import { Emitter } from '../../../../base/common/event' +import { Disposable } from '../../../../base/common/lifecycle' +import { + type CreateSessionInput, type DesktopEvent, type DesktopImageAttachment, type RuntimeSummary, + type SessionDetail, type WorkbenchBootstrap, type Workspace, +} from '../../../../shared/contracts' +import { canRestartSession } from '../../../../shared/contracts/session-lifecycle' +import { type DesktopBackend } from '../../common/backend' +import { createBackendRegistry } from './loopal-backend-registry' +import { LoopalBackendServices } from './loopal-backend-services' +import { type DesktopHostClient, type LoopalDesktopBackendOptions } from './loopal-backend-types' +import { bindDesktopPreferences, DesktopPreferencesService } from '../settings/desktop-preferences-service' +import { LoopalMetaHubRuntime } from '../federation/loopal-metahub-runtime' +import { type LoopalBackendOperations } from './loopal-backend-operations' +import { + archivedSession, bootstrapRuntime, mergeRuntimeCatalog, stoppedSession, unknownSession, +} from './loopal-backend-lifecycle' +import { LoopalSessionDirectory } from '../sessions/loopal-session-directory' +import { type SessionRuntimeHandle, SessionRuntimeRegistry } from '../runtime/session-runtime-registry' +import { SessionDirectoryAuthority } from '../sessions/session-directory-authority' +import { LoopalSessionWorkspaces } from '../sessions/loopal-session-workspaces' +import { backendSnapshot } from './loopal-backend-snapshot' +import { createSessionDirectoryCommand } from '../sessions/session-directory-command' +import { createLoopalSession } from '../sessions/loopal-session-creation' +export type { DesktopHostClient, LoopalDesktopBackendOptions } from './loopal-backend-types' +export interface LoopalDesktopBackend extends LoopalBackendOperations {} +export class LoopalDesktopBackend extends Disposable implements DesktopBackend { + private readonly events = this.register(new Emitter()) + private readonly registry: SessionRuntimeRegistry + private readonly directory: LoopalSessionDirectory + private readonly services: LoopalBackendServices + private readonly now: () => Date + private readonly workspaces: LoopalSessionWorkspaces + private readonly sessionDirectories: SessionDirectoryAuthority + private readonly metaHub: LoopalMetaHubRuntime + private readonly preferences: DesktopPreferencesService + private bootstrapping?: Promise + private bootstrapped = false + private activeSessionId?: string + private hostStatus: DesktopHostClient['currentStatus'] = 'stopped' + readonly onEvent = this.events.event + constructor(private readonly options: LoopalDesktopBackendOptions) { + super() + this.now = options.now ?? (() => new Date()) + const workspace: Workspace = { + id: 'local-workspace', + name: basename(options.cwd) || 'Workspace', + rootUri: pathToFileURL(options.cwd).href, + kind: 'folder', + } + this.workspaces = new LoopalSessionWorkspaces(workspace, options.cwd, options.sessionStatePath) + this.metaHub = this.register(new LoopalMetaHubRuntime({ + binaryPath: options.binaryPath, parentPid: options.parentPid ?? process.pid, + ...(options.metaHubSettingsPath ? { settingsPath: options.metaHubSettingsPath } : {}), + })) + this.preferences = new DesktopPreferencesService(options.desktopPreferencesPath) + this.registry = this.register(createBackendRegistry({ + ...options, + getMetaHubStartup: () => this.metaHub.startup, + })) + this.directory = this.register(new LoopalSessionDirectory( + this.registry, + this.now, + workspace.name, + { + event: (event) => { + if (event.type === 'host_status') this.hostStatus = event.status + if (event.type === 'runtime_updated' && event.runtime.state === 'crashed') { + void this.workspaces.stopped(event.runtime.sessionId).catch(() => undefined) + } + this.events.fire(event) + }, + service: (event) => this.services.accept(event), + }, + )) + this.sessionDirectories = new SessionDirectoryAuthority( + options.sessionDirectoryRequest ?? createSessionDirectoryCommand(options.binaryPath), + ) + this.services = new LoopalBackendServices({ + workspace: (workspaceId) => this.workspaceRuntime(workspaceId), + liveSession: (sessionId) => this.liveSessionRuntime(sessionId), + }, this.directory, (event) => this.events.fire(event)) + Object.assign(this, this.services.operations()) + Object.assign(this, this.metaHub.operations(this.directory, this.now)) + Object.assign(this, bindDesktopPreferences(this.preferences)) + } + bootstrap(): Promise { + if (this.bootstrapped) return Promise.resolve(this.snapshot()) + this.bootstrapping ??= this.bootstrapInner() + return this.bootstrapping + } + async openSession(sessionId: string): Promise { + await this.ensureBootstrapped() + const live = this.directory.liveSession(sessionId) + if (live) await live.initialize() + const detail = this.directory.detail(sessionId) + if (!detail) throw unknownSession(sessionId) + this.activeSessionId = sessionId + await this.workspaces.select(sessionId) + return detail + } + async createSession(input: CreateSessionInput): Promise { + await this.ensureBootstrapped() + const detail = await createLoopalSession(input, { + directories: this.sessionDirectories, registry: this.registry, + directory: this.directory, workspaces: this.workspaces, + }) + this.activeSessionId = detail.session.id + return detail + } + async authorizeSessionDirectory(path: string) { + return this.sessionDirectories.authorize(path) + } + async stopSession(sessionId: string): Promise { + await this.ensureBootstrapped() + if (!this.directory.session(sessionId)) throw unknownSession(sessionId) + await this.workspaces.stopped(sessionId) + const runtime = this.directory.runtimeForSession(sessionId) + if (!runtime) return + await this.registry.stop(runtime.runtimeId) + } + async restartSession(sessionId: string): Promise { + await this.ensureBootstrapped() + const session = this.directory.session(sessionId) + if (!session) throw unknownSession(sessionId) + if (!canRestartSession(session)) throw archivedSession(sessionId) + const current = this.directory.runtimeForSession(sessionId) + const runtime = current + ? await this.registry.restart(current.runtimeId) + : await this.registry.resume({ + workspaceId: session.workspaceId, + cwd: this.workspaces.cwd(sessionId, session.workspaceId), + sessionId, + }) + await this.directory.attach(runtime, true) + this.activeSessionId = sessionId + await this.workspaces.started(runtime, this.directory.session(sessionId), true) + return this.directory.runtime(runtime.runtimeId)! + } + async sendMessage( + sessionId: string, text: string, _token?: CancellationToken, agentId = 'main', + images: readonly DesktopImageAttachment[] = [], + ): Promise { + await this.ensureBootstrapped() + const state = this.directory.liveSession(sessionId) + if (!state) { + if (!this.directory.session(sessionId)) throw unknownSession(sessionId) + throw stoppedSession(sessionId) + } + await state.send(text, agentId, images) + } + async shutdown(): Promise { + await Promise.all([this.workspaces.flush(), this.metaHub.flush(), this.preferences.flush()]) + try { await this.registry.shutdownAll() } + finally { await this.metaHub.stop() } + } + private async bootstrapInner(): Promise { + await Promise.all([this.metaHub.load(), this.preferences.getDesktopPreferences()]) + await this.workspaces.load(this.directory) + const resume = this.workspaces.bootstrapTarget + const { runtime, activeSessionId } = await bootstrapRuntime({ + registry: this.registry, directory: this.directory, resumeState: this.workspaces.lifecycle, + workspaceId: resume.workspaceId, cwd: resume.cwd, + ...(resume.resumeSessionId ? { resumeSessionId: resume.resumeSessionId } : {}), + }) + this.activeSessionId = activeSessionId + this.hostStatus = runtime.host.currentStatus + this.bootstrapped = true + return this.snapshot() + } + private snapshot(): WorkbenchBootstrap { + return backendSnapshot({ + hostStatus: this.hostStatus, workspaces: this.workspaces.values(), + sessions: this.directory.sessionValues(), runtimes: this.directory.runtimeValues(), + ...(this.activeSessionId ? { activeSessionId: this.activeSessionId } : {}), + }) + } + private async workspaceRuntime(workspaceId: string): Promise { + await this.ensureBootstrapped() + this.workspaces.require(workspaceId) + const leader = this.directory.leaders.current(workspaceId) + if (leader) return leader + throw new Error(`Workspace has no live runtime; restart a session: ${workspaceId}`) + } + private async liveSessionRuntime(sessionId: string): Promise { + await this.ensureBootstrapped() + return this.directory.runtimeForSession(sessionId) + } + private async ensureBootstrapped(): Promise { if (!this.bootstrapped) await this.bootstrap() } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.test.ts new file mode 100644 index 00000000..ebca33fa --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type AgentControlCommand } from '../../../../shared/contracts' +import { FakeDesktopBackend } from './fake-backend' + +async function richTarget(backend: FakeDesktopBackend) { + const runtime = (await backend.bootstrap()).runtimes.find((candidate) => ( + candidate.sessionId === 'session-desktop' + ))! + return { + sessionId: runtime.sessionId, runtimeId: runtime.id, + generation: runtime.generation, agentId: runtime.rootAgent, + } +} + +describe('FakeAgentControl edges', () => { + it('projects thinking, rewind, and status commands', async () => { + const backend = new FakeDesktopBackend() + const target = await richTarget(backend) + const commands: AgentControlCommand[] = [ + { type: 'thinking', config: { type: 'auto' } }, + { type: 'thinking', config: { type: 'disabled' } }, + { type: 'thinking', config: { type: 'budget', tokens: 4_096 } }, + { type: 'rewind', turnIndex: 1 }, + { type: 'mcp_status' }, + ] + for (const command of commands) await backend.controlAgent({ target, command }) + const detail = await backend.openSession(target.sessionId) + expect(detail.agents[0]?.thinkingConfig).toBe('4096 tokens') + expect(detail.conversation).toHaveLength(1) + expect(detail.view?.goal).toMatchObject({ status: 'active' }) + backend.dispose() + }) + + it('leaves unknown resource IDs unchanged and tolerates sessions without a rich view', async () => { + const backend = new FakeDesktopBackend() + const target = await richTarget(backend) + for (const command of [ + { type: 'mcp_disconnect', server: 'missing' }, + { type: 'background_task_kill', id: 'missing' }, + { type: 'cron_delete', id: 'missing' }, + ] as const) await backend.controlAgent({ target, command }) + const detail = await backend.openSession(target.sessionId) + expect(detail.view?.mcpServers[0]?.status).toBe('ready') + expect(detail.view?.backgroundTasks[0]?.status).toBe('running') + expect(detail.view?.crons).toHaveLength(1) + + backend.dispose() + }) + + it('rejects every malformed live identity and cancellation', async () => { + const backend = new FakeDesktopBackend() + const target = await richTarget(backend) + for (const stale of [ + { ...target, sessionId: 'missing' }, + { ...target, runtimeId: 'missing' }, + { ...target, generation: target.generation + 1 }, + ]) await expect(backend.controlAgent({ + target: stale, command: { type: 'clear' }, + })).rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + await expect(backend.controlAgent( + { target, command: { type: 'clear' } }, CancellationToken.Cancelled, + )).rejects.toThrow('cancelled') + backend.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.ts new file mode 100644 index 00000000..1ed498d5 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-agent-control.ts @@ -0,0 +1,138 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type AgentControlCommand, + type AgentControlInput, + type AgentControlTarget, + type DesktopEvent, + type SessionDetail, +} from '../../../../shared/contracts' +import { type AgentControlOperations } from '../attention/loopal-agent-control' +import { type FakeSessionCatalog } from './fake-session-fixtures' + +export function bindFakeAgentControl( + catalog: FakeSessionCatalog, + now: () => string, + fire: (event: DesktopEvent) => void, +): AgentControlOperations { + const service = new FakeAgentControl(catalog, now, fire) + return { + interruptAgent: service.interruptAgent.bind(service), + controlAgent: service.controlAgent.bind(service), + } +} + +class FakeAgentControl implements AgentControlOperations { + constructor( + private readonly catalog: FakeSessionCatalog, + private readonly now: () => string, + private readonly fire: (event: DesktopEvent) => void, + ) {} + + async interruptAgent( + target: AgentControlTarget, + token = CancellationToken.None, + ): Promise { + const { detail, agent } = this.resolve(target, token) + agent.status = 'waiting' + this.settle(detail) + detail.session = { ...detail.session, status: 'waiting', updatedAt: this.now() } + this.publish(detail) + } + + async controlAgent( + input: AgentControlInput, + token = CancellationToken.None, + ): Promise { + const { detail, agent } = this.resolve(input.target, token) + this.apply(detail, agent, input.command) + detail.session = { ...detail.session, updatedAt: this.now() } + this.publish(detail) + } + + private resolve(target: AgentControlTarget, token: CancellationToken) { + throwIfCancelled(token) + const detail = this.catalog.details.get(target.sessionId) + const runtime = this.catalog.runtimes.get(target.runtimeId) + if (!detail || !runtime || detail.session.activeRuntimeId !== target.runtimeId + || runtime.sessionId !== target.sessionId || runtime.generation !== target.generation) { + throw taggedError('RUNTIME_GONE', `Session runtime is gone: ${target.sessionId}`) + } + const agent = detail.agents.find((candidate) => candidate.id === target.agentId) + if (!agent) throw taggedError('AGENT_GONE', `Agent is not in this runtime: ${target.agentId}`) + return { detail, agent } + } + + private apply( + detail: SessionDetail, + agent: SessionDetail['agents'][number], + command: AgentControlCommand, + ): void { + if (command.type === 'mode') { + agent.mode = command.mode + detail.session = { ...detail.session, mode: command.mode } + } else if (command.type === 'clear') { + detail.conversation = [] + this.settle(detail) + } else if (command.type === 'compact' && detail.view) { + detail.view = { + ...detail.view, + compactBanner: command.instructions + ? `Summarizing conversation context: ${command.instructions}` + : 'Summarizing conversation context.', + } + } else if (command.type === 'model') { + agent.model = command.model + detail.session = { ...detail.session, model: command.model } + } else if (command.type === 'rewind') { + detail.conversation = detail.conversation.slice(0, command.turnIndex) + } else if (command.type === 'thinking') { + agent.thinkingConfig = thinkingLabel(command.config) + } else if (command.type === 'permission') agent.permissionMode = command.mode + else if (command.type === 'decision') agent.decisionMode = command.mode + else if (command.type === 'sandbox') agent.sandboxPolicy = command.policy + else if (command.type === 'suspend') { + agent.status = 'suspended' + this.settle(detail) + } + else if (command.type === 'unsuspend') agent.status = 'waiting' + else this.applyResource(detail, command) + } + + private applyResource(detail: SessionDetail, command: AgentControlCommand): void { + const view = detail.view + if (!view) return + if (command.type === 'mcp_reconnect' || command.type === 'mcp_disconnect') { + view.mcpServers = view.mcpServers.map((server) => server.name === command.server + ? { ...server, status: command.type === 'mcp_reconnect' ? 'ready' : 'disconnected' } + : server) + } else if (command.type === 'background_task_kill') { + view.backgroundTasks = view.backgroundTasks.map((task) => task.id === command.id + ? { ...task, status: 'killed' as const } + : task) + } else if (command.type === 'cron_delete') { + view.crons = view.crons.filter((cron) => cron.id !== command.id) + } + } + + private publish(detail: SessionDetail): void { + this.fire({ type: 'session_updated', session: { ...detail.session } }) + this.fire({ type: 'session_detail_replaced', detail: structuredClone(detail) }) + } + + private settle(detail: SessionDetail): void { + if (detail.view) detail.view = { + ...detail.view, streamingText: '', streamingThinking: '', thinkingActive: false, + compactBanner: null, + } + } +} + +function thinkingLabel(config: Extract['config']): string { + if (config.type === 'effort') return config.level + if (config.type === 'budget') return `${config.tokens} tokens` + return config.type +} + +function taggedError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-preferences.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-preferences.test.ts new file mode 100644 index 00000000..4c33c857 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-preferences.test.ts @@ -0,0 +1,31 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { FakeDesktopBackend, type FakeBackendClock } from './fake-backend' + +const clock: FakeBackendClock = { + now: () => new Date('2026-07-12T00:00:00.000Z'), + delay: async () => undefined, +} + +describe('FakeDesktopBackend preferences', () => { + let root: string | undefined + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }) + }) + + it('persists Desktop preferences when configured with a user data path', async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-desktop-fake-preferences-')) + const path = join(root, 'desktop-preferences.json') + const first = new FakeDesktopBackend(clock, path) + expect(await first.getDesktopPreferences()).toEqual({ locale: 'system' }) + await first.updateDesktopPreferences({ locale: 'zh-CN' }) + first.dispose() + + const restored = new FakeDesktopBackend(clock, path) + expect(await restored.getDesktopPreferences()).toEqual({ locale: 'zh-CN' }) + restored.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-types.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-types.ts new file mode 100644 index 00000000..15330bcb --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend-types.ts @@ -0,0 +1,26 @@ +import { type CodeWorkbenchOperations } from '../workspace/loopal-code-workbench' +import { type AgentControlOperations } from '../attention/loopal-agent-control' +import { type FakeMetaHubOperations } from './fake-metahub' +import { type LoopalSettingsOperations } from './fake-loopal-settings' +import { type LoopalMcpSettingsOperations } from '../settings/loopal-mcp-settings-service' +import { type DesktopPreferencesOperations } from '../settings/desktop-preferences-service' +import { type LoopalSkillPluginOperations } from '../settings/loopal-skill-plugin-service' + +export interface FakeBackendClock { + now(): Date + delay(milliseconds: number): Promise +} + +export const systemClock: FakeBackendClock = { + now: () => new Date(), + delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +} + +export interface FakeBackendOperations extends + CodeWorkbenchOperations, + AgentControlOperations, + FakeMetaHubOperations, + LoopalSettingsOperations, + LoopalMcpSettingsOperations, + LoopalSkillPluginOperations, + DesktopPreferencesOperations {} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.test.ts new file mode 100644 index 00000000..c6f440d6 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { FakeDesktopBackend, type FakeBackendClock } from './fake-backend' + +function clock(): FakeBackendClock { + return { + now: () => new Date('2026-07-11T12:00:00.000Z'), + delay: async () => undefined, + } +} + +describe('FakeDesktopBackend', () => { + it('supports its real system clock defaults', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-11T13:00:00.000Z')) + try { + const backend = new FakeDesktopBackend() + const pending = backend.sendMessage('session-desktop', 'Use the real clock adapter') + await vi.advanceTimersByTimeAsync(15) + await pending + const result = await backend.openSession('session-desktop') + expect(result.session.updatedAt).toBe('2026-07-11T13:00:00.015Z') + backend.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('returns deterministic split session and runtime catalogs', async () => { + const backend = new FakeDesktopBackend(clock()) + const bootstrap = await backend.bootstrap() + expect(bootstrap).toMatchObject({ protocolVersion: 2, activeSessionId: 'session-desktop' }) + expect(bootstrap.sessions).toHaveLength(3) + expect(bootstrap.runtimes).toHaveLength(2) + const complete = await backend.openSession('session-audit') + expect(complete.artifacts[0]).toMatchObject({ + sessionId: 'session-audit', title: 'Architecture findings.md', + }) + backend.dispose() + }) + + it('keeps conversations isolated and emits scoped entries', async () => { + const backend = new FakeDesktopBackend(clock()) + const protocolBefore = await backend.openSession('session-protocol') + const events: unknown[] = [] + backend.onEvent((event) => events.push(event)) + await backend.sendMessage('session-desktop', 'Add protocol tests') + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'conversation_entry', sessionId: 'session-desktop' }), + expect.objectContaining({ + type: 'artifact_created', artifact: expect.objectContaining({ + sessionId: 'session-desktop', producerAgentId: 'agent-root', + }), + }), + ])) + const desktop = await backend.openSession('session-desktop') + const protocolAfter = await backend.openSession('session-protocol') + expect(desktop.conversation.at(-1)?.role).toBe('assistant') + expect(protocolAfter.conversation).toEqual(protocolBefore.conversation) + backend.dispose() + }) + + it('routes messages and artifacts into a selected child conversation', async () => { + const backend = new FakeDesktopBackend(clock()) + const events: unknown[] = [] + backend.onEvent((event) => events.push(event)) + await backend.sendMessage( + 'session-desktop', 'Verify child routing', CancellationToken.None, 'agent-e2e', + ) + const detail = await backend.openSession('session-desktop') + const child = detail.agents.find((agent) => agent.id === 'agent-e2e') + expect(child?.conversation?.map((entry) => entry.text)).toEqual(expect.arrayContaining([ + 'Verify child routing', 'Loopal handled this message inside the selected session runtime.', + ])) + expect(events).toContainEqual(expect.objectContaining({ + type: 'artifact_created', artifact: expect.objectContaining({ + producerAgentId: 'agent-e2e', + }), + })) + await expect(backend.sendMessage( + 'session-desktop', 'missing', CancellationToken.None, 'retired-agent', + )).rejects.toThrow('Agent is not available') + backend.dispose() + }) + + it('creates, stops, and restarts with increasing generations', async () => { + const backend = new FakeDesktopBackend(clock()) + const selected = await backend.authorizeSessionDirectory(process.cwd()) + const created = await backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + expect(created.session).toMatchObject({ status: 'running', activeRuntimeId: expect.any(String) }) + await backend.stopSession(created.session.id) + const stopped = await backend.openSession(created.session.id) + expect(stopped.session.status).toBe('stopped') + expect(stopped.session.activeRuntimeId).toBeUndefined() + const second = await backend.restartSession(created.session.id) + const third = await backend.restartSession(created.session.id) + expect([second.generation, third.generation]).toEqual([2, 3]) + expect((await backend.openSession(created.session.id)).session.activeRuntimeId).toBe(third.id) + await backend.stopSession('session-audit') + backend.dispose() + }) + + it('rejects unknown sessions and cancellation', async () => { + const backend = new FakeDesktopBackend(clock()) + await expect(backend.openSession('missing')).rejects.toThrow('Session not found') + await expect(backend.sendMessage('missing', 'hello')).rejects.toThrow('Session not found') + await expect(backend.createSession({ authorizationId: '5d0c638c-d44c-4f47-818b-62e6b599e31c', launchMode: 'directory' })).rejects.toThrow('authorization') + const source = new CancellationTokenSource() + source.cancel() + await expect(backend.bootstrap(source.token)).rejects.toThrow('cancelled') + await expect(backend.openSession('session-desktop', source.token)).rejects.toThrow('cancelled') + backend.dispose() + }) + + it('observes cancellation after asynchronous work starts and returns clones', async () => { + let release: (() => void) | undefined + const backend = new FakeDesktopBackend({ + now: clock().now, + delay: () => new Promise((resolve) => { release = resolve }), + }) + const source = new CancellationTokenSource() + const pending = backend.sendMessage('session-desktop', 'cancel me', source.token) + source.cancel() + release?.() + await expect(pending).rejects.toThrow('cancelled') + const detail = await backend.openSession('session-desktop') + detail.conversation.length = 0 + expect((await backend.openSession('session-desktop')).conversation.length).toBeGreaterThan(0) + backend.dispose() + }) + + it('projects core control actions through authoritative detail events', async () => { + const backend = new FakeDesktopBackend(clock()) + const runtime = (await backend.bootstrap()).runtimes.find((item) => ( + item.sessionId === 'session-desktop' + ))! + const target = { + sessionId: runtime.sessionId, runtimeId: runtime.id, + generation: runtime.generation, agentId: runtime.rootAgent, + } + const events: unknown[] = [] + backend.onEvent((event) => events.push(event)) + await backend.controlAgent({ target, command: { type: 'mode', mode: 'plan' } }) + expect((await backend.openSession(target.sessionId)).agents[0]?.mode).toBe('plan') + await backend.controlAgent({ target, command: { type: 'compact' } }) + expect((await backend.openSession(target.sessionId)).view?.compactBanner) + .toBe('Summarizing conversation context.') + await backend.controlAgent({ target, command: { type: 'suspend' } }) + expect((await backend.openSession(target.sessionId)).agents[0]?.status).toBe('suspended') + await backend.controlAgent({ target, command: { type: 'unsuspend' } }) + await backend.interruptAgent(target) + expect((await backend.openSession(target.sessionId)).agents[0]?.status).toBe('waiting') + await backend.controlAgent({ target, command: { type: 'clear' } }) + expect((await backend.openSession(target.sessionId)).conversation).toEqual([]) + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'session_detail_replaced' }), + expect.objectContaining({ type: 'session_updated' }), + ])) + backend.dispose() + }) + + it('supports resource controls and rejects stale or unknown targets', async () => { + const backend = new FakeDesktopBackend(clock()) + const runtime = (await backend.bootstrap()).runtimes.find((item) => ( + item.sessionId === 'session-desktop' + ))! + const target = { + sessionId: runtime.sessionId, runtimeId: runtime.id, + generation: runtime.generation, agentId: runtime.rootAgent, + } + for (const command of [ + { type: 'model', model: 'gpt-5-mini' } as const, + { type: 'thinking', config: { type: 'effort', level: 'high' } } as const, + { type: 'permission', mode: 'ask_any_write' } as const, + { type: 'decision', mode: 'classifier' } as const, + { type: 'sandbox', policy: 'read_only' } as const, + { type: 'mcp_disconnect', server: 'filesystem' } as const, + { type: 'mcp_reconnect', server: 'filesystem' } as const, + { type: 'background_task_kill', id: 'bg-bazel' } as const, + { type: 'cron_delete', id: 'cron-health' } as const, + ]) await backend.controlAgent({ target, command }) + const detail = await backend.openSession(target.sessionId) + expect(detail.agents[0]).toMatchObject({ + model: 'gpt-5-mini', thinkingConfig: 'high', permissionMode: 'ask_any_write', + decisionMode: 'classifier', sandboxPolicy: 'read_only', + }) + expect(detail.view?.backgroundTasks[0]?.status).toBe('killed') + expect(detail.view?.crons).toEqual([]) + expect(detail.view?.mcpServers[0]?.status).toBe('ready') + await expect(backend.interruptAgent({ ...target, generation: 9 })) + .rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + await expect(backend.interruptAgent({ ...target, agentId: 'completed' })) + .rejects.toMatchObject({ code: 'AGENT_GONE' }) + backend.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.ts new file mode 100644 index 00000000..3e40d07b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-backend.ts @@ -0,0 +1,200 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { Emitter } from '../../../../base/common/event' +import { type IDisposable } from '../../../../base/common/lifecycle' +import { + type Artifact, type ConversationEntry, type CreateSessionInput, type DesktopEvent, + type DesktopImageAttachment, type RuntimeSummary, type SessionDetail, type WorkbenchBootstrap, + type SessionDirectorySelection, +} from '../../../../shared/contracts' +import { canRestartSession, isSessionLive } from '../../../../shared/contracts/session-lifecycle' +import { type DesktopBackend } from '../../common/backend' +import { bindFakeCodeWorkbench } from './fake-code-workbench' +import { createFakeSessionCatalog } from './fake-session-fixtures' +import { bindFakeAgentControl } from './fake-agent-control' +import { appendFakeAgentMessage, fakeProducerAgent } from './fake-message-target' +import { bindFakeMetaHub } from './fake-metahub' +import { bindFakeLoopalSettings } from './fake-loopal-settings' +import { bindFakeMcpSettings } from './fake-mcp-settings' +import { bindFakeSkillPlugins } from './fake-skill-plugin-settings' +import { bindDesktopPreferences, DesktopPreferencesService } from '../settings/desktop-preferences-service' +import { + type FakeBackendClock, type FakeBackendOperations, systemClock, +} from './fake-backend-types' +import { FakeSessionDirectoryAuthority } from './fake-session-directory' +import { LoopalWorkspaceCatalog } from '../workspace/loopal-workspace-catalog' +export { type FakeBackendClock } from './fake-backend-types' +export interface FakeDesktopBackend extends FakeBackendOperations {} +export class FakeDesktopBackend implements DesktopBackend, IDisposable { + private readonly emitter = new Emitter() + private readonly catalog + private sequence = 100 + private readonly directories = new FakeSessionDirectoryAuthority() + private readonly workspaces: LoopalWorkspaceCatalog + readonly onEvent = this.emitter.event + constructor( + private readonly clock: FakeBackendClock = systemClock, + desktopPreferencesPath?: string, + ) { + this.catalog = createFakeSessionCatalog(this.iso()) + this.workspaces = new LoopalWorkspaceCatalog(this.catalog.workspace) + Object.assign(this, bindFakeCodeWorkbench( + this.catalog.workspace.id, + (event) => this.emitter.fire(event), + )) + Object.assign(this, bindFakeAgentControl( + this.catalog, () => this.iso(), (event) => this.emitter.fire(event), + )) + Object.assign(this, bindFakeLoopalSettings(this.catalog.workspace.id)) + Object.assign(this, bindFakeMcpSettings(this.catalog.workspace.id)) + Object.assign(this, bindFakeSkillPlugins(this.catalog.workspace.id)) + Object.assign(this, bindDesktopPreferences( + new DesktopPreferencesService(desktopPreferencesPath), + )) + Object.assign(this, bindFakeMetaHub((target) => { + const runtime = this.catalog.runtimes.get(target.runtimeId) + return this.catalog.details.get(target.sessionId)?.session.activeRuntimeId === target.runtimeId + && runtime?.sessionId === target.sessionId + && runtime.generation === target.generation + && runtime.state === 'ready' + }, (target, state) => { + const detail = this.catalog.details.get(target.sessionId) + if (!detail) return + detail.metaHub = structuredClone(state) + this.emitter.fire({ type: 'session_detail_replaced', detail: structuredClone(detail) }) + })) + } + async bootstrap(token = CancellationToken.None): Promise { + throwIfCancelled(token) + return { + protocolVersion: 2, + hostStatus: 'ready', + workspaces: [...this.workspaces.values()], + sessions: [...this.catalog.details.values()].map(({ session }) => ({ ...session })), + runtimes: [...this.catalog.runtimes.values()].map((runtime) => ({ ...runtime })), + activeSessionId: 'session-desktop', + } + } + async openSession( + sessionId: string, token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + return structuredClone(this.requireDetail(sessionId)) + } + async createSession( + input: CreateSessionInput, token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const target = await this.directories.prepare(input) + const workspace = this.workspaces.ensure(target.path, target.kind) + const id = `session-${this.sequence++}` + const runtime = this.newRuntime(id, workspace.id, 1) + const detail: SessionDetail = { + session: { + id, workspaceId: workspace.id, title: 'New session', model: 'gpt-5', mode: 'agent', + status: 'running', createdAt: this.iso(), updatedAt: this.iso(), + activeRuntimeId: runtime.id, + }, + conversation: [], agents: [{ id: runtime.rootAgent, name: 'Loopal', status: 'running' }], artifacts: [], + } + this.catalog.details.set(id, detail) + this.catalog.runtimes.set(runtime.id, runtime) + this.emitter.fire({ type: 'session_updated', session: { ...detail.session } }) + this.emitter.fire({ type: 'runtime_updated', runtime: { ...runtime } }) + return structuredClone(detail) + } + authorizeSessionDirectory(path: string): Promise { + return this.directories.authorize(path) + } + async stopSession(sessionId: string, token = CancellationToken.None): Promise { + throwIfCancelled(token) + const detail = this.requireDetail(sessionId) + const runtime = this.activeRuntime(detail) + if (runtime) this.updateRuntime(runtime, 'stopped') + const { activeRuntimeId: _active, ...session } = detail.session + detail.session = { ...session, status: 'stopped', updatedAt: this.iso() } + this.publishSession(detail) + } + async restartSession( + sessionId: string, token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const detail = this.requireDetail(sessionId) + if (!canRestartSession(detail.session)) throw new Error(`Archived session cannot be restarted: ${sessionId}`) + const active = this.activeRuntime(detail) + if (active) this.updateRuntime(active, 'stopped') + const generation = Math.max(0, ...[...this.catalog.runtimes.values()] + .filter((runtime) => runtime.sessionId === sessionId) + .map((runtime) => runtime.generation)) + 1 + const runtime = this.newRuntime(sessionId, detail.session.workspaceId, generation) + this.catalog.runtimes.set(runtime.id, runtime) + detail.session = { + ...detail.session, activeRuntimeId: runtime.id, + status: 'running', updatedAt: this.iso(), attention: undefined, + } + this.emitter.fire({ type: 'runtime_updated', runtime: { ...runtime } }) + this.publishSession(detail) + return { ...runtime } + } + async sendMessage( + sessionId: string, text: string, + token = CancellationToken.None, agentId = 'main', + images: readonly DesktopImageAttachment[] = [], + ): Promise { + throwIfCancelled(token) + const detail = this.requireDetail(sessionId) + if (!isSessionLive(detail.session)) throw new Error(`Session is not running; restart it first: ${sessionId}`) + const publish = (entry: ConversationEntry): void => { + if (appendFakeAgentMessage(detail, agentId, entry)) this.publishEntry(detail, entry) + else this.emitter.fire({ type: 'session_detail_replaced', detail: structuredClone(detail) }) + } + publish({ ...this.entry('user', text), ...(images.length ? { imageCount: images.length } : {}) }) + await this.clock.delay(15) + throwIfCancelled(token) + publish(this.entry( + 'assistant', 'Loopal handled this message inside the selected session runtime.', + )) + const artifact: Artifact = { + id: `artifact-${this.sequence++}`, sessionId, title: 'Execution summary.md', + kind: 'report', uri: `loopal-artifact://${sessionId}/execution-summary.md`, + mediaType: 'text/markdown', producerAgentId: fakeProducerAgent(detail, agentId), + createdAt: this.iso(), + } + detail.artifacts.push(artifact) + detail.session = { ...detail.session, updatedAt: this.iso() } + this.emitter.fire({ type: 'artifact_created', artifact }) + this.publishSession(detail) + } + dispose(): void { this.emitter.dispose() } + private requireDetail(sessionId: string): SessionDetail { + const detail = this.catalog.details.get(sessionId) + if (!detail) throw new Error(`Session not found: ${sessionId}`) + return detail + } + private activeRuntime(detail: SessionDetail): RuntimeSummary | undefined { + return detail.session.activeRuntimeId ? this.catalog.runtimes.get( + detail.session.activeRuntimeId, + ) : undefined + } + private newRuntime(sessionId: string, workspaceId: string, generation: number): RuntimeSummary { + return { + id: `${sessionId}-runtime-${generation}`, sessionId, workspaceId, generation, + state: 'ready', rootAgent: 'agent-root', startedAt: this.iso(), + } + } + private updateRuntime(runtime: RuntimeSummary, state: RuntimeSummary['state']): void { + const next = { ...runtime, state } + this.catalog.runtimes.set(next.id, next) + this.emitter.fire({ type: 'runtime_updated', runtime: next }) + } + private publishSession(detail: SessionDetail): void { + this.emitter.fire({ type: 'session_updated', session: { ...detail.session } }) + } + private publishEntry(detail: SessionDetail, entry: ConversationEntry): void { + detail.conversation.push(entry) + this.emitter.fire({ type: 'conversation_entry', sessionId: detail.session.id, entry }) + } + private entry(role: ConversationEntry['role'], text: string): ConversationEntry { + return { id: `message-${this.sequence++}`, role, text, createdAt: this.iso() } + } + private iso(): string { return this.clock.now().toISOString() } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.test.ts new file mode 100644 index 00000000..b761d1e2 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.test.ts @@ -0,0 +1,50 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { FakeDesktopBackend } from './fake-backend' + +describe('Fake code workbench binding', () => { + it('exposes workspace services through DesktopBackend', async () => { + const backend = new FakeDesktopBackend() + const bootstrap = await backend.bootstrap() + const workspaceId = bootstrap.workspaces[0]!.id + const listing = await backend.listDirectory({ workspaceId, path: '' }, CancellationToken.None) + expect(listing.entries.map((entry) => entry.name)).toContain('src') + const file = await backend.readFile({ workspaceId, path: 'README.md' }, CancellationToken.None) + await backend.writeFile({ + workspaceId, path: 'README.md', content: '# Changed', expectedVersion: file.version, + }, CancellationToken.None) + await backend.gitStage({ workspaceId, path: 'README.md' }, CancellationToken.None) + await expect(backend.gitStatus(workspaceId, CancellationToken.None)).resolves.toMatchObject({ + changes: [expect.objectContaining({ indexStatus: 'M' })], + }) + backend.dispose() + }) + + it('publishes permission and question resolutions', async () => { + const backend = new FakeDesktopBackend() + const events: unknown[] = [] + backend.onEvent((event) => events.push(event)) + await backend.respondPermission({ + sessionId: 'session-desktop', runtimeId: 'runtime-desktop-1', generation: 1, + agentId: 'worker', requestId: 'p', decision: 'allow_once', + }, CancellationToken.None) + await backend.respondQuestion({ + sessionId: 'session-protocol', runtimeId: 'runtime-protocol-1', generation: 1, + agentId: 'worker', requestId: 'q', answers: ['yes'], + }, CancellationToken.None) + expect(events).toEqual([ + { + type: 'permission_resolved', sessionId: 'session-desktop', + runtimeId: 'runtime-desktop-1', generation: 1, agentId: 'worker', requestId: 'p', + }, + { + type: 'question_resolved', sessionId: 'session-protocol', + runtimeId: 'runtime-protocol-1', generation: 1, agentId: 'worker', requestId: 'q', + }, + ]) + await expect(backend.respondPermission({ + sessionId: 'session-desktop', runtimeId: 'runtime-desktop-1', generation: 1, + agentId: 'worker', requestId: 'late', decision: 'deny', + }, CancellationToken.Cancelled)).rejects.toThrow('Operation cancelled') + backend.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.ts new file mode 100644 index 00000000..5bb3c41f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-code-workbench.ts @@ -0,0 +1,48 @@ +import { throwIfCancelled } from '../../../../base/common/cancellation' +import { type DesktopEvent } from '../../../../shared/contracts' +import { type CodeWorkbenchOperations } from '../workspace/loopal-code-workbench' +import { FakeWorkspaceService } from './fake-workspace' + +export function bindFakeCodeWorkbench( + workspaceId: string, + emit: (event: DesktopEvent) => void, +): CodeWorkbenchOperations { + const workspace = new FakeWorkspaceService(workspaceId, emit) + return { + listDirectory: workspace.listDirectory.bind(workspace), + readFile: workspace.readFile.bind(workspace), + writeFile: workspace.writeFile.bind(workspace), + searchWorkspace: workspace.search.bind(workspace), + gitStatus: workspace.gitStatus.bind(workspace), + gitDiff: workspace.gitDiff.bind(workspace), + gitStage: workspace.gitStage.bind(workspace), + gitUnstage: workspace.gitUnstage.bind(workspace), + listWorktrees: workspace.listWorktrees.bind(workspace), + createWorktree: workspace.createWorktree.bind(workspace), + removeWorktree: workspace.removeWorktree.bind(workspace), + respondPermission: async (input, token) => { + throwIfCancelled(token) + emit({ + type: 'permission_resolved', sessionId: input.sessionId, + runtimeId: input.runtimeId, generation: input.generation, + agentId: input.agentId, requestId: input.requestId, + }) + }, + respondQuestion: async (input, token) => { + throwIfCancelled(token) + emit({ + type: 'question_resolved', sessionId: input.sessionId, + runtimeId: input.runtimeId, generation: input.generation, + agentId: input.agentId, requestId: input.requestId, + }) + }, + respondPlanApproval: async (input, token) => { + throwIfCancelled(token) + emit({ + type: 'plan_approval_resolved', sessionId: input.sessionId, + runtimeId: input.runtimeId, generation: input.generation, + agentId: input.agentId, requestId: input.requestId, + }) + }, + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-lifecycle.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-lifecycle.test.ts new file mode 100644 index 00000000..da6a1cd8 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-lifecycle.test.ts @@ -0,0 +1,24 @@ +import { type SessionDetail } from '../../../../shared/contracts' +import { FakeDesktopBackend } from './fake-backend' + +describe('FakeDesktopBackend lifecycle parity', () => { + it('rejects sends after stop and restarts of archived sessions', async () => { + const backend = new FakeDesktopBackend() + const selected = await backend.authorizeSessionDirectory(process.cwd()) + const created = await backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + await backend.stopSession(created.session.id) + await expect(backend.sendMessage(created.session.id, 'must not run')) + .rejects.toThrow('restart it first') + + const internals = backend as unknown as { + catalog: { details: Map } + } + const detail = internals.catalog.details.get(created.session.id)! + detail.session = { ...detail.session, status: 'archived' } + await expect(backend.restartSession(created.session.id)) + .rejects.toThrow('Archived session') + backend.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.test.ts new file mode 100644 index 00000000..d9f5972b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { + bindFakeLoopalSettings, + bindUnavailableLoopalSettings, +} from './fake-loopal-settings' + +describe('fake Loopal settings boundaries', () => { + it('keeps typed defaults per workspace and validates updates', async () => { + const service = bindFakeLoopalSettings('workspace') + const before = await service.getLoopalSettings('workspace', CancellationToken.None) + expect(before.settings.model).toBe('claude-opus-4-8') + const settings = { ...before.settings, model: 'updated' } + await expect(service.updateLoopalSettings({ workspaceId: 'workspace', settings }, + CancellationToken.None)).resolves.toMatchObject({ settings: { model: 'updated' } }) + await expect(service.getLoopalSettings('workspace', CancellationToken.None)).resolves + .toMatchObject({ settings: { model: 'updated' } }) + const provider = await service.updateLoopalSettings({ + workspaceId: 'workspace', settings, + providerUpdates: { anthropic: { + enabled: true, baseUrl: 'https://api.example.test/v1', + apiKeyEnv: 'ANTHROPIC_KEY', apiKey: 'write-only-test-value', + } }, + }, CancellationToken.None) + expect(provider.providers.anthropic).toEqual({ + enabled: true, baseUrl: 'https://api.example.test/v1', + apiKeyEnv: 'ANTHROPIC_KEY', apiKeyConfigured: true, + }) + expect(JSON.stringify(provider)).not.toContain('write-only-test-value') + const cleared = await service.updateLoopalSettings({ + workspaceId: 'workspace', settings, + providerUpdates: { anthropic: { clearApiKey: true } }, + }, CancellationToken.None) + expect(cleared.providers.anthropic.apiKeyConfigured).toBe(false) + await expect(service.getLoopalSettings('other', CancellationToken.None)).rejects + .toThrow('Unknown workspace') + await expect(service.updateLoopalSettings({ + workspaceId: 'workspace', settings: { ...settings, microcompactIdleMinutes: 1441 }, + }, CancellationToken.None)).rejects.toThrow() + await expect(service.getLoopalSettings('workspace', CancellationToken.Cancelled)).rejects + .toThrow('cancelled') + }) + + it('fails closed when the bundled Host is unavailable', async () => { + const service = bindUnavailableLoopalSettings('sidecar missing') + await expect(service.getLoopalSettings('workspace', CancellationToken.None)).rejects + .toThrow('sidecar missing') + const fallback = bindFakeLoopalSettings('workspace') + const current = await fallback.getLoopalSettings('workspace', CancellationToken.None) + await expect(service.updateLoopalSettings({ + workspaceId: 'workspace', settings: current.settings, + }, CancellationToken.None)).rejects.toThrow('sidecar missing') + }) + + it('models built-in and compatible provider override lifecycles', async () => { + const service = bindFakeLoopalSettings('workspace') + const initial = await service.getLoopalSettings('workspace', CancellationToken.None) + const update = (providerUpdates: NonNullable[0]['providerUpdates']>) => service.updateLoopalSettings({ + workspaceId: 'workspace', settings: initial.settings, providerUpdates, + }, CancellationToken.None) + + const created = await update({ + anthropic: { enabled: true }, + openai: { + baseUrl: 'https://openai.example.test/v1', apiKeyEnv: 'OPENAI_TEST_KEY', + apiKey: 'private-openai-key', + }, + google: { enabled: false }, + openaiCompatible: [{ + name: 'custom', baseUrl: 'https://custom.example.test/v1', + apiKeyEnv: 'CUSTOM_KEY', modelPrefix: 'custom/', apiKey: 'private-custom-key', + }], + }) + expect(created.providers).toMatchObject({ + anthropic: { enabled: true }, + openai: { enabled: true, apiKeyConfigured: true }, + google: { enabled: false }, + }) + expect(created.openaiCompatible[0]).toEqual({ + name: 'custom', baseUrl: 'https://custom.example.test/v1', + apiKeyEnv: 'CUSTOM_KEY', modelPrefix: 'custom/', apiKeyConfigured: true, + }) + expect(JSON.stringify(created)).not.toContain('private-') + + const changed = await update({ + anthropic: { remove: true }, + openai: { clearApiKey: true }, + google: { enabled: true }, + openaiCompatible: [ + { name: 'custom', clearApiKey: true }, + { name: 'second', baseUrl: 'https://second.example.test/v1' }, + { name: 'missing', remove: true }, + ], + }) + expect(changed.providers).toMatchObject({ + anthropic: { enabled: false }, + openai: { enabled: true, apiKeyConfigured: false }, + google: { enabled: true }, + }) + expect(changed.openaiCompatible).toEqual([ + expect.objectContaining({ name: 'custom', apiKeyConfigured: false }), + expect.objectContaining({ name: 'second', apiKeyConfigured: false }), + ]) + + const removed = await update({ + openai: { enabled: false }, + openaiCompatible: [{ name: 'custom', remove: true }], + }) + expect(removed.providers.openai.enabled).toBe(false) + expect(removed.openaiCompatible.map((item) => item.name)).toEqual(['second']) + expect(removed.configuredProviders).toContain('openai-compatible: second') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.ts new file mode 100644 index 00000000..d60678ce --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-loopal-settings.ts @@ -0,0 +1,140 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + UpdateLoopalSettingsInputSchema, + type LoopalBuiltInProviders, + type LoopalDefaultSettings, + type LoopalOpenAiCompatibleSettings, + type LoopalProviderUpdates, +} from '../../../../shared/contracts' +import { type LoopalSettingsOperations } from '../settings/loopal-settings-service' +export type { LoopalSettingsOperations } from '../settings/loopal-settings-service' + +export function bindFakeLoopalSettings(workspaceId: string): LoopalSettingsOperations { + let value: LoopalDefaultSettings = { + workspaceId, + settings: { + model: 'claude-opus-4-8', modelRouting: emptyRouting(), + permissionMode: 'bypass', decisionMode: 'manual', + sandboxPolicy: 'default_write', thinking: { type: 'auto' }, maxContextTokens: 0, + memoryEnabled: true, microcompactIdleMinutes: 60, + telemetryEnabled: true, outputStyle: '', + }, + configuredProviders: ['test-provider'], + providers: emptyProviders(), + openaiCompatible: [], + resolvedEntries: [{ key: 'model', value: 'claude-opus-4-8' }], + settingSources: ['project local overrides'], + } + const requireWorkspace = (input: string): void => { + if (input !== workspaceId) throw new Error(`Unknown workspace: ${input}`) + } + return { + getLoopalSettings: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + requireWorkspace(input) + return structuredClone(value) + }, + updateLoopalSettings: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = UpdateLoopalSettingsInputSchema.parse(input) + requireWorkspace(parsed.workspaceId) + const providers = applyProviders(value.providers, parsed.providerUpdates ?? {}) + const openaiCompatible = applyCompatible( + value.openaiCompatible, parsed.providerUpdates?.openaiCompatible ?? [], + ) + value = { + ...value, settings: parsed.settings, providers, openaiCompatible, + configuredProviders: [ + 'test-provider', + ...Object.entries(providers).filter(([, item]) => item.enabled).map(([name]) => name), + ...openaiCompatible.map((item) => `openai-compatible: ${item.name}`), + ], + resolvedEntries: resolvedEntries(parsed.settings), + } + return structuredClone(value) + }, + } +} + +function applyCompatible( + current: LoopalOpenAiCompatibleSettings[], + updates: NonNullable, +): LoopalOpenAiCompatibleSettings[] { + const next = structuredClone(current) + for (const update of updates) { + const index = next.findIndex((item) => item.name === update.name) + if (update.remove) { + if (index >= 0) next.splice(index, 1) + continue + } + const item = index >= 0 ? next[index]! : { + name: update.name, baseUrl: '', apiKeyEnv: '', modelPrefix: '', apiKeyConfigured: false, + } + const changed = { + ...item, + baseUrl: update.baseUrl ?? item.baseUrl, + apiKeyEnv: update.apiKeyEnv ?? item.apiKeyEnv, + modelPrefix: update.modelPrefix ?? item.modelPrefix, + apiKeyConfigured: update.apiKey !== undefined + ? true : update.clearApiKey ? false : item.apiKeyConfigured, + } + if (index >= 0) next[index] = changed + else next.push(changed) + } + return next +} + +function emptyRouting() { + return { default: '', summarization: '', classification: '', refine: '' } +} + +function emptyProviders(): LoopalBuiltInProviders { + const empty = () => ({ enabled: false, baseUrl: '', apiKeyEnv: '', apiKeyConfigured: false }) + return { anthropic: empty(), openai: empty(), google: empty() } +} + +function applyProviders( + current: LoopalBuiltInProviders, + updates: LoopalProviderUpdates, +): LoopalBuiltInProviders { + const next = structuredClone(current) + for (const name of ['anthropic', 'openai', 'google'] as const) { + const update = updates[name] + if (!update) continue + if (update.remove || update.enabled === false) { + next[name] = { enabled: false, baseUrl: '', apiKeyEnv: '', apiKeyConfigured: false } + continue + } + const item = next[name] + const changes = update.baseUrl !== undefined || update.apiKeyEnv !== undefined + || update.apiKey !== undefined || update.clearApiKey + next[name] = { + enabled: update.enabled ?? (changes ? true : item.enabled), + baseUrl: update.baseUrl ?? item.baseUrl, + apiKeyEnv: update.apiKeyEnv ?? item.apiKeyEnv, + apiKeyConfigured: update.apiKey !== undefined + ? true : update.clearApiKey ? false : item.apiKeyConfigured, + } + } + return next +} + +function resolvedEntries(settings: LoopalDefaultSettings['settings']) { + return [ + { key: 'model', value: settings.model }, + ...Object.entries(settings.modelRouting).map(([key, value]) => ({ + key: `model_routing.${key}`, value: value || '(default)', + })), + ] +} + +export function bindUnavailableLoopalSettings(reason: string): LoopalSettingsOperations { + const fail = (token: CancellationToken): never => { + throwIfCancelled(token) + throw new Error(reason) + } + return { + getLoopalSettings: async (_workspaceId, token) => fail(token), + updateLoopalSettings: async (_input, token) => fail(token), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.test.ts new file mode 100644 index 00000000..b4ac8d51 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { bindFakeMcpSettings, bindUnavailableMcpSettings } from './fake-mcp-settings' + +describe('fake MCP Settings', () => { + it('models secret status and disabled definitions without retaining values', async () => { + const service = bindFakeMcpSettings('workspace') + const token = CancellationToken.None + const server = { + type: 'stdio' as const, name: 'local', command: 'node', args: [], enabled: false, + timeoutMs: 30_000, sharing: 'hub-singleton' as const, cwdIsolation: null, + secretPatches: [{ + target: 'env' as const, name: 'TOKEN', operation: 'set' as const, value: 'secret', + }], + } + const created = await service.upsertMcpServer({ workspaceId: 'workspace', server }, token) + expect(created.servers[0]).toMatchObject({ enabled: false, env: [{ + name: 'TOKEN', configured: true, + }] }) + expect(JSON.stringify(created)).not.toContain('secret') + const removed = await service.upsertMcpServer({ + workspaceId: 'workspace', server: { + ...server, enabled: true, + secretPatches: [{ target: 'env', name: 'TOKEN', operation: 'remove' }], + }, + }, token) + expect(removed.servers[0]).toMatchObject({ enabled: true, env: [] }) + await expect(service.deleteMcpServer({ + workspaceId: 'outside', name: 'local', + }, token)).rejects.toThrow('Unknown workspace') + await expect(service.deleteMcpServer({ + workspaceId: 'workspace', name: 'local', + }, token)).resolves.toMatchObject({ servers: [] }) + }) + + it('models HTTP headers, immutable reads, and unavailable operations', async () => { + const service = bindFakeMcpSettings('workspace') + const server = { + type: 'streamable-http' as const, name: 'remote', url: 'https://mcp.example.test/api', + enabled: true, timeoutMs: 5_000, sharing: 'per-agent' as const, + secretPatches: [{ + target: 'header' as const, name: 'Authorization', operation: 'set' as const, + value: 'Bearer private', + }], + } + const created = await service.upsertMcpServer( + { workspaceId: 'workspace', server }, CancellationToken.None, + ) + expect(created.servers[0]).toMatchObject({ + type: 'streamable-http', source: 'local', + headers: [{ name: 'Authorization', configured: true }], + }) + expect(JSON.stringify(created)).not.toContain('Bearer private') + + created.servers.length = 0 + expect(await service.listMcpServers('workspace', CancellationToken.None)) + .toHaveProperty('servers.length', 1) + const updated = await service.upsertMcpServer({ + workspaceId: 'workspace', server: { + ...server, + secretPatches: [ + { target: 'header', name: 'Authorization', operation: 'remove' }, + { target: 'header', name: 'X-Token', operation: 'set', value: 'replacement' }, + ], + }, + }, CancellationToken.None) + expect(updated.servers[0]).toMatchObject({ + headers: [{ name: 'X-Token', configured: true }], + }) + await expect(service.listMcpServers('other', CancellationToken.None)) + .rejects.toThrow('Unknown workspace') + + const unavailable = bindUnavailableMcpSettings('settings offline') + await expect(unavailable.listMcpServers('workspace', CancellationToken.None)) + .rejects.toThrow('settings offline') + await expect(unavailable.upsertMcpServer( + { workspaceId: 'workspace', server }, CancellationToken.Cancelled, + )).rejects.toThrow('cancelled') + await expect(unavailable.deleteMcpServer( + { workspaceId: 'workspace', name: 'remote' }, CancellationToken.None, + )).rejects.toThrow('settings offline') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.ts new file mode 100644 index 00000000..3a6087fc --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-mcp-settings.ts @@ -0,0 +1,73 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + DeleteMcpServerInputSchema, + UpsertMcpServerInputSchema, + type McpServerDefinition, + type McpServersResponse, +} from '../../../../shared/contracts' +import { type LoopalMcpSettingsOperations } from '../settings/loopal-mcp-settings-service' + +export function bindFakeMcpSettings(workspaceId: string): LoopalMcpSettingsOperations { + let servers: McpServerDefinition[] = [] + const response = (): McpServersResponse => ({ workspaceId, servers: structuredClone(servers) }) + const requireWorkspace = (input: string): void => { + if (input !== workspaceId) throw new Error(`Unknown workspace: ${input}`) + } + return { + listMcpServers: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + requireWorkspace(input) + return response() + }, + upsertMcpServer: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = UpsertMcpServerInputSchema.parse(input) + requireWorkspace(parsed.workspaceId) + const previous = servers.find((server) => server.name === parsed.server.name) + const statuses = applySecretPatches(previous, parsed.server.secretPatches) + let next: McpServerDefinition + if (parsed.server.type === 'stdio') { + const { secretPatches: _secretPatches, ...definition } = parsed.server + next = { ...definition, source: 'local', env: statuses } + } else { + const { secretPatches: _secretPatches, ...definition } = parsed.server + next = { ...definition, source: 'local', headers: statuses } + } + servers = [...servers.filter((server) => server.name !== next.name), next] + return response() + }, + deleteMcpServer: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = DeleteMcpServerInputSchema.parse(input) + requireWorkspace(parsed.workspaceId) + servers = servers.filter((server) => server.name !== parsed.name) + return response() + }, + } +} + +export function bindUnavailableMcpSettings(reason: string): LoopalMcpSettingsOperations { + const fail = (token: CancellationToken): never => { + throwIfCancelled(token) + throw new Error(reason) + } + return { + listMcpServers: async (_workspaceId, token) => fail(token), + upsertMcpServer: async (_input, token) => fail(token), + deleteMcpServer: async (_input, token) => fail(token), + } +} + +function applySecretPatches( + previous: McpServerDefinition | undefined, + patches: readonly { name: string; operation: 'set' | 'remove' }[], +) { + const current = previous?.type === 'stdio' ? previous.env + : previous?.type === 'streamable-http' ? previous.headers : [] + const names = new Set(current.filter((secret) => secret.configured).map((secret) => secret.name)) + for (const patch of patches) { + if (patch.operation === 'set') names.add(patch.name) + else names.delete(patch.name) + } + return [...names].sort().map((name) => ({ name, configured: true })) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.test.ts new file mode 100644 index 00000000..fc01d21f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.test.ts @@ -0,0 +1,23 @@ +import { richDetail, richTimestamp } from '../../../../../test/fixtures/workbench/rich-session' +import { appendFakeAgentMessage, fakeProducerAgent } from './fake-message-target' + +const entry = { + id: 'message', role: 'user' as const, text: 'hello', createdAt: richTimestamp, +} + +describe('fake message targeting', () => { + it('distinguishes root and child conversations', () => { + const detail = richDetail() + expect(appendFakeAgentMessage(detail, 'main', entry)).toBe(true) + expect(appendFakeAgentMessage(detail, 'agent-root', entry)).toBe(true) + expect(appendFakeAgentMessage(detail, 'agent-e2e', entry)).toBe(false) + expect(detail.agents[1]?.conversation).toContainEqual(entry) + delete detail.agents[1]!.conversation + expect(appendFakeAgentMessage(detail, 'agent-e2e', entry)).toBe(false) + expect(fakeProducerAgent(detail, 'main')).toBe('agent-root') + expect(fakeProducerAgent(detail, 'agent-e2e')).toBe('agent-e2e') + detail.agents.length = 0 + expect(fakeProducerAgent(detail, 'main')).toBe('main') + expect(() => appendFakeAgentMessage(detail, 'missing', entry)).toThrow('not available') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.ts new file mode 100644 index 00000000..9f9443b9 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-message-target.ts @@ -0,0 +1,22 @@ +import { type ConversationEntry, type SessionDetail } from '../../../../shared/contracts' + +export function appendFakeAgentMessage( + detail: SessionDetail, + requestedAgentId: string, + entry: ConversationEntry, +): boolean { + const root = detail.agents.find((agent) => !agent.parentId) + const target = requestedAgentId === 'main' + ? root + : detail.agents.find((agent) => agent.id === requestedAgentId) + if (!target) throw new Error(`Agent is not available: ${requestedAgentId}`) + if (!target.parentId) return true + target.conversation = [...target.conversation ?? [], entry] + return false +} + +export function fakeProducerAgent(detail: SessionDetail, requestedAgentId: string): string { + return requestedAgentId === 'main' + ? detail.agents.find((agent) => !agent.parentId)?.id ?? 'main' + : requestedAgentId +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.test.ts new file mode 100644 index 00000000..7a002e57 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.test.ts @@ -0,0 +1,108 @@ +import { FakeDesktopBackend } from './fake-backend' +import { bindFakeMetaHub } from './fake-metahub' + +const first = { sessionId: 'session-a', runtimeId: 'runtime-a', generation: 1 } +const second = { sessionId: 'session-b', runtimeId: 'runtime-b', generation: 2 } +const restarted = { sessionId: 'session-a', runtimeId: 'runtime-a-new', generation: 2 } + +describe('fake MetaHub membership', () => { + it('tracks exact runtime targets independently and publishes every disconnect on stop', async () => { + const live = new Set([key(first), key(second)]) + const publish = vi.fn() + const operations = bindFakeMetaHub((target) => live.has(key(target)), publish) + await operations.updateMetaHubSettings({ + address: 'meta:9', hubName: 'fake', joinOnStart: false, + startLocalOnLaunch: false, token: 'secret', + }) + + await operations.joinMetaHub({ ...first, hubName: 'fake-a' }) + await operations.joinMetaHub({ ...second, hubName: 'fake-b' }) + await expect(operations.getMetaHubStatus(first)).resolves.toMatchObject({ + state: 'connected', hubName: 'fake-a', + hubs: [expect.objectContaining({ name: 'fake-a' }), expect.objectContaining({ name: 'fake-b' })], + topology: [expect.objectContaining({ id: 'fake-a/main' }), + expect.objectContaining({ id: 'fake-b/main' })], + }) + await operations.disconnectMetaHub(first) + await expect(operations.getMetaHubStatus(first)).resolves.toMatchObject({ + state: 'disconnected', + }) + await expect(operations.getMetaHubStatus(second)).resolves.toMatchObject({ + state: 'connected', + }) + + await operations.joinMetaHub({ ...first, hubName: 'fake-a' }) + publish.mockClear() + await operations.stopLocalMetaHub() + expect(publish.mock.calls).toEqual(expect.arrayContaining([ + [first, expect.objectContaining({ state: 'disconnected' })], + [second, expect.objectContaining({ state: 'disconnected' })], + ])) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('uses the fake backend active ready generation as its authority', async () => { + const backend = new FakeDesktopBackend() + const runtimes = (await backend.bootstrap()).runtimes + const desktop = runtimeTarget(runtimes.find(({ sessionId }) => sessionId === 'session-desktop')!) + const protocol = runtimeTarget(runtimes.find(({ sessionId }) => sessionId === 'session-protocol')!) + await backend.startLocalMetaHub({ bindAddress: '127.0.0.1:0' }) + + await backend.joinMetaHub({ ...desktop, hubName: 'fake-desktop' }) + await backend.joinMetaHub({ ...protocol, hubName: 'fake-protocol' }) + await backend.disconnectMetaHub(desktop) + await expect(backend.getMetaHubStatus(desktop)).resolves.toMatchObject({ state: 'disconnected' }) + await expect(backend.getMetaHubStatus(protocol)).resolves.toMatchObject({ state: 'connected' }) + + await expect(backend.getMetaHubStatus({ ...desktop, generation: 99 })).rejects + .toThrow('runtime is gone') + const restarted = await backend.restartSession(desktop.sessionId) + await expect(backend.getMetaHubStatus(desktop)).rejects.toThrow('runtime is gone') + await expect(backend.getMetaHubStatus(runtimeTarget(restarted))).resolves + .toMatchObject({ state: 'disconnected' }) + await backend.stopSession(protocol.sessionId) + await expect(backend.getMetaHubStatus(protocol)).rejects.toThrow('runtime is gone') + backend.dispose() + }) + + it('prunes stopped generations before computing state or publishing', async () => { + const live = new Set([key(first), key(second)]) + const publish = vi.fn() + const operations = bindFakeMetaHub((target) => live.has(key(target)), publish) + await operations.updateMetaHubSettings({ + address: 'meta:9', hubName: 'fake', joinOnStart: false, + startLocalOnLaunch: false, token: 'secret', + }) + await operations.joinMetaHub({ ...first, hubName: 'fake-old' }) + await operations.joinMetaHub({ ...second, hubName: 'fake-peer' }) + + live.delete(key(first)) + live.add(key(restarted)) + publish.mockClear() + await expect(operations.joinMetaHub({ ...restarted, hubName: 'fake-new' })).resolves + .toMatchObject({ + hubs: [expect.objectContaining({ name: 'fake-peer' }), + expect.objectContaining({ name: 'fake-new' })], + }) + expect(publishedKeys(publish)).toEqual(expect.arrayContaining([ + key(second), key(restarted), + ])) + expect(publishedKeys(publish)).not.toContain(key(first)) + + publish.mockClear() + await operations.stopLocalMetaHub() + expect(publishedKeys(publish)).not.toContain(key(first)) + }) +}) + +function key(target: { sessionId: string; runtimeId: string; generation: number }): string { + return JSON.stringify([target.sessionId, target.runtimeId, target.generation]) +} + +function runtimeTarget(runtime: { id: string; sessionId: string; generation: number }) { + return { sessionId: runtime.sessionId, runtimeId: runtime.id, generation: runtime.generation } +} + +function publishedKeys(publish: ReturnType): string[] { + return publish.mock.calls.map(([target]) => key(target)) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.ts new file mode 100644 index 00000000..3f1ad137 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-metahub.ts @@ -0,0 +1,140 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type LocalMetaHubStatus, + type MetaHubRuntimeState, + type MetaHubRuntimeTarget, + type MetaHubSettings, +} from '../../../../shared/contracts' +import { type MetaHubOperations } from '../federation/loopal-metahub-service' + +export interface FakeMetaHubOperations extends MetaHubOperations { + getLocalMetaHubStatus(token?: CancellationToken): Promise + startLocalMetaHub( + input: { bindAddress: string }, token?: CancellationToken, + ): Promise + stopLocalMetaHub(token?: CancellationToken): Promise +} + +export function bindFakeMetaHub( + hasRuntime: (target: MetaHubRuntimeTarget) => boolean, + publish?: (target: MetaHubRuntimeTarget, state: MetaHubRuntimeState) => void, +): FakeMetaHubOperations { + let secret: string | undefined + let settings: MetaHubSettings = { + address: '', hubName: 'desktop-fake', joinOnStart: false, + startLocalOnLaunch: false, tokenConfigured: false, + } + const connectedTargets = new Map() + let local: LocalMetaHubStatus = { state: 'stopped' } + const disconnected = (): MetaHubRuntimeState => ({ + state: 'disconnected', hubs: [], topology: [], refreshedAt: new Date().toISOString(), + }) + const prune = (): void => { + for (const [key, { target }] of connectedTargets) { + if (!hasRuntime(target)) connectedTargets.delete(key) + } + } + const state = (key: string): MetaHubRuntimeState => { + prune() + const membership = connectedTargets.get(key) + if (!membership) return disconnected() + const members = [...connectedTargets.values()] + return { + state: 'connected', address: membership.address, hubName: membership.hubName, + hubs: members.map(({ hubName }) => ({ + name: hubName, status: 'connected', agentCount: 1, capabilities: ['desktop'], + })), + topology: members.map(({ hubName }) => ({ + id: `${hubName}/main`, name: 'main', hub: hubName, + hubPath: [hubName], children: [], lifecycle: 'running', + })), + refreshedAt: new Date().toISOString(), + } + } + const requireRuntime = (target: MetaHubRuntimeTarget, token: CancellationToken): void => { + throwIfCancelled(token) + if (!hasRuntime(target)) throw new Error(`Session runtime is gone: ${target.sessionId}`) + } + const publishConnected = (): void => { + prune() + for (const [key, membership] of connectedTargets) { + publish?.(membership.target, state(key)) + } + } + return { + getMetaHubSettings: async (token = CancellationToken.None) => { + throwIfCancelled(token); return { ...settings } + }, + updateMetaHubSettings: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + secret = input.clearToken ? undefined : input.token ?? secret + settings = { ...input, tokenConfigured: Boolean(secret) } + delete (settings as Partial).token + delete (settings as Partial).clearToken + return { ...settings } + }, + getMetaHubStatus: async (target, token = CancellationToken.None) => { + requireRuntime(target, token); return state(targetKey(target)) + }, + joinMetaHub: async (input, token = CancellationToken.None) => { + requireRuntime(input, token) + if (!input.token && !secret) throw new Error('MetaHub token is required') + const activeTarget = targetCopy(input) + const key = targetKey(activeTarget) + connectedTargets.set(key, { + target: activeTarget, + address: input.address ?? settings.address, + hubName: input.hubName ?? settings.hubName, + }) + const next = state(key) + publishConnected() + return next + }, + disconnectMetaHub: async (target, token = CancellationToken.None) => { + requireRuntime(target, token) + const activeTarget = targetCopy(target) + connectedTargets.delete(targetKey(activeTarget)) + const next = disconnected() + publish?.(activeTarget, next) + publishConnected() + return next + }, + getLocalMetaHubStatus: async (token = CancellationToken.None) => { + throwIfCancelled(token); return { ...local } + }, + startLocalMetaHub: async (_input, token = CancellationToken.None) => { + throwIfCancelled(token) + local = { state: 'running', address: '127.0.0.1:39000' } + settings = { ...settings, address: local.address!, tokenConfigured: true } + secret = 'fake-managed-secret' + return { ...local } + }, + stopLocalMetaHub: async (token = CancellationToken.None) => { + throwIfCancelled(token) + prune() + const targets = [...connectedTargets.values()].map(({ target }) => target) + connectedTargets.clear() + local = { state: 'stopped' } + secret = undefined + settings = { + ...settings, address: '', joinOnStart: false, tokenConfigured: false, + } + for (const activeTarget of targets) publish?.(activeTarget, disconnected()) + return { ...local } + }, + } +} + +function targetCopy(value: MetaHubRuntimeTarget): MetaHubRuntimeTarget { + return { + sessionId: value.sessionId, runtimeId: value.runtimeId, generation: value.generation, + } +} + +function targetKey(value: MetaHubRuntimeTarget): string { + return JSON.stringify([value.sessionId, value.runtimeId, value.generation]) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-rich-session.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-rich-session.ts new file mode 100644 index 00000000..71aaf648 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-rich-session.ts @@ -0,0 +1,80 @@ +import { + type AgentSummary, + type ConversationEntry, + type SessionView, +} from '../../../../shared/contracts' + +export function fakeRichConversation(now: string): ConversationEntry[] { + return [{ + id: 'desktop-assistant', role: 'assistant', agentId: 'agent-root', createdAt: now, + text: '# Verified desktop state\n\n**Bazel-only** bridge is ready.\n\n- Renderer connected\n- Host ready\n\n```ts\nconst desktop = ready\n```', + imageCount: 1, + skill: { name: 'desktop', userArgs: 'verify' }, + toolCalls: [{ + id: 'tool-build', name: 'Bash', summary: 'Build LoopalDesktop', status: 'succeeded', + input: { command: 'bazel build //apps/desktop:out' }, output: 'Build completed', + durationMs: 1_250, + }], + }, { + id: 'desktop-thinking', role: 'thinking', agentId: 'agent-root', createdAt: now, + text: 'Checking the renderer and Host state.', streaming: true, + }, { + id: 'desktop-resume-warning', role: 'system', agentId: 'agent-root', createdAt: now, + text: 'Session resume warning: one scheduled job needs review.', eventNotice: true, + }] +} + +export function fakeRichAgents(): AgentSummary[] { + return [{ + id: 'agent-root', name: 'Loopal', status: 'running', model: 'gpt-5', mode: 'act', + thinkingConfig: 'auto', permissionMode: 'ask_dangerous', decisionMode: 'classifier', + sandboxPolicy: 'default_write', lastTool: 'Running Electron verification', + children: ['agent-e2e'], + telemetry: { + turnCount: 3, inputTokens: 1_200, outputTokens: 320, + cacheCreationTokens: 40, cacheReadTokens: 80, thinkingTokens: 90, + contextWindow: 200_000, toolsInFlight: 1, toolCount: 4, + }, + }, { + id: 'agent-e2e', name: 'E2E specialist', status: 'waiting', parentId: 'agent-root', + model: 'gpt-5-mini', mode: 'act', + conversation: [{ + id: 'agent-e2e-result', role: 'assistant', agentId: 'agent-e2e', + text: 'E2E specialist verified the Electron renderer.', + createdAt: '2026-07-11T12:00:00.000Z', + }], + }] +} + +export function fakeSessionView(now: string): SessionView { + return { + revision: 7, historyTruncated: true, + streamingText: '', streamingThinking: 'Checking state', thinkingActive: true, + retryBanner: 'Retrying one transient provider response.', + compactBanner: 'Summarizing context for the next turn.', + goal: { + id: 'goal-desktop', objective: 'Ship a verified Loopal Desktop', status: 'active', + createdAt: now, updatedAt: now, + }, + tasks: [{ + id: 'task-shell', subject: 'Wire the desktop shell', description: 'Connect every pane.', + activeForm: 'Wiring the desktop shell', status: 'in_progress', blockedBy: [], + blocks: ['task-e2e'], + }, { + id: 'task-e2e', subject: 'Verify the Electron workbench', description: 'Run Playwright.', + status: 'pending', blockedBy: ['task-shell'], blocks: [], + }], + backgroundTasks: [{ + id: 'bg-bazel', description: 'Bazel test runner', status: 'running', exitCode: null, + output: 'Testing //apps/desktop:e2e', createdAt: now, + }], + crons: [{ + id: 'cron-health', schedule: '*/15 * * * *', prompt: 'Check Desktop Host health', + recurring: true, durable: true, nextFireAt: now, + }], + mcpServers: [{ + name: 'filesystem', transport: 'stdio', source: 'builtin', status: 'ready', + toolCount: 8, resourceCount: 2, promptCount: 1, errors: [], + }], + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.test.ts new file mode 100644 index 00000000..56580810 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.test.ts @@ -0,0 +1,39 @@ +import { execFile as execFileCallback } from 'node:child_process' +import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { FakeSessionDirectoryAuthority } from './fake-session-directory' + +const execFile = promisify(execFileCallback) + +describe('FakeSessionDirectoryAuthority', () => { + it('preserves a selected Git subdirectory inside its worktree', async () => { + const root = await mkdtemp(join(tmpdir(), 'loopal-fake-directory-')) + const nested = join(root, 'nested') + try { + await mkdir(nested) + await writeFile(join(nested, 'tracked.txt'), 'fixture\n') + await git(root, ['init', '-q', '--initial-branch=main']) + await git(root, ['config', 'user.name', 'Loopal Test']) + await git(root, ['config', 'user.email', 'loopal@example.invalid']) + await git(root, ['add', '.']) + await git(root, ['commit', '-qm', 'fixture']) + const authority = new FakeSessionDirectoryAuthority() + const selected = await authority.authorize(nested) + const prepared = await authority.prepare({ + authorizationId: selected.authorizationId, + launchMode: 'worktree', worktreeName: 'nested-test', + }) + const expected = join(root, '.loopal', 'worktrees', 'nested-test', 'nested') + expect(await realpath(prepared.path)).toBe(await realpath(expected)) + expect((await stat(join(prepared.path, 'tracked.txt'))).isFile()).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + +async function git(cwd: string, args: readonly string[]): Promise { + await execFile('git', args, { cwd }) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.ts new file mode 100644 index 00000000..63dd1515 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-directory.ts @@ -0,0 +1,77 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdir, realpath, stat } from 'node:fs/promises' +import { basename, join, relative } from 'node:path' +import { promisify } from 'node:util' +import { + type CreateSessionInput, type SessionDirectorySelection, + SessionDirectorySelectionSchema, +} from '../../../../shared/contracts' +import { type PreparedSessionDirectory } from '../sessions/session-directory-authority' + +const execute = promisify(execFile) + +interface Entry { + readonly path: string + readonly git?: { readonly root: string; readonly branch?: string; readonly dirty: boolean } +} + +export class FakeSessionDirectoryAuthority { + private readonly entries = new Map() + + async authorize(candidate: string): Promise { + const path = await realpath(candidate) + if (!(await stat(path)).isDirectory() || !basename(path)) { + throw new Error('unsafe_working_directory: directory required') + } + const git = await inspectGit(path) + const authorizationId = randomUUID() + this.entries.set(authorizationId, { path, ...(git ? { git } : {}) }) + return SessionDirectorySelectionSchema.parse({ + authorizationId, path, name: basename(path), git, + suggestedWorktreeName: `loopal-${Date.now()}`, + }) + } + + async prepare(input: CreateSessionInput) + : Promise { + const selected = this.entries.get(input.authorizationId) + if (!selected) throw new Error('directory_authorization_invalid: selection expired') + if (input.launchMode === 'directory') { + this.entries.delete(input.authorizationId) + return { path: selected.path, kind: 'folder' } + } + if (!selected.git) throw new Error('not_git_repository: worktree mode requires Git') + this.entries.delete(input.authorizationId) + const root = join(selected.git.root, '.loopal', 'worktrees', input.worktreeName) + await mkdir(join(selected.git.root, '.loopal', 'worktrees'), { recursive: true }) + try { + await execute('git', [ + 'worktree', 'add', '-b', `loopal-wt-${input.worktreeName}`, root, + ], { cwd: selected.git.root }) + } catch (error) { + this.entries.set(input.authorizationId, selected) + throw new Error(`worktree_creation_failed: ${errorMessage(error)}`) + } + return { + path: join(root, relative(selected.git.root, selected.path)), + kind: 'git_worktree', branch: `loopal-wt-${input.worktreeName}`, + } + } +} + +async function inspectGit(path: string): Promise { + try { + const root = (await execute('git', ['rev-parse', '--show-toplevel'], { cwd: path })) + .stdout.trim() + const branchValue = (await execute('git', ['branch', '--show-current'], { cwd: path })) + .stdout.trim() + const dirty = (await execute('git', ['status', '--porcelain'], { cwd: path })) + .stdout.trim().length > 0 + return { root: await realpath(root), ...(branchValue ? { branch: branchValue } : {}), dirty } + } catch { return undefined } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-fixtures.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-fixtures.ts new file mode 100644 index 00000000..0262d984 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-session-fixtures.ts @@ -0,0 +1,103 @@ +import { + type RuntimeSummary, + type SessionDetail, + type Workspace, +} from '../../../../shared/contracts' +import { fakeRichAgents, fakeRichConversation, fakeSessionView } from './fake-rich-session' + +export interface FakeSessionCatalog { + readonly workspace: Workspace + readonly details: Map + readonly runtimes: Map +} + +export function createFakeSessionCatalog(now: string): FakeSessionCatalog { + const workspace: Workspace = { + id: 'workspace-loopal', + name: 'Loopal', + rootUri: 'file:///workspace/loopal', + kind: 'git_worktree', + } + const runtimeDesktop = runtime( + 'runtime-desktop-1', 'session-desktop', workspace.id, 'agent-root', now, + ) + const runtimeProtocol = runtime( + 'runtime-protocol-1', 'session-protocol', workspace.id, 'agent-protocol', now, + ) + const details = new Map([ + ['session-desktop', { + session: { + id: 'session-desktop', workspaceId: workspace.id, + title: 'Build LoopalDesktop foundation', model: 'gpt-5', mode: 'agent', + status: 'running', createdAt: now, updatedAt: now, + activeRuntimeId: runtimeDesktop.id, + }, + conversation: [ + entry('desktop-user', 'user', 'Build a durable desktop workbench around Loopal.', now), + ...fakeRichConversation(now), + ], + agents: fakeRichAgents(), + artifacts: [], + view: fakeSessionView(now), + }], + ['session-protocol', { + session: { + id: 'session-protocol', workspaceId: workspace.id, + title: 'Version Desktop Control Protocol', model: 'gpt-5', mode: 'agent', + status: 'waiting', attention: 'permission', createdAt: now, updatedAt: now, + activeRuntimeId: runtimeProtocol.id, + }, + conversation: [entry( + 'protocol-system', 'system', + 'Waiting for permission to start the protocol compatibility check.', now, + )], + agents: [{ id: 'agent-protocol', name: 'Protocol agent', status: 'waiting' }], + artifacts: [], + }], + ['session-audit', { + session: { + id: 'session-audit', workspaceId: workspace.id, + title: 'Audit reference applications', model: 'gpt-5', mode: 'agent', + status: 'stopped', attention: 'completed', createdAt: now, updatedAt: now, + }, + conversation: [entry( + 'audit-assistant', 'assistant', + 'AgentsMesh and Synapse architecture audit complete.', now, + )], + agents: [{ id: 'agent-audit', name: 'Architecture agent', status: 'completed' }], + artifacts: [{ + id: 'artifact-audit', sessionId: 'session-audit', + title: 'Architecture findings.md', kind: 'document', + uri: 'loopal-artifact://session-audit/findings.md', mediaType: 'text/markdown', + producerAgentId: 'agent-audit', createdAt: now, + }], + }], + ]) + return { + workspace, + details, + runtimes: new Map([[runtimeDesktop.id, runtimeDesktop], [runtimeProtocol.id, runtimeProtocol]]), + } +} + +function runtime( + id: string, + sessionId: string, + workspaceId: string, + rootAgent: string, + startedAt: string, +): RuntimeSummary { + return { + id, sessionId, workspaceId, generation: 1, + state: 'ready', rootAgent, startedAt, + } +} + +function entry( + id: string, + role: 'user' | 'assistant' | 'system', + text: string, + createdAt: string, +) { + return { id, role, text, createdAt } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.test.ts new file mode 100644 index 00000000..e35899a0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.test.ts @@ -0,0 +1,46 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { bindFakeSkillPlugins } from './fake-skill-plugin-settings' + +describe('fake Skill and Plugin settings', () => { + it('models duplicate definitions, create-only writes, CAS updates, and deletes', async () => { + const service = bindFakeSkillPlugins('workspace') + const initial = await service.listSkills('workspace', CancellationToken.None) + const commits = initial.skills.filter((skill) => skill.name === '/commit') + expect(commits).toHaveLength(2) + expect(commits.find((skill) => skill.scope === 'global')?.effective).toBe(false) + expect(commits.find((skill) => skill.scope === 'project')?.effective).toBe(true) + + const created = await service.upsertGlobalSkill({ + workspaceId: 'workspace', name: '/ship', description: 'Ship', body: 'Ship $ARGUMENTS', + }, CancellationToken.None) + expect(created).toMatchObject({ name: '/ship', effective: true, hasArguments: true }) + await expect(service.upsertGlobalSkill({ + workspaceId: 'workspace', name: '/ship', description: 'Overwrite', body: 'unsafe', + }, CancellationToken.None)).rejects.toThrow('changed on disk') + const updated = await service.upsertGlobalSkill({ + workspaceId: 'workspace', name: '/ship', description: 'Ship safely', body: 'Ship', + expectedRevision: created.revision, + }, CancellationToken.None) + await expect(service.deleteGlobalSkill({ + workspaceId: 'workspace', name: '/ship', expectedRevision: created.revision, + }, CancellationToken.None)).rejects.toThrow('changed on disk') + const removed = await service.deleteGlobalSkill({ + workspaceId: 'workspace', name: '/ship', expectedRevision: updated.revision, + }, CancellationToken.None) + expect(removed.skills.some((skill) => skill.name === '/ship')).toBe(false) + await expect(service.getSkill({ + workspaceId: 'workspace', name: '/audit', + }, CancellationToken.None)).rejects.toThrow('Global skill not found') + }) + + it('lists Plugin contributions without offering mutation', async () => { + const service = bindFakeSkillPlugins('workspace') + await expect(service.listPlugins('workspace', CancellationToken.None)).resolves.toEqual({ + workspaceId: 'workspace', + plugins: [expect.objectContaining({ + name: 'quality', source: 'plugin:quality', skills: ['/audit'], + mcpServers: ['reviewer'], hookCount: 1, + })], + }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.ts new file mode 100644 index 00000000..0782f411 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-skill-plugin-settings.ts @@ -0,0 +1,107 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + DeleteGlobalSkillInputSchema, + GetSkillInputSchema, + UpsertGlobalSkillInputSchema, + type PluginSummary, + type SkillDetail, + type SkillSummary, + type SkillsResponse, +} from '../../../../shared/contracts' +import { type LoopalSkillPluginOperations } from '../settings/loopal-skill-plugin-service' + +export function bindFakeSkillPlugins(workspaceId: string): LoopalSkillPluginOperations { + let sequence = 2 + const globals = new Map([[ + '/commit', detail(workspaceId, '/commit', 'Create a focused commit', 'Commit $ARGUMENTS', 1), + ]]) + const readonly: SkillSummary[] = [{ + name: '/audit', description: 'Audit the current workspace', hasArguments: true, + source: 'plugin:quality', scope: 'plugin', editable: false, effective: true, + }, { + name: '/commit', description: 'Project commit policy', hasArguments: true, + source: 'project', scope: 'project', editable: false, effective: true, + }] + const plugins: PluginSummary[] = [{ + name: 'quality', source: 'plugin:quality', skills: ['/audit'], mcpServers: ['reviewer'], + hookCount: 1, hasSettings: true, hasInstructions: true, hasMemory: false, + }] + const requireWorkspace = (value: string): void => { + if (value !== workspaceId) throw new Error(`Unknown workspace: ${value}`) + } + const list = (): SkillsResponse => ({ + workspaceId, + skills: [...globals.values()].map((entry) => summary(entry, readonly)) + .concat(structuredClone(readonly)), + }) + return { + listSkills: async (input, token = CancellationToken.None) => { + throwIfCancelled(token); requireWorkspace(input); return list() + }, + getSkill: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = GetSkillInputSchema.parse(input); requireWorkspace(parsed.workspaceId) + const skill = globals.get(parsed.name) + if (!skill) throw new Error(`Global skill not found: ${parsed.name}`) + return structuredClone(skill) + }, + upsertGlobalSkill: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = UpsertGlobalSkillInputSchema.parse(input); requireWorkspace(parsed.workspaceId) + const current = globals.get(parsed.name) + if (current && parsed.expectedRevision !== current.revision) throw conflict(parsed.name) + if (!current && parsed.expectedRevision !== undefined) throw conflict(parsed.name) + const next = detail( + workspaceId, parsed.name, parsed.description, parsed.body, sequence++, readonly, + ) + globals.set(parsed.name, next) + return structuredClone(next) + }, + deleteGlobalSkill: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const parsed = DeleteGlobalSkillInputSchema.parse(input); requireWorkspace(parsed.workspaceId) + const current = globals.get(parsed.name) + if (!current || current.revision !== parsed.expectedRevision) throw conflict(parsed.name) + globals.delete(parsed.name) + return list() + }, + listPlugins: async (input, token = CancellationToken.None) => { + throwIfCancelled(token); requireWorkspace(input) + return { workspaceId, plugins: structuredClone(plugins) } + }, + } +} + +export function bindUnavailableSkillPlugins(reason: string): LoopalSkillPluginOperations { + const fail = (token: CancellationToken): never => { + throwIfCancelled(token); throw new Error(reason) + } + return { + listSkills: async (_workspaceId, token) => fail(token), + getSkill: async (_input, token) => fail(token), + upsertGlobalSkill: async (_input, token) => fail(token), + deleteGlobalSkill: async (_input, token) => fail(token), + listPlugins: async (_workspaceId, token) => fail(token), + } +} + +function detail( + workspaceId: string, name: string, description: string, body: string, revision: number, + inherited: readonly SkillSummary[] = [], +): SkillDetail { + return { + workspaceId, name, description, body, hasArguments: body.includes('$ARGUMENTS'), + source: 'global', scope: 'global', editable: true, + effective: !inherited.some((skill) => skill.name === name && skill.effective), + revision: revision.toString(16).padStart(64, '0'), + } +} + +function summary(skill: SkillDetail, inherited: readonly SkillSummary[]): SkillSummary { + const { workspaceId: _workspaceId, body: _body, ...value } = skill + return { ...value, effective: !inherited.some((item) => item.name === skill.name && item.effective) } +} + +function conflict(name: string): Error { + return new Error(`Skill changed on disk; reload before editing: ${name}`) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.test.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.test.ts new file mode 100644 index 00000000..b0769ddb --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.test.ts @@ -0,0 +1,136 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { type DesktopEvent } from '../../../../shared/contracts' +import { FakeWorkspaceService } from './fake-workspace' + +function fixture() { + const events: DesktopEvent[] = [] + return { + events, + service: new FakeWorkspaceService('workspace', (event) => events.push(event)), + } +} + +describe('FakeWorkspaceService', () => { + it('lists and reads its deterministic tree', async () => { + const { service } = fixture() + const root = await service.listDirectory({ workspaceId: 'workspace', path: '' }, CancellationToken.None) + expect(root.entries.map((entry) => entry.name)).toEqual(['src', 'README.md']) + const src = await service.listDirectory({ + workspaceId: 'workspace', path: 'src', + }, CancellationToken.None) + expect(src.entries.map((entry) => entry.name)).toEqual(['main.rs', 'workspace.rs']) + await expect(service.readFile({ + workspaceId: 'workspace', path: 'README.md', + }, CancellationToken.None)).resolves.toMatchObject({ + version: 'fake-1', languageId: 'markdown', readonly: false, + }) + await expect(service.readFile({ + workspaceId: 'workspace', path: 'src', + }, CancellationToken.None)).rejects.toThrow('File not found') + }) + + it('enforces CAS writes and publishes file and Git invalidations', async () => { + const { service, events } = fixture() + const updated = await service.writeFile({ + workspaceId: 'workspace', path: 'README.md', content: '# Updated', + expectedVersion: 'fake-1', + }, CancellationToken.None) + expect(updated.version).toBe('fake-2') + expect(events).toEqual([ + { type: 'file_changed', workspaceId: 'workspace', path: 'README.md', kind: 'changed' }, + { type: 'git_changed', workspaceId: 'workspace' }, + ]) + await expect(service.writeFile({ + workspaceId: 'workspace', path: 'README.md', content: 'stale', + expectedVersion: 'fake-1', + }, CancellationToken.None)).rejects.toThrow('FILE_VERSION_CONFLICT') + await expect(service.writeFile({ + workspaceId: 'workspace', path: 'new.ts', content: 'export {}', expectedVersion: null, + }, CancellationToken.None)).resolves.toMatchObject({ languageId: 'typescript' }) + const plain = await service.writeFile({ + workspaceId: 'workspace', path: 'notes.txt', content: 'plain', expectedVersion: null, + }, CancellationToken.None) + expect(plain.languageId).toBe('plaintext') + await expect(service.gitStatus('workspace', CancellationToken.None)).resolves.toEqual( + expect.objectContaining({ changes: expect.arrayContaining([ + expect.objectContaining({ path: 'new.ts', worktreeStatus: '?' }), + ]) }), + ) + }) + + it('searches with glob and truncation and computes Git state', async () => { + const { service } = fixture() + await expect(service.search({ + workspaceId: 'workspace', query: 'loopal', glob: '*.md', maxResults: 10, + }, CancellationToken.None)).resolves.toMatchObject({ + matches: [{ path: 'README.md', line: 1, column: 3 }], truncated: false, + }) + await expect(service.search({ + workspaceId: 'workspace', query: 'loopal', maxResults: 1, + }, CancellationToken.None)).resolves.toMatchObject({ truncated: true }) + await expect(service.search({ + workspaceId: 'workspace', query: 'Loopal', glob: '*', + }, CancellationToken.None)).resolves.toMatchObject({ truncated: false }) + await service.writeFile({ + workspaceId: 'workspace', path: 'README.md', content: '# Changed', expectedVersion: 'fake-1', + }, CancellationToken.None) + await expect(service.gitStatus('workspace', CancellationToken.None)).resolves.toMatchObject({ + branch: 'main', changes: [{ path: 'README.md', worktreeStatus: 'M' }], + }) + await expect(service.gitDiff({ + workspaceId: 'workspace', path: 'README.md', + }, CancellationToken.None)).resolves.toMatchObject({ original: '# Loopal\n\nAgent workbench.\n' }) + }) + + it('creates and safely removes worktrees', async () => { + const { service } = fixture() + await expect(service.listWorktrees('workspace', CancellationToken.None)).resolves.toHaveLength(2) + await expect(service.createWorktree({ + workspaceId: 'workspace', name: 'feature', + }, CancellationToken.None)).resolves.toMatchObject({ id: 'feature', isMain: false }) + await expect(service.createWorktree({ + workspaceId: 'workspace', name: 'feature', + }, CancellationToken.None)).rejects.toThrow('WORKTREE_EXISTS') + await expect(service.removeWorktree({ + workspaceId: 'workspace', name: 'review', force: false, + }, CancellationToken.None)).rejects.toThrow('WORKTREE_DIRTY') + await service.removeWorktree({ + workspaceId: 'workspace', name: 'review', force: true, + }, CancellationToken.None) + await expect(service.removeWorktree({ + workspaceId: 'workspace', name: 'missing', force: true, + }, CancellationToken.None)).rejects.toThrow('WORKTREE_NOT_FOUND') + }) + + it('stages and unstages changed files', async () => { + const { service, events } = fixture() + await service.writeFile({ + workspaceId: 'workspace', path: 'README.md', content: '# Staged', + expectedVersion: 'fake-1', + }, CancellationToken.None) + await service.gitStage({ + workspaceId: 'workspace', path: 'README.md', + }, CancellationToken.None) + await expect(service.gitStatus('workspace', CancellationToken.None)).resolves.toMatchObject({ + changes: [{ path: 'README.md', indexStatus: 'M', worktreeStatus: ' ' }], + }) + await service.gitUnstage({ + workspaceId: 'workspace', path: 'README.md', + }, CancellationToken.None) + await expect(service.gitStatus('workspace', CancellationToken.None)).resolves.toMatchObject({ + changes: [{ path: 'README.md', indexStatus: ' ', worktreeStatus: 'M' }], + }) + expect(events.filter((event) => event.type === 'git_changed')).toHaveLength(3) + await expect(service.gitStage({ + workspaceId: 'workspace', path: 'src', + }, CancellationToken.None)).rejects.toThrow('Git change not found') + }) + + it('rejects unknown workspaces and cancellation', async () => { + const { service } = fixture() + await expect(service.gitStatus('other', CancellationToken.None)).rejects.toThrow('Unknown workspace') + await expect(service.listDirectory({ + workspaceId: 'workspace', path: '', + }, CancellationToken.Cancelled)).rejects.toThrow('Operation cancelled') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.ts b/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.ts new file mode 100644 index 00000000..2aa12b9f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/fake/fake-workspace.ts @@ -0,0 +1,189 @@ +import { basename, dirname, extname } from 'node:path/posix' +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type CreateWorktreeInput, + type DesktopEvent, + type GitStageInput, + type GitUnstageInput, + type ListDirectoryInput, + type ReadFileInput, + type RemoveWorktreeInput, + type WorkspaceSearchInput, + type WriteFileInput, + type Worktree, +} from '../../../../shared/contracts' + +interface FakeNode { + kind: 'file' | 'directory' + content: string + version: number + original: string + staged: boolean +} + +export class FakeWorkspaceService { + private readonly nodes = new Map([ + ['README.md', file('# Loopal\n\nAgent workbench.\n')], + ['src', directory()], + ['src/main.rs', file('fn main() {\n println!("loopal");\n}\n')], + ['src/workspace.rs', file('pub struct Workspace;\n')], + ]) + private readonly worktrees: Worktree[] = [{ + id: 'main', path: '/workspace/loopal', branch: 'main', head: '87ad2b93', + isMain: true, hasChanges: false, + }, { + id: 'review', path: '/workspace/loopal/.loopal/worktrees/review', branch: 'loopal-wt-review', + head: '87ad2b93', isMain: false, hasChanges: true, + }] + + constructor( + private readonly workspaceId: string, + private readonly emit: (event: DesktopEvent) => void, + ) {} + + async listDirectory(input: ListDirectoryInput, token: CancellationToken) { + this.check(input.workspaceId, token) + const entries = [...this.nodes.entries()] + .filter(([path]) => dirname(path) === (input.path || '.')) + .map(([path, node]) => ({ + path, + name: basename(path), + kind: node.kind, + size: new TextEncoder().encode(node.content).length, + })) + .sort((left, right) => kindRank(left.kind) - kindRank(right.kind) + || left.name.localeCompare(right.name)) + return { workspaceId: this.workspaceId, path: input.path, entries } + } + + async readFile(input: ReadFileInput, token: CancellationToken) { + this.check(input.workspaceId, token) + const node = this.nodes.get(input.path) + if (!node || node.kind !== 'file') throw new Error(`File not found: ${input.path}`) + return document(this.workspaceId, input.path, node) + } + + async writeFile(input: WriteFileInput, token: CancellationToken) { + this.check(input.workspaceId, token) + const current = this.nodes.get(input.path) + const version = current ? `fake-${current.version}` : null + if (version !== input.expectedVersion) throw new Error('FILE_VERSION_CONFLICT') + const node: FakeNode = { + kind: 'file', content: input.content, version: (current?.version ?? 0) + 1, + original: current?.original ?? '', staged: current?.staged ?? false, + } + this.nodes.set(input.path, node) + this.emit({ + type: 'file_changed', workspaceId: this.workspaceId, path: input.path, + kind: current ? 'changed' : 'created', + }) + this.emit({ type: 'git_changed', workspaceId: this.workspaceId }) + return document(this.workspaceId, input.path, node) + } + + async search(input: WorkspaceSearchInput, token: CancellationToken) { + this.check(input.workspaceId, token) + const limit = input.maxResults ?? 200 + const matches = [] + for (const [path, node] of this.nodes) { + if (node.kind !== 'file' || (input.glob && !globMatches(path, input.glob))) continue + for (const [index, text] of node.content.split('\n').entries()) { + const column = text.toLocaleLowerCase().indexOf(input.query.toLocaleLowerCase()) + if (column < 0) continue + matches.push({ path, line: index + 1, column: column + 1, preview: text }) + if (matches.length === limit) return { matches, truncated: true } + } + } + return { matches, truncated: false } + } + + async gitStatus(workspaceId: string, token: CancellationToken) { + this.check(workspaceId, token) + const changes = [...this.nodes.entries()] + .filter(([, node]) => node.kind === 'file' && node.content !== node.original) + .map(([path, node]) => ({ + path, + indexStatus: node.staged ? (node.original ? 'M' : 'A') : ' ', + worktreeStatus: node.staged ? ' ' : node.original ? 'M' : '?', + })) + return { branch: 'main', ahead: 0, behind: 0, changes } + } + + async gitDiff(input: ReadFileInput, token: CancellationToken) { + const current = await this.readFile(input, token) + const original = this.nodes.get(input.path)!.original + return { + path: input.path, original, modified: current.content, + patch: `--- a/${input.path}\n+++ b/${input.path}\n-${original}\n+${current.content}`, + } + } + + async gitStage(input: GitStageInput, token: CancellationToken) { + this.change(input.workspaceId, input.path, token).staged = true + this.emit({ type: 'git_changed', workspaceId: this.workspaceId }) + } + + async gitUnstage(input: GitUnstageInput, token: CancellationToken) { + this.change(input.workspaceId, input.path, token).staged = false + this.emit({ type: 'git_changed', workspaceId: this.workspaceId }) + } + + async listWorktrees(workspaceId: string, token: CancellationToken) { + this.check(workspaceId, token) + return structuredClone(this.worktrees) + } + + async createWorktree(input: CreateWorktreeInput, token: CancellationToken) { + this.check(input.workspaceId, token) + if (this.worktrees.some((item) => item.id === input.name)) throw new Error('WORKTREE_EXISTS') + const worktree: Worktree = { + id: input.name, path: `/workspace/loopal/.loopal/worktrees/${input.name}`, + branch: `loopal-wt-${input.name}`, head: '87ad2b93', isMain: false, hasChanges: false, + } + this.worktrees.push(worktree) + return structuredClone(worktree) + } + + async removeWorktree(input: RemoveWorktreeInput, token: CancellationToken) { + this.check(input.workspaceId, token) + const index = this.worktrees.findIndex((item) => item.id === input.name && !item.isMain) + const worktree = this.worktrees[index] + if (!worktree) throw new Error('WORKTREE_NOT_FOUND') + if (worktree.hasChanges && !input.force) throw new Error('WORKTREE_DIRTY') + this.worktrees.splice(index, 1) + } + + private check(workspaceId: string, token: CancellationToken): void { + throwIfCancelled(token) + if (workspaceId !== this.workspaceId) throw new Error(`Unknown workspace: ${workspaceId}`) + } + + private change(workspaceId: string, path: string, token: CancellationToken): FakeNode { + this.check(workspaceId, token) + const node = this.nodes.get(path) + if (!node || node.kind !== 'file' || node.content === node.original) { + throw new Error(`Git change not found: ${path}`) + } + return node + } +} + +function file(content: string): FakeNode { + return { kind: 'file', content, original: content, version: 1, staged: false } +} +function directory(): FakeNode { + return { kind: 'directory', content: '', original: '', version: 1, staged: false } +} +function document(workspaceId: string, path: string, node: FakeNode) { + const languages: Record = { '.md': 'markdown', '.rs': 'rust', '.ts': 'typescript' } + return { + workspaceId, path, content: node.content, version: `fake-${node.version}`, + languageId: languages[extname(path)] ?? 'plaintext', readonly: false, + } +} +function globMatches(path: string, glob: string): boolean { + return glob === '*' || path.endsWith(glob.replace(/^\*+/, '')) +} +function kindRank(kind: 'file' | 'directory' | 'symlink'): number { + return kind === 'directory' ? 0 : kind === 'file' ? 1 : 2 +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-local-metahub.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-local-metahub.ts new file mode 100644 index 00000000..e75c0a25 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-local-metahub.ts @@ -0,0 +1,55 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type LocalMetaHubStatus, + type StartLocalMetaHubInput, +} from '../../../../shared/contracts' +import { type LoopalMetaHubCoordinator } from './loopal-metahub-coordinator' +import { type LoopalMetaHubSettings } from './loopal-metahub-settings' + +export interface LocalMetaHubOperations { + getLocalMetaHubStatus(token?: CancellationToken): Promise + startLocalMetaHub( + input: StartLocalMetaHubInput, token?: CancellationToken, + ): Promise + stopLocalMetaHub(token?: CancellationToken): Promise +} + +export function bindLocalMetaHub( + coordinator: LoopalMetaHubCoordinator, + settings: LoopalMetaHubSettings, +): LocalMetaHubOperations { + return { + getLocalMetaHubStatus: async (token = CancellationToken.None) => { + throwIfCancelled(token) + return coordinator.status + }, + startLocalMetaHub: async (input, token = CancellationToken.None) => { + throwIfCancelled(token) + const managed = await coordinator.start(input.bindAddress) + throwIfCancelled(token) + await settings.useManaged(managed.address, managed.token) + return coordinator.status + }, + stopLocalMetaHub: async (token = CancellationToken.None) => { + throwIfCancelled(token) + const status = coordinator.status + const address = coordinator.ownedAddress + ?? (status.state === 'running' ? status.address : undefined) + await coordinator.stop() + if (address) await settings.clearManaged(address) + throwIfCancelled(token) + return coordinator.status + }, + } +} + +export async function startLocalOnLaunch( + coordinator: LoopalMetaHubCoordinator, + settings: LoopalMetaHubSettings, +): Promise { + if (!settings.publicValue.startLocalOnLaunch) return + try { + const managed = await coordinator.start('127.0.0.1:0') + await settings.useManaged(managed.address, managed.token) + } catch {} +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-bind.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-bind.ts new file mode 100644 index 00000000..bc6d1d0f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-bind.ts @@ -0,0 +1,42 @@ +import { type MetaHubRuntimeTarget } from '../../../../shared/contracts' +import { LoopalMetaHubService, type MetaHubOperations } from './loopal-metahub-service' +import { type LoopalMetaHubSettings } from './loopal-metahub-settings' +import { type LoopalSessionDirectory } from '../sessions/loopal-session-directory' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +export function bindMetaHub( + settings: LoopalMetaHubSettings, + directory: LoopalSessionDirectory, + now: () => Date, +): MetaHubOperations { + const service = new LoopalMetaHubService({ + settings, + runtime: (target) => resolveRuntime(directory, target), + resync: async (sessionId) => { + const live = directory.liveSession(sessionId) + if (live) await live.resync() + }, + now, + }) + return { + getMetaHubSettings: service.getMetaHubSettings.bind(service), + updateMetaHubSettings: service.updateMetaHubSettings.bind(service), + getMetaHubStatus: service.getMetaHubStatus.bind(service), + joinMetaHub: service.joinMetaHub.bind(service), + disconnectMetaHub: service.disconnectMetaHub.bind(service), + } +} + +function resolveRuntime( + directory: LoopalSessionDirectory, + target: MetaHubRuntimeTarget, +): SessionRuntimeHandle { + const runtime = directory.runtimeForSession(target.sessionId) + if (!runtime || runtime.runtimeId !== target.runtimeId + || runtime.generation !== target.generation || runtime.host.currentStatus !== 'ready') { + throw Object.assign(new Error(`Session runtime is gone: ${target.sessionId}`), { + code: 'RUNTIME_GONE', + }) + } + return runtime +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.test.ts new file mode 100644 index 00000000..3ed1acfc --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.test.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { vi } from 'vitest' +import { + LoopalMetaHubCoordinator, + type SpawnMetaHubProcess, +} from './loopal-metahub-coordinator' + +class FakeChild extends EventEmitter { + readonly pid: number + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + exitCode: number | null = null + readonly kill = vi.fn((signal: NodeJS.Signals) => { + queueMicrotask(() => this.exit(null, signal)) + return true + }) + constructor(pid: number) { super(); this.pid = pid } + exit(code: number | null, signal: NodeJS.Signals | null): void { + if (this.exitCode !== null) return + this.exitCode = code ?? (signal ? 1 : 0) + this.emit('exit', code, signal) + } +} + +function handshake(child: FakeChild, token = 'private-token'): void { + child.stdout.write(`LOOPAL_METAHUB ${JSON.stringify({ + protocol_version: 1, + phase: 'ready', + address: '127.0.0.1:4567', + token, + pid: child.pid, + parent_pid: 321, + })}\n`) +} + +describe('LoopalMetaHubCoordinator', () => { + it('uses shell-free machine startup and never exposes the token in public status', async () => { + const child = new FakeChild(654) + const spawn = vi.fn(() => child as never) as unknown as SpawnMetaHubProcess + const coordinator = new LoopalMetaHubCoordinator('/bin/loopal', 321, spawn) + const pending = coordinator.start('127.0.0.1:0') + handshake(child) + await expect(pending).resolves.toEqual({ + address: '127.0.0.1:4567', token: 'private-token', + }) + expect(spawn).toHaveBeenCalledWith('/bin/loopal', [ + '--meta-hub', '127.0.0.1:0', '--meta-hub-parent-pid', '321', + ]) + expect(coordinator.status).toEqual({ state: 'running', address: '127.0.0.1:4567' }) + expect(coordinator.ownedAddress).toBe('127.0.0.1:4567') + expect(JSON.stringify(coordinator.status)).not.toContain('private-token') + await coordinator.stop() + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + expect(coordinator.status).toEqual({ state: 'stopped' }) + expect(coordinator.ownedAddress).toBeUndefined() + }) + + it('keeps stop authoritative when startup is interrupted', async () => { + const child = new FakeChild(700) + const coordinator = new LoopalMetaHubCoordinator( + '/bin/loopal', 321, () => child as never, + ) + const pending = coordinator.start('127.0.0.1:0') + await coordinator.stop() + await expect(pending).rejects.toThrow() + expect(coordinator.status).toEqual({ state: 'stopped' }) + }) + + it('supports rapid stop and restart without stale generation state', async () => { + const first = new FakeChild(701) + const second = new FakeChild(702) + const spawn = vi.fn() + .mockReturnValueOnce(first as never) + .mockReturnValueOnce(second as never) + const coordinator = new LoopalMetaHubCoordinator('/bin/loopal', 321, spawn) + const firstStart = coordinator.start('127.0.0.1:0') + const stop = coordinator.stop() + const secondStart = coordinator.start('127.0.0.1:0') + await Promise.allSettled([firstStart, stop]) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + handshake(second, 'second-secret') + await expect(secondStart).resolves.toEqual({ + address: '127.0.0.1:4567', token: 'second-secret', + }) + expect(coordinator.status.state).toBe('running') + await coordinator.stop() + }) + + it('retains the owned address after a crash until explicit stop', async () => { + const child = new FakeChild(703) + const coordinator = new LoopalMetaHubCoordinator('/bin/loopal', 321, () => child as never) + const pending = coordinator.start('127.0.0.1:0') + handshake(child) + await pending + child.exit(9, null) + expect(coordinator.status.state).toBe('failed') + expect(coordinator.ownedAddress).toBe('127.0.0.1:4567') + await coordinator.stop() + expect(coordinator.ownedAddress).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.ts new file mode 100644 index 00000000..4e6e7290 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-coordinator.ts @@ -0,0 +1,176 @@ +import { + spawn, + type ChildProcessByStdio, +} from 'node:child_process' +import { createInterface } from 'node:readline' +import { type Readable } from 'node:stream' +import { z } from 'zod' +import { type IDisposable } from '../../../../base/common/lifecycle' +import { type LocalMetaHubStatus } from '../../../../shared/contracts' + +const PREFIX = 'LOOPAL_METAHUB ' +const HandshakeSchema = z.object({ + protocol_version: z.literal(1), + phase: z.literal('ready'), + address: z.string().min(1), + token: z.string().min(1), + pid: z.number().int().positive(), + parent_pid: z.number().int().positive(), +}) + +export interface ManagedMetaHub { + readonly address: string + readonly token: string +} +type MetaHubChild = ChildProcessByStdio +export type SpawnMetaHubProcess = ( + binary: string, + args: readonly string[], +) => MetaHubChild + +export class LoopalMetaHubCoordinator implements IDisposable { + private child: MetaHubChild | undefined + private ready: ManagedMetaHub | undefined + private pending: Promise | undefined + private stopping: Promise | undefined + private failure: string | undefined + private managedAddress: string | undefined + private generation = 0 + + constructor( + private readonly binaryPath: string, + private readonly parentPid: number, + private readonly spawnProcess: SpawnMetaHubProcess = defaultSpawn, + ) {} + + get status(): LocalMetaHubStatus { + if (this.ready) return { state: 'running', address: this.ready.address } + if (this.pending) return { state: 'starting' } + if (this.failure) return { state: 'failed', error: this.failure } + return { state: 'stopped' } + } + + get ownedAddress(): string | undefined { return this.managedAddress } + + start(bindAddress: string): Promise { + if (this.ready) return Promise.resolve(this.ready) + if (this.stopping) return this.stopping.then(() => this.start(bindAddress)) + if (this.pending) return this.pending + const generation = ++this.generation + const pending = this.startProcess(bindAddress, generation) + const tracked = pending.finally(() => { + if (this.pending === tracked) this.pending = undefined + }) + this.pending = tracked + return this.pending + } + + stop(): Promise { + if (this.stopping) return this.stopping + const generation = ++this.generation + const stopping = this.stopInternal(generation) + const tracked = stopping.finally(() => { + if (this.stopping === tracked) this.stopping = undefined + }) + this.stopping = tracked + return this.stopping + } + + private async stopInternal(_generation: number): Promise { + const child = this.child + this.child = undefined + this.ready = undefined + this.failure = undefined + this.managedAddress = undefined + if (child && child.exitCode === null) { + child.kill('SIGTERM') + const exited = new Promise((resolve) => child.once('exit', () => resolve())) + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((resolve) => { timer = setTimeout(resolve, 3_000) }) + await Promise.race([exited, timeout]) + clearTimeout(timer) + if (child.exitCode === null) child.kill('SIGKILL') + } + await this.pending?.catch(() => undefined) + } + + dispose(): void { void this.stop() } + + private async startProcess( + bindAddress: string, + generation: number, + ): Promise { + this.failure = undefined + const child = this.spawnProcess(this.binaryPath, [ + '--meta-hub', bindAddress, + '--meta-hub-parent-pid', String(this.parentPid), + ]) + this.child = child + child.stderr.resume() + try { + const value = await waitForHandshake(child, this.parentPid) + if (this.child !== child || this.generation !== generation) { + throw new Error('Local MetaHub startup was superseded') + } + this.ready = { address: value.address, token: value.token } + this.managedAddress = value.address + child.once('exit', (code, signal) => { + if (this.child !== child || this.generation !== generation) return + this.child = undefined + this.ready = undefined + this.failure = `Local MetaHub exited (code=${String(code)}, signal=${String(signal)})` + }) + return this.ready + } catch (error) { + const owns = this.child === child && this.generation === generation + if (owns) this.child = undefined + if (child.exitCode === null) child.kill('SIGKILL') + const message = errorMessage(error) + if (owns) this.failure = message + throw new Error(message) + } + } +} + +async function waitForHandshake( + child: MetaHubChild, + parentPid: number, +): Promise> { + return new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }) + const timer = setTimeout(() => finish(new Error('Local MetaHub startup timed out')), 10_000) + const finish = (error?: Error, value?: z.infer): void => { + clearTimeout(timer) + lines.close() + child.off('error', onError) + child.off('exit', onExit) + error ? reject(error) : resolve(value!) + } + const onError = (error: Error): void => finish(error) + const onExit = (): void => finish(new Error('Local MetaHub exited before ready')) + child.once('error', onError) + child.once('exit', onExit) + lines.on('line', (line) => { + if (!line.startsWith(PREFIX)) return + try { + const value = HandshakeSchema.parse(JSON.parse(line.slice(PREFIX.length))) + if (value.pid !== child.pid || value.parent_pid !== parentPid) { + throw new Error('Local MetaHub handshake PID mismatch') + } + finish(undefined, value) + } catch (error) { finish(new Error('Invalid local MetaHub handshake', { cause: error })) } + }) + }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function defaultSpawn(binary: string, args: readonly string[]): MetaHubChild { + return spawn(binary, [...args], { + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.test.ts new file mode 100644 index 00000000..d1613f4c --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.test.ts @@ -0,0 +1,161 @@ +import { Emitter } from '../../../../base/common/event' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { + loadMetaHubState, + mergeRemoteAgents, + projectTopology, +} from './loopal-metahub-projection' + +function host(request: DesktopHostClient['request']): DesktopHostClient { + const status = new Emitter() + const notifications = new Emitter() + return { + currentStatus: 'ready', + onStatus: status.event, + onNotification: notifications.event, + request, + start: async () => ({ sessionId: 's', serverVersion: '1', pid: 1 }), + stop: async () => undefined, + dispose: () => { status.dispose(); notifications.dispose() }, + } +} + +const status = { + agent_count: 1, + uplink: { connected: true, hub_name: 'hub-a', address: '127.0.0.1:9' }, +} +const list = { + hubs: [ + { name: 'hub-b', status: 'Degraded', agent_count: 2, capabilities: [] }, + { name: 'hub-a', status: 'Connected', agent_count: 1, capabilities: ['desktop'] }, + ], +} +const topology = { + hubs: [{ hub: 'hub-b', topology: { agents: [{ + name: 'child', parent: 'hub-a/main', children: [], lifecycle: 'running' as const, + }] } }], +} + +describe('MetaHub projection', () => { + it('fails open when an older host does not expose Hub status', async () => { + const value = await loadMetaHubState(host(async () => { + throw new Error('method not found') + }), new Date('2026-01-01T00:00:00Z')) + expect(value).toEqual({ + state: 'error', hubs: [], topology: [], error: 'method not found', + refreshedAt: '2026-01-01T00:00:00.000Z', + }) + }) + + it('deduplicates concurrent loads and serves the short-lived cache', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const request = vi.fn(async (method: string) => { + if (method === 'hub/status') return status + await gate + return method === 'meta/list_hubs' ? list : topology + }) + const target = host(request) + const now = new Date('2026-01-01T00:00:00Z') + const first = loadMetaHubState(target, now) + const second = loadMetaHubState(target, now) + await vi.waitFor(() => expect(request).toHaveBeenCalledWith( + 'hub/status', {}, expect.any(AbortSignal), + )) + release() + expect(await first).toEqual(await second) + expect(request).toHaveBeenCalledTimes(3) + await loadMetaHubState(target, new Date(now.getTime() + 500)) + expect(request).toHaveBeenCalledTimes(3) + await loadMetaHubState(target, new Date(now.getTime() + 500), true) + expect(request).toHaveBeenCalledTimes(6) + }) + + it('bounds remote queries and reports a state error without rejecting', async () => { + vi.useFakeTimers() + const target = host(async (method, _params, signal) => { + if (method === 'hub/status') return status + return new Promise((_, reject) => { + signal?.addEventListener('abort', () => reject(new Error('query aborted'))) + }) + }) + const pending = loadMetaHubState(target, new Date(), true) + await vi.advanceTimersByTimeAsync(2_501) + await expect(pending).resolves.toMatchObject({ state: 'error', error: 'query aborted' }) + vi.useRealTimers() + }) + + it('projects qualified paths and joins remote parents to the local root', () => { + const projected = projectTopology(topology) + expect(projected[0]).toMatchObject({ + id: 'hub-b/child', parentId: 'hub-a/main', hubPath: ['hub-b'], + }) + const merged = mergeRemoteAgents([ + { id: 'main', name: 'Loopal', status: 'running', children: ['child'] }, + { id: 'child', name: 'child', status: 'running', parentId: 'main', shadow: true }, + ], { + state: 'connected', hubName: 'hub-a', hubs: [], topology: projected, + refreshedAt: new Date().toISOString(), + }) + expect(merged[1]).toMatchObject({ + id: 'hub-b/child', parentId: 'main', qualifiedName: 'hub-b/child', controllable: true, + }) + expect(merged).toHaveLength(2) + expect(merged[0]?.children).toEqual([]) + + const completed = mergeRemoteAgents([ + { id: 'main', name: 'Loopal', status: 'waiting', children: ['child'] }, + { id: 'child', name: 'child', status: 'completed', parentId: 'main', shadow: true }, + ], { + state: 'connected', hubName: 'hub-a', hubs: [], + topology: projected.map((agent) => ({ ...agent, lifecycle: 'finished' as const })), + refreshedAt: new Date().toISOString(), + }) + expect(completed.map((agent) => agent.id)).toEqual(['main', 'hub-b/child']) + expect(completed[1]).toMatchObject({ status: 'completed', qualifiedName: 'hub-b/child' }) + + const roots = mergeRemoteAgents([ + { id: 'main', name: 'Loopal', status: 'waiting', children: [] }, + ], { + state: 'connected', hubName: 'hub-a', hubs: [], topology: [{ + id: 'hub-b/main', name: 'main', hub: 'hub-b', hubPath: ['hub-b'], + children: [], lifecycle: 'running', + }], refreshedAt: new Date().toISOString(), + }) + expect(roots.map((agent) => agent.id)).toEqual(['main', 'hub-b/main']) + }) + + it('projects partial, failed, and error-bearing remote topologies', () => { + const projected = projectTopology({ hubs: [ + { hub: 'broken', topology: { error: 'unreachable' } }, + { hub: 'remote', topology: { agents: [ + { + name: 'starting', parent: 'root', children: ['failed'], lifecycle: 'spawning', + model: 'mock-model', error: null, + }, + { + name: 'failed', parent: null, children: [], lifecycle: 'failed', + model: null, error: 'worker stopped', + }, + ] } }, + ] }) + expect(projected).toEqual([ + expect.objectContaining({ + id: 'remote/failed', error: 'worker stopped', lifecycle: 'failed', + }), + expect.objectContaining({ + id: 'remote/starting', parentId: 'remote/root', model: 'mock-model', + children: ['remote/failed'], lifecycle: 'spawning', + }), + ]) + + const merged = mergeRemoteAgents([], { + state: 'connected', hubName: 'local', hubs: [], topology: projected, + refreshedAt: '2026-01-01T00:00:00.000Z', + }) + expect(merged).toEqual([ + expect.objectContaining({ id: 'remote/failed', status: 'failed' }), + expect.objectContaining({ id: 'remote/starting', status: 'starting' }), + ]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.ts new file mode 100644 index 00000000..94579bb5 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-projection.ts @@ -0,0 +1,187 @@ +import { + type AgentSummary, + type MetaHubRuntimeState, + type MetaHubTopologyAgent, +} from '../../../../shared/contracts' +import { + HubStatusWireSchema, + MetaHubListWireSchema, + MetaHubTopologyWireSchema, + type MetaHubTopologyWire, +} from './loopal-metahub-wire' +import { type DesktopHostClient } from '../backend/loopal-backend-types' + +const CACHE_TTL_MS = 2_000 +const QUERY_TIMEOUT_MS = 2_500 +interface CachedState { + value?: MetaHubRuntimeState + loadedAt?: number + pending?: Promise +} +const states = new WeakMap() + +export async function loadMetaHubState( + host: DesktopHostClient, + now: Date, + force = false, +): Promise { + const cache = states.get(host) ?? {} + states.set(host, cache) + if (!force && cache.value && cache.loadedAt !== undefined + && now.getTime() - cache.loadedAt < CACHE_TTL_MS) return cache.value + if (cache.pending) return cache.pending + cache.pending = queryMetaHubState(host, now).then((value) => { + cache.value = value + cache.loadedAt = now.getTime() + return value + }).finally(() => { delete cache.pending }) + return cache.pending +} + +export function invalidateMetaHubState(host: DesktopHostClient): void { + states.delete(host) +} + +export function readMetaHubState( + host: DesktopHostClient, + now: Date, +): MetaHubRuntimeState { + return states.get(host)?.value ?? { + state: 'disconnected', hubs: [], topology: [], refreshedAt: now.toISOString(), + } +} + +export function sameMetaHubState( + left: MetaHubRuntimeState | undefined, + right: MetaHubRuntimeState, +): boolean { + if (!left) return false + const { refreshedAt: _leftTime, ...leftValue } = left + const { refreshedAt: _rightTime, ...rightValue } = right + return JSON.stringify(leftValue) === JSON.stringify(rightValue) +} + +async function queryMetaHubState( + host: DesktopHostClient, + now: Date, +): Promise { + let status: ReturnType + try { + status = HubStatusWireSchema.parse(await request(host, 'hub/status')) + } catch (error) { + return { + state: 'error', hubs: [], topology: [], error: errorMessage(error), + refreshedAt: now.toISOString(), + } + } + if (!status.uplink?.connected) { + return { state: 'disconnected', hubs: [], topology: [], refreshedAt: now.toISOString() } + } + try { + const [list, topology] = await Promise.all([ + request(host, 'meta/list_hubs'), + request(host, 'meta/topology'), + ]) + return { + state: 'connected', + address: status.uplink.address ?? undefined, + hubName: status.uplink.hub_name, + hubs: MetaHubListWireSchema.parse(list).hubs.map((hub) => ({ + name: hub.name, + status: hubStatus(hub.status), + agentCount: hub.agent_count, + capabilities: hub.capabilities, + })).sort((left, right) => left.name.localeCompare(right.name)), + topology: projectTopology(MetaHubTopologyWireSchema.parse(topology)), + refreshedAt: now.toISOString(), + } + } catch (error) { + return { + state: 'error', + address: status.uplink.address ?? undefined, + hubName: status.uplink.hub_name, + hubs: [], topology: [], error: errorMessage(error), refreshedAt: now.toISOString(), + } + } +} + +async function request(host: DesktopHostClient, method: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), QUERY_TIMEOUT_MS) + try { return await host.request(method, {}, controller.signal) } + finally { clearTimeout(timer) } +} + +export function projectTopology(value: MetaHubTopologyWire): MetaHubTopologyAgent[] { + return value.hubs.flatMap(({ hub, topology }) => { + if ('error' in topology) return [] + return topology.agents.map((agent) => { + const id = qualified(hub, agent.name) + return { + id, name: agent.name, hub, hubPath: [hub], lifecycle: agent.lifecycle, + children: agent.children.map((child) => qualified(hub, child)), + ...(agent.parent ? { parentId: parentId(hub, agent.parent) } : {}), + ...(agent.model ? { model: agent.model } : {}), + ...(agent.error ? { error: agent.error } : {}), + } + }) + }).sort((left, right) => left.id.localeCompare(right.id)) +} + +export function mergeRemoteAgents( + local: readonly AgentSummary[], + state: MetaHubRuntimeState, +): AgentSummary[] { + const localHub = state.hubName + const remoteTopology = state.topology.filter((agent) => agent.hub !== localHub) + const remote = remoteTopology.map((agent) => ({ + id: agent.id, + name: `${agent.name} · ${agent.hub}`, + status: lifecycleStatus(agent.lifecycle), + parentId: normalizeLocal(agent.parentId, localHub), + children: agent.children.map((child) => normalizeLocal(child, localHub)!), + model: agent.model, + error: agent.error, + hubPath: agent.hubPath, + qualifiedName: agent.id, + controllable: agent.lifecycle === 'running' || agent.lifecycle === 'spawning', + })).map(removeUndefined) + const replacedProxies = new Set(local.filter((agent) => agent.shadow + && remoteTopology.some((remoteAgent) => remoteAgent.name === agent.id + && normalizeLocal(remoteAgent.parentId, localHub) === agent.parentId)) + .map((agent) => agent.id)) + const retained = local.filter((agent) => !replacedProxies.has(agent.id)).map((agent) => ({ + ...agent, + ...(agent.children ? { + children: agent.children.filter((child) => !replacedProxies.has(child)), + } : {}), + })) + const ids = new Set(retained.map((agent) => agent.id)) + return [...retained, ...remote.filter((agent) => !ids.has(agent.id))] +} + +function qualified(hub: string, agent: string): string { return `${hub}/${leaf(agent)}` } +function normalizeLocal(value: string | undefined, localHub: string | undefined): string | undefined { + if (!value || !localHub || !value.startsWith(`${localHub}/`)) return value + return leaf(value) +} +function parentId(hub: string, parent: string): string { + return parent.includes('/') ? parent : qualified(hub, parent) +} +function leaf(value: string): string { return value.split('/').at(-1) || value } +function hubStatus(value: string): 'connected' | 'degraded' | 'disconnected' { + const status = value.toLowerCase() + return status === 'degraded' || status === 'disconnected' ? status : 'connected' +} +function lifecycleStatus(value: MetaHubTopologyAgent['lifecycle']): AgentSummary['status'] { + if (value === 'spawning') return 'starting' + if (value === 'finished') return 'completed' + if (value === 'failed') return 'failed' + return 'running' +} +function errorMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 500) +} +function removeUndefined>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-runtime.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-runtime.ts new file mode 100644 index 00000000..39d17c50 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-runtime.ts @@ -0,0 +1,49 @@ +import { type IDisposable } from '../../../../base/common/lifecycle' +import { type MetaHubStartupOptions } from '../../../desktop-host/node/host/desktop-host-types' +import { bindLocalMetaHub, type LocalMetaHubOperations, startLocalOnLaunch } from './loopal-local-metahub' +import { bindMetaHub } from './loopal-metahub-bind' +import { LoopalMetaHubCoordinator } from './loopal-metahub-coordinator' +import { type MetaHubOperations } from './loopal-metahub-service' +import { LoopalMetaHubSettings } from './loopal-metahub-settings' +import { type LoopalSessionDirectory } from '../sessions/loopal-session-directory' + +export type MetaHubBackendOperations = MetaHubOperations & LocalMetaHubOperations + +export class LoopalMetaHubRuntime implements IDisposable { + private readonly settings: LoopalMetaHubSettings + private readonly coordinator: LoopalMetaHubCoordinator + + constructor(options: { + readonly binaryPath: string + readonly parentPid: number + readonly settingsPath?: string + }) { + this.settings = new LoopalMetaHubSettings(options.settingsPath) + this.coordinator = new LoopalMetaHubCoordinator(options.binaryPath, options.parentPid) + } + + get startup(): MetaHubStartupOptions | undefined { + if (this.settings.publicValue.startLocalOnLaunch + && this.coordinator.status.state !== 'running') return undefined + return this.settings.startup + } + + operations( + directory: LoopalSessionDirectory, + now: () => Date, + ): MetaHubBackendOperations { + return { + ...bindMetaHub(this.settings, directory, now), + ...bindLocalMetaHub(this.coordinator, this.settings), + } + } + + async load(): Promise { + await this.settings.load() + await startLocalOnLaunch(this.coordinator, this.settings) + } + + flush(): Promise { return this.settings.flush() } + stop(): Promise { return this.coordinator.stop() } + dispose(): void { this.coordinator.dispose() } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.test.ts new file mode 100644 index 00000000..db5f994f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.test.ts @@ -0,0 +1,94 @@ +import { Emitter } from '../../../../base/common/event' +import { CancellationToken } from '../../../../base/common/cancellation' +import { LoopalMetaHubService } from './loopal-metahub-service' +import { LoopalMetaHubSettings } from './loopal-metahub-settings' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +const target = { sessionId: 'session', runtimeId: 'runtime', generation: 1 } + +function fixture() { + let connected = false + const requests = vi.fn(async (method: string) => { + if (method === 'hub/status') return { + agent_count: 1, + uplink: connected ? { + connected: true, hub_name: 'desktop-a', address: '127.0.0.1:9000', + } : null, + } + if (method === 'hub/join_meta') { connected = true; return { connected: true } } + if (method === 'hub/leave_meta') { connected = false; return { connected: false } } + if (method === 'meta/list_hubs') return { hubs: [{ + name: 'desktop-a', status: 'Connected', agent_count: 1, capabilities: [], + }] } + if (method === 'meta/topology') return { hubs: [{ + hub: 'desktop-a', topology: { agents: [] }, + }] } + throw new Error(`unexpected ${method}`) + }) + const statuses = new Emitter() + const notifications = new Emitter() + const host: DesktopHostClient = { + currentStatus: 'ready', onStatus: statuses.event, onNotification: notifications.event, + request: requests, start: async () => ({ sessionId: 'session', serverVersion: '1', pid: 1 }), + stop: async () => undefined, dispose: () => undefined, + } + const runtime: SessionRuntimeHandle = { ...target, workspaceId: 'workspace', host } + const settings = new LoopalMetaHubSettings() + const resync = vi.fn(async () => undefined) + const service = new LoopalMetaHubService({ + settings, runtime: () => runtime, resync, now: () => new Date('2026-01-01T00:00:00Z'), + }) + return { service, settings, requests, resync, setConnected: (value: boolean) => { connected = value } } +} + +describe('LoopalMetaHubService', () => { + it('updates secret-backed settings, joins, reconnects, refreshes, and disconnects', async () => { + const value = fixture() + await value.service.updateMetaHubSettings({ + address: '127.0.0.1:9000', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, token: 'secret', + }) + expect(await value.service.getMetaHubSettings()).toMatchObject({ tokenConfigured: true }) + await expect(value.service.joinMetaHub(target)).resolves.toMatchObject({ + state: 'connected', hubName: 'desktop-a', + }) + await expect(value.service.getMetaHubStatus(target)).resolves.toMatchObject({ + state: 'connected', hubs: [expect.objectContaining({ name: 'desktop-a' })], + }) + await value.service.joinMetaHub({ + ...target, address: '127.0.0.1:9000', hubName: 'desktop-a', token: 'replacement', + }) + expect(value.requests.mock.calls.filter(([method]) => method === 'hub/leave_meta')).toHaveLength(1) + await expect(value.service.disconnectMetaHub(target)).resolves.toMatchObject({ + state: 'disconnected', hubs: [], + }) + expect(value.resync).toHaveBeenCalledTimes(3) + }) + + it('rejects incomplete credentials and cancellation before RPC', async () => { + const value = fixture() + await expect(value.service.joinMetaHub(target)).rejects.toThrow('required') + await expect(value.service.getMetaHubSettings(CancellationToken.Cancelled)).rejects + .toThrow('cancelled') + await expect(value.service.updateMetaHubSettings({ + address: '', hubName: 'desktop-a', joinOnStart: false, + startLocalOnLaunch: false, + }, CancellationToken.Cancelled)).rejects.toThrow('cancelled') + }) + + it('leaves an error-state uplink before replacing it', async () => { + const value = fixture() + await value.settings.update({ + address: '127.0.0.1:9000', hubName: 'desktop-a', joinOnStart: false, + startLocalOnLaunch: false, token: 'secret', + }) + value.setConnected(true) + value.requests.mockImplementationOnce(async () => ({ + agent_count: 1, + uplink: { connected: true, hub_name: 'desktop-a', address: '127.0.0.1:9000' }, + })) + await value.service.joinMetaHub(target) + expect(value.requests).toHaveBeenCalledWith('hub/leave_meta', {}, expect.any(AbortSignal)) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.ts new file mode 100644 index 00000000..1cfeb2fe --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-service.ts @@ -0,0 +1,123 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + type JoinMetaHubInput, + type MetaHubRuntimeState, + type MetaHubRuntimeTarget, + type MetaHubSettings, + type UpdateMetaHubSettingsInput, +} from '../../../../shared/contracts' +import { + invalidateMetaHubState, + loadMetaHubState, +} from './loopal-metahub-projection' +import { type LoopalMetaHubSettings } from './loopal-metahub-settings' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +export interface MetaHubOperations { + getMetaHubSettings(token?: CancellationToken): Promise + updateMetaHubSettings( + input: UpdateMetaHubSettingsInput, token?: CancellationToken, + ): Promise + getMetaHubStatus( + target: MetaHubRuntimeTarget, token?: CancellationToken, + ): Promise + joinMetaHub(input: JoinMetaHubInput, token?: CancellationToken): Promise + disconnectMetaHub( + target: MetaHubRuntimeTarget, token?: CancellationToken, + ): Promise +} + +interface MetaHubServiceOptions { + readonly settings: LoopalMetaHubSettings + readonly runtime: (target: MetaHubRuntimeTarget) => SessionRuntimeHandle + readonly resync: (sessionId: string) => Promise + readonly now: () => Date +} + +export class LoopalMetaHubService implements MetaHubOperations { + constructor(private readonly options: MetaHubServiceOptions) {} + + async getMetaHubSettings(token = CancellationToken.None): Promise { + throwIfCancelled(token) + return this.options.settings.publicValue + } + + async updateMetaHubSettings( + input: UpdateMetaHubSettingsInput, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const value = await this.options.settings.update(input) + throwIfCancelled(token) + return value + } + + async getMetaHubStatus( + target: MetaHubRuntimeTarget, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const runtime = this.options.runtime(target) + const value = await loadMetaHubState(runtime.host, this.options.now(), true) + throwIfCancelled(token) + return value + } + + async joinMetaHub( + input: JoinMetaHubInput, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const runtime = this.options.runtime(input) + const stored = this.options.settings.credentials + const address = input.address ?? stored?.address + const hubName = input.hubName ?? stored?.hubName + const secret = input.token ?? stored?.token + if (!address || !hubName || !secret) { + throw new Error('MetaHub address, hub name, and token are required') + } + const current = await loadMetaHubState(runtime.host, this.options.now(), true) + if (current.state !== 'disconnected') { + await call(runtime, 'hub/leave_meta', {}, token) + } + throwIfCancelled(token) + await call(runtime, 'hub/join_meta', { + address, hub_name: hubName, token: secret, + }, token) + invalidateMetaHubState(runtime.host) + const value = await loadMetaHubState(runtime.host, this.options.now(), true) + await this.options.resync(input.sessionId) + return value + } + + async disconnectMetaHub( + target: MetaHubRuntimeTarget, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + const runtime = this.options.runtime(target) + await call(runtime, 'hub/leave_meta', {}, token) + invalidateMetaHubState(runtime.host) + const value = await loadMetaHubState(runtime.host, this.options.now(), true) + await this.options.resync(target.sessionId) + return value + } +} + +async function call( + runtime: SessionRuntimeHandle, + method: string, + params: unknown, + token: CancellationToken, +): Promise { + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, params, controller.signal) + throwIfCancelled(token) + return result + } finally { + subscription.dispose() + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.test.ts new file mode 100644 index 00000000..ae26b361 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LoopalMetaHubSettings } from './loopal-metahub-settings' + +describe('LoopalMetaHubSettings', () => { + let root = '' + let path = '' + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-metahub-settings-')) + path = join(root, 'nested', 'metahub.json') + }) + afterEach(async () => rm(root, { recursive: true, force: true })) + + it('persists credentials privately without returning the token', async () => { + const settings = new LoopalMetaHubSettings(path) + await settings.load() + const publicValue = await settings.update({ + address: '127.0.0.1:9000', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, token: 'cluster-secret', + }) + expect(publicValue).toEqual({ + address: '127.0.0.1:9000', hubName: 'desktop-a', joinOnStart: true, + startLocalOnLaunch: false, tokenConfigured: true, + }) + expect(JSON.stringify(publicValue)).not.toContain('cluster-secret') + expect(settings.startup).toEqual({ + address: '127.0.0.1:9000', hubName: 'desktop-a', token: 'cluster-secret', + }) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) + + const restored = new LoopalMetaHubSettings(path) + await restored.load() + expect(restored.publicValue.tokenConfigured).toBe(true) + expect(await readFile(path, 'utf8')).toContain('cluster-secret') + }) + + it('does not join a runtime merely because the managed coordinator starts', async () => { + const settings = new LoopalMetaHubSettings() + await settings.update({ + address: '', hubName: 'desktop-local', joinOnStart: false, + startLocalOnLaunch: true, token: 'old', + }) + await settings.useManaged('127.0.0.1:3456', 'managed-secret') + expect(settings.startup).toBeUndefined() + await settings.update({ + address: '127.0.0.1:3456', hubName: 'desktop-local', joinOnStart: false, + startLocalOnLaunch: false, clearToken: true, + }) + expect(settings.publicValue.tokenConfigured).toBe(false) + expect(settings.startup).toBeUndefined() + }) + + it('clears only the active managed credential and disables stale startup', async () => { + const settings = new LoopalMetaHubSettings(path) + await settings.update({ + address: '127.0.0.1:4567', hubName: 'desktop-local', joinOnStart: true, + startLocalOnLaunch: true, token: 'managed-secret', + }) + expect(await settings.clearManaged('127.0.0.1:9999')).toBe(false) + expect(await settings.clearManaged('127.0.0.1:4567')).toBe(true) + expect(settings.publicValue).toMatchObject({ + address: '', joinOnStart: false, startLocalOnLaunch: true, tokenConfigured: false, + }) + expect(settings.startup).toBeUndefined() + expect(await readFile(path, 'utf8')).not.toContain('managed-secret') + + await settings.useManaged('127.0.0.1:5000', 'local-secret') + await settings.update({ + address: 'meta.example:9000', hubName: 'external', joinOnStart: true, + startLocalOnLaunch: false, token: 'external-secret', + }) + expect(await settings.clearManaged('127.0.0.1:5000')).toBe(false) + expect(settings.credentials).toEqual({ + address: 'meta.example:9000', hubName: 'external', token: 'external-secret', + }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.ts new file mode 100644 index 00000000..c64b446f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-settings.ts @@ -0,0 +1,119 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { z } from 'zod' +import { + MetaHubSettingsSchema, + UpdateMetaHubSettingsInputSchema, + type MetaHubSettings, + type UpdateMetaHubSettingsInput, +} from '../../../../shared/contracts' +import { type MetaHubStartupOptions } from '../../../desktop-host/node/host/desktop-host-types' + +const StoredSchema = MetaHubSettingsSchema.omit({ tokenConfigured: true }).extend({ + version: z.literal(1), + token: z.string().max(4096).optional(), +}) +type Stored = z.infer + +export class LoopalMetaHubSettings { + private value: Stored = { + version: 1, + address: '', + hubName: `desktop-${randomUUID().slice(0, 8)}`, + joinOnStart: false, + startLocalOnLaunch: false, + } + private writes = Promise.resolve() + + constructor(private readonly path?: string) {} + + get publicValue(): MetaHubSettings { + const { token: _token, version: _version, ...value } = this.value + return { ...value, tokenConfigured: Boolean(this.value.token) } + } + + get startup(): MetaHubStartupOptions | undefined { + if (!this.value.joinOnStart || !this.value.address || !this.value.token) return undefined + return { + address: this.value.address, + hubName: this.value.hubName, + token: this.value.token, + } + } + + get credentials(): MetaHubStartupOptions | undefined { + if (!this.value.address || !this.value.token) return undefined + return { + address: this.value.address, + hubName: this.value.hubName, + token: this.value.token, + } + } + + async load(): Promise { + if (!this.path) return + try { + this.value = StoredSchema.parse(JSON.parse(await readFile(this.path, 'utf8'))) + } catch (error) { + if (!isMissing(error)) await this.persist() + } + } + + async update(input: UpdateMetaHubSettingsInput): Promise { + const next = UpdateMetaHubSettingsInputSchema.parse(input) + const token = next.clearToken ? undefined : next.token ?? this.value.token + this.value = { + version: 1, + address: next.address, + hubName: next.hubName, + joinOnStart: next.joinOnStart, + startLocalOnLaunch: next.startLocalOnLaunch, + ...(token ? { token } : {}), + } + await this.persist() + return this.publicValue + } + + async useManaged(address: string, token: string): Promise { + this.value = { ...this.value, address, token } + await this.persist() + } + + async clearManaged(address: string): Promise { + if (this.value.address !== address) return false + this.value = { + version: 1, + address: '', + hubName: this.value.hubName, + joinOnStart: false, + startLocalOnLaunch: this.value.startLocalOnLaunch, + } + await this.persist() + return true + } + + flush(): Promise { return this.writes } + + private persist(): Promise { + if (!this.path) return Promise.resolve() + const snapshot = JSON.stringify(this.value) + const write = async (): Promise => { + await mkdir(dirname(this.path!), { recursive: true }) + const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }) + await rename(temporary, this.path!) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } + } + const queued = this.writes.then(write, write) + this.writes = queued + return queued + } +} + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-support.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-support.test.ts new file mode 100644 index 00000000..00cb87ff --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-support.test.ts @@ -0,0 +1,162 @@ +import { CancellationToken } from '../../../../base/common/cancellation' +import { bindFakeMetaHub } from '../fake/fake-metahub' +import { bindLocalMetaHub, startLocalOnLaunch } from './loopal-local-metahub' +import { bindMetaHub } from './loopal-metahub-bind' +import { type LoopalMetaHubCoordinator } from './loopal-metahub-coordinator' +import { LoopalMetaHubSettings } from './loopal-metahub-settings' +import { LoopalMetaHubRuntime } from './loopal-metahub-runtime' +import { bindUnavailableMetaHub } from '../unavailable/unavailable-metahub' + +const target = { sessionId: 'session', runtimeId: 'runtime', generation: 1 } + +describe('MetaHub support bindings', () => { + it('runs local coordinator operations and fail-open autostart', async () => { + let status = { state: 'stopped' as const } + let ownedAddress: string | undefined + const coordinator = { + get status() { return status }, + get ownedAddress() { return ownedAddress }, + start: vi.fn(async () => { + ownedAddress = '127.0.0.1:9' + status = { state: 'running' as const, address: ownedAddress } as never + return { address: '127.0.0.1:9', token: 'managed-secret' } + }), + stop: vi.fn(async () => { + status = { state: 'stopped' } + ownedAddress = undefined + }), + } as unknown as LoopalMetaHubCoordinator + const settings = new LoopalMetaHubSettings() + const operations = bindLocalMetaHub(coordinator, settings) + expect(await operations.getLocalMetaHubStatus()).toEqual({ state: 'stopped' }) + await expect(operations.startLocalMetaHub({ bindAddress: '127.0.0.1:0' })) + .resolves.toEqual({ state: 'running', address: '127.0.0.1:9' }) + expect(settings.credentials).toMatchObject({ token: 'managed-secret' }) + await expect(operations.stopLocalMetaHub()).resolves.toEqual({ state: 'stopped' }) + expect(settings.credentials).toBeUndefined() + await expect(operations.getLocalMetaHubStatus(CancellationToken.Cancelled)).rejects + .toThrow('cancelled') + + await settings.update({ + address: '', hubName: 'local', joinOnStart: false, + startLocalOnLaunch: true, + }) + await startLocalOnLaunch(coordinator, settings) + expect(coordinator.start).toHaveBeenCalled() + const failingValue = { start: vi.fn(async () => { throw new Error('no port') }) } + const failing = failingValue as unknown as LoopalMetaHubCoordinator + await expect(startLocalOnLaunch(failing, settings)).resolves.toBeUndefined() + await settings.update({ + address: '', hubName: 'local', joinOnStart: false, + startLocalOnLaunch: false, + }) + await startLocalOnLaunch(coordinator, settings) + }) + + it('provides complete deterministic fake operations', async () => { + const publish = vi.fn() + const operations = bindFakeMetaHub( + (value) => value.runtimeId === 'runtime', publish, + ) + expect(await operations.getMetaHubSettings()).toMatchObject({ tokenConfigured: false }) + await operations.updateMetaHubSettings({ + address: 'meta:9', hubName: 'fake-a', joinOnStart: true, + startLocalOnLaunch: false, token: 'secret', + }) + await expect(operations.joinMetaHub(target)).resolves.toMatchObject({ state: 'connected' }) + expect(publish).toHaveBeenLastCalledWith(target, expect.objectContaining({ state: 'connected' })) + await expect(operations.getMetaHubStatus(target)).resolves.toMatchObject({ + topology: [expect.objectContaining({ id: 'fake-a/main' })], + }) + await expect(operations.startLocalMetaHub({ bindAddress: '127.0.0.1:0' })) + .resolves.toMatchObject({ state: 'running' }) + await expect(operations.stopLocalMetaHub()).resolves.toEqual({ state: 'stopped' }) + expect(publish).toHaveBeenLastCalledWith(target, expect.objectContaining({ state: 'disconnected' })) + await expect(operations.joinMetaHub(target)).rejects.toThrow('token') + await operations.updateMetaHubSettings({ + address: 'meta:9', hubName: 'fake-a', joinOnStart: false, + startLocalOnLaunch: false, token: 'secret', + }) + await operations.joinMetaHub(target) + await expect(operations.disconnectMetaHub(target)).resolves.toMatchObject({ + state: 'disconnected', + }) + await expect(operations.getMetaHubStatus({ ...target, runtimeId: 'gone' })).rejects + .toThrow('gone') + await operations.updateMetaHubSettings({ + address: '', hubName: 'fake-a', joinOnStart: false, + startLocalOnLaunch: false, clearToken: true, + }) + await expect(operations.joinMetaHub(target)).rejects.toThrow('token') + await bindFakeMetaHub(() => true).getMetaHubSettings() + }) + + it('clears a crashed managed credential without touching replacement external settings', async () => { + const settings = new LoopalMetaHubSettings() + await settings.update({ + address: '127.0.0.1:4567', hubName: 'local', joinOnStart: true, + startLocalOnLaunch: false, token: 'local-secret', + }) + const coordinator = { + status: { state: 'failed', error: 'crashed' }, + ownedAddress: '127.0.0.1:4567', + stop: vi.fn(async () => undefined), + } as unknown as LoopalMetaHubCoordinator + const operations = bindLocalMetaHub(coordinator, settings) + await operations.stopLocalMetaHub() + expect(settings.publicValue).toMatchObject({ + address: '', joinOnStart: false, tokenConfigured: false, + }) + + await settings.update({ + address: 'meta.example:9000', hubName: 'external', joinOnStart: true, + startLocalOnLaunch: false, token: 'external-secret', + }) + await operations.stopLocalMetaHub() + expect(settings.credentials?.address).toBe('meta.example:9000') + }) + + it('exposes safe unavailable defaults and rejects mutations', async () => { + const operations = bindUnavailableMetaHub('sidecar missing') + expect(await operations.getMetaHubSettings()).toMatchObject({ tokenConfigured: false }) + expect(await operations.getLocalMetaHubStatus()).toEqual({ state: 'stopped' }) + await expect(operations.joinMetaHub(target)).rejects.toThrow('sidecar missing') + await expect(operations.updateMetaHubSettings({ + address: '', hubName: 'x', joinOnStart: false, startLocalOnLaunch: false, + })).rejects.toThrow('sidecar missing') + await expect(operations.getMetaHubStatus(target)).rejects.toThrow('sidecar missing') + await expect(operations.disconnectMetaHub(target)).rejects.toThrow('sidecar missing') + await expect(operations.startLocalMetaHub({ bindAddress: '127.0.0.1:0' })).rejects + .toThrow('sidecar missing') + await expect(operations.stopLocalMetaHub()).rejects.toThrow('sidecar missing') + }) + + it('binds exact runtime generation and requests a live resync', async () => { + const settings = new LoopalMetaHubSettings() + const host = { currentStatus: 'ready', request: vi.fn(async () => ({ + agent_count: 1, uplink: null, + })) } + const resync = vi.fn(async () => undefined) + const directory = { + runtimeForSession: () => ({ ...target, workspaceId: 'workspace', host }), + liveSession: () => ({ resync }), + } + const operations = bindMetaHub(settings, directory as never, () => new Date()) + await expect(operations.getMetaHubStatus(target)).resolves.toMatchObject({ + state: 'disconnected', + }) + await expect(operations.getMetaHubStatus({ ...target, generation: 2 })).rejects + .toMatchObject({ code: 'RUNTIME_GONE' }) + + const composition = new LoopalMetaHubRuntime({ + binaryPath: '/missing-loopal', parentPid: process.pid, + }) + await composition.load() + expect(composition.startup).toBeUndefined() + expect(composition.operations(directory as never, () => new Date())) + .toHaveProperty('joinMetaHub') + await composition.flush() + await composition.stop() + composition.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.test.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.test.ts new file mode 100644 index 00000000..e32d92b0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.test.ts @@ -0,0 +1,61 @@ +import { type MetaHubRuntimeState } from '../../../../shared/contracts' +import { LoopalMetaHubWatcher } from './loopal-metahub-watcher' + +describe('LoopalMetaHubWatcher', () => { + it('coalesces starts, ignores equal refresh timestamps, and disposes its timer', async () => { + vi.useFakeTimers() + const current: MetaHubRuntimeState = { + state: 'disconnected', hubs: [], topology: [], refreshedAt: '2026-01-01T00:00:00.000Z', + } + const request = vi.fn(async () => ({ agent_count: 1, uplink: null })) + const changed = vi.fn(async () => undefined) + const watcher = new LoopalMetaHubWatcher( + { request } as never, + () => new Date('2026-01-01T00:00:02.000Z'), + () => current, + changed, + ) + watcher.start() + watcher.start() + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(1) + expect(changed).not.toHaveBeenCalled() + watcher.dispose() + await vi.advanceTimersByTimeAsync(2_100) + expect(request).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('contains refresh callback failures and keeps polling until disposal', async () => { + vi.useFakeTimers() + const request = vi.fn(async () => { throw new Error('old host') }) + const changed = vi.fn(async () => { throw new Error('render gone') }) + const watcher = new LoopalMetaHubWatcher( + { request } as never, () => new Date(), () => undefined, changed, + ) + watcher.start() + await vi.advanceTimersByTimeAsync(1) + expect(changed).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(2_001) + expect(request).toHaveBeenCalledTimes(2) + watcher.dispose() + vi.useRealTimers() + }) + + it('does not revive after disposal while a poll is in flight', async () => { + vi.useFakeTimers() + let resolve!: (value: unknown) => void + const request = vi.fn(() => new Promise((done) => { resolve = done })) + const watcher = new LoopalMetaHubWatcher( + { request } as never, () => new Date(), () => undefined, vi.fn(async () => undefined), + ) + watcher.start() + await vi.advanceTimersByTimeAsync(1) + watcher.dispose() + resolve({ agent_count: 0, uplink: null }) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(2_100) + expect(request).toHaveBeenCalledOnce() + vi.useRealTimers() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.ts new file mode 100644 index 00000000..ef8dddce --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-watcher.ts @@ -0,0 +1,44 @@ +import { type MetaHubRuntimeState } from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { loadMetaHubState, sameMetaHubState } from './loopal-metahub-projection' + +export class LoopalMetaHubWatcher { + private timer: ReturnType | undefined + private active = false + + constructor( + private readonly host: DesktopHostClient, + private readonly now: () => Date, + private readonly current: () => MetaHubRuntimeState | undefined, + private readonly changed: () => Promise, + ) {} + + start(): void { + if (this.active) return + this.active = true + this.schedule(0) + } + + dispose(): void { + this.active = false + if (this.timer) clearTimeout(this.timer) + this.timer = undefined + } + + private schedule(delay: number): void { + if (!this.active || this.timer) return + this.timer = setTimeout(() => { + this.timer = undefined + void this.poll() + }, delay) + this.timer.unref?.() + } + + private async poll(): Promise { + try { + const state = await loadMetaHubState(this.host, this.now(), true) + if (this.active && !sameMetaHubState(this.current(), state)) await this.changed() + } catch {} + finally { this.schedule(2_000) } + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-wire.ts b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-wire.ts new file mode 100644 index 00000000..d3854f23 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/federation/loopal-metahub-wire.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { TopologySchema } from '../runtime/loopal-wire' + +export const HubStatusWireSchema = z.object({ + agent_count: z.number().int().nonnegative(), + uplink: z.object({ + connected: z.boolean(), + hub_name: z.string().min(1), + address: z.string().nullable().optional(), + }).nullable(), +}) + +export const MetaHubListWireSchema = z.object({ + hubs: z.array(z.object({ + name: z.string().min(1), + status: z.string(), + agent_count: z.number().int().nonnegative(), + capabilities: z.array(z.string()).default([]), + })), +}) + +export const MetaHubTopologyWireSchema = z.object({ + hubs: z.array(z.object({ + hub: z.string().min(1), + topology: TopologySchema.or(z.object({ error: z.string() })), + })), +}) + +export type MetaHubTopologyWire = z.infer diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.test.ts new file mode 100644 index 00000000..67fbfd36 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.test.ts @@ -0,0 +1,28 @@ +import { projectModifiedFiles } from './loopal-artifact-projection' + +describe('Loopal artifact projection', () => { + it('deduplicates paths and assigns stable file types', () => { + const artifacts = projectModifiedFiles('session', 'worker', [ + './src/main.rs', 'src/main.rs', 'README.md', 'icon.svg', 'photo.png', 'photo.jpg', + 'guide.pdf', 'notes.txt', ' ', + ], '2026-07-11T12:00:00.000Z') + expect(artifacts).toHaveLength(7) + expect(artifacts.map(({ title, kind, mediaType }) => ({ title, kind, mediaType }))) + .toEqual([ + { title: 'main.rs', kind: 'code', mediaType: 'text/plain' }, + { title: 'README.md', kind: 'document', mediaType: 'text/markdown' }, + { title: 'icon.svg', kind: 'image', mediaType: 'image/svg+xml' }, + { title: 'photo.png', kind: 'image', mediaType: 'image/png' }, + { title: 'photo.jpg', kind: 'image', mediaType: 'image/jpeg' }, + { title: 'guide.pdf', kind: 'document', mediaType: 'application/pdf' }, + { title: 'notes.txt', kind: 'document', mediaType: 'text/plain' }, + ]) + expect(artifacts[0]).toMatchObject({ + sessionId: 'session', producerAgentId: 'worker', + uri: 'loopal-workspace://src%2Fmain.rs', + createdAt: '2026-07-11T12:00:00.000Z', + }) + expect(projectModifiedFiles('session', 'other', ['src/main.rs'], '2026-07-11T12:00:00.000Z')[0]?.id) + .not.toBe(artifacts[0]?.id) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.ts new file mode 100644 index 00000000..1dddce98 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-artifact-projection.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto' +import { basename, extname } from 'node:path' +import { type Artifact } from '../../../../shared/contracts' + +export function projectModifiedFiles( + sessionId: string, + agentId: string, + paths: readonly string[], + createdAt: string, +): Artifact[] { + return [...new Set(paths.map(normalizePath).filter(Boolean))].map((path) => ({ + id: `file-${digest(`${agentId}\0${path}`)}`, + sessionId, + title: basename(path), + ...artifactType(path), + uri: `loopal-workspace://${encodeURIComponent(path)}`, + producerAgentId: agentId, + createdAt, + })) +} + +function normalizePath(value: string): string { + return value.trim().replace(/^\.\//, '') +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 16) +} + +function artifactType(path: string): Pick { + const extension = extname(path).toLowerCase() + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(extension)) { + const subtype = extension === '.jpg' ? 'jpeg' : extension.slice(1) + return { kind: 'image', mediaType: extension === '.svg' ? 'image/svg+xml' : `image/${subtype}` } + } + if (['.md', '.mdx', '.txt', '.pdf'].includes(extension)) { + const mediaType = extension === '.pdf' + ? 'application/pdf' + : extension === '.txt' ? 'text/plain' : 'text/markdown' + return { kind: 'document', mediaType } + } + return { kind: 'code', mediaType: 'text/plain' } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-control-event-projector.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-control-event-projector.test.ts new file mode 100644 index 00000000..1b562078 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-control-event-projector.test.ts @@ -0,0 +1,41 @@ +import { LoopalEventProjector } from './loopal-event-projector' + +const now = () => new Date('2026-07-14T12:00:00.000Z') + +describe('LoopalEventProjector runtime control notices', () => { + it('appends structured notices only after authoritative runtime events', () => { + const append = vi.fn() + const appendAgent = vi.fn() + const projector = new LoopalEventProjector(now, { + append, + appendAgent, + updateSession: vi.fn(), + attention: vi.fn(), + }) + projector.finishSync(0, { worker: 0 }) + + projector.accept(wire({ ModeChanged: { mode: 'plan' } }, 1)) + projector.accept(wire({ Compacted: { + kept: 4, summarized: 7, tokens_before: 8_000, tokens_after: 3_000, + strategy: 'manual', files_rehydrated: 0, + } }, 1, 'worker')) + + expect(append).toHaveBeenCalledWith(expect.objectContaining({ + role: 'system', text: 'Agent mode changed to plan.', + eventNotice: { kind: 'mode_changed', values: { value: 'plan' } }, + })) + expect(appendAgent).toHaveBeenCalledWith(expect.objectContaining({ + role: 'system', text: 'Context compacted: 8000 → 3000 tokens.', + eventNotice: { + kind: 'context_compacted', values: { tokensBefore: 8_000, tokensAfter: 3_000 }, + }, + }), 'worker') + }) +}) + +function wire(payload: unknown, rev: number, agent = 'main') { + return { + agent_name: { hub: [], agent }, event_id: rev, + turn_id: 1, correlation_id: 2, rev, payload, + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-normalizers.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-normalizers.ts new file mode 100644 index 00000000..166ef34d --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-normalizers.ts @@ -0,0 +1,15 @@ +import { type AgentSummary, type ConversationEntry } from '../../../../shared/contracts' + +export function normalizeRole(role: string): ConversationEntry['role'] { + return role === 'user' || role === 'assistant' ? role : 'system' +} + +export function normalizeAgentStatus(status: string): AgentSummary['status'] { + if (status === 'Starting') return 'starting' + if (status === 'Running') return 'running' + if (status === 'WaitingForInput') return 'waiting' + if (status === 'Suspended') return 'suspended' + if (status === 'Finished') return 'completed' + if (status === 'Error') return 'failed' + return 'idle' +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.test.ts new file mode 100644 index 00000000..40c4727b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.test.ts @@ -0,0 +1,66 @@ +import { projectEventNotice } from './loopal-event-notice' + +describe('projectEventNotice', () => { + it('projects transient lifecycle events that are absent from ViewSnapshot', () => { + expect(text('ContinuationSkipped', { reason: 'goal changed' })) + .toBe('Continuation skipped: goal changed') + expect(text('DegenerationDetected', { + signal: 'barren_streak', count: 3, + })).toBe('Degeneration detected: barren streak (3).') + expect(text('ContinuationGateChanged', { + open: false, closed_reason: 'idle_timeout', wake_deadline: '2026-07-12T12:00:00Z', + })).toBe('Automatic continuation paused: idle timeout until 2026-07-12T12:00:00Z.') + expect(text('ContinuationGateChanged', { open: true })) + .toBe('Automatic continuation resumed.') + }) + + it('projects authoritative runtime changes with structured localization data', () => { + const changes = [ + ['ModeChanged', { mode: 'plan' }, 'Agent mode changed to plan.', 'mode_changed'], + ['ModelChanged', { model: 'claude-opus' }, 'Model changed to claude-opus.', 'model_changed'], + ['PermissionModeChanged', { mode: 'ask_dangerous' }, + 'Permission mode changed to ask dangerous.', 'permission_mode_changed'], + ['DecisionModeChanged', { mode: 'manual' }, + 'Decision mode changed to manual.', 'decision_mode_changed'], + ['SandboxPolicyChanged', { policy: 'read_only' }, + 'Sandbox policy changed to read only.', 'sandbox_policy_changed'], + ] as const + for (const [kind, value, fallback, noticeKind] of changes) { + expect(projectEventNotice(kind, value)).toEqual({ + text: fallback, + runtime: { kind: noticeKind, values: { value: Object.values(value)[0] } }, + }) + } + expect(projectEventNotice('ThinkingChanged', { thinking_config: '{}' })).toEqual({ + text: 'Thinking configuration changed.', runtime: { kind: 'thinking_changed' }, + }) + expect(projectEventNotice('Cleared', { context_window: 200_000 })).toEqual({ + text: 'Conversation cleared.', runtime: { kind: 'conversation_cleared' }, + }) + expect(projectEventNotice('Rewound', { remaining_turns: 2 })).toEqual({ + text: 'Conversation rewound; 2 turns remain.', + runtime: { kind: 'conversation_rewound', values: { remaining: 2 } }, + }) + expect(projectEventNotice('Compacted', { + tokens_before: 8_000, tokens_after: 3_000, kept: 4, summarized: 7, + })).toEqual({ + text: 'Context compacted: 8000 → 3000 tokens.', + runtime: { + kind: 'context_compacted', values: { tokensBefore: 8_000, tokensAfter: 3_000 }, + }, + }) + }) + + it('ignores malformed warnings and unrelated events', () => { + expect(projectEventNotice('SessionResumeWarnings', { warnings: [1, null] })).toBeUndefined() + expect(text('TurnCancelled', null)).toBe('Turn cancelled.') + expect(text('DegenerationDetected', { count: Number.NaN })) + .toBe('Degeneration detected.') + expect(projectEventNotice('Stream', { text: 'answer' })).toBeUndefined() + expect(projectEventNotice('ModeChanged', {})).toBeUndefined() + }) +}) + +function text(kind: string, value: unknown): string | undefined { + return projectEventNotice(kind, value)?.text +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.ts new file mode 100644 index 00000000..a2be3bcb --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-notice.ts @@ -0,0 +1,118 @@ +import { type RuntimeEventNotice } from '../../../../shared/contracts' + +export interface ProjectedEventNotice { + readonly text: string + readonly runtime?: RuntimeEventNotice +} + +export function projectEventNotice(kind: string, value: unknown): ProjectedEventNotice | undefined { + const fields = record(value) + if (kind === 'SessionResumeWarnings') { + const warnings = Array.isArray(fields?.warnings) + ? fields.warnings.filter((item): item is string => typeof item === 'string') + : [] + return warnings.length > 0 ? notice(`Session resume warning: ${warnings.join('; ')}`) : undefined + } + if (kind === 'Interrupted') return notice('Turn interrupted.') + if (kind === 'TurnCancelled') return notice(detail('Turn cancelled', fields?.cause)) + if (kind === 'ContinuationSkipped') return notice(detail('Continuation skipped', fields?.reason)) + if (kind === 'DegenerationDetected') { + const signal = label(fields?.signal) + const count = number(fields?.count) + return notice(`Degeneration detected${signal ? `: ${signal}` : ''}${count ? ` (${count})` : ''}.`) + } + if (kind === 'ContinuationGateChanged') { + if (fields?.open === true) return notice('Automatic continuation resumed.') + const reason = label(fields?.closed_reason) + const deadline = typeof fields?.wake_deadline === 'string' ? fields.wake_deadline : undefined + return notice(`Automatic continuation paused${reason ? `: ${reason}` : ''}${ + deadline ? ` until ${deadline}` : ''}.`) + } + const changed = changedNotice(kind, fields) + if (changed) return changed + if (kind === 'ThinkingChanged') { + return runtimeNotice('Thinking configuration changed.', 'thinking_changed') + } + if (kind === 'Cleared') { + return runtimeNotice('Conversation cleared.', 'conversation_cleared') + } + if (kind === 'Rewound') { + const remaining = count(fields?.remaining_turns) + return remaining === undefined + ? notice('Conversation rewound.') + : runtimeNotice(`Conversation rewound; ${remaining} turns remain.`, 'conversation_rewound', { + remaining, + }) + } + if (kind === 'Compacted') { + const before = count(fields?.tokens_before) + const after = count(fields?.tokens_after) + return before === undefined || after === undefined + ? notice('Context compacted.') + : runtimeNotice(`Context compacted: ${before} → ${after} tokens.`, 'context_compacted', { + tokensBefore: before, tokensAfter: after, + }) + } + return undefined +} + +function changedNotice( + kind: string, + fields: Record | undefined, +): ProjectedEventNotice | undefined { + const definitions = { + ModeChanged: ['mode', 'Agent mode', 'mode_changed'], + ModelChanged: ['model', 'Model', 'model_changed'], + PermissionModeChanged: ['mode', 'Permission mode', 'permission_mode_changed'], + DecisionModeChanged: ['mode', 'Decision mode', 'decision_mode_changed'], + SandboxPolicyChanged: ['policy', 'Sandbox policy', 'sandbox_policy_changed'], + } as const + const definition = definitions[kind as keyof typeof definitions] + if (!definition) return undefined + const [field, subject, noticeKind] = definition + const raw = string(fields?.[field]) + return raw + ? runtimeNotice(`${subject} changed to ${label(raw)}.`, noticeKind, { value: raw }) + : undefined +} + +function notice(text: string): ProjectedEventNotice { + return { text } +} + +function runtimeNotice( + text: string, + kind: RuntimeEventNotice['kind'], + values?: RuntimeEventNotice['values'], +): ProjectedEventNotice { + return { text, runtime: { kind, ...(values ? { values } : {}) } } +} + +function detail(prefix: string, value: unknown): string { + return typeof value === 'string' && value.length > 0 ? `${prefix}: ${value}` : `${prefix}.` +} + +function label(value: unknown): string | undefined { + const text = string(value) + return text + ? text.replaceAll('_', ' ') + : undefined +} + +function number(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined +} + +function count(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function string(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.test.ts new file mode 100644 index 00000000..301c243c --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import { + LoopalEventProjector, + normalizeAgentStatus, + normalizeRole, +} from './loopal-event-projector' + +const now = () => new Date('2026-07-11T12:00:00.000Z') + +function wire(payload: unknown, revision?: number, address: unknown = { hub: [], agent: 'main' }) { + return { + agent_name: address, + event_id: 7, + turn_id: 1, + correlation_id: 2, + ...(revision === undefined ? {} : { rev: revision }), + payload, + } +} + +function harness() { + const append = vi.fn() + const appendAgent = vi.fn() + const updateSession = vi.fn() + const attention = vi.fn() + const overflow = vi.fn() + const artifacts = vi.fn() + const projector = new LoopalEventProjector(now, { + append, appendAgent, updateSession, attention, overflow, artifacts, + }) + return { projector, append, appendAgent, updateSession, attention, overflow, artifacts } +} + +describe('LoopalEventProjector', () => { + it('buffers during sync, replays fresh revisions, and drops duplicates', () => { + const { projector, updateSession } = harness() + projector.accept(wire('Started', 2)) + expect(updateSession).not.toHaveBeenCalled() + projector.finishSync(1) + expect(updateSession).toHaveBeenCalledWith('running') + projector.accept(wire('Running', 2)) + expect(updateSession).toHaveBeenCalledOnce() + projector.beginSync() + projector.accept(wire('Running', 4)) + projector.finishSync(3) + expect(updateSession).toHaveBeenCalledTimes(2) + }) + + it('projects every lifecycle, attention, error, and tool payload', () => { + const { projector, append, updateSession, attention } = harness() + projector.finishSync(0) + projector.accept(wire({ Stream: { text: 'answer' } }, 1)) + projector.accept(wire({ ToolPermissionRequest: { id: 'p' } }, 2)) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ role: 'assistant', text: 'answer' })) + expect(attention).toHaveBeenCalledWith('permission_requested', { id: 'p' }, 'main') + expect(updateSession).toHaveBeenCalledWith('waiting', 'permission') + + projector.accept(wire({ UserQuestionRequest: { id: 'q' } }, 3)) + projector.accept(wire({ ToolPermissionResolved: { id: 'p' } }, 4)) + projector.accept(wire({ UserQuestionResolved: { id: 'q' } }, 5)) + expect(attention).toHaveBeenCalledWith('question_requested', { id: 'q' }, 'main') + expect(attention).toHaveBeenCalledWith('permission_resolved', { id: 'p' }, 'main') + expect(attention).toHaveBeenCalledWith('question_resolved', { id: 'q' }, 'main') + + projector.accept(wire({ Error: { message: 'failed' } }, 6)) + projector.accept(wire({ Error: null }, 7)) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ + id: 'event-7', role: 'error', text: 'failed', + })) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ text: 'Loopal runtime failed' })) + expect(updateSession).toHaveBeenCalledWith('failed', 'failure') + + for (const payload of ['AwaitingInput', 'Finished', { TurnCompleted: {} }]) { + projector.accept(wire(payload)) + } + expect(updateSession).not.toHaveBeenCalledWith('waiting', 'completed') + projector.accept(wire('Running')) + projector.accept(wire({ TurnCompleted: {} })) + expect(updateSession).not.toHaveBeenLastCalledWith('waiting', 'completed') + projector.accept(wire('AwaitingInput')) + expect(updateSession).toHaveBeenLastCalledWith('waiting', 'completed') + projector.accept(wire({ ToolCall: { name: 'Read' } })) + projector.accept(wire({ ToolCall: {} })) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ text: 'Running Read' })) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ text: 'Running tool' })) + }) + + it('ignores malformed, foreign, empty, and unsupported payloads', () => { + const { projector, append, updateSession } = harness() + projector.finishSync(0) + projector.accept({ invalid: true }) + projector.accept(wire('Running', undefined, null)) + projector.accept(wire('Running', undefined, { hub: ['remote'], agent: 'main' })) + projector.accept(wire('Running', undefined, { hub: [], agent: 'worker' })) + projector.accept(wire(42)) + projector.accept(wire(null)) + projector.accept(wire({})) + projector.accept(wire({ Unknown: {} })) + projector.accept(wire({ Stream: null })) + projector.accept(wire({ Stream: { text: 42 } })) + projector.accept(wire({ ToolCall: null })) + expect(append).not.toHaveBeenCalled() + expect(updateSession).not.toHaveBeenCalled() + }) + + it('routes sub-agent attention with independent revisions', () => { + const { projector, attention } = harness() + projector.finishSync(20, { worker: 2 }) + projector.accept(wire({ UserQuestionRequest: { id: 'old' } }, 2, { + hub: [], agent: 'worker', + })) + projector.accept(wire({ UserQuestionRequest: { id: 'fresh' } }, 3, { + hub: [], agent: 'worker', + })) + expect(attention).toHaveBeenCalledOnce() + expect(attention).toHaveBeenCalledWith( + 'question_requested', { id: 'fresh' }, 'worker', + ) + }) + + it('projects modified files from any local agent', () => { + const { projector, artifacts } = harness() + projector.finishSync(0, { worker: 0 }) + projector.accept(wire({ TurnDiffSummary: { + modified_files: ['src/main.rs', 42], + } }, 1, { hub: [], agent: 'worker' })) + expect(artifacts).toHaveBeenCalledWith(['src/main.rs'], 'worker') + projector.accept(wire({ TurnDiffSummary: { modified_files: 'invalid' } }, 2)) + expect(artifacts).toHaveBeenCalledOnce() + }) + + it('keeps event-only lifecycle notices visible for root and child agents', () => { + const { projector, append, appendAgent, updateSession } = harness() + projector.finishSync(0, { worker: 0 }) + projector.accept(wire({ SessionResumeWarnings: { + session_id: 'session', warnings: ['scheduler restore failed', 4], + } }, 1)) + projector.accept(wire('Interrupted', 2)) + projector.accept(wire({ TurnCancelled: { cause: 'parent abort' } }, 3, { + hub: [], agent: 'worker', + })) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ + id: 'event-7', text: 'Session resume warning: scheduler restore failed', + agentId: 'main', eventNotice: true, + })) + expect(append).toHaveBeenCalledWith(expect.objectContaining({ + id: 'event-7', text: 'Turn interrupted.', eventNotice: true, + })) + expect(appendAgent).toHaveBeenCalledWith(expect.objectContaining({ + id: 'event-7', text: 'Turn cancelled: parent abort', eventNotice: true, + }), 'worker') + expect(updateSession).toHaveBeenLastCalledWith('waiting') + + projector.accept(wire({ Error: { message: 'child failed to start' } }, 4, { + hub: [], agent: 'worker', + })) + expect(appendAgent).toHaveBeenCalledWith(expect.objectContaining({ + role: 'error', text: 'child failed to start', agentId: 'worker', + }), 'worker') + }) + + it('bounds sync buffering and reports one overflow', () => { + const { projector, overflow, updateSession } = harness() + for (let index = 0; index < 70; index += 1) projector.accept(wire('Running', index + 1)) + projector.finishSync(0) + expect(overflow).toHaveBeenCalledOnce() + expect(updateSession).not.toHaveBeenCalled() + projector.beginSync() + projector.accept(wire('Running', 100)) + projector.finishSync(99) + expect(updateSession).toHaveBeenCalledOnce() + }) + + it('resyncs when authoritative history is newer than the snapshot', () => { + const { projector, overflow } = harness() + projector.accept(wire({ SessionHistoryLoaded: { messages: [] } }, 4)) + projector.finishSync(3) + expect(overflow).toHaveBeenCalledOnce() + projector.accept(wire({ SessionHistoryLoaded: { messages: [] } }, 4)) + expect(overflow).toHaveBeenCalledOnce() + }) + + it('normalizes snapshot roles and statuses', () => { + expect(normalizeRole('user')).toBe('user') + expect(normalizeRole('assistant')).toBe('assistant') + expect(normalizeRole('tool')).toBe('system') + expect(['Running', 'Starting'].map(normalizeAgentStatus)).toEqual(['running', 'starting']) + expect(['WaitingForInput', 'Suspended'].map(normalizeAgentStatus)).toEqual(['waiting', 'suspended']) + expect(normalizeAgentStatus('Finished')).toBe('completed') + expect(normalizeAgentStatus('Error')).toBe('failed') + expect(normalizeAgentStatus('Unknown')).toBe('idle') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.ts new file mode 100644 index 00000000..11fec5a6 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-event-projector.ts @@ -0,0 +1,184 @@ +import { randomUUID } from 'node:crypto' +import { + type ConversationEntry, + type SessionStatus, + type SessionSummary, +} from '../../../../shared/contracts' +import { AgentEventSchema } from '../runtime/loopal-wire' +import { attentionKindForPayload, type AttentionEventKind } from '../attention/loopal-attention' +import { projectEventNotice } from './loopal-event-notice' + +export { normalizeAgentStatus, normalizeRole } from './loopal-event-normalizers' + +interface ProjectionSink { + append(entry: ConversationEntry): void + appendAgent?(entry: ConversationEntry, agentId: string): void + updateSession(status: SessionStatus, attention?: SessionSummary['attention']): void + attention(kind: AttentionEventKind, value: unknown, agentId: string): void + artifacts?(paths: readonly string[], agentId: string): void + overflow?(): void +} + +const SYNC_BUFFER_LIMIT = 64 + +export class LoopalEventProjector { + private assistantBuffer = '' + private syncing = true + private readonly lastRevisions = new Map() + private readonly buffered: unknown[] = [] + private overflowed = false + private failed = false + + constructor( + private readonly now: () => Date, + private readonly sink: ProjectionSink, + ) {} + + beginSync(): void { + this.syncing = true + this.assistantBuffer = '' + this.overflowed = false + } + + finishSync(revision: number, revisions?: Readonly>): void { + this.lastRevisions.set('main', revision) + for (const [agentId, value] of Object.entries(revisions ?? {})) { + this.lastRevisions.set(agentId, value) + } + const buffered = this.buffered.splice(0) + this.syncing = false + if (this.overflowed) { + this.overflowed = false + this.sink.overflow?.() + return + } + for (const event of buffered) this.apply(event) + } + + accept(value: unknown): void { + if (!this.syncing) { + this.apply(value) + return + } + if (this.overflowed) return + if (this.buffered.length >= SYNC_BUFFER_LIMIT) { + this.buffered.length = 0 + this.overflowed = true + } else this.buffered.push(value) + } + + private apply(value: unknown): void { + const event = AgentEventSchema.safeParse(value) + if (!event.success) return + const address = event.data.agent_name + if (!address || address.hub.length !== 0) return + if (event.data.rev !== undefined) { + if (event.data.rev <= (this.lastRevisions.get(address.agent) ?? 0)) return + this.lastRevisions.set(address.agent, event.data.rev) + } + const payload = unpackPayload(event.data.payload) + if (!payload) return + const attention = attentionKindForPayload(payload.kind) + if (attention) { + const requested = attention.endsWith('_requested') + if (requested && address.agent === 'main') this.flushAssistant() + this.sink.attention(attention, payload.value, address.agent) + if (requested) this.sink.updateSession('waiting', attention === 'permission_requested' + ? 'permission' : attention === 'question_requested' ? 'question' : 'plan') + return + } + if (payload.kind === 'TurnDiffSummary' && isRecord(payload.value)) { + const paths = Array.isArray(payload.value.modified_files) + ? payload.value.modified_files.filter((path): path is string => typeof path === 'string') + : [] + if (paths.length > 0) this.sink.artifacts?.(paths, address.agent) + return + } + const notice = projectEventNotice(payload.kind, payload.value) + if (notice) { + const entry = { + ...this.entry('system', notice.text, event.data.event_id), + agentId: address.agent, + eventNotice: notice.runtime ?? true, + } + if (address.agent === 'main') this.sink.append(entry) + else this.sink.appendAgent?.(entry, address.agent) + } + if (payload.kind === 'Error') { + if (address.agent === 'main') this.flushAssistant() + const message = isRecord(payload.value) && typeof payload.value.message === 'string' + ? payload.value.message + : 'Loopal runtime failed' + const entry = { ...this.entry('error', message, event.data.event_id), agentId: address.agent } + if (address.agent === 'main') { + this.sink.append(entry) + this.failed = true + this.sink.updateSession('failed', 'failure') + } else this.sink.appendAgent?.(entry, address.agent) + return + } + if (address.agent !== 'main') return + if (payload.kind === 'Stream' && isRecord(payload.value)) { + if (typeof payload.value.text === 'string') this.assistantBuffer += payload.value.text + return + } + if (payload.kind === 'SessionHistoryLoaded') { + this.sink.overflow?.() + return + } + if (payload.kind === 'Running' || payload.kind === 'Started') { + this.failed = false + this.sink.updateSession('running') + return + } + if (payload.kind === 'TurnCompleted') { + this.flushAssistant() + return + } + if (payload.kind === 'Interrupted' || payload.kind === 'TurnCancelled') { + this.flushAssistant() + if (!this.failed) this.sink.updateSession('waiting') + return + } + if (payload.kind === 'AwaitingInput' || payload.kind === 'Finished') { + this.flushAssistant() + if (!this.failed) this.sink.updateSession('waiting', 'completed') + return + } + if (payload.kind === 'ToolCall' && isRecord(payload.value)) { + this.flushAssistant() + const name = typeof payload.value.name === 'string' ? payload.value.name : 'tool' + this.sink.append(this.entry('system', `Running ${name}`, event.data.event_id)) + } + } + + private flushAssistant(): void { + if (!this.assistantBuffer) return + this.sink.append(this.entry('assistant', this.assistantBuffer)) + this.assistantBuffer = '' + } + + private entry( + role: ConversationEntry['role'], + text: string, + eventId?: number, + ): ConversationEntry { + return { + id: eventId ? `event-${eventId}` : randomUUID(), + role, + text, + createdAt: this.now().toISOString(), + } + } +} + +function unpackPayload(value: unknown): { kind: string; value?: unknown } | undefined { + if (typeof value === 'string') return { kind: value } + if (!isRecord(value)) return undefined + const entry = Object.entries(value)[0] + return entry ? { kind: entry[0], value: entry[1] } : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-llm-state-projection.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-llm-state-projection.test.ts new file mode 100644 index 00000000..08aac839 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-llm-state-projection.test.ts @@ -0,0 +1,116 @@ +import { vi } from 'vitest' +import { LoopalEventProjector } from './loopal-event-projector' +import { projectMessages } from './loopal-message-projection' +import { projectAgent, projectSessionView } from './loopal-view-projection' +import { ViewSnapshotSchema, type ViewSnapshot } from '../runtime/loopal-wire' + +const now = new Date('2026-07-12T12:00:00.000Z') + +describe('LLM authoritative Desktop projection', () => { + it('projects server tools, tool failures, partial streams, and continuation state', () => { + const snapshot = stateSnapshot() + const entries = projectMessages(snapshot, now) + const server = entries.find((entry) => entry.id === 'server-tool')?.toolCalls?.[0] + const failed = entries.find((entry) => entry.id === 'failed-tool')?.toolCalls?.[0] + + expect(server).toMatchObject({ + id: 'search-1', name: 'web_search', status: 'succeeded', + input: { query: 'Loopal Desktop' }, output: 'two verified sources', + }) + expect(failed).toMatchObject({ + id: 'write-1', name: 'Write', status: 'failed', detail: 'permission denied', + }) + expect(entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'system', text: 'Output truncated (max_tokens). Auto-continuing (1/8)', + }), + expect.objectContaining({ + role: 'system', text: expect.stringContaining('Context compacted (smart)'), + }), + expect.objectContaining({ + id: 'main-streaming-assistant', text: 'partial response before continuation', + streaming: true, + }), + ])) + expect(projectSessionView(snapshot)).toMatchObject({ + historyTruncated: true, + retryBanner: 'Provider unavailable. Retrying in 2.0s (1/6)', + compactBanner: '⠉ summarizing context — model request', + streamingText: 'partial response before continuation', + }) + expect(projectAgent(snapshot, undefined, now).telemetry).toMatchObject({ + inputTokens: 1_000, outputTokens: 200, cacheCreationTokens: 50, + cacheReadTokens: 400, thinkingTokens: 80, toolsInFlight: 0, toolCount: 2, + }) + }) + + it('projects fatal errors and continuation governance notices through live events', () => { + const append = vi.fn() + const updateSession = vi.fn() + const projector = new LoopalEventProjector(() => now, { + append, updateSession, attention: vi.fn(), + }) + projector.finishSync(0) + projector.accept(event({ Stream: { text: 'partial before fatal error' } }, 1)) + projector.accept(event({ DegenerationDetected: { + signal: 'barren_streak', count: 3, + } }, 2)) + projector.accept(event({ ContinuationGateChanged: { + open: false, closed_reason: 'idle_timeout', wake_deadline: '2026-07-12T13:00:00Z', + } }, 3)) + projector.accept(event({ ContinuationGateChanged: { open: true } }, 4)) + projector.accept(event({ ContinuationSkipped: { reason: 'goal changed' } }, 5)) + projector.accept(event({ Error: { message: 'provider request failed fatally' } }, 6)) + + expect(append.mock.calls.map(([entry]) => entry.text)).toEqual([ + 'Degeneration detected: barren streak (3).', + 'Automatic continuation paused: idle timeout until 2026-07-12T13:00:00Z.', + 'Automatic continuation resumed.', + 'Continuation skipped: goal changed', + 'partial before fatal error', + 'provider request failed fatally', + ]) + expect(append.mock.calls.slice(0, 4).every(([entry]) => entry.eventNotice)).toBe(true) + expect(append).toHaveBeenLastCalledWith(expect.objectContaining({ role: 'error' })) + expect(updateSession).toHaveBeenLastCalledWith('failed', 'failure') + }) +}) + +function stateSnapshot(): ViewSnapshot { + return ViewSnapshotSchema.parse({ + rev: 9, + state: { agent: { + name: 'main', observable: { status: 'Running', model: 'claude-opus-4-8' }, + conversation: { + history_truncated: true, + streaming_text: 'partial response before continuation', + retry_banner: 'Provider unavailable. Retrying in 2.0s (1/6)', + compact_banner: '⠉ summarizing context — model request', + turn_count: 2, input_tokens: 1_000, output_tokens: 200, context_window: 200_000, + cache_creation_tokens: 50, cache_read_tokens: 400, thinking_tokens: 80, + messages: [ + message('server-tool', tool('search-1', 'web_search', { + state: 'done', outcome: { type: 'success', content: 'two verified sources' }, + }, { query: 'Loopal Desktop' })), + message('failed-tool', tool('write-1', 'Write', { + state: 'done', outcome: { type: 'failure', error: 'permission denied' }, + }, { file_path: 'denied.txt' })), + { role: 'system', content: 'Output truncated (max_tokens). Auto-continuing (1/8)' }, + { role: 'system', content: 'Context compacted (smart): 8→2 messages (6 summarized), 12000→3000 tokens (75% freed).' }, + ], + }, + } }, + }) +} + +function message(id: string, invocation: unknown) { + return { message_id: id, role: 'assistant', content: '', tool_calls: [invocation] } +} + +function tool(id: string, name: string, state: object, input: object) { + return { id, name, summary: `${name}(fixture)`, input, state } +} + +function event(payload: unknown, rev: number) { + return { agent_name: { hub: [], agent: 'main' }, event_id: rev, rev, payload } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-message-projection.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-message-projection.ts new file mode 100644 index 00000000..5b7d1c5b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-message-projection.ts @@ -0,0 +1,143 @@ +import { createHash } from 'node:crypto' +import { type ConversationEntry } from '../../../../shared/contracts' +import { projectTool } from './loopal-tool-projection' +import { type ViewSnapshot } from '../runtime/loopal-wire' + +type WireMessage = ViewSnapshot['state']['agent']['conversation']['messages'][number] + +export function projectMessages( + snapshot: ViewSnapshot, + now: Date, + previous: readonly ConversationEntry[] = [], +): ConversationEntry[] { + const agent = snapshot.state.agent + const old = new Map(previous.map((entry) => [entry.id, entry])) + const occurrences = new Map() + const entries = agent.conversation.messages.map((message) => { + const id = message.message_id ?? fallbackId(agent.name, message, occurrences) + return projectMessage(message, agent.name, id, old.get(id)?.createdAt ?? now.toISOString()) + }) + appendStreaming(entries, old, agent.name, 'thinking', agent.conversation.streaming_thinking, now) + appendStreaming(entries, old, agent.name, 'assistant', agent.conversation.streaming_text, now) + return mergeEventNotices(entries, previous, agent.name) +} + +function mergeEventNotices( + entries: ConversationEntry[], + previous: readonly ConversationEntry[], + agentId: string, +): ConversationEntry[] { + const ids = new Set(entries.map((entry) => entry.id)) + const notices = previous.filter((entry) => ( + entry.eventNotice && (entry.agentId ?? agentId) === agentId && !ids.has(entry.id) + )).slice(-64) + return [...entries, ...notices].sort((left, right) => ( + left.createdAt.localeCompare(right.createdAt) + )) +} + +function projectMessage( + message: WireMessage, + agentId: string, + id: string, + createdAt: string, +): ConversationEntry { + const skill = message.skill_info + const inbox = message.inbox + const role = normalizeConversationRole(message.role) + const thinking = role === 'thinking' ? parseThinking(message.content) : undefined + return { + id, + role, + text: thinking?.text ?? message.content, + createdAt, + agentId, + imageCount: message.image_count, + toolCalls: message.tool_calls.map(projectTool), + ...(thinking ? { thinkingTokens: thinking.tokens } : {}), + ...(skill ? { skill: { name: skill.name, userArgs: skill.user_args } } : {}), + ...(inbox ? { + inbox: { + source: stringifySource(inbox.source), + ...(inbox.summary ? { summary: inbox.summary } : {}), + }, + } : {}), + } +} + +function appendStreaming( + entries: ConversationEntry[], + previous: ReadonlyMap, + agentId: string, + role: 'thinking' | 'assistant', + text: string, + now: Date, +): void { + if (!text) return + const id = `${agentId}-streaming-${role}` + entries.push({ + id, role, text, agentId, streaming: true, + createdAt: previous.get(id)?.createdAt ?? now.toISOString(), + }) +} + +export function normalizeConversationRole(value: string): ConversationEntry['role'] { + if (value === 'user' || value === 'assistant' || value === 'thinking' + || value === 'error' || value === 'welcome') return value + return 'system' +} + +function fallbackId( + agentId: string, + message: WireMessage, + occurrences: Map, +): string { + const digest = createHash('sha256').update(JSON.stringify([ + message.role, message.content, message.image_count, + message.tool_calls.map((tool) => tool.id), + ])).digest('hex').slice(0, 16) + const count = occurrences.get(digest) ?? 0 + occurrences.set(digest, count + 1) + return `${agentId}-message-${digest}${count ? `-${count}` : ''}` +} + +function parseThinking(content: string): { tokens: number; text: string } { + const [first, ...rest] = content.split('\n') + const tokens = /^\d+$/.test(first ?? '') ? Number(first) : 0 + return tokens > 0 ? { tokens, text: rest.join('\n') } : { tokens: 0, text: content } +} + +function stringifySource(value: unknown): string { + if (typeof value === 'string') { + return value === 'Human' || value === 'Scheduled' ? value.toLowerCase() : value + } + if (isRecord(value)) { + const address = sourceAddress(value) + if (address) return address + if (typeof value.System === 'string') return `system:${value.System}` + } + try { return JSON.stringify(value) } + catch { return 'agent' } +} + +function sourceAddress(value: Record): string | undefined { + if (isRecord(value.Agent)) return formatAddress(value.Agent) + if (isRecord(value.AgentResult) && isRecord(value.AgentResult.child)) { + return formatAddress(value.AgentResult.child) + } + if (isRecord(value.Channel) && isRecord(value.Channel.from)) { + return formatAddress(value.Channel.from) + } + return undefined +} + +function formatAddress(value: Record): string | undefined { + if (typeof value.agent !== 'string') return undefined + const hubs = Array.isArray(value.hub) + ? value.hub.filter((part): part is string => typeof part === 'string') : [] + return [...hubs, value.agent].join('/') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-projection-edges.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-projection-edges.test.ts new file mode 100644 index 00000000..cde2f39d --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-projection-edges.test.ts @@ -0,0 +1,165 @@ +import { projectMessages } from './loopal-message-projection' +import { richSnapshot } from './loopal-rich-projection.test' +import { projectTool } from './loopal-tool-projection' +import { + projectAgent, projectSessionView, retiredAgent, topologyAgent, +} from './loopal-view-projection' + +const now = new Date('2026-07-11T12:00:00.000Z') + +describe('Loopal rich projection edges', () => { + it('handles duplicate fallback messages and cyclic inbox sources', () => { + const snapshot = richSnapshot() + const source: Record = {} + source.self = source + const original = snapshot.state.agent.conversation.messages[3]! + const { message_id: _id, ...withoutId } = original + const duplicate = { ...structuredClone(withoutId), role: 'user', content: 'same' } + const thinking = { + ...snapshot.state.agent.conversation.messages[0]!, content: 'plain thinking', + } + const circular = { + ...original, message_id: 'circular', tool_calls: [], + inbox: { message_id: 'inbox', source, summary: null }, + } + snapshot.state.agent.conversation.messages = [ + duplicate, structuredClone(duplicate), thinking, circular, + ] + snapshot.state.agent.conversation.streaming_text = '' + snapshot.state.agent.conversation.streaming_thinking = '' + const messages = projectMessages(snapshot, now) + expect(messages[0]?.id).toMatch(/^worker-message-/) + expect(messages[1]?.id).toBe(`${messages[0]?.id}-1`) + expect(messages[0]).toMatchObject({ role: 'user', toolCalls: expect.any(Array) }) + expect(messages[2]).toMatchObject({ + role: 'thinking', text: 'plain thinking', thinkingTokens: 0, + }) + expect(messages[3]?.inbox).toEqual({ source: 'agent' }) + expect(messages.some((message) => message.streaming)).toBe(false) + }) + + it('labels every structured inbox source without leaking wire JSON', () => { + const snapshot = richSnapshot() + const template = snapshot.state.agent.conversation.messages[3]! + const sources = [ + { Agent: { hub: ['hub-b'], agent: 'main' } }, + { AgentResult: { child: { hub: ['hub-c'], agent: 'worker' } } }, + { Channel: { channel: 'ops', from: { hub: ['hub-d'], agent: 'relay' } } }, + { System: 'goal_continuation' }, + ] + snapshot.state.agent.conversation.messages = sources.map((source, index) => ({ + ...template, message_id: `source-${index}`, tool_calls: [], + inbox: { message_id: `inbox-${index}`, source, summary: null }, + })) + snapshot.state.agent.conversation.streaming_text = '' + snapshot.state.agent.conversation.streaming_thinking = '' + expect(projectMessages(snapshot, now).map((message) => message.inbox?.source)).toEqual([ + 'hub-b/main', 'hub-c/worker', 'hub-d/relay', 'system:goal_continuation', + ]) + }) + + it('retains bounded event notices across authoritative snapshot replacement', () => { + const snapshot = richSnapshot() + snapshot.state.agent.name = 'main' + snapshot.state.agent.conversation.streaming_text = '' + snapshot.state.agent.conversation.streaming_thinking = '' + const previous = Array.from({ length: 70 }, (_, index) => ({ + id: `event-${index}`, role: 'system' as const, text: `notice ${index}`, + agentId: index === 0 ? 'worker' : 'main', eventNotice: true, + createdAt: new Date(now.getTime() + index * 1_000).toISOString(), + })) + const messages = projectMessages(snapshot, new Date(now.getTime() + 100_000), previous) + const notices = messages.filter((message) => message.eventNotice) + expect(notices).toHaveLength(64) + expect(notices[0]?.text).toBe('notice 6') + expect(notices.at(-1)?.text).toBe('notice 69') + expect(notices.some((message) => message.agentId === 'worker')).toBe(false) + }) + + it('projects absent, malformed, and metadata-only tool fields', () => { + const base = richSnapshot().state.agent.conversation.messages[3]!.tool_calls[0]! + const { input: _input, ...withoutInput } = base + expect(projectTool({ + ...withoutInput, summary: '', metadata: null, + state: { state: 'running', last_progress: { tail: 3 } }, + })).toEqual(expect.objectContaining({ summary: 'Read', status: 'running' })) + expect(projectTool({ + ...base, state: { + state: 'done', duration: { secs: -1 }, outcome: { type: 'success', content: 4 }, + }, + })).toMatchObject({ status: 'succeeded', durationMs: 0 }) + expect(projectTool({ + ...base, metadata: { kind: 'failure', reason: 'reason' }, + state: { state: 'done', duration: {}, outcome: { type: 'failure', error: 4 } }, + })).toMatchObject({ status: 'failed', detail: 'failure: reason', durationMs: 0 }) + expect(projectTool({ + ...base, metadata: { kind: 'cancelled', cause: 'parent' }, state: { state: 'unknown' }, + })).toMatchObject({ detail: 'cancelled: parent' }) + expect(projectTool({ + ...base, metadata: { kind: 4 }, state: { state: 'unknown', duration: [] }, + }).detail).toBeUndefined() + expect(projectTool({ + ...base, metadata: [], state: { state: 'unknown', duration: 'invalid' }, + }).durationMs).toBeUndefined() + }) + + it('projects empty resources and every aggregate status fallback', () => { + const snapshot = richSnapshot() + const state = snapshot.state + const task = state.tasks[0]! + state.tasks = [ + { ...task, status: 'completed', active_form: null }, + { ...task, id: 'pending', status: 'unknown', active_form: null }, + ] + const background = state.bg_tasks.bg! + state.bg_tasks = { + complete: { ...background, id: 'complete', status: 'Completed' }, + killed: { ...background, id: 'killed', status: 'Killed' }, + running: { ...background, id: 'running', status: 'Unknown' }, + } + state.crons = [{ ...state.crons[0]!, next_fire_unix_ms: null }] + state.mcp_status = null + state.thread_goal = null + delete state.hub_degraded_since_ms + const view = projectSessionView(snapshot) + expect(view.tasks.map((item) => item.status)).toEqual(['completed', 'pending']) + expect(view.backgroundTasks.map((item) => item.status)).toEqual([ + 'completed', 'killed', 'running', + ]) + expect(view.crons[0]?.nextFireAt).toBeUndefined() + expect(view.mcpServers).toEqual([]) + expect(view.goal).toBeUndefined() + expect(view.hubDegradedSince).toBeUndefined() + + const goal = richSnapshot() + for (const status of ['paused', 'complete', 'infeasible', 'unknown']) { + goal.state.thread_goal!.status = status + expect(projectSessionView(goal).goal?.status).toBe( + status === 'unknown' ? 'active' : status, + ) + } + }) + + it('covers topology and agent fallbacks without inventing state', () => { + const snapshot = richSnapshot() + snapshot.state.agent.parent = 'remote/' + snapshot.state.agent.observable.model = '' + for (const tool of snapshot.state.agent.conversation.messages[3]!.tool_calls) { + if (tool.state.state === 'pending' || tool.state.state === 'running') tool.summary = '' + } + const previous = { id: 'worker', name: 'Old', status: 'waiting' as const, conversation: [] } + expect(projectAgent(snapshot, undefined, now, previous)).toMatchObject({ + parentId: 'remote/', children: [], status: 'running', + }) + expect(topologyAgent({ + name: 'unknown', parent: 'remote/', children: [], lifecycle: 'unknown' as never, + }, { ...previous, telemetry: { + turnCount: 0, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, thinkingTokens: 0, contextWindow: 0, toolsInFlight: 0, toolCount: 0, + }, view: projectSessionView(snapshot) })).toMatchObject({ + status: 'idle', parentId: 'remote/', view: { revision: 12 }, + }) + const main = { id: 'main', name: 'Loopal', status: 'running' as const } + expect(retiredAgent(main)).toBe(main) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-rich-projection.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-rich-projection.test.ts new file mode 100644 index 00000000..a86d027e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-rich-projection.test.ts @@ -0,0 +1,196 @@ +import { projectMessages } from './loopal-message-projection' +import { projectTool } from './loopal-tool-projection' +import { + normalizeAgentStatus, + projectAgent, + projectSessionView, + retiredAgent, + topologyAgent, +} from './loopal-view-projection' +import { ViewSnapshotSchema, type ViewSnapshot } from '../runtime/loopal-wire' + +const now = new Date('2026-07-11T12:00:00.000Z') + +describe('rich Loopal view projection', () => { + it('projects stable rich messages, streaming state, and every tool terminal state', () => { + const snapshot = richSnapshot() + const messages = projectMessages(snapshot, now) + expect(messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'thinking', text: 'check the repository', thinkingTokens: 120, + }), + expect.objectContaining({ role: 'error', text: 'provider failed' }), + expect.objectContaining({ role: 'welcome' }), + expect.objectContaining({ + id: 'tools', role: 'assistant', imageCount: 2, + skill: { name: 'review', userArgs: '--deep' }, + inbox: { source: 'parent', summary: 'delegated' }, + }), + expect.objectContaining({ id: 'worker-streaming-thinking', streaming: true }), + expect.objectContaining({ id: 'worker-streaming-assistant', streaming: true }), + ])) + const tools = messages.find((message) => message.id === 'tools')!.toolCalls! + expect(tools.map((tool) => tool.status)).toEqual([ + 'pending', 'running', 'succeeded', 'failed', 'stale', 'cancelled', + ]) + expect(tools[1]).toMatchObject({ progress: 'halfway' }) + expect(tools[2]).toMatchObject({ output: 'ok', durationMs: 1500, batchId: 'batch' }) + expect(tools[3]).toMatchObject({ detail: 'boom' }) + expect(tools[4]).toMatchObject({ detail: 'watchdog_timeout' }) + expect(tools[5]).toMatchObject({ detail: 'user_interrupt' }) + const projectedAgain = projectMessages( + snapshot, new Date('2026-07-12T12:00:00.000Z'), messages, + ) + expect(projectedAgain.map(({ id, createdAt }) => ({ id, createdAt }))) + .toEqual(messages.map(({ id, createdAt }) => ({ id, createdAt }))) + }) + + it('projects agents, topology fallback, retired nodes, and aggregate resources', () => { + const snapshot = richSnapshot() + const topology = { + name: 'worker', parent: 'remote/main', children: ['child'], + lifecycle: 'running' as const, model: 'topology-model', + } + const agent = projectAgent(snapshot, topology, now) + expect(agent).toMatchObject({ + id: 'worker', parentId: 'main', children: ['child'], status: 'running', + model: 'model', lastTool: 'Running', + telemetry: { turnCount: 3, toolsInFlight: 2, toolCount: 6, contextWindow: 2000 }, + view: { revision: 12, thinkingActive: true }, + }) + const view = projectSessionView(snapshot) + expect(view).toMatchObject({ + revision: 12, historyTruncated: true, retryBanner: 'retrying', + compactBanner: 'compacting', hubDegradedSince: '2023-11-14T22:13:20.000Z', + goal: { id: 'goal', status: 'active' }, + tasks: [{ id: '1', status: 'in_progress', activeForm: 'Testing' }], + backgroundTasks: [{ id: 'bg', status: 'failed', exitCode: 7 }], + crons: [{ id: 'cron', nextFireAt: '2023-11-14T22:15:00.000Z' }], + mcpServers: [{ name: 'git', toolCount: 2 }], + }) + expect(['Starting', 'Running', 'WaitingForInput', 'Suspended', 'Finished', 'Error', '?'] + .map(normalizeAgentStatus)).toEqual([ + 'starting', 'running', 'waiting', 'suspended', 'completed', 'failed', 'idle', + ]) + expect(['spawning', 'running', 'finished', 'failed'].map((lifecycle) => ( + topologyAgent({ + name: lifecycle, children: [], + lifecycle: lifecycle as 'spawning' | 'running' | 'finished' | 'failed', + ...(lifecycle === 'failed' ? { error: 'spawn failed' } : {}), + }).status + ))).toEqual(['starting', 'running', 'completed', 'failed']) + expect(topologyAgent({ name: 'bad', children: [], lifecycle: 'failed', error: 'boom' }).error) + .toBe('boom') + expect(['spawning', 'finished', 'failed'].map((lifecycle) => projectAgent( + snapshot, + { ...topology, lifecycle: lifecycle as 'spawning' | 'finished' | 'failed' }, + now, + ).status)).toEqual(['starting', 'completed', 'failed']) + expect(projectAgent(snapshot, { ...topology, lifecycle: 'failed', error: 'boom' }, now)) + .toMatchObject({ status: 'failed', error: 'boom' }) + expect(retiredAgent({ id: 'old', name: 'Old', status: 'running', lastTool: 'Read' })) + .toEqual({ id: 'old', name: 'Old', status: 'completed' }) + const failed = { id: 'failed', name: 'Failed', status: 'failed' as const } + expect(retiredAgent(failed)).toBe(failed) + }) + + it('handles unknown tool states and typed metadata fallbacks', () => { + const base = richSnapshot().state.agent.conversation.messages[3]!.tool_calls[0]! + expect(projectTool({ ...base, state: { state: 'unknown' } })).toMatchObject({ status: 'failed' }) + expect(projectTool({ + ...base, metadata: { kind: 'bytes_written', count: 42 }, + state: { state: 'stale' }, + })).toMatchObject({ detail: '42 bytes written' }) + expect(projectTool({ + ...base, metadata: { kind: 'custom' }, state: { state: 'stale' }, + })).toMatchObject({ detail: 'custom' }) + }) + + it('does not project completed thinking as a live thinking row', () => { + const snapshot = richSnapshot() + snapshot.state.agent.conversation.streaming_thinking = '' + snapshot.state.agent.conversation.thinking_active = false + const messages = projectMessages(snapshot, now) + expect(messages.some((entry) => entry.id === 'worker-streaming-thinking')).toBe(false) + expect(messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'thinking', text: 'check the repository' }), + expect.objectContaining({ id: 'worker-streaming-assistant', streaming: true }), + ])) + }) +}) + +export function richSnapshot(): ViewSnapshot { + const tools = [ + tool('pending', {}), + tool('running', { last_progress: { tail: 'halfway' } }), + tool('done', { + duration: { secs: 1, nanos: 500_000_000 }, outcome: { type: 'success', content: 'ok' }, + }, 'batch'), + tool('done', { duration: { secs: 0, nanos: 1 }, outcome: { type: 'failure', error: 'boom' } }), + tool('stale', { reason: 'watchdog_timeout' }), + tool('cancelled', { cause: 'user_interrupt' }), + ] + return ViewSnapshotSchema.parse({ + rev: 12, + state: { + agent: { + name: 'worker', parent: 'main', children: [], + observable: { + status: 'Running', turn_count: 3, input_tokens: 100, output_tokens: 50, + model: 'model', mode: 'act', thinking_config: 'high', + permission_mode: 'ask', decision_mode: 'manual', sandbox_policy: 'read_only', + }, + conversation: { + history_truncated: true, streaming_text: 'answering', + streaming_thinking: 'considering', thinking_active: true, + retry_banner: 'retrying', compact_banner: 'compacting', + turn_count: 3, input_tokens: 100, output_tokens: 50, context_window: 2000, + cache_creation_tokens: 10, cache_read_tokens: 20, thinking_tokens: 120, + messages: [ + { role: 'thinking', content: '120\ncheck the repository' }, + { role: 'error', content: 'provider failed' }, + { role: 'welcome', content: 'hello' }, + { + message_id: 'tools', role: 'assistant', content: 'working', image_count: 2, + skill_info: { name: 'review', user_args: '--deep' }, + inbox: { + message_id: 'inbox', source: { Agent: { agent: 'parent' } }, summary: 'delegated', + }, + tool_calls: tools, + }, + ], + }, + }, + tasks: [{ + id: '1', subject: 'Test', description: 'Run tests', active_form: 'Testing', + status: 'in_progress', blocked_by: [], blocks: ['2'], + }], + bg_tasks: { bg: { + id: 'bg', description: 'build', status: 'Failed', exit_code: 7, + output: 'failed', created_at_unix_ms: 1_700_000_000_000, + } }, + crons: [{ + id: 'cron', cron_expr: '* * * * *', prompt: 'check', recurring: true, + durable: true, next_fire_unix_ms: 1_700_000_100_000, + }], + mcp_status: [{ + name: 'git', transport: 'stdio', source: 'project', status: 'connected', + tool_count: 2, resource_count: 1, prompt_count: 0, errors: [], + }], + thread_goal: { + goal_id: 'goal', objective: 'Ship', status: 'active', + created_at: now.toISOString(), updated_at: now.toISOString(), + }, + hub_degraded_since_ms: 1_700_000_000_000, + }, + }) +} + +function tool(state: string, fields: Record, batchId?: string) { + return { + id: `tool-${state}-${JSON.stringify(fields).length}`, + name: 'Read', summary: state === 'running' ? 'Running' : state, + input: { path: 'README.md' }, state: { state, ...fields }, + ...(batchId ? { batch_id: batchId } : {}), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.test.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.test.ts new file mode 100644 index 00000000..e6d88169 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { type SessionSummary } from '../../../../shared/contracts' +import { + hostSession, + runtimeFields, + runtimeState, + runtimeSummary, +} from './loopal-runtime-projection' + +const now = new Date('2026-07-11T12:00:00.000Z') +const scope = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 2, +} +const session: SessionSummary = { + id: 'session', workspaceId: 'workspace', title: 'Session', model: 'model', mode: 'agent', + status: 'running', activeRuntimeId: 'old', attention: 'permission', + createdAt: now.toISOString(), updatedAt: now.toISOString(), +} + +describe('Loopal runtime projection', () => { + it('maps every Host status and preserves an existing start time', () => { + expect(runtimeSummary(scope, 'ready', now)).toMatchObject({ + id: 'runtime', state: 'ready', startedAt: now.toISOString(), + }) + expect(runtimeSummary(scope, 'stopping', now, '2026-07-11T10:00:00.000Z')) + .toMatchObject({ state: 'stopping', startedAt: '2026-07-11T10:00:00.000Z' }) + expect([ + runtimeState('stopped'), runtimeState('crashed'), runtimeState('spawning'), + runtimeState('alive'), runtimeState('registering'), + ]).toEqual(['stopped', 'crashed', 'starting', 'starting', 'starting']) + }) + + it('clears stale runtime attention for normal transitions and marks crashes', () => { + const statusEvent = (status: 'ready' | 'spawning' | 'stopping' | 'stopped' | 'crashed') => ({ + ...scope, status, + }) + expect(hostSession(session, statusEvent('ready'), now.toISOString())).toMatchObject({ + status: 'waiting', activeRuntimeId: 'runtime', + }) + expect(hostSession(session, statusEvent('spawning'), now.toISOString())).toMatchObject({ + status: 'starting', activeRuntimeId: 'runtime', + }) + for (const status of ['stopping', 'stopped'] as const) { + expect(hostSession(session, statusEvent(status), now.toISOString())).toEqual( + expect.not.objectContaining({ activeRuntimeId: expect.anything(), attention: expect.anything() }), + ) + } + expect(hostSession(session, statusEvent('crashed'), now.toISOString())).toMatchObject({ + status: 'failed', attention: 'failure', + }) + expect(runtimeFields(session)).toEqual({ + status: 'running', activeRuntimeId: 'old', attention: 'permission', + }) + const { attention: _attention, ...plain } = session + expect(runtimeFields(plain)).toEqual({ status: 'running', activeRuntimeId: 'old' }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.ts new file mode 100644 index 00000000..2fdad3df --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-runtime-projection.ts @@ -0,0 +1,62 @@ +import { + type HostStatus, + type RuntimeSummary, + type SessionSummary, +} from '../../../../shared/contracts' +import { + type SessionRuntimeScope, + type SessionRuntimeStatusEvent, +} from '../runtime/session-runtime-registry' + +export function runtimeSummary( + scope: SessionRuntimeScope, + status: HostStatus, + now: Date, + startedAt?: string, +): RuntimeSummary { + return { + id: scope.runtimeId, + sessionId: scope.sessionId, + workspaceId: scope.workspaceId, + generation: scope.generation, + state: runtimeState(status), + rootAgent: 'main', + startedAt: startedAt ?? now.toISOString(), + } +} + +export function runtimeState(status: HostStatus): RuntimeSummary['state'] { + if (status === 'ready') return 'ready' + if (status === 'stopping') return 'stopping' + if (status === 'stopped') return 'stopped' + if (status === 'crashed') return 'crashed' + return 'starting' +} + +export function hostSession( + session: SessionSummary, + event: SessionRuntimeStatusEvent, + updatedAt: string, +): SessionSummary { + const { activeRuntimeId: _runtime, attention: _attention, ...base } = session + if (event.status === 'crashed') { + return { ...base, status: 'failed', attention: 'failure', updatedAt } + } + if (event.status === 'stopping' || event.status === 'stopped') { + return { ...base, status: 'stopped', updatedAt } + } + return { + ...base, + status: event.status === 'ready' ? 'waiting' : 'starting', + activeRuntimeId: event.runtimeId, + updatedAt, + } +} + +export function runtimeFields(session: SessionSummary) { + return { + status: session.status, + activeRuntimeId: session.activeRuntimeId, + ...(session.attention ? { attention: session.attention } : {}), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-tool-projection.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-tool-projection.ts new file mode 100644 index 00000000..2938e213 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-tool-projection.ts @@ -0,0 +1,71 @@ +import { type ToolInvocation } from '../../../../shared/contracts' +import { type ViewSnapshot } from '../runtime/loopal-wire' + +type WireTool = ViewSnapshot['state']['agent']['conversation']['messages'][number]['tool_calls'][number] + +export function projectTool(tool: WireTool): ToolInvocation { + const state = tool.state + const status = toolStatus(state) + const outcome = record(state.outcome) + const output = status === 'succeeded' && typeof outcome?.content === 'string' + ? outcome.content + : undefined + const failure = status === 'failed' && typeof outcome?.error === 'string' + ? outcome.error + : undefined + const progress = record(state.last_progress)?.tail + const detail = failure ?? stateDetail(state, tool.metadata) + return { + id: tool.id, + name: tool.name, + summary: tool.summary || tool.name, + status, + ...(tool.input !== undefined ? { input: tool.input } : {}), + ...(output !== undefined ? { output } : {}), + ...(typeof progress === 'string' ? { progress } : {}), + ...(detail !== undefined ? { detail } : {}), + ...(durationMilliseconds(state.duration) !== undefined + ? { durationMs: durationMilliseconds(state.duration)! } + : {}), + ...(tool.batch_id ? { batchId: tool.batch_id } : {}), + } +} + +function toolStatus(state: Record): ToolInvocation['status'] { + if (state.state === 'pending') return 'pending' + if (state.state === 'running') return 'running' + if (state.state === 'stale') return 'stale' + if (state.state === 'cancelled') return 'cancelled' + if (state.state === 'done') { + return record(state.outcome)?.type === 'failure' ? 'failed' : 'succeeded' + } + return 'failed' +} + +function stateDetail( + state: Record, + metadata: unknown, +): string | undefined { + if (state.state === 'stale' && typeof state.reason === 'string') return state.reason + if (state.state === 'cancelled' && typeof state.cause === 'string') return state.cause + const value = record(metadata) + if (!value || typeof value.kind !== 'string') return undefined + if (typeof value.reason === 'string') return `${value.kind}: ${value.reason}` + if (typeof value.cause === 'string') return `${value.kind}: ${value.cause}` + if (typeof value.count === 'number') return `${value.count} bytes written` + return value.kind +} + +function durationMilliseconds(value: unknown): number | undefined { + const duration = record(value) + if (!duration) return undefined + const secs = typeof duration.secs === 'number' ? duration.secs : 0 + const nanos = typeof duration.nanos === 'number' ? duration.nanos : 0 + return Math.max(0, secs * 1_000 + nanos / 1_000_000) +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} diff --git a/apps/desktop/src/platform/loopal-backend/node/projections/loopal-view-projection.ts b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-view-projection.ts new file mode 100644 index 00000000..33f198fd --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/projections/loopal-view-projection.ts @@ -0,0 +1,166 @@ +import { + type AgentSummary, + type SessionView, +} from '../../../../shared/contracts' +import { projectMessages } from './loopal-message-projection' +import { type Topology, type ViewSnapshot } from '../runtime/loopal-wire' + +export function projectAgent( + snapshot: ViewSnapshot, + topology: Topology['agents'][number] | undefined, + now: Date, + previous?: AgentSummary, +): AgentSummary { + const agent = snapshot.state.agent + const conversation = agent.conversation + const tools = conversation.messages.flatMap((message) => message.tool_calls) + const activeTools = tools.filter((tool) => ( + tool.state.state === 'pending' || tool.state.state === 'running' + )) + const parent = topology?.parent ?? agent.parent + return { + id: agent.name, + name: agent.name === 'main' ? 'Loopal' : agent.name, + status: agentStatus(agent.observable.status, topology?.lifecycle), + ...(parent ? { parentId: leafName(parent) } : {}), + children: topology?.children ?? agent.children, + ...(topology?.error ? { error: topology.error } : {}), + ...(topology?.shadow ? { shadow: true } : {}), + ...(agent.observable.model ? { model: agent.observable.model } : {}), + mode: agent.observable.mode, + thinkingConfig: agent.observable.thinking_config, + permissionMode: agent.observable.permission_mode, + decisionMode: agent.observable.decision_mode, + sandboxPolicy: agent.observable.sandbox_policy, + ...(activeTools.at(-1)?.summary ? { lastTool: activeTools.at(-1)!.summary } : {}), + telemetry: { + turnCount: conversation.turn_count, + inputTokens: conversation.input_tokens, + outputTokens: conversation.output_tokens, + cacheCreationTokens: conversation.cache_creation_tokens, + cacheReadTokens: conversation.cache_read_tokens, + thinkingTokens: conversation.thinking_tokens, + contextWindow: conversation.context_window, + toolsInFlight: activeTools.length, + toolCount: tools.length, + }, + conversation: projectMessages(snapshot, now, previous?.conversation), + view: projectSessionView(snapshot), + } +} + +export function topologyAgent( + value: Topology['agents'][number], + previous?: AgentSummary, +): AgentSummary { + return { + id: value.name, + name: value.name === 'main' ? 'Loopal' : value.name, + status: topologyStatus(value.lifecycle), + ...(value.parent ? { parentId: leafName(value.parent) } : {}), + children: value.children, + ...(value.error ? { error: value.error } : {}), + ...(value.shadow ? { shadow: true } : {}), + ...(value.model ? { model: value.model } : {}), + ...(previous?.conversation ? { conversation: previous.conversation } : {}), + ...(previous?.telemetry ? { telemetry: previous.telemetry } : {}), + ...(previous?.view ? { view: previous.view } : {}), + } +} + +export function retiredAgent(previous: AgentSummary): AgentSummary { + if (previous.id === 'main' || ['completed', 'failed'].includes(previous.status)) return previous + const { lastTool: _lastTool, ...rest } = previous + return { ...rest, status: 'completed' } +} + +export function projectSessionView(snapshot: ViewSnapshot): SessionView { + const state = snapshot.state + const conversation = state.agent.conversation + return { + revision: snapshot.rev, + historyTruncated: conversation.history_truncated, + streamingText: conversation.streaming_text, + streamingThinking: conversation.streaming_thinking, + thinkingActive: conversation.thinking_active, + retryBanner: conversation.retry_banner ?? null, + compactBanner: conversation.compact_banner ?? null, + tasks: state.tasks.map((task) => ({ + id: task.id, subject: task.subject, description: task.description, + ...(task.active_form ? { activeForm: task.active_form } : {}), + status: taskStatus(task.status), blockedBy: task.blocked_by, blocks: task.blocks, + })), + backgroundTasks: Object.values(state.bg_tasks).map((task) => ({ + id: task.id, description: task.description, status: bgStatus(task.status), + exitCode: task.exit_code ?? null, output: task.output, + createdAt: new Date(task.created_at_unix_ms).toISOString(), + })), + crons: state.crons.map((cron) => ({ + id: cron.id, schedule: cron.cron_expr, prompt: cron.prompt, + recurring: cron.recurring, durable: cron.durable, + ...(cron.next_fire_unix_ms !== undefined && cron.next_fire_unix_ms !== null + ? { nextFireAt: new Date(cron.next_fire_unix_ms).toISOString() } + : {}), + })), + mcpServers: (state.mcp_status ?? []).map((server) => ({ + name: server.name, transport: server.transport, source: server.source, + status: server.status, toolCount: server.tool_count, + resourceCount: server.resource_count, promptCount: server.prompt_count, + errors: server.errors, + })), + ...(state.thread_goal ? { goal: { + id: state.thread_goal.goal_id, objective: state.thread_goal.objective, + status: goalStatus(state.thread_goal.status), + createdAt: state.thread_goal.created_at, updatedAt: state.thread_goal.updated_at, + } } : {}), + ...(state.hub_degraded_since_ms !== undefined && state.hub_degraded_since_ms !== null + ? { hubDegradedSince: new Date(state.hub_degraded_since_ms).toISOString() } + : {}), + } +} + +export function normalizeAgentStatus(value: string): AgentSummary['status'] { + if (value === 'Starting') return 'starting' + if (value === 'Running') return 'running' + if (value === 'WaitingForInput') return 'waiting' + if (value === 'Suspended') return 'suspended' + if (value === 'Finished') return 'completed' + if (value === 'Error') return 'failed' + return 'idle' +} + +function agentStatus( + observable: string, + lifecycle: Topology['agents'][number]['lifecycle'] | undefined, +): AgentSummary['status'] { + if (lifecycle === 'spawning') return 'starting' + if (lifecycle === 'finished') return 'completed' + if (lifecycle === 'failed') return 'failed' + return normalizeAgentStatus(observable) +} + +function topologyStatus(value: string): AgentSummary['status'] { + if (value === 'spawning') return 'starting' + if (value === 'running') return 'running' + if (value === 'finished') return 'completed' + if (value === 'failed') return 'failed' + return 'idle' +} + +function taskStatus(value: string): 'pending' | 'in_progress' | 'completed' { + return value === 'in_progress' || value === 'completed' ? value : 'pending' +} + +function bgStatus(value: string): 'running' | 'completed' | 'failed' | 'killed' { + const lower = value.toLowerCase() + return lower === 'completed' || lower === 'failed' || lower === 'killed' ? lower : 'running' +} + +function goalStatus(value: string): 'active' | 'paused' | 'complete' | 'infeasible' { + const lower = value.toLowerCase() + return lower === 'paused' || lower === 'complete' || lower === 'infeasible' ? lower : 'active' +} + +function leafName(value: string): string { + return value.split('/').at(-1) || value +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.test.ts new file mode 100644 index 00000000..29599dba --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { type AgentControlCommand } from '../../../../shared/contracts' +import { toHubControlCommand } from './loopal-control-wire' + +describe('Loopal control wire mapping', () => { + it('maps the complete Desktop allowlist to Rust serde shapes', () => { + const cases: readonly (readonly [AgentControlCommand, unknown])[] = [ + [{ type: 'mode', mode: 'act' }, { ModeSwitch: 'Act' }], + [{ type: 'mode', mode: 'plan' }, { ModeSwitch: 'Plan' }], + [{ type: 'clear' }, 'Clear'], + [{ type: 'compact' }, { Compact: { instructions: null } }], + [{ type: 'compact', instructions: 'keep tests' }, { + Compact: { instructions: 'keep tests' }, + }], + [{ type: 'model', model: 'gpt-5' }, { ModelSwitch: 'gpt-5' }], + [{ type: 'rewind', turnIndex: 3 }, { Rewind: { turn_index: 3 } }], + [{ type: 'thinking', config: { type: 'effort', level: 'high' } }, { + ThinkingSwitch: '{"type":"effort","level":"high"}', + }], + [{ type: 'permission', mode: 'ask_any_write' }, { + PermissionModeSwitch: 'ask_any_write', + }], + [{ type: 'decision', mode: 'manual' }, { DecisionModeSwitch: 'manual' }], + [{ type: 'sandbox', policy: 'default_write' }, { + SandboxPolicySwitch: 'default_write', + }], + [{ type: 'suspend' }, 'Suspend'], [{ type: 'unsuspend' }, 'Unsuspend'], + [{ type: 'mcp_status' }, 'QueryMcpStatus'], + [{ type: 'mcp_reconnect', server: 'fs' }, { McpReconnect: { server: 'fs' } }], + [{ type: 'mcp_disconnect', server: 'fs' }, { McpDisconnect: { server: 'fs' } }], + [{ type: 'background_task_kill', id: 'bg' }, { BgTaskKill: { id: 'bg' } }], + [{ type: 'cron_delete', id: 'cron' }, { CronDelete: { id: 'cron' } }], + ] + for (const [command, expected] of cases) { + expect(toHubControlCommand(command)).toEqual(expected) + } + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.ts new file mode 100644 index 00000000..e8ababc9 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-control-wire.ts @@ -0,0 +1,38 @@ +import { type AgentControlCommand } from '../../../../shared/contracts' + +export function toHubControlCommand(command: AgentControlCommand): unknown { + switch (command.type) { + case 'mode': + return { ModeSwitch: command.mode === 'act' ? 'Act' : 'Plan' } + case 'clear': + return 'Clear' + case 'compact': + return { Compact: { instructions: command.instructions ?? null } } + case 'model': + return { ModelSwitch: command.model } + case 'rewind': + return { Rewind: { turn_index: command.turnIndex } } + case 'thinking': + return { ThinkingSwitch: JSON.stringify(command.config) } + case 'permission': + return { PermissionModeSwitch: command.mode } + case 'decision': + return { DecisionModeSwitch: command.mode } + case 'sandbox': + return { SandboxPolicySwitch: command.policy } + case 'suspend': + return 'Suspend' + case 'unsuspend': + return 'Unsuspend' + case 'mcp_status': + return 'QueryMcpStatus' + case 'mcp_reconnect': + return { McpReconnect: { server: command.server } } + case 'mcp_disconnect': + return { McpDisconnect: { server: command.server } } + case 'background_task_kill': + return { BgTaskKill: { id: command.id } } + case 'cron_delete': + return { CronDelete: { id: command.id } } + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.metahub.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.metahub.test.ts new file mode 100644 index 00000000..68d6d4d2 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.metahub.test.ts @@ -0,0 +1,40 @@ +import { expect, it, vi } from 'vitest' +import { + liveSessionHarness as harness, +} from './loopal-live-session.test-fixtures' + +it('polls cluster topology off the snapshot path and adds then removes remote Agents', async () => { + vi.useFakeTimers() + const value = harness() + const base = value.request.getMockImplementation()! + let remote = true + value.request.mockImplementation(async (method, params, signal) => { + if (method === 'hub/status') return remote ? { + agent_count: 1, + uplink: { connected: true, hub_name: 'hub-a', address: 'meta:9' }, + } : { agent_count: 1, uplink: null } + if (method === 'meta/list_hubs') return { hubs: [ + { name: 'hub-a', status: 'Connected', agent_count: 1, capabilities: [] }, + { name: 'hub-b', status: 'Connected', agent_count: 1, capabilities: [] }, + ] } + if (method === 'meta/topology') return { hubs: [ + { hub: 'hub-a', topology: { agents: [] } }, + { hub: 'hub-b', topology: { agents: [{ + name: 'main', parent: null, children: [], lifecycle: 'running', + }] } }, + ] } + return base(method, params, signal) + }) + await value.state.initialize() + await vi.advanceTimersByTimeAsync(1) + expect(value.state.detail.agents).toContainEqual( + expect.objectContaining({ id: 'hub-b/main', qualifiedName: 'hub-b/main' }), + ) + remote = false + await vi.advanceTimersByTimeAsync(2_001) + expect(value.state.detail.agents.map((agent) => agent.id)).not.toContain('hub-b/main') + expect(value.events.filter((event) => event.type === 'session_detail_replaced').length) + .toBeGreaterThanOrEqual(2) + value.state.dispose() + vi.useRealTimers() +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test-fixtures.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test-fixtures.ts new file mode 100644 index 00000000..1288609f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test-fixtures.ts @@ -0,0 +1,49 @@ +import { vi } from 'vitest' +import { type DesktopEvent, type SessionSummary } from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { LoopalLiveSession } from './loopal-live-session' +import { type SessionRuntimeHandle } from './session-runtime-registry' + +export const liveSessionNow = () => new Date('2026-07-11T12:00:00.000Z') +export const liveSessionSummary: SessionSummary = { + id: 'session', workspaceId: 'workspace', title: 'Session', model: 'model', mode: 'agent', + status: 'waiting', activeRuntimeId: 'runtime', + createdAt: liveSessionNow().toISOString(), updatedAt: liveSessionNow().toISOString(), +} + +export function liveSessionEvent(payload: unknown, revision: number, agent = 'main') { + return { + agent_name: { hub: [], agent }, event_id: revision, turn_id: 1, + correlation_id: 2, rev: revision, payload, + } +} + +export function liveSessionHarness(requestImpl?: DesktopHostClient['request']) { + const request = vi.fn(requestImpl ?? (async (method) => { + if (method === 'view/snapshot') return { + rev: 2, + state: { agent: { + name: 'main', observable: { status: 'WaitingForInput' }, + conversation: { streaming_text: '', messages: [] }, + } }, + } + if (method === 'hub/topology') return { agents: [{ + name: 'main', parent: null, children: [], lifecycle: 'running', model: 'model', + }] } + if (method === 'hub/list_agents') { + return { agents: [{ name: 'main', state: 'connected' }] } + } + if (method === 'hub/status') return { agent_count: 1, uplink: null } + return { ok: true } + })) + const runtime = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 1, + host: { request } as unknown as DesktopHostClient, + } satisfies SessionRuntimeHandle + const events: DesktopEvent[] = [] + const summaries: SessionSummary[] = [] + const state = new LoopalLiveSession(runtime, liveSessionSummary, liveSessionNow, { + event: (value) => events.push(value), summary: (value) => summaries.push(value), + }) + return { state, request, events, summaries } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test.ts new file mode 100644 index 00000000..118c9303 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest' +import { + liveSessionEvent as event, liveSessionHarness as harness, + liveSessionSummary as summary, +} from './loopal-live-session.test-fixtures' + +describe('LoopalLiveSession', () => { + it('guards unavailable detail and inactive sessions', async () => { + const { state, request, events, summaries } = harness() + expect(() => state.detail).toThrow('not ready') + state.replaceSummary({ ...summary, title: 'Before snapshot' }) + state.accept('unknown', {}) + await state.send('before detail') + expect(request).toHaveBeenCalledWith('hub/route', expect.anything()) + expect(events).toEqual([]) + expect(summaries).toHaveLength(1) + state.dispose() + state.accept('agent/event', event('Running', 3)) + await state.initialize() + expect(() => state.detail).toThrow('not ready') + }) + + it('updates initialized detail, tracks both attention kinds, and resolves them on retire', async () => { + const { state, request, events, summaries } = harness() + await state.initialize() + const next = { ...summary, title: 'Renamed' } + state.replaceSummary(next) + expect(state.detail.session.title).toBe('Renamed') + await state.send('hello') + expect(events).toContainEqual({ + type: 'conversation_entry', sessionId: 'session', + entry: expect.objectContaining({ role: 'user', text: 'hello' }), + }) + expect(summaries.at(-1)).toMatchObject({ status: 'running' }) + state.detail.agents.push({ + id: 'worker', name: 'Worker', parentId: 'main', status: 'waiting', + }) + await state.send('hello child', 'worker') + expect(request).toHaveBeenCalledWith('hub/route', expect.objectContaining({ + target: { hub: [], agent: 'worker' }, + })) + expect(state.detail.agents.find((agent) => agent.id === 'worker')?.conversation) + .toContainEqual(expect.objectContaining({ text: 'hello child', agentId: 'worker' })) + expect(events).toContainEqual(expect.objectContaining({ type: 'session_detail_replaced' })) + + state.accept('agent/event', event({ + ToolPermissionRequest: { id: 'permission', name: 'Read', input: 'README.md' }, + }, 3)) + state.accept('agent/event', event({ + UserQuestionRequest: { + id: 'question', questions: [{ question: 'Continue?', options: [], allow_multiple: false }], + }, + }, 4)) + state.accept('agent/event', event({ ToolPermissionResolved: { id: 'permission' } }, 5)) + state.accept('agent/event', event({ UserQuestionResolved: { id: 'question' } }, 6)) + state.accept('agent/event', event({ + UserQuestionRequest: { + id: 'pending', questions: [{ question: 'Again?', options: [], allow_multiple: false }], + }, + }, 7)) + state.accept('agent/event', event({ ToolPermissionRequest: {} }, 8)) + expect(state.retire()).toEqual([{ + type: 'question_resolved', sessionId: 'session', runtimeId: 'runtime', + generation: 1, agentId: 'main', requestId: 'pending', + }]) + expect(state.retire()).toEqual([]) + const summaryCount = summaries.length + await state.send('late') + await state.send('late child', 'worker') + expect(summaries).toHaveLength(summaryCount) + }) + + it('drops a snapshot that completes after disposal', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const base = harness() + const implementation = base.request.getMockImplementation()! + base.request.mockImplementation(async (method, params, signal) => { + if (method === 'view/snapshot') await gate + return implementation(method, params, signal) + }) + const pending = base.state.initialize() + base.state.dispose() + release() + await pending + expect(() => base.state.detail).toThrow('not ready') + }) + + it('retains deduplicated artifacts produced by root and child turns', async () => { + const { state, events } = harness() + await state.initialize() + state.accept('agent/event', event({ + TurnDiffSummary: { modified_files: ['./src/main.rs', 'src/main.rs', 'README.md'] }, + }, 3, 'worker')) + expect(state.detail.artifacts).toHaveLength(2) + expect(state.detail.artifacts[0]).toMatchObject({ + title: 'main.rs', kind: 'code', producerAgentId: 'worker', + }) + expect(events.filter((item) => item.type === 'artifact_created')).toHaveLength(2) + state.accept('agent/event', event({ + TurnDiffSummary: { modified_files: ['src/main.rs'] }, + }, 4, 'worker')) + expect(state.detail.artifacts).toHaveLength(2) + }) + + it('cancels pending invalidation timers on overflow and disposal', async () => { + const { state, events } = harness() + await state.initialize() + state.accept('agent/event', event({ Stream: { text: 'live' } }, 3)) + state.accept('agent/event', event('AwaitingInput', 4)) + expect(events).toContainEqual(expect.objectContaining({ + type: 'conversation_entry', entry: expect.objectContaining({ text: 'live' }), + })) + state.accept('agent/event', event({ SessionHistoryLoaded: { messages: [] } }, 5)) + state.dispose() + state.dispose() + + const direct = harness() + await direct.state.initialize() + direct.state.accept('agent/event', event({ SessionHistoryLoaded: { messages: [] } }, 3)) + direct.state.dispose() + + const failed = harness() + await failed.state.initialize() + const before = failed.request.mock.calls.length + failed.request.mockRejectedValueOnce(new Error('timer refresh failed')) + failed.state.accept('agent/event', event('Running', 3)) + await vi.waitFor(() => expect(failed.request.mock.calls.length).toBeGreaterThan(before)) + }) + + it('contains explicit resync and trailing overflow refresh failures', async () => { + const explicit = harness() + await explicit.state.initialize() + const before = explicit.request.mock.calls.length + explicit.request.mockRejectedValueOnce(new Error('resync failed')) + explicit.state.accept('view/resync_required', {}) + await vi.waitFor(() => expect(explicit.request.mock.calls.length).toBeGreaterThan(before)) + + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const overflow = harness() + const implementation = overflow.request.getMockImplementation()! + let snapshots = 0 + overflow.request.mockImplementation(async (method, params, signal) => { + if (method === 'view/snapshot' && ++snapshots === 1) await gate + else if (method === 'view/snapshot') throw new Error('trailing refresh failed') + return implementation(method, params, signal) + }) + const pending = overflow.state.initialize() + await vi.waitFor(() => expect(snapshots).toBe(1)) + for (let revision = 3; revision < 75; revision += 1) { + overflow.state.accept('agent/event', event('Running', revision)) + } + release() + await expect(pending).rejects.toThrow('trailing refresh failed') + }) + + it('restarts a refresh invalidated at the drain boundary', async () => { + const { state, events } = harness() + const internals = state as unknown as { + refresh(emit: boolean): Promise + runRefreshLoop(): Promise + } + const drain = internals.runRefreshLoop.bind(state) + let runs = 0 + internals.runRefreshLoop = async () => { + runs += 1 + await drain() + if (runs === 1) queueMicrotask(() => void internals.refresh(true)) + } + await state.initialize() + await vi.waitFor(() => expect(runs).toBe(2)) + await vi.waitFor(() => expect(events).toContainEqual( + expect.objectContaining({ type: 'session_detail_replaced' }), + )) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.ts new file mode 100644 index 00000000..c72d59b9 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-live-session.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto' +import { + type ConversationEntry, + type DesktopEvent, + type DesktopImageAttachment, + type SessionDetail, + type SessionStatus, + type SessionSummary, +} from '../../../../shared/contracts' +import { LoopalEventProjector } from '../projections/loopal-event-projector' +import { projectModifiedFiles } from '../projections/loopal-artifact-projection' +import { LoopalLiveAttention } from '../attention/loopal-live-attention' +import { loadSessionDetail } from '../sessions/loopal-session-snapshot' +import { LoopalMetaHubWatcher } from '../federation/loopal-metahub-watcher' +import { type SessionRuntimeHandle } from './session-runtime-registry' +interface LiveSessionSink { + event(event: DesktopEvent): void + summary(summary: SessionSummary): void +} + +export class LoopalLiveSession { + private readonly projector: LoopalEventProjector + private readonly attention: LoopalLiveAttention + private detailValue?: SessionDetail + private refreshing: Promise | undefined + private refreshPending = false + private refreshShouldEmit = false + private refreshTimer: ReturnType | undefined + private readonly metaHub: LoopalMetaHubWatcher + private active = true + + constructor( + readonly runtime: SessionRuntimeHandle, + private summaryValue: SessionSummary, + private readonly now: () => Date, + private readonly sink: LiveSessionSink, + ) { + this.attention = new LoopalLiveAttention(runtime, now, sink.event) + this.projector = new LoopalEventProjector(now, { + append: (entry) => this.append(entry), + appendAgent: (entry, agentId) => this.appendToAgent(agentId, entry), + updateSession: (status, attention) => this.update(status, attention), + overflow: () => this.requestAuthoritativeRefresh(), + attention: (kind, value, agentId) => this.attention.accept(kind, value, agentId), + artifacts: (paths, agentId) => this.recordArtifacts(paths, agentId), + }) + this.metaHub = new LoopalMetaHubWatcher( + runtime.host, now, () => this.detailValue?.metaHub, () => this.refresh(true), + ) + } + + get detail(): SessionDetail { + if (!this.detailValue) throw new Error(`Session detail is not ready: ${this.summaryValue.id}`) + return this.detailValue + } + + get cachedDetail(): SessionDetail | undefined { return this.detailValue } + + replaceSummary(summary: SessionSummary): void { + this.summaryValue = summary + if (this.detailValue) this.detailValue = { ...this.detailValue, session: summary } + } + + async initialize(): Promise { + await this.refresh(false) + this.metaHub.start() + } + + resync(): Promise { + return this.refresh(true) + } + + accept(method: string, params: unknown): void { + if (!this.active) return + if (method === 'agent/event') { + this.projector.accept(params) + this.invalidate() + } + else if (method === 'view/resync_required') { + void this.refresh(true).catch(() => undefined) + } + } + + async send( + text: string, agentId = 'main', images: readonly DesktopImageAttachment[] = [], + ): Promise { + const id = randomUUID() + const entry: ConversationEntry = { + id, role: 'user', text, agentId, createdAt: this.now().toISOString(), + ...(images.length ? { imageCount: images.length } : {}), + } + await this.runtime.host.request('hub/route', { + id, source: 'Human', target: { hub: [], agent: agentId }, + content: { + text, images: images.map(({ mediaType, data }) => ({ media_type: mediaType, data })), + }, + timestamp: this.now().toISOString(), + }) + if (agentId === 'main') this.append(entry) + else this.appendToAgent(agentId, entry) + this.update('running') + } + + dispose(): void { + this.active = false + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = undefined + this.metaHub.dispose() + } + + retire(): readonly DesktopEvent[] { + this.active = false + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = undefined + this.metaHub.dispose() + return this.attention.retire() + } + + private refresh(emit: boolean): Promise { + this.refreshPending = true + this.refreshShouldEmit ||= emit + this.refreshing ??= this.runRefreshLoop().finally(() => { + this.refreshing = undefined + if (this.refreshPending && this.active) void this.refresh(false).catch(() => undefined) + }) + return this.refreshing + } + private invalidate(): void { + if (this.refreshTimer || !this.active) return + this.refreshTimer = setTimeout(() => { + this.refreshTimer = undefined + void this.refresh(true).catch(() => undefined) + }, 16) + } + private requestAuthoritativeRefresh(): void { + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = undefined + void this.refresh(true).catch(() => undefined) + } + private recordArtifacts(paths: readonly string[], agentId: string): void { + const detail = this.detail + const known = new Set(detail.artifacts.map((artifact) => artifact.id)) + const created = projectModifiedFiles( + this.summaryValue.id, agentId, paths, this.now().toISOString(), + ).filter((artifact) => !known.has(artifact.id)) + if (created.length === 0) return + this.detailValue = { + ...detail, + artifacts: [...detail.artifacts, ...created], + } + for (const artifact of created) this.sink.event({ type: 'artifact_created', artifact }) + } + + private async runRefreshLoop(): Promise { + while (this.refreshPending && this.active) { + this.refreshPending = false + const emit = this.refreshShouldEmit + this.refreshShouldEmit = false + const snapshot = await loadSessionDetail( + this.runtime.host, this.summaryValue, this.now, this.projector, + this.detailValue, + ) + if (!this.active) return + this.detailValue = { ...snapshot.detail, session: this.summaryValue } + this.projector.finishSync(snapshot.revision, snapshot.revisions) + this.attention.reconcile(snapshot.pendingAttention) + if (emit) this.sink.event({ type: 'session_detail_replaced', detail: this.detailValue }) + } + } + + private append(entry: ConversationEntry): void { + if (!this.active || !this.detailValue) return + this.detailValue = { + ...this.detailValue, + conversation: [...this.detailValue.conversation, entry], + } + this.sink.event({ type: 'conversation_entry', sessionId: this.summaryValue.id, entry }) + } + + private appendToAgent(agentId: string, entry: ConversationEntry): void { + if (!this.active || !this.detailValue) return + const agents = this.detailValue.agents.map((agent) => agent.id === agentId + ? { ...agent, conversation: [...agent.conversation ?? [], entry] } + : agent) + this.detailValue = { ...this.detailValue, agents } + this.sink.event({ type: 'session_detail_replaced', detail: this.detailValue }) + } + + private update(status: SessionStatus, attention?: SessionSummary['attention']): void { + if (!this.active) return + const { attention: _old, ...summary } = this.summaryValue + this.replaceSummary({ + ...summary, + status, + updatedAt: this.now().toISOString(), + ...(attention ? { attention } : {}), + }) + this.sink.summary(this.summaryValue) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-wire.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-wire.ts new file mode 100644 index 00000000..91bf1df0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/loopal-wire.ts @@ -0,0 +1,141 @@ +import { z } from 'zod' + +const ToolStateSchema = z.object({ state: z.string() }).passthrough() +const WireToolSchema = z.object({ + id: z.string(), + name: z.string(), + summary: z.string().default(''), + input: z.unknown().optional(), + state: ToolStateSchema, + batch_id: z.string().nullable().optional(), + metadata: z.unknown().nullable().optional(), +}) + +const WireMessageSchema = z.object({ + role: z.string(), + content: z.string(), + tool_calls: z.array(WireToolSchema).default([]), + image_count: z.number().int().nonnegative().default(0), + skill_info: z.object({ name: z.string(), user_args: z.string() }).nullable().optional(), + inbox: z.object({ + message_id: z.string(), + source: z.unknown(), + summary: z.string().nullable().optional(), + }).nullable().optional(), + message_id: z.string().optional(), + ui_local: z.boolean().default(false), +}) + +const WireConversationSchema = z.object({ + messages: z.array(WireMessageSchema).default([]), + history_truncated: z.boolean().default(false), + streaming_text: z.string().default(''), + streaming_thinking: z.string().default(''), + thinking_active: z.boolean().default(false), + pending_permission: z.object({ + id: z.string(), name: z.string(), input: z.unknown(), cursor: z.string().optional(), + }).nullable().optional(), + pending_question: z.object({ + id: z.string(), + questions: z.array(z.object({ + question: z.string(), header: z.string().nullable().optional(), + options: z.array(z.object({ label: z.string(), description: z.string() })), + allow_multiple: z.boolean(), + })), + classifier_status: z.object({ kind: z.string() }).passthrough().optional(), + }).nullable().optional(), + pending_plan_approval: z.object({ + id: z.string(), plan_content: z.string(), plan_path: z.string(), + }).nullable().optional(), + retry_banner: z.string().nullable().optional(), + compact_banner: z.string().nullable().optional(), + turn_count: z.number().int().nonnegative().default(0), + input_tokens: z.number().int().nonnegative().default(0), + output_tokens: z.number().int().nonnegative().default(0), + context_window: z.number().int().nonnegative().default(0), + cache_creation_tokens: z.number().int().nonnegative().default(0), + cache_read_tokens: z.number().int().nonnegative().default(0), + thinking_tokens: z.number().int().nonnegative().default(0), +}) + +const WireAgentSchema = z.object({ + name: z.string(), + session_id: z.string().nullable().optional(), + parent: z.string().nullable().optional(), + children: z.array(z.string()).default([]), + observable: z.object({ + status: z.string(), + turn_count: z.number().int().nonnegative().default(0), + input_tokens: z.number().int().nonnegative().default(0), + output_tokens: z.number().int().nonnegative().default(0), + model: z.string().default(''), + thinking_config: z.string().default('auto'), + mode: z.string().default('act'), + permission_mode: z.string().default(''), + decision_mode: z.string().default(''), + sandbox_policy: z.string().default(''), + }), + conversation: WireConversationSchema, +}) + +const WireTaskSchema = z.object({ + id: z.string(), subject: z.string(), description: z.string().default(''), + active_form: z.string().nullable().optional(), status: z.string(), + blocked_by: z.array(z.string()).default([]), blocks: z.array(z.string()).default([]), +}) +const WireBackgroundTaskSchema = z.object({ + id: z.string(), description: z.string(), status: z.string(), + exit_code: z.number().int().nullable().optional(), output: z.string().default(''), + created_at_unix_ms: z.number().nonnegative(), +}) +const WireCronSchema = z.object({ + id: z.string(), cron_expr: z.string().default(''), prompt: z.string(), + recurring: z.boolean(), durable: z.boolean().default(false), + next_fire_unix_ms: z.number().nullable().optional(), +}) +const WireMcpSchema = z.object({ + name: z.string(), transport: z.string(), source: z.string(), status: z.string(), + tool_count: z.number().int().nonnegative(), resource_count: z.number().int().nonnegative(), + prompt_count: z.number().int().nonnegative(), errors: z.array(z.string()).default([]), +}) +const WireGoalSchema = z.object({ + goal_id: z.string(), objective: z.string(), status: z.string(), + created_at: z.string(), updated_at: z.string(), +}) + +export const ViewSnapshotSchema = z.object({ + rev: z.number().int().nonnegative(), + state: z.object({ + agent: WireAgentSchema, + tasks: z.array(WireTaskSchema).default([]), + crons: z.array(WireCronSchema).default([]), + bg_tasks: z.record(z.string(), WireBackgroundTaskSchema).default({}), + mcp_status: z.array(WireMcpSchema).nullable().optional(), + thread_goal: WireGoalSchema.nullable().optional(), + hub_degraded_since_ms: z.number().nonnegative().nullable().optional(), + }), +}) +export type ViewSnapshot = z.infer + +export const AgentListSchema = z.object({ + agents: z.array(z.object({ name: z.string(), state: z.string() })), +}) + +export const TopologySchema = z.object({ + agents: z.array(z.object({ + name: z.string(), parent: z.string().nullable().optional(), + children: z.array(z.string()).default([]), + lifecycle: z.enum(['spawning', 'running', 'finished', 'failed']), + error: z.string().nullable().optional(), + model: z.string().nullable().optional(), + shadow: z.boolean().optional(), + })), +}) +export type Topology = z.infer + +export const AgentEventSchema = z.object({ + agent_name: z.object({ hub: z.array(z.string()), agent: z.string() }).nullable().optional(), + event_id: z.number().optional(), + rev: z.number().int().nonnegative().optional(), + payload: z.unknown(), +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-activation.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-activation.ts new file mode 100644 index 00000000..30913e75 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-activation.ts @@ -0,0 +1,30 @@ +import { SessionRuntimeEntry } from './session-runtime-registry-entry' +import { type SessionRuntimeActivation } from './session-runtime-registry-types' + +interface HostSession { + readonly sessionId: string +} + +export class SessionRuntimeActivationGate { + private sessionId?: string + private activation?: Promise + + constructor( + private readonly entry: SessionRuntimeEntry, + private readonly callback?: SessionRuntimeActivation, + ) {} + + readonly activate = (session: HostSession): Promise => { + if (this.sessionId && this.sessionId !== session.sessionId) { + return Promise.reject(new Error('Desktop Host changed Session during activation')) + } + if (!this.activation) { + this.sessionId = session.sessionId + this.entry.markSessionCreated(session.sessionId) + this.activation = Promise.resolve().then(async () => { + await this.callback?.(session.sessionId) + }) + } + return this.activation + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.test.ts new file mode 100644 index 00000000..0a227fa0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionRuntimeEntry } from './session-runtime-registry-entry' +import { FakeRuntimeHost } from './session-runtime-registry.test-fixtures' + +describe('SessionRuntimeEntry', () => { + it('overflows from a pre-bind status and emits one scoped resync', () => { + const host = new FakeRuntimeHost('session') + const status = vi.fn() + const notification = vi.fn() + const crashed = vi.fn() + const entry = new SessionRuntimeEntry( + 'workspace', '/workspace', 'runtime', 3, host, undefined, + { status, notification, crashed }, + ) + expect(entry.resumeInput()).toBeUndefined() + for (let index = 0; index < 64; index += 1) { + host.notify('agent/event', { index }) + } + host.crash() + const handle = entry.bindSession('session') + expect(handle).toMatchObject({ sessionId: 'session', runtimeId: 'runtime', generation: 3 }) + expect(status).toHaveBeenCalledWith(expect.objectContaining({ status: 'crashed' })) + expect(notification).toHaveBeenCalledWith(expect.objectContaining({ + method: 'view/resync_required', params: { reason: 'pre_ready_buffer_overflow' }, + })) + expect(crashed).toHaveBeenCalledOnce() + entry.dispose() + host.notify('agent/event', { late: true }) + expect(notification).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.ts new file mode 100644 index 00000000..860e7f7e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-entry.ts @@ -0,0 +1,137 @@ +import { DisposableStore } from '../../../../base/common/lifecycle' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { + type SessionRuntimeHandle, + type SessionRuntimeHostStatus, + type SessionRuntimeNotificationEvent, + type SessionRuntimeStatusEvent, + type SessionRuntimeWorkspace, +} from './session-runtime-registry-types' + +const PRE_READY_BUFFER_LIMIT = 64 + +interface EntrySink { + status(event: SessionRuntimeStatusEvent): void + notification(event: SessionRuntimeNotificationEvent): void + crashed(): void +} + +export class SessionRuntimeEntry implements SessionRuntimeWorkspace { + readonly subscriptions = new DisposableStore() + readonly statuses: SessionRuntimeHostStatus[] = [] + readonly notifications: Array<{ method: string; params: unknown }> = [] + sessionId: string | undefined + handle?: SessionRuntimeHandle + ready?: Promise + retiring?: Promise + private overflowed = false + private createdSessionId?: string + + constructor( + readonly workspaceId: string, + readonly cwd: string, + readonly runtimeId: string, + readonly generation: number, + readonly host: DesktopHostClient, + sessionId: string | undefined, + private readonly sink: EntrySink, + ) { + this.sessionId = sessionId + this.subscriptions.add(host.onStatus((status) => this.acceptStatus(status))) + this.subscriptions.add(host.onNotification((event) => { + this.acceptNotification(event.method, event.params) + })) + } + + bindSession(sessionId: string): SessionRuntimeHandle { + this.markSessionCreated(sessionId) + this.sessionId = sessionId + this.handle = Object.freeze({ + ...this.scope(), + host: this.host, + }) + for (const status of this.statuses.splice(0)) { + this.sink.status({ ...this.scope(), status }) + } + if (this.overflowed) { + this.sink.notification({ + ...this.scope(), + method: 'view/resync_required', + params: { reason: 'pre_ready_buffer_overflow' }, + }) + } else { + for (const event of this.notifications.splice(0)) { + this.sink.notification({ ...this.scope(), ...event }) + } + } + this.notifications.length = 0 + return this.handle + } + + markSessionCreated(sessionId: string): void { + const known = this.sessionId ?? this.createdSessionId + if (known && known !== sessionId) { + throw new Error(`Desktop Host changed Session from ${known} to ${sessionId}`) + } + this.createdSessionId = sessionId + } + + resumeInput() { + const sessionId = this.sessionId ?? this.createdSessionId + if (!sessionId) return undefined + return { + workspaceId: this.workspaceId, + cwd: this.cwd, + sessionId, + } + } + + dispose(): void { + this.subscriptions.dispose() + } + + private acceptStatus(status: SessionRuntimeHostStatus): void { + if (this.sessionId) this.sink.status({ ...this.scope(), status }) + else this.bufferStatus(status) + if (status === 'crashed') this.sink.crashed() + } + + private acceptNotification(method: string, params: unknown): void { + if (this.sessionId) { + this.sink.notification({ ...this.scope(), method, params }) + } else if (!this.overflowed) { + if (this.bufferSize() >= PRE_READY_BUFFER_LIMIT) this.markOverflow() + else this.notifications.push({ method, params }) + } + } + + private bufferStatus(status: SessionRuntimeHostStatus): void { + if (this.overflowed) { + this.statuses.splice(0, this.statuses.length, status) + } else if (this.bufferSize() >= PRE_READY_BUFFER_LIMIT) { + this.markOverflow() + this.statuses.push(status) + } else { + this.statuses.push(status) + } + } + + private markOverflow(): void { + this.overflowed = true + this.statuses.length = 0 + this.notifications.length = 0 + } + + private bufferSize(): number { + return this.statuses.length + this.notifications.length + } + + private scope() { + return { + workspaceId: this.workspaceId, + sessionId: this.sessionId!, + runtimeId: this.runtimeId, + generation: this.generation, + } + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-types.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-types.ts new file mode 100644 index 00000000..e4b2e425 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-types.ts @@ -0,0 +1,56 @@ +import { type DesktopHostClient } from '../backend/loopal-backend-types' + +export interface SessionRuntimeWorkspace { + readonly workspaceId: string + readonly cwd: string +} + +export interface SessionRuntimeResumeInput extends SessionRuntimeWorkspace { + readonly sessionId: string +} + +export interface SessionRuntimeHostInput { + readonly workspaceId: string + readonly cwd: string + readonly resumeSessionId?: string +} + +export interface SessionRuntimeAllocation { + readonly runtimeId: string + readonly generation: number +} +export type SessionRuntimeActivation = (sessionId: string) => Promise + +export type SessionRuntimeHostFactory = ( + input: SessionRuntimeHostInput, + allocation: SessionRuntimeAllocation, +) => DesktopHostClient + +export interface SessionRuntimeScope { + readonly workspaceId: string + readonly sessionId: string + readonly runtimeId: string + readonly generation: number +} + +export interface SessionRuntimeHandle extends SessionRuntimeScope { + readonly host: DesktopHostClient +} + +export type SessionRuntimeHostStatus = DesktopHostClient['currentStatus'] + +export interface SessionRuntimeStatusEvent extends SessionRuntimeScope { + readonly status: SessionRuntimeHostStatus +} + +export interface SessionRuntimeNotificationEvent extends SessionRuntimeScope { + readonly method: string + readonly params: unknown +} + +export interface SessionRuntimeRegistryOptions { + readonly maxLive: number + readonly maxTombstones?: number + readonly createHost: SessionRuntimeHostFactory + readonly createRuntimeId?: () => string +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-validation.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-validation.ts new file mode 100644 index 00000000..5b367716 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry-validation.ts @@ -0,0 +1,9 @@ +export function requirePositive(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Session runtime ${name} must be a positive integer`) + } +} + +export function requireText(value: string, name: string): void { + if (!value.trim()) throw new Error(`Session runtime ${name} is required`) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.edges.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.edges.test.ts new file mode 100644 index 00000000..3762ea4b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.edges.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createRegistryHarness, + FakeRuntimeHost, + workspaceA, +} from './session-runtime-registry.test-fixtures' +import { SessionRuntimeRegistry } from './session-runtime-registry' + +describe('SessionRuntimeRegistry edges', () => { + it('reports pending entries, replays buffered notifications, and exposes membership', async () => { + const harness = createRegistryHarness() + const notifications: unknown[] = [] + harness.registry.onNotification((event) => notifications.push(event)) + harness.delayNext() + const pending = harness.registry.resume({ ...workspaceA, sessionId: 'session-a' }) + expect(harness.registry.listLive()).toEqual([]) + harness.hosts[0]!.notify('agent/event', { buffered: true }) + harness.hosts[0]!.releaseStart() + const runtime = await pending + expect(harness.registry.has(runtime.runtimeId)).toBe(true) + expect(notifications).toEqual([{ + workspaceId: 'workspace-a', sessionId: 'session-a', runtimeId: 'runtime-1', generation: 1, + method: 'agent/event', params: { buffered: true }, + }]) + }) + + it('makes tombstone stop idempotent and rejects an unrestartable ID', async () => { + const { registry } = createRegistryHarness() + const runtime = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + await registry.stop(runtime.runtimeId) + await expect(registry.stop(runtime.runtimeId)).resolves.toBeUndefined() + await expect(registry.restart('missing')).rejects.toThrow('cannot be restarted') + }) + + it('rejects duplicate live and tombstoned runtime IDs', async () => { + const liveHosts: FakeRuntimeHost[] = [] + const live = new SessionRuntimeRegistry({ + maxLive: 2, + createRuntimeId: () => 'duplicate', + createHost: () => { + const host = new FakeRuntimeHost(`session-${liveHosts.length}`) + liveHosts.push(host) + return host + }, + }) + await live.startFresh(workspaceA) + expect(() => live.startFresh(workspaceA)).toThrow('Duplicate runtime ID') + await live.shutdownAll() + + const tombstone = new SessionRuntimeRegistry({ + maxLive: 1, + createRuntimeId: () => 'same', + createHost: (input) => new FakeRuntimeHost(input.resumeSessionId ?? 'session'), + }) + const runtime = await tombstone.resume({ ...workspaceA, sessionId: 'session' }) + await tombstone.stop(runtime.runtimeId) + expect(() => tombstone.startFresh(workspaceA)).toThrow('Duplicate runtime ID') + }) + + it('rejects conflicting fresh session ownership', async () => { + const registry = new SessionRuntimeRegistry({ + maxLive: 2, + createRuntimeId: (() => { let value = 0; return () => `runtime-${++value}` })(), + createHost: () => new FakeRuntimeHost('same-session'), + }) + await registry.startFresh(workspaceA) + await expect(registry.startFresh(workspaceA)).rejects.toThrow('already has a live runtime') + await registry.shutdownAll() + }) + + it('validates text and tombstone limits', () => { + expect(() => new SessionRuntimeRegistry({ + maxLive: 1, maxTombstones: 0, createHost: () => new FakeRuntimeHost('session'), + })).toThrow('maxTombstones') + const registry = new SessionRuntimeRegistry({ + maxLive: 1, createHost: () => new FakeRuntimeHost('session'), + }) + expect(() => registry.resume({ ...workspaceA, sessionId: ' ' })).toThrow('sessionId') + expect(() => registry.startFresh({ ...workspaceA, workspaceId: ' ' })).toThrow('workspaceId') + expect(() => registry.startFresh({ ...workspaceA, cwd: ' ' })).toThrow('cwd') + }) + + it('swallows asynchronous cleanup failures when disposed or crash-retired', async () => { + const first = createRegistryHarness() + await first.registry.resume({ ...workspaceA, sessionId: 'session-a' }) + first.hosts[0]!.stopError = new Error('dispose stop failed') + first.registry.dispose() + await vi.waitFor(() => expect(first.registry.liveCount).toBe(0)) + + const second = createRegistryHarness() + await second.registry.resume({ ...workspaceA, sessionId: 'session-b' }) + second.hosts[0]!.stopError = new Error('crash stop failed') + second.hosts[0]!.crash() + await vi.waitFor(() => expect(second.registry.liveCount).toBe(0)) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.lifecycle.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.lifecycle.test.ts new file mode 100644 index 00000000..775fbe45 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.lifecycle.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createRegistryHarness, + workspaceA, + workspaceB, +} from './session-runtime-registry.test-fixtures' + +describe('SessionRuntimeRegistry lifecycle', () => { + it('keeps two Hosts live and scopes their status and notifications', async () => { + const { registry, hosts } = createRegistryHarness() + const statuses: unknown[] = [] + const notifications: unknown[] = [] + registry.onStatus((event) => statuses.push(event)) + registry.onNotification((event) => notifications.push(event)) + + const [a, b] = await Promise.all([ + registry.startFresh(workspaceA), + registry.resume({ ...workspaceB, sessionId: 'session-b' }), + ]) + + expect(registry.liveCount).toBe(2) + expect(registry.listLive()).toEqual([a, b]) + expect([a.runtimeId, b.runtimeId]).toEqual(['runtime-1', 'runtime-2']) + expect([a.generation, b.generation]).toEqual([1, 2]) + hosts[0]!.notify('agent/event', { value: 'a' }) + hosts[1]!.notify('agent/event', { value: 'b' }) + expect(notifications).toEqual([ + { ...scope(a), method: 'agent/event', params: { value: 'a' } }, + { ...scope(b), method: 'agent/event', params: { value: 'b' } }, + ]) + expect(statuses).toContainEqual({ ...scope(a), status: 'ready' }) + expect(statuses).toContainEqual({ ...scope(b), status: 'ready' }) + }) + + it('deduplicates concurrent resume before the Host is ready', async () => { + const harness = createRegistryHarness() + harness.delayNext() + const input = { ...workspaceA, sessionId: 'session-a' } + const first = harness.registry.resume(input) + const second = harness.registry.resume(input) + + expect(harness.hosts).toHaveLength(1) + expect(harness.registry.liveCount).toBe(1) + harness.hosts[0]!.releaseStart() + await expect(first).resolves.toBe(await second) + expect(harness.hosts[0]!.startCalls).toBe(1) + }) + + it('activates an old-Host READY fallback once before flushing fresh status', async () => { + const { registry, hosts } = createRegistryHarness() + const activated = vi.fn(async () => undefined) + const visibility: boolean[] = [] + registry.onStatus(() => visibility.push(activated.mock.calls.length === 1)) + + await registry.startFresh(workspaceA, activated) + + expect(activated).toHaveBeenCalledOnce() + expect(hosts[0]!.activationProvided).toBe(true) + expect(visibility.length).toBeGreaterThan(0) + expect(visibility.every(Boolean)).toBe(true) + }) + + it('does not install a creation activation callback when resuming', async () => { + const { registry, hosts } = createRegistryHarness() + await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + expect(hosts[0]!.activationProvided).toBe(false) + }) + + it('stops A without touching B', async () => { + const { registry, hosts } = createRegistryHarness() + const a = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + const b = await registry.resume({ ...workspaceB, sessionId: 'session-b' }) + + await registry.stop(a.runtimeId) + + expect(registry.liveCount).toBe(1) + expect(registry.get(a.runtimeId)).toBeUndefined() + expect(registry.getBySession('session-b')).toBe(b) + expect(hosts[0]).toMatchObject({ stopCalls: 1, disposeCalls: 1 }) + expect(hosts[1]).toMatchObject({ stopCalls: 0, disposeCalls: 0 }) + }) + + it('retires a crashed A without touching B', async () => { + const { registry, hosts } = createRegistryHarness() + const a = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + const b = await registry.resume({ ...workspaceB, sessionId: 'session-b' }) + + hosts[0]!.crash() + await vi.waitFor(() => expect(registry.get(a.runtimeId)).toBeUndefined()) + + expect(registry.liveCount).toBe(1) + expect(registry.getBySession('session-b')).toBe(b) + expect(hosts[0]).toMatchObject({ stopCalls: 1, disposeCalls: 1 }) + expect(hosts[1]).toMatchObject({ stopCalls: 0, disposeCalls: 0 }) + }) + + it('waits for crash retirement before resuming the same session', async () => { + const harness = createRegistryHarness() + const old = await harness.registry.resume({ ...workspaceA, sessionId: 'session-a' }) + harness.hosts[0]!.delayStop() + harness.hosts[0]!.crash() + + const resumed = harness.registry.resume({ ...workspaceA, sessionId: 'session-a' }) + expect(harness.hosts).toHaveLength(1) + harness.hosts[0]!.releaseStop() + + await expect(resumed).resolves.toMatchObject({ + sessionId: 'session-a', runtimeId: 'runtime-2', generation: 2, + }) + expect(await resumed).not.toBe(old) + expect(harness.hosts).toHaveLength(2) + }) +}) + +function scope(value: { + workspaceId: string + sessionId: string + runtimeId: string + generation: number +}) { + return { + workspaceId: value.workspaceId, + sessionId: value.sessionId, + runtimeId: value.runtimeId, + generation: value.generation, + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.limits.test.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.limits.test.ts new file mode 100644 index 00000000..bc75850f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.limits.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { SessionRuntimeRegistry } from './session-runtime-registry' +import { + createRegistryHarness, + FakeRuntimeHost, + workspaceA, + workspaceB, +} from './session-runtime-registry.test-fixtures' + +describe('SessionRuntimeRegistry limits and recovery', () => { + it('restarts with a new runtime ID and globally increasing generation', async () => { + const { registry, hosts, inputs } = createRegistryHarness() + const a = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + const b = await registry.resume({ ...workspaceB, sessionId: 'session-b' }) + const restarted = await registry.restart(a.runtimeId) + + expect([a.generation, b.generation, restarted.generation]).toEqual([1, 2, 3]) + expect(restarted).toMatchObject({ + runtimeId: 'runtime-3', sessionId: 'session-a', workspaceId: 'workspace-a', + }) + expect(hosts[0]).toMatchObject({ stopCalls: 1, disposeCalls: 1 }) + expect(inputs[2]).toEqual({ + workspaceId: 'workspace-a', cwd: '/workspace/a', resumeSessionId: 'session-a', + }) + }) + + it('enforces quota atomically and frees it after stop', async () => { + const { registry } = createRegistryHarness({ maxLive: 1 }) + const a = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + expect(() => registry.resume({ ...workspaceB, sessionId: 'session-b' })) + .toThrow('quota exceeded (1)') + + await registry.stop(a.runtimeId) + await expect(registry.resume({ ...workspaceB, sessionId: 'session-b' })) + .resolves.toMatchObject({ sessionId: 'session-b' }) + }) + + it('bounds tombstones while retaining recent restart data', async () => { + const { registry } = createRegistryHarness({ maxTombstones: 1 }) + const a = await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + await registry.stop(a.runtimeId) + const b = await registry.resume({ ...workspaceB, sessionId: 'session-b' }) + await registry.stop(b.runtimeId) + + await expect(registry.stop(a.runtimeId)).rejects.toThrow('Unknown session runtime') + await expect(registry.restart(b.runtimeId)).resolves.toMatchObject({ + sessionId: 'session-b', generation: 3, + }) + }) + + it('stops every live Host once even when one stop fails', async () => { + const { registry, hosts } = createRegistryHarness() + await registry.resume({ ...workspaceA, sessionId: 'session-a' }) + await registry.resume({ ...workspaceB, sessionId: 'session-b' }) + hosts[0]!.stopError = new Error('stop-a failed') + + await expect(registry.shutdownAll()).rejects.toThrow('Failed to stop session runtimes') + await expect(registry.shutdownAll()).rejects.toThrow('Failed to stop session runtimes') + expect(hosts.map((host) => host.stopCalls)).toEqual([1, 1]) + expect(hosts.map((host) => host.disposeCalls)).toEqual([1, 1]) + expect(registry.liveCount).toBe(0) + expect(() => registry.startFresh(workspaceA)).toThrow('shut down') + }) + + it('turns pre-ready notification overflow into a scoped resync', async () => { + const harness = createRegistryHarness() + const events: unknown[] = [] + harness.registry.onNotification((event) => events.push(event)) + harness.delayNext() + const starting = harness.registry.startFresh(workspaceA) + for (let index = 0; index < 80; index += 1) { + harness.hosts[0]!.notify('agent/event', { index }) + } + harness.hosts[0]!.releaseStart() + const runtime = await starting + + expect(events).toEqual([{ + workspaceId: runtime.workspaceId, + sessionId: runtime.sessionId, + runtimeId: runtime.runtimeId, + generation: runtime.generation, + method: 'view/resync_required', + params: { reason: 'pre_ready_buffer_overflow' }, + }]) + }) + + it('rejects invalid construction and a mismatched resume result', async () => { + expect(() => new SessionRuntimeRegistry({ maxLive: 0, createHost: () => undefined as never })) + .toThrow('positive integer') + const registry = new SessionRuntimeRegistry({ + maxLive: 1, + createHost: () => new FakeRuntimeHost('different-session'), + }) + await expect(registry.resume({ ...workspaceA, sessionId: 'expected-session' })) + .rejects.toThrow('expected expected-session') + expect(registry.liveCount).toBe(0) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.test-fixtures.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.test-fixtures.ts new file mode 100644 index 00000000..ee64f2ba --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.test-fixtures.ts @@ -0,0 +1,130 @@ +import { DeferredPromise } from '../../../../base/common/async' +import { Emitter } from '../../../../base/common/event' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { type DesktopHostActivation } from '../../../desktop-host/node/host/desktop-host' +import { SessionRuntimeRegistry } from './session-runtime-registry' + +export class FakeRuntimeHost implements DesktopHostClient { + private readonly statuses = new Emitter() + private readonly notifications = new Emitter<{ method: string; params: unknown }>() + private readonly gate = new DeferredPromise() + private readonly stopGate = new DeferredPromise() + private delayed = false + private delayedStop = false + currentStatus: DesktopHostClient['currentStatus'] = 'stopped' + startCalls = 0 + stopCalls = 0 + disposeCalls = 0 + stopError?: Error + emitSessionCreated = false + failAfterSessionCreated?: Error + activationProvided = false + + readonly onStatus = this.statuses.event + readonly onNotification = this.notifications.event + + constructor(readonly sessionId: string) {} + + delayStart(): void { + this.delayed = true + } + + releaseStart(): void { + this.gate.resolve(undefined) + } + + delayStop(): void { + this.delayedStop = true + } + + releaseStop(): void { + this.stopGate.resolve(undefined) + } + + async start(activate?: DesktopHostActivation) { + this.startCalls += 1 + this.activationProvided = activate !== undefined + this.setStatus('spawning') + if (this.delayed) await this.gate.promise + const session = { sessionId: this.sessionId, serverVersion: 'test', pid: this.startCalls } + if (this.emitSessionCreated) { + await activate?.(session) + if (this.failAfterSessionCreated) throw this.failAfterSessionCreated + } + this.setStatus('ready') + return session + } + + async request(method: string, params?: unknown): Promise { + return { method, params } + } + + async stop(): Promise { + this.stopCalls += 1 + this.setStatus('stopping') + if (this.delayedStop) await this.stopGate.promise + if (this.stopError) throw this.stopError + this.setStatus('stopped') + } + + crash(): void { + this.setStatus('crashed') + } + + notify(method: string, params: unknown): void { + this.notifications.fire({ method, params }) + } + + dispose(): void { + this.disposeCalls += 1 + this.statuses.dispose() + this.notifications.dispose() + } + + private setStatus(status: DesktopHostClient['currentStatus']): void { + this.currentStatus = status + this.statuses.fire(status) + } +} + +export function createRegistryHarness(options: { + maxLive?: number + maxTombstones?: number + freshSessions?: string[] +} = {}) { + const hosts: FakeRuntimeHost[] = [] + const inputs: Array<{ cwd: string; resumeSessionId?: string }> = [] + const runtimeIds: string[] = [] + const fresh = [...(options.freshSessions ?? ['fresh-a', 'fresh-b', 'fresh-c'])] + let delayedNext = false + let nextRuntime = 0 + const registry = new SessionRuntimeRegistry({ + maxLive: options.maxLive ?? 4, + ...(options.maxTombstones === undefined ? {} : { maxTombstones: options.maxTombstones }), + createRuntimeId: () => { + const id = `runtime-${++nextRuntime}` + runtimeIds.push(id) + return id + }, + createHost: (input) => { + inputs.push(input) + const host = new FakeRuntimeHost(input.resumeSessionId ?? fresh.shift() ?? 'fresh') + if (delayedNext) { + delayedNext = false + host.delayStart() + } + hosts.push(host) + return host + }, + }) + return { + registry, + hosts, + inputs, + runtimeIds, + delayNext: () => { delayedNext = true }, + } +} + +export const workspaceA = { workspaceId: 'workspace-a', cwd: '/workspace/a' } +export const workspaceB = { workspaceId: 'workspace-b', cwd: '/workspace/b' } diff --git a/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.ts b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.ts new file mode 100644 index 00000000..6563cf04 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/runtime/session-runtime-registry.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto' +import { Emitter, type Event } from '../../../../base/common/event' +import { SessionRuntimeActivationGate } from './session-runtime-activation' +import { SessionRuntimeEntry } from './session-runtime-registry-entry' +import { + type SessionRuntimeHandle, + type SessionRuntimeActivation, + type SessionRuntimeNotificationEvent, + type SessionRuntimeRegistryOptions, + type SessionRuntimeResumeInput, + type SessionRuntimeStatusEvent, + type SessionRuntimeWorkspace, +} from './session-runtime-registry-types' +import { requirePositive, requireText } from './session-runtime-registry-validation' +export * from './session-runtime-registry-types' +interface RuntimeTombstone { + readonly runtimeId: string + readonly generation: number + readonly resume: SessionRuntimeResumeInput +} +export class SessionRuntimeRegistry { + private readonly statuses = new Emitter() + private readonly notifications = new Emitter() + private readonly entries = new Map() + private readonly sessions = new Map() + private readonly tombstones = new Map() + private readonly createRuntimeId: () => string + private readonly maxTombstones: number + private generation = 0 + private closed = false + private shutdown?: Promise + readonly onStatus: Event = this.statuses.event + readonly onNotification: Event = this.notifications.event + + constructor(private readonly options: SessionRuntimeRegistryOptions) { + requirePositive(options.maxLive, 'maxLive') + if (options.maxTombstones !== undefined) { + requirePositive(options.maxTombstones, 'maxTombstones') + } + this.maxTombstones = options.maxTombstones ?? Math.max(16, options.maxLive * 4) + this.createRuntimeId = options.createRuntimeId ?? randomUUID + } + + get liveCount(): number { + return this.entries.size + } + has(runtimeId: string): boolean { return this.entries.has(runtimeId) } + startFresh( + input: SessionRuntimeWorkspace, + activate?: SessionRuntimeActivation, + ): Promise { + return this.allocate(input, undefined, activate) + } + + resume(input: SessionRuntimeResumeInput): Promise { + requireText(input.sessionId, 'sessionId') + const existing = this.sessions.get(input.sessionId) + if (existing?.retiring) return existing.retiring.then(() => this.resume(input)) + if (existing) return existing.ready! + return this.allocate(input, input.sessionId) + } + + get(runtimeId: string): SessionRuntimeHandle | undefined { + return this.entries.get(runtimeId)?.handle + } + + getBySession(sessionId: string): SessionRuntimeHandle | undefined { + return this.sessions.get(sessionId)?.handle + } + + listLive(): readonly SessionRuntimeHandle[] { + return [...this.entries.values()].flatMap((entry) => entry.handle ? [entry.handle] : []) + } + + stop(runtimeId: string): Promise { + const entry = this.entries.get(runtimeId) + if (entry) return this.retire(entry) + if (this.tombstones.has(runtimeId)) return Promise.resolve() + return Promise.reject(new Error(`Unknown session runtime: ${runtimeId}`)) + } + + async restart(runtimeId: string): Promise { + const entry = this.entries.get(runtimeId) + const resume = entry?.resumeInput() ?? this.tombstones.get(runtimeId)?.resume + if (!resume) throw new Error(`Session runtime cannot be restarted: ${runtimeId}`) + if (entry) await this.retire(entry) + return this.resume(resume) + } + + shutdownAll(): Promise { + this.closed = true + this.shutdown ??= this.shutdownInternal() + return this.shutdown + } + + dispose(): void { + void this.shutdownAll().catch(() => undefined) + this.statuses.dispose() + this.notifications.dispose() + } + + private allocate( + input: SessionRuntimeWorkspace, + sessionId?: string, + activate?: SessionRuntimeActivation, + ): Promise { + if (this.closed) throw new Error('Session runtime registry is shut down') + requireText(input.workspaceId, 'workspaceId') + requireText(input.cwd, 'cwd') + if (this.entries.size >= this.options.maxLive) { + throw new Error(`Session runtime quota exceeded (${this.options.maxLive})`) + } + const runtimeId = this.createRuntimeId() + if (this.entries.has(runtimeId) || this.tombstones.has(runtimeId)) { + throw new Error(`Duplicate runtime ID: ${runtimeId}`) + } + const generation = ++this.generation + let entry!: SessionRuntimeEntry + entry = new SessionRuntimeEntry( + input.workspaceId, + input.cwd, + runtimeId, + generation, + this.options.createHost(sessionId === undefined + ? { workspaceId: input.workspaceId, cwd: input.cwd } + : { workspaceId: input.workspaceId, cwd: input.cwd, resumeSessionId: sessionId }, + { runtimeId, generation }), + sessionId, + { + status: (event) => this.statuses.fire(event), + notification: (event) => this.notifications.fire(event), + crashed: () => void this.retire(entry).catch(() => undefined), + }, + ) + this.entries.set(runtimeId, entry) + if (sessionId) this.sessions.set(sessionId, entry) + entry.ready = this.startEntry(entry, sessionId, activate) + return entry.ready + } + + private async startEntry( + entry: SessionRuntimeEntry, + expected?: string, + activate?: SessionRuntimeActivation, + ): Promise { + try { + const activation = expected ? undefined : new SessionRuntimeActivationGate(entry, activate) + const started = await entry.host.start(activation?.activate) + await activation?.activate(started) + if (expected && started.sessionId !== expected) { + throw new Error(`Desktop Host resumed ${started.sessionId}; expected ${expected}`) + } + const owner = this.sessions.get(started.sessionId) + if (owner && owner !== entry) { + throw new Error(`Session already has a live runtime: ${started.sessionId}`) + } + const handle = entry.bindSession(started.sessionId) + this.sessions.set(started.sessionId, entry) + return handle + } catch (error) { + await this.retire(entry) + throw error + } + } + + private retire(entry: SessionRuntimeEntry): Promise { + entry.retiring ??= this.retireEntry(entry) + return entry.retiring + } + + private async retireEntry(entry: SessionRuntimeEntry): Promise { + try { + await entry.host.stop() + } finally { + this.entries.delete(entry.runtimeId) + const resume = entry.resumeInput() + if (resume && this.sessions.get(resume.sessionId) === entry) { + this.sessions.delete(resume.sessionId) + this.remember({ runtimeId: entry.runtimeId, generation: entry.generation, resume }) + } + entry.dispose() + entry.host.dispose() + } + } + + private remember(tombstone: RuntimeTombstone): void { + this.tombstones.set(tombstone.runtimeId, tombstone) + while (this.tombstones.size > this.maxTombstones) { + this.tombstones.delete(this.tombstones.keys().next().value!) + } + } + + private async shutdownInternal(): Promise { + const results = await Promise.allSettled([...this.entries.values()].map((entry) => { + return this.retire(entry) + })) + const errors = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []) + if (errors.length > 0) throw new AggregateError(errors, 'Failed to stop session runtimes') + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-activation.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-activation.test.ts new file mode 100644 index 00000000..81ebf6c8 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-activation.test.ts @@ -0,0 +1,163 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { type Workspace } from '../../../../shared/contracts' +import { createBackend } from '../backend/loopal-backend.test-fixtures' +import { nativeTestFileUri, nativeTestPath } from '../backend/loopal-backend.test-paths' + +describe('Loopal session creation activation', () => { + let root = '' + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }) + root = '' + }) + + it('retains and persists a worktree when startup fails after session creation', async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-created-session-')) + const statePath = join(root, 'sessions.json') + const methods: string[] = [] + const request = vi.fn(async (method: string) => { + methods.push(method) + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', head: 'a'.repeat(40), dirty: false }, + } + if (method.endsWith('prepareWorktree')) return { + path: '/project/.loopal/worktrees/recover', + branch: 'loopal-wt-recover', name: 'recover', + } + return { path: '/project/.loopal/worktrees/recover', removed: true } + }) + const { backend, hosts } = createBackend({ + sessionStatePath: statePath, + sessionDirectoryRequest: request, + hostSetup: (host, index) => { + if (index !== 1) return + host.emitSessionCreated = true + host.failAfterSessionCreated = new Error('root_view_not_ready') + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/project') + const input = { + authorizationId: selected.authorizationId, + launchMode: 'worktree' as const, + worktreeName: 'recover', + } + + await expect(backend.createSession(input)).rejects.toThrow( + 'session_created_recovery_required: Loopal session session-3 was created', + ) + expect(hosts[1]!.start).toHaveBeenCalledWith(expect.any(Function)) + expect(methods).not.toContain('desktop/cleanupWorktree') + expect((await backend.bootstrap()).workspaces).toContainEqual(expect.objectContaining({ + rootUri: nativeTestFileUri('/project/.loopal/worktrees/recover'), kind: 'git_worktree', + })) + const persisted = JSON.parse(await readFile(statePath, 'utf8')) + expect(persisted.runningSessionIds).not.toContain('session-3') + expect(persisted.sessionLocations).toContainEqual(expect.objectContaining({ + sessionId: 'session-3', cwd: nativeTestPath('/project/.loopal/worktrees/recover'), + })) + await expect(backend.createSession({ + authorizationId: input.authorizationId, launchMode: 'directory', + })) + .rejects.toThrow('directory_authorization_invalid') + }) + + it('commits the workspace catalog before buffered runtime events flush', async () => { + const { backend } = createBackend({ + sessionDirectoryRequest: async () => ({ path: '/project/feature', name: 'feature' }), + hostSetup: (host, index) => { if (index === 1) host.emitSessionCreated = true }, + }) + await backend.bootstrap() + const visibility: boolean[] = [] + const internals = backend as unknown as { + workspaces: { values(): readonly Workspace[] } + } + backend.onEvent((event) => { + if (event.type !== 'runtime_updated' || event.runtime.sessionId !== 'session-3') return + visibility.push(internals.workspaces.values().some( + ({ rootUri }) => rootUri === nativeTestFileUri('/project/feature'), + )) + }) + const selected = await backend.authorizeSessionDirectory('/project/feature') + + await backend.createSession({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + expect(visibility.length).toBeGreaterThan(0) + expect(visibility.every(Boolean)).toBe(true) + }) + + it.each([ + 'desktop_protocol_drain_incomplete: stdout did not close after SIGKILL', + 'desktop_process_termination_unconfirmed: Host did not exit after SIGKILL', + 'desktop_session_creation_state_unknown: Host failed after ALIVE', + ])('retains an authorized worktree when creation is unknown: %s', async (failure) => { + const methods: string[] = [] + const request = vi.fn(async (method: string) => { + methods.push(method) + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', head: 'b'.repeat(40), dirty: false }, + } + if (method.endsWith('prepareWorktree')) return { + path: '/project/.loopal/worktrees/unknown', + branch: 'loopal-wt-unknown', name: 'unknown', + } + return { path: '/project/.loopal/worktrees/unknown', removed: true } + }) + const { backend } = createBackend({ + sessionDirectoryRequest: request, + hostSetup: (host, index) => { + if (index === 1) host.start.mockRejectedValueOnce(new Error(failure)) + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/project') + const input = { + authorizationId: selected.authorizationId, + launchMode: 'worktree' as const, + worktreeName: 'unknown', + } + + await expect(backend.createSession(input)).rejects.toThrow( + 'worktree_retained: session_creation_state_unknown', + ) + expect(methods).not.toContain('desktop/cleanupWorktree') + expect((await backend.bootstrap()).workspaces).toContainEqual(expect.objectContaining({ + rootUri: nativeTestFileUri('/project/.loopal/worktrees/unknown'), + })) + await expect(backend.createSession({ + authorizationId: input.authorizationId, launchMode: 'directory', + })) + .rejects.toThrow('directory_authorization_invalid') + }) + + it('retains and consumes a direct-directory grant when creation is unknown', async () => { + const request = vi.fn(async () => ({ path: '/project/direct', name: 'direct' })) + const { backend } = createBackend({ + sessionDirectoryRequest: request, + hostSetup: (host, index) => { + if (index === 1) host.start.mockRejectedValueOnce(new Error( + 'desktop_session_creation_state_unknown: Host failed after ALIVE', + )) + }, + }) + await backend.bootstrap() + const selected = await backend.authorizeSessionDirectory('/project/direct') + const input = { + authorizationId: selected.authorizationId, launchMode: 'directory' as const, + } + + await expect(backend.createSession(input)).rejects.toThrow( + 'directory_retained: session_creation_state_unknown', + ) + expect((await backend.bootstrap()).workspaces).toContainEqual(expect.objectContaining({ + rootUri: nativeTestFileUri('/project/direct'), kind: 'folder', + })) + await expect(backend.createSession(input)).rejects.toThrow('directory_authorization_invalid') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.test.ts new file mode 100644 index 00000000..cc4fe59b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { fallbackSession, stoppedSession } from './loopal-session-catalog' + +describe('Loopal session catalog projection', () => { + it('preserves persisted titles and uses the workspace only for fresh fallback', () => { + const persisted = stoppedSession({ + id: 'session', title: 'Custom investigation', model: 'model', mode: 'agent', + createdAt: '2026-07-11T10:00:00.000Z', updatedAt: '2026-07-11T11:00:00.000Z', + }, 'workspace') + expect(persisted.title).toBe('Custom investigation') + expect(fallbackSession( + 'opaque-session-id', 'workspace', 'project', '2026-07-11T12:00:00.000Z', + ).title).toBe('Loopal session · project') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.ts new file mode 100644 index 00000000..dacf8e07 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-catalog.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' +import { type SessionSummary } from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' + +const CatalogSessionSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + model: z.string().min(1), + mode: z.string().min(1), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) + +const SessionCatalogSchema = z.array(CatalogSessionSchema) +export type CatalogSession = z.infer + +export async function loadSessionCatalog( + host: DesktopHostClient, + workspaceId: string, +): Promise { + return SessionCatalogSchema.parse( + await host.request('desktop/listSessions', { workspaceId }), + ) +} + +export function stoppedSession( + value: CatalogSession, + workspaceId: string, +): SessionSummary { + return { ...value, workspaceId, status: 'stopped' } +} + +export function fallbackSession( + sessionId: string, + workspaceId: string, + workspaceName: string, + now: string, +): SessionSummary { + return { + id: sessionId, + workspaceId, + title: sessionTitle(workspaceName), + model: 'loopal-default', + mode: 'agent', + status: 'stopped', + createdAt: now, + updatedAt: now, + } +} + +function sessionTitle(workspaceName: string): string { + return `Loopal session · ${workspaceName}` +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-creation.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-creation.ts new file mode 100644 index 00000000..7be65426 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-creation.ts @@ -0,0 +1,110 @@ +import { type CreateSessionInput, type SessionDetail } from '../../../../shared/contracts' +import { LoopalSessionDirectory } from './loopal-session-directory' +import { mergeRuntimeCatalog } from '../backend/loopal-backend-lifecycle' +import { LoopalSessionWorkspaces } from './loopal-session-workspaces' +import { type WorkspaceCatalogStage } from '../workspace/loopal-workspace-catalog' +import { SessionDirectoryAuthority } from './session-directory-authority' +import { + type SessionRuntimeHandle, SessionRuntimeRegistry, +} from '../runtime/session-runtime-registry' + +interface SessionCreationServices { + readonly directories: SessionDirectoryAuthority + readonly registry: SessionRuntimeRegistry + readonly directory: LoopalSessionDirectory + readonly workspaces: LoopalSessionWorkspaces +} + +export async function createLoopalSession( + input: CreateSessionInput, + services: SessionCreationServices, +): Promise { + const claim = await services.directories.claim(input) + let runtime: SessionRuntimeHandle | undefined + let activatedSessionId: string | undefined + let workspaceStage: WorkspaceCatalogStage | undefined + try { + const stage = services.workspaces.stage(claim.target) + workspaceStage = stage + const workspace = stage.workspace + runtime = await services.registry.startFresh({ + workspaceId: workspace.id, cwd: claim.target.path, + }, async (sessionId) => { + if (activatedSessionId && activatedSessionId !== sessionId) { + throw new Error('Desktop Host changed Session during creation') + } + activatedSessionId = sessionId + claim.commit() + stage.commit() + await services.workspaces.created(sessionId, workspace.id) + }) + await services.workspaces.started(runtime, undefined, true) + const state = await services.directory.attach(runtime, true) + await mergeRuntimeCatalog(services.directory, runtime, true) + await services.workspaces.started(runtime, state.detail.session, true) + return state.detail + } catch (error) { + if (activatedSessionId) { + if (runtime) await services.registry.stop(runtime.runtimeId).catch(() => undefined) + await services.workspaces.stopped(activatedSessionId).catch(() => undefined) + throw recoveryError(error, activatedSessionId, claim.target.path) + } + if (isCommitUnknown(error)) { + claim.commit() + workspaceStage?.commit() + throw unknownCreationError(error, claim.target) + } + try { + await claim.rollback() + } catch (rollbackError) { + throw retainedWorktreeError(error, rollbackError, claim.target.path) + } + throw error + } +} + +function recoveryError(error: unknown, sessionId: string, path: string): Error { + return new Error( + `session_created_recovery_required: Loopal session ${sessionId} was created at ${path}; ` + + `Desktop initialization failed (${message(error)})`, + { cause: error }, + ) +} + +function retainedWorktreeError( + creationError: unknown, + rollbackError: unknown, + path: string, +): Error { + const error = new Error( + `worktree_retained: session creation failed (${message(creationError)}); ` + + `cleanup failed and the worktree was retained at ${path} (${message(rollbackError)})`, + { cause: new AggregateError([creationError, rollbackError]) }, + ) + return error +} + +function unknownCreationError( + error: unknown, + target: { path: string; kind: 'folder' | 'git_worktree' }, +): Error { + const retained = target.kind === 'git_worktree' ? 'worktree_retained' : 'directory_retained' + return new Error( + `${retained}: session_creation_state_unknown; retained ${target.path} for recovery ` + + `(${message(error)})`, + { cause: error }, + ) +} + +function isCommitUnknown(value: unknown): boolean { + if (value instanceof AggregateError) return value.errors.some(isCommitUnknown) + if (!(value instanceof Error)) return false + return value.message.includes('desktop_protocol_drain_incomplete') + || value.message.includes('desktop_process_termination_unconfirmed') + || value.message.includes('desktop_session_creation_state_unknown') + || isCommitUnknown(value.cause) +} + +function message(value: unknown): string { + return value instanceof Error ? value.message : String(value) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.test.ts new file mode 100644 index 00000000..b7b9c81b --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { type DesktopEvent } from '../../../../shared/contracts' +import { FakeHost, agentEvent, timestamp } from '../backend/loopal-backend.test-fixtures' +import { LoopalSessionDirectory } from './loopal-session-directory' +import { + SessionRuntimeRegistry, + type SessionRuntimeHandle, + type SessionRuntimeStatusEvent, +} from '../runtime/session-runtime-registry' + +function row(id: string) { + return { + id, title: `Catalog ${id}`, model: 'model', mode: 'agent', + createdAt: '2026-07-11T10:00:00.000Z', updatedAt: '2026-07-11T11:00:00.000Z', + } +} + +function harness(sessionId = 'session') { + const catalog = [row(sessionId)] + const hosts: FakeHost[] = [] + let runtimeIndex = 0 + const registry = new SessionRuntimeRegistry({ + maxLive: 4, + createRuntimeId: () => `runtime-${++runtimeIndex}`, + createHost: (input) => { + const host = new FakeHost(input.resumeSessionId ?? sessionId, catalog) + hosts.push(host) + return host + }, + }) + const events: DesktopEvent[] = [] + const services: unknown[] = [] + const directory = new LoopalSessionDirectory( + registry, () => timestamp, 'project', { + event: (event) => events.push(event), + service: (event) => services.push(event), + }, + ) + return { catalog, hosts, registry, directory, events, services } +} + +describe('LoopalSessionDirectory', () => { + it('retains the latest detail after its runtime stops', async () => { + const { registry, directory, catalog } = harness() + const runtime = await registry.resume({ + workspaceId: 'workspace', cwd: '/workspace', sessionId: 'session', + }) + directory.mergeCatalog(catalog, 'workspace') + await directory.attach(runtime) + await registry.stop(runtime.runtimeId) + + expect(directory.liveSession('session')).toBeUndefined() + const detail = directory.detail('session')! + expect(detail).toMatchObject({ + session: { status: 'stopped' }, + conversation: [expect.objectContaining({ text: 'Answer from session' })], + }) + expect(detail.session.activeRuntimeId).toBeUndefined() + }) + + it('buffers pre-attach events, bounds overflow, and replays a resync', async () => { + const { registry, directory, hosts, catalog, events } = harness() + const runtime = await registry.resume({ + workspaceId: 'workspace', cwd: '/workspace', sessionId: 'session', + }) + for (let revision = 3; revision < 75; revision += 1) { + hosts[0]!.notification('agent/event', agentEvent('Running', revision, revision)) + } + hosts[0]!.notification('agent/event', agentEvent('Running', 100, 100)) + directory.mergeCatalog(catalog, 'workspace') + await directory.attach(runtime) + await vi.waitFor(() => expect(events).toContainEqual(expect.objectContaining({ + type: 'session_detail_replaced', + }))) + expect(directory.liveSession('session')).toBeDefined() + expect(directory.runtime('runtime-1')).toMatchObject({ state: 'ready' }) + }) + + it('preserves live catalog fields, replaces a different runtime, and creates fallback sessions', async () => { + const { registry, directory, hosts, catalog, events, services } = harness() + const runtime = await registry.resume({ + workspaceId: 'workspace', cwd: '/workspace', sessionId: 'session', + }) + directory.mergeCatalog(catalog, 'workspace') + const original = await directory.attach(runtime) + const dispose = vi.spyOn(original, 'dispose') + directory.mergeCatalog([{ ...catalog[0]!, title: 'Renamed in catalog' }], 'workspace', true) + expect(directory.session('session')).toMatchObject({ + title: 'Renamed in catalog', activeRuntimeId: 'runtime-1', status: 'waiting', + }) + + const replacementHost = new FakeHost('session', catalog) + await replacementHost.start() + const replacement: SessionRuntimeHandle = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime-new', + generation: 5, host: replacementHost, + } + await directory.attach(replacement) + expect(dispose).toHaveBeenCalledOnce() + expect(directory.runtimeValues().filter((value) => value.sessionId === 'session')) + .toEqual([expect.objectContaining({ id: 'runtime-new', generation: 5 })]) + + const fallbackHost = new FakeHost('fresh-session', catalog) + await fallbackHost.start() + await directory.attach({ + workspaceId: 'workspace', sessionId: 'fresh-session', runtimeId: 'runtime-fresh', + generation: 6, host: fallbackHost, + }) + expect(directory.session('fresh-session')?.title).toBe('Loopal session · project') + const count = events.length + const stale: SessionRuntimeStatusEvent = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime-stale', + generation: 4, status: 'ready', + } + const internals = directory as unknown as { + acceptStatus(event: SessionRuntimeStatusEvent): void + acceptNotification(event: typeof stale & { method: string; params: unknown }): void + } + internals.acceptStatus(stale) + internals.acceptNotification({ ...stale, method: 'workspace/unknown', params: {} }) + expect(events).toHaveLength(count) + expect(services).toHaveLength(1) + directory.dispose() + expect(directory.liveSession('session')).toBeUndefined() + expect(hosts).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.ts new file mode 100644 index 00000000..a769a528 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-directory.ts @@ -0,0 +1,200 @@ +import { DisposableStore } from '../../../../base/common/lifecycle' +import { + type DesktopEvent, + type RuntimeSummary, + type SessionDetail, + type SessionSummary, +} from '../../../../shared/contracts' +import { LoopalLiveSession } from '../runtime/loopal-live-session' +import { + fallbackSession, + stoppedSession, + type CatalogSession, +} from './loopal-session-catalog' +import { + type SessionRuntimeHandle, + type SessionRuntimeNotificationEvent, + type SessionRuntimeScope, + type SessionRuntimeStatusEvent, + SessionRuntimeRegistry, +} from '../runtime/session-runtime-registry' +import { LoopalWorkspaceLeaders } from '../workspace/loopal-workspace-leaders' +import { + hostSession, + runtimeFields, + runtimeSummary, +} from '../projections/loopal-runtime-projection' +const PENDING_LIMIT = 64 +interface DirectorySink { + event(event: DesktopEvent): void + service(event: SessionRuntimeNotificationEvent): void +} +export class LoopalSessionDirectory { + private readonly subscriptions = new DisposableStore() + private readonly sessions = new Map() + private readonly runtimes = new Map() + private readonly live = new Map() + private readonly details = new Map() + private readonly pending = new Map() + readonly leaders = new LoopalWorkspaceLeaders() + constructor( + private readonly registry: SessionRuntimeRegistry, + private readonly now: () => Date, + private readonly workspaceName: string, + private readonly sink: DirectorySink, + ) { + this.subscriptions.add(registry.onStatus((event) => this.acceptStatus(event))) + this.subscriptions.add(registry.onNotification((event) => this.acceptNotification(event))) + } + session(id: string): SessionSummary | undefined { return this.sessions.get(id) } + liveSession(id: string): LoopalLiveSession | undefined { return this.live.get(id) } + detail(id: string): SessionDetail | undefined { + const session = this.sessions.get(id) + if (!session) return undefined + const cached = this.live.get(id)?.cachedDetail ?? this.details.get(id) + return cached + ? { ...cached, session } + : { session, conversation: [], agents: [], artifacts: [] } + } + runtimeForSession(id: string): SessionRuntimeHandle | undefined { + return this.live.get(id)?.runtime + } + runtime(id: string): RuntimeSummary | undefined { return this.runtimes.get(id) } + sessionValues(): readonly SessionSummary[] { return [...this.sessions.values()] } + runtimeValues(): readonly RuntimeSummary[] { return [...this.runtimes.values()] } + markSessionUnavailable(id: string): void { + const session = this.sessions.get(id) + if (!session) return + const { activeRuntimeId: _runtime, ...stopped } = session + this.storeSummary({ ...stopped, status: 'failed', attention: 'failure' }, false) + } + + mergeCatalog( + values: readonly CatalogSession[], + workspaceId: string, + emit = false, + ): void { + for (const value of values) { + const previous = this.sessions.get(value.id) + const ownerWorkspaceId = previous?.workspaceId ?? workspaceId + const next = previous?.activeRuntimeId + ? { ...stoppedSession(value, ownerWorkspaceId), ...runtimeFields(previous) } + : stoppedSession(value, ownerWorkspaceId) + this.storeSummary(next, emit) + } + } + + async attach(runtime: SessionRuntimeHandle, emit = true): Promise { + const becameLeader = this.leaders.add(runtime) + if (becameLeader && emit) { + this.sink.event({ type: 'host_status', status: runtime.host.currentStatus }) + } + const currentRuntime = this.runtimes.get(runtime.runtimeId) + ?? runtimeSummary(runtime, runtime.host.currentStatus, this.now()) + this.storeRuntime(currentRuntime) + if (emit) this.sink.event({ type: 'runtime_updated', runtime: currentRuntime }) + const existing = this.sessions.get(runtime.sessionId) + ?? fallbackSession( + runtime.sessionId, runtime.workspaceId, this.workspaceName, this.now().toISOString(), + ) + const { attention: _attention, activeRuntimeId: _oldRuntime, ...base } = existing + const summary = { + ...base, + status: 'waiting' as const, + activeRuntimeId: runtime.runtimeId, + updatedAt: this.now().toISOString(), + } + this.storeSummary(summary, emit) + const previous = this.live.get(runtime.sessionId) + if (previous && previous.runtime.runtimeId !== runtime.runtimeId) previous.dispose() + const state = new LoopalLiveSession(runtime, summary, this.now, { + event: (event) => this.sink.event(event), + summary: (next) => this.storeSummary(next, true), + }) + this.live.set(runtime.sessionId, state) + for (const event of this.pending.get(runtime.runtimeId) ?? []) { + state.accept(event.method, event.params) + } + this.pending.delete(runtime.runtimeId) + await state.initialize() + return state + } + + dispose(): void { + this.subscriptions.dispose() + for (const state of this.live.values()) state.dispose() + this.live.clear() + this.pending.clear() + } + + private acceptStatus(event: SessionRuntimeStatusEvent): void { + const previous = this.runtimes.get(event.runtimeId) + if (previous?.state === 'crashed' && event.status !== 'crashed') return + const runtime = runtimeSummary(event, event.status, this.now(), previous?.startedAt) + if (!this.storeRuntime(runtime)) return + this.sink.event({ type: 'runtime_updated', runtime }) + const session = this.sessions.get(event.sessionId) + if (session) this.storeSummary(hostSession(session, event, this.now().toISOString()), true) + const hostStatuses = this.leaders.transition( + event.runtimeId, event.workspaceId, event.status, + ) + if (event.status === 'stopping' || event.status === 'stopped' || event.status === 'crashed') { + this.retireLive(event) + } + for (const status of hostStatuses) this.sink.event({ type: 'host_status', status }) + } + + private acceptNotification(event: SessionRuntimeNotificationEvent): void { + if (event.method !== 'agent/event' && event.method !== 'view/resync_required') { + this.sink.service(event) + return + } + const state = this.live.get(event.sessionId) + if (state?.runtime.runtimeId === event.runtimeId + && state.runtime.generation === event.generation) { + state.accept(event.method, event.params) + } else if (!state && this.registry.has(event.runtimeId)) { + this.buffer(event) + } + } + + private buffer(event: SessionRuntimeNotificationEvent): void { + const buffered = this.pending.get(event.runtimeId) ?? [] + if (buffered.length >= PENDING_LIMIT) { + buffered.splice(0, buffered.length, { + ...event, + method: 'view/resync_required', + params: { reason: 'session_attach_buffer_overflow' }, + }) + } else if (buffered[0]?.method !== 'view/resync_required') { + buffered.push(event) + } + this.pending.set(event.runtimeId, buffered) + } + + private storeSummary(summary: SessionSummary, emit: boolean): void { + this.sessions.set(summary.id, summary) + this.live.get(summary.id)?.replaceSummary(summary) + if (emit) this.sink.event({ type: 'session_updated', session: summary }) + } + + private storeRuntime(runtime: RuntimeSummary): boolean { + for (const existing of this.runtimes.values()) { + if (existing.sessionId !== runtime.sessionId || existing.id === runtime.id) continue + if (existing.generation > runtime.generation) return false + this.runtimes.delete(existing.id) + } + this.runtimes.set(runtime.id, runtime) + return true + } + + private retireLive(scope: SessionRuntimeScope): void { + const state = this.live.get(scope.sessionId) + if (state?.runtime.runtimeId === scope.runtimeId) { + if (state.cachedDetail) this.details.set(scope.sessionId, state.cachedDetail) + for (const event of state.retire()) this.sink.event(event) + this.live.delete(scope.sessionId) + } + this.pending.delete(scope.runtimeId) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.test.ts new file mode 100644 index 00000000..10c2a1d4 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LoopalSessionResumeState } from './loopal-session-resume-state' + +describe('LoopalSessionResumeState', () => { + let root = '' + let path = '' + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-desktop-state-')) + path = join(root, 'nested', 'lifecycle.json') + }) + + afterEach(async () => rm(root, { recursive: true, force: true })) + + it('persists the selected running sessions and explicit stops atomically', async () => { + const state = new LoopalSessionResumeState('/workspace', path) + await state.load() + await state.started('session-a', true) + await state.started('session-b', false) + await state.select('session-b') + expect(state.resumeSessionId).toBe('session-b') + await state.stopped('session-b') + await state.flush() + expect(state.activeSessionId).toBe('session-b') + expect(state.resumeSessionId).toBe('session-a') + + const restored = new LoopalSessionResumeState('/workspace', path) + await restored.load() + expect(restored.activeSessionId).toBe('session-b') + expect(restored.resumeSessionId).toBe('session-a') + expect((await readdir(join(root, 'nested'))).filter((name) => name.endsWith('.tmp'))) + .toEqual([]) + }) + + it('ignores missing, corrupt, and foreign-workspace records', async () => { + const missing = new LoopalSessionResumeState('/workspace', path) + await expect(missing.load()).resolves.toBeUndefined() + expect(missing.resumeSessionId).toBeUndefined() + + await mkdir(join(root, 'nested')) + await writeFile(path, '{broken') + const corrupt = new LoopalSessionResumeState('/workspace', path) + await corrupt.load() + expect(corrupt.activeSessionId).toBeUndefined() + + await writeFile(path, JSON.stringify({ + version: 1, workspace: '/other', activeSessionId: 'foreign', + runningSessionIds: ['foreign'], + })) + const foreign = new LoopalSessionResumeState('/workspace', path) + await foreign.load() + expect(foreign.resumeSessionId).toBeUndefined() + }) + + it('records a created location without claiming that the Session is running', async () => { + const state = new LoopalSessionResumeState('/workspace', path) + const location = { + sessionId: 'session-created', workspaceId: 'workspace-created', + cwd: '/workspace/created', name: 'created', kind: 'git_worktree' as const, + } + await state.created(location) + expect(state.location(location.sessionId)).toEqual(location) + expect(state.runningSessionIds).not.toContain(location.sessionId) + expect(state.resumeSessionId).toBeUndefined() + + const restored = new LoopalSessionResumeState('/workspace', path) + await restored.load() + expect(restored.location(location.sessionId)).toEqual(location) + expect(restored.resumeSessionId).toBeUndefined() + await restored.started(location.sessionId, true, location) + expect(restored.resumeSessionId).toBe(location.sessionId) + }) + + it('works in memory and bounds persisted runtime candidates', async () => { + const state = new LoopalSessionResumeState('/workspace') + await state.load() + for (let index = 0; index < 40; index += 1) { + await state.started(`session-${index}`, index === 0) + } + await state.select('not-running') + expect(state.resumeSessionId).toBe('session-39') + await expect(state.flush()).resolves.toBeUndefined() + await expect(readFile(path)).rejects.toThrow() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.ts new file mode 100644 index 00000000..0fc8c671 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-resume-state.ts @@ -0,0 +1,159 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { z } from 'zod' + +const SessionLocationSchema = z.object({ + sessionId: z.string().min(1), + workspaceId: z.string().min(1), + cwd: z.string().min(1), + name: z.string().min(1), + kind: z.enum(['folder', 'git_worktree']), + title: z.string().min(1).optional(), + model: z.string().min(1).optional(), + mode: z.string().min(1).optional(), + createdAt: z.string().datetime().optional(), + updatedAt: z.string().datetime().optional(), +}) +export type SessionLocation = z.infer + +const PersistedStateSchema = z.object({ + version: z.literal(2), + workspace: z.string().min(1), + activeSessionId: z.string().min(1).optional(), + runningSessionIds: z.array(z.string().min(1)).max(32), + sessionLocations: z.array(SessionLocationSchema).max(128), +}) +const LegacyStateSchema = z.object({ + version: z.literal(1), workspace: z.string().min(1), + activeSessionId: z.string().min(1).optional(), + runningSessionIds: z.array(z.string().min(1)).max(32), +}) + +type PersistedState = z.infer + +export class LoopalSessionResumeState { + private value: PersistedState + private writes = Promise.resolve() + + constructor( + private readonly workspace: string, + private readonly path?: string, + ) { + this.value = { version: 2, workspace, runningSessionIds: [], sessionLocations: [] } + } + + get activeSessionId(): string | undefined { return this.value.activeSessionId } + get runningSessionIds(): readonly string[] { return this.value.runningSessionIds } + get resumeSessionId(): string | undefined { + const active = this.value.activeSessionId + return active && this.value.runningSessionIds.includes(active) + ? active + : this.value.runningSessionIds[0] + } + get locations(): readonly SessionLocation[] { return this.value.sessionLocations } + location(sessionId: string): SessionLocation | undefined { + return this.value.sessionLocations.find((value) => value.sessionId === sessionId) + } + + async load(): Promise { + if (!this.path) return + try { + const raw: unknown = JSON.parse(await readFile(this.path, 'utf8')) + const current = PersistedStateSchema.safeParse(raw) + const legacy = LegacyStateSchema.safeParse(raw) + const parsed = current.success ? current.data : legacy.success ? { + ...legacy.data, version: 2 as const, sessionLocations: [], + } : undefined + if (parsed?.workspace === this.workspace) this.value = parsed + } catch (error) { + if (!isMissing(error)) this.value = this.empty() + } + } + + select(sessionId: string): Promise { + this.value = { ...this.value, activeSessionId: sessionId } + return this.persist() + } + + created(location: SessionLocation): Promise { + this.value = { + ...this.value, + sessionLocations: [ + location, + ...this.value.sessionLocations.filter((value) => value.sessionId !== location.sessionId), + ].slice(0, 128), + } + return this.persist() + } + + started(sessionId: string, select: boolean, location?: SessionLocation): Promise { + const runningSessionIds = [ + sessionId, ...this.value.runningSessionIds.filter((id) => id !== sessionId), + ].slice(0, 32) + this.value = { + ...this.value, + runningSessionIds, + sessionLocations: location ? [ + location, + ...this.value.sessionLocations.filter((value) => value.sessionId !== sessionId), + ].slice(0, 128) : this.value.sessionLocations, + ...(select ? { activeSessionId: sessionId } : {}), + } + return this.persist() + } + + stopped(sessionId: string): Promise { + this.value = { + ...this.value, + runningSessionIds: this.value.runningSessionIds.filter((id) => id !== sessionId), + } + return this.persist() + } + + discardUnavailable(sessionIds: readonly string[]): Promise { + const unavailable = new Set(sessionIds) + this.value = { + ...this.value, + runningSessionIds: this.value.runningSessionIds.filter((id) => !unavailable.has(id)), + ...(this.value.activeSessionId && unavailable.has(this.value.activeSessionId) + ? { activeSessionId: undefined } : {}), + } + return this.persist() + } + + normalizeRunning(sessionIds: readonly string[], activeSessionId: string): Promise { + const runningSessionIds = [...new Set(sessionIds)].slice(0, 32) + this.value = { ...this.value, runningSessionIds, activeSessionId } + return this.persist() + } + + flush(): Promise { return this.writes } + + private persist(): Promise { + if (!this.path) return Promise.resolve() + const snapshot = JSON.stringify(this.value) + const persist = async (): Promise => { + await mkdir(dirname(this.path!), { recursive: true }) + const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }) + await rename(temporary, this.path!) + } finally { + try { await rm(temporary, { force: true }) } + catch {} + } + } + const write = this.writes.then(persist, persist) + this.writes = write + return write + } + + private empty(): PersistedState { + return { version: 2, workspace: this.workspace, runningSessionIds: [], sessionLocations: [] } + } +} + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.test.ts new file mode 100644 index 00000000..4b60b607 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import { type SessionSummary } from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { LoopalEventProjector } from '../projections/loopal-event-projector' +import { loadSessionDetail } from './loopal-session-snapshot' + +const now = () => new Date('2026-07-11T12:00:00.000Z') +const session: SessionSummary = { + id: 'session', workspaceId: 'workspace', title: 'Session', model: 'model', mode: 'agent', + status: 'waiting', createdAt: now().toISOString(), updatedAt: now().toISOString(), +} + +describe('loadSessionDetail', () => { + it('normalizes missing message IDs and includes authoritative streaming text', async () => { + const request = vi.fn(async (method: string, params?: unknown) => method === 'hub/list_agents' + ? { agents: [ + { name: 'main', state: 'local' }, { name: 'worker', state: 'connected' }, + ] } + : method === 'view/snapshot' + ? (() => { + const agent = (params as { agent?: string })?.agent ?? 'main' + return { + rev: 9, + state: { agent: { + name: agent, observable: { status: 'Running' }, + conversation: { + streaming_text: agent === 'main' ? 'partial' : '', + messages: agent === 'main' ? [{ role: 'tool', content: 'tool output' }] : [], + ...(agent === 'main' ? { + pending_permission: { id: 'permission', name: 'Read', input: 'README.md' }, + } : { + pending_question: { + id: 'question', + questions: [{ question: 'Continue?', options: [], allow_multiple: false }], + classifier_status: { kind: 'running', elapsed_ms: 10 }, + }, + }), + }, + } }, + } + })() + : { agents: [ + { name: 'main', parent: null, children: ['worker'], lifecycle: 'running' }, + { name: 'worker', parent: 'main', children: [], lifecycle: 'running' }, + ] }) + const projector = new LoopalEventProjector(now, { + append: vi.fn(), updateSession: vi.fn(), attention: vi.fn(), + }) + const result = await loadSessionDetail( + { request } as unknown as DesktopHostClient, session, now, projector, + ) + expect(result).toMatchObject({ + revision: 9, + detail: { + conversation: [ + { id: expect.stringMatching(/^main-message-/), role: 'system', text: 'tool output' }, + { id: 'main-streaming-assistant', role: 'assistant', text: 'partial' }, + ], + agents: [ + { id: 'main', status: 'running', controllable: true }, + { id: 'worker', status: 'running', controllable: true }, + ], + }, + pendingAttention: [ + expect.objectContaining({ + kind: 'permission_requested', agentId: 'main', + value: expect.objectContaining({ id: 'permission' }), + }), + expect.objectContaining({ + kind: 'question_requested', agentId: 'worker', + value: expect.objectContaining({ id: 'question', classifier_running: true }), + }), + ], + }) + expect(request).toHaveBeenCalledWith('view/snapshot', { agent: 'main' }) + expect(request).toHaveBeenCalledWith('hub/topology', {}) + expect(request).toHaveBeenCalledWith('view/snapshot', { agent: 'worker' }) + expect(request).not.toHaveBeenCalledWith('hub/status', expect.anything()) + expect(request).not.toHaveBeenCalledWith('meta/topology', expect.anything()) + }) + + it('falls back to topology and retained agents when a child snapshot is unavailable', async () => { + const main = { + rev: 3, + state: { agent: { + name: 'main', observable: { status: 'WaitingForInput' }, + conversation: { messages: [] }, + } }, + } + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === 'hub/topology') return { agents: [{ + name: 'worker', parent: 'main', children: [], lifecycle: 'finished', model: 'mini', + }] } + if (method === 'hub/list_agents') { + return { agents: [{ name: 'main', state: 'connected' }, { name: 'worker', state: 'shadow' }] } + } + if ((params as { agent?: string })?.agent === 'worker') throw new Error('gone') + return main + }) + const previous = { + session, + conversation: [], + artifacts: [{ + id: 'artifact', sessionId: session.id, title: 'result.md', kind: 'document' as const, + uri: 'loopal-workspace://result.md', mediaType: 'text/markdown', + producerAgentId: 'worker', createdAt: now().toISOString(), + }], + agents: [{ + id: 'worker', name: 'Worker', status: 'running' as const, + conversation: [{ + id: 'result', role: 'assistant' as const, text: 'done', createdAt: now().toISOString(), + }], + telemetry: { + turnCount: 1, inputTokens: 1, outputTokens: 1, cacheCreationTokens: 0, + cacheReadTokens: 0, thinkingTokens: 0, contextWindow: 10, + toolsInFlight: 0, toolCount: 0, + }, + }, { id: 'retired', name: 'Retired', status: 'running' as const }], + } + const projector = new LoopalEventProjector(now, { + append: vi.fn(), updateSession: vi.fn(), attention: vi.fn(), + }) + const result = await loadSessionDetail( + { request } as unknown as DesktopHostClient, session, now, projector, previous, + ) + expect(result.detail.agents).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'main', status: 'waiting', controllable: true }), + expect.objectContaining({ id: 'worker', status: 'completed', controllable: false, conversation: previous.agents[0]!.conversation }), + expect.objectContaining({ id: 'retired', status: 'completed', controllable: false }), + ])) + expect(result.detail.artifacts).toEqual(previous.artifacts) + }) + + it('keeps rendering while live-agent discovery is unavailable', async () => { + const request = vi.fn(async (method: string) => { + if (method === 'hub/list_agents') throw new Error('temporarily unavailable') + if (method === 'hub/topology') return { agents: [] } + return { + rev: 1, + state: { agent: { + name: 'main', observable: { status: 'Running' }, conversation: { messages: [] }, + } }, + } + }) + const projector = new LoopalEventProjector(now, { + append: vi.fn(), updateSession: vi.fn(), attention: vi.fn(), + }) + const result = await loadSessionDetail( + { request } as unknown as DesktopHostClient, session, now, projector, + ) + expect(result.detail.agents[0]).toMatchObject({ + id: 'main', status: 'running', controllable: false, + }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.ts new file mode 100644 index 00000000..5bb33791 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-snapshot.ts @@ -0,0 +1,171 @@ +import { + type AgentSummary, + type SessionDetail, + type SessionSummary, +} from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { type AttentionEventKind } from '../attention/loopal-attention' +import { LoopalEventProjector } from '../projections/loopal-event-projector' +import { projectMessages } from '../projections/loopal-message-projection' +import { + projectAgent, + projectSessionView, + retiredAgent, + topologyAgent, +} from '../projections/loopal-view-projection' +import { + TopologySchema, + ViewSnapshotSchema, + AgentListSchema, + type Topology, + type ViewSnapshot, +} from '../runtime/loopal-wire' +import { mergeRemoteAgents, readMetaHubState } from '../federation/loopal-metahub-projection' + +export async function loadSessionDetail( + host: DesktopHostClient, + session: SessionSummary, + now: () => Date, + projector: LoopalEventProjector, + previous?: SessionDetail, +): Promise<{ + detail: SessionDetail + revision: number + revisions: Readonly> + pendingAttention: readonly SnapshotAttention[] +}> { + projector.beginSync() + const [mainValue, topologyValue, controllable] = await Promise.all([ + host.request('view/snapshot', { agent: 'main' }), + host.request('hub/topology', {}), + loadControllableAgents(host), + ]) + const metaHub = readMetaHubState(host, now()) + const main = ViewSnapshotSchema.parse(mainValue) + const topology = TopologySchema.parse(topologyValue) + const snapshots = await loadAgentSnapshots(host, main, topology) + const previousLocal = previous?.agents.filter((agent) => !agent.qualifiedName) ?? [] + const agents = mergeRemoteAgents( + mergeAgents(snapshots, topology, previousLocal, now(), controllable), + metaHub, + ) + return { + detail: { + session, + conversation: projectMessages(main, now(), previous?.conversation), + agents, + artifacts: previous?.artifacts ?? [], + view: projectSessionView(main), + metaHub, + }, + revision: main.rev, + revisions: Object.fromEntries( + [...snapshots].map(([agentId, snapshot]) => [agentId, snapshot.rev]), + ), + pendingAttention: [...snapshots].flatMap(([agentId, snapshot]) => ( + snapshotAttention(agentId, snapshot) + )), + } +} + +async function loadControllableAgents(host: DesktopHostClient): Promise> { + try { + const list = AgentListSchema.parse(await host.request('hub/list_agents', {})) + return new Set(list.agents.filter((agent) => ( + agent.state === 'local' || agent.state === 'connected' + )).map((agent) => agent.name)) + } catch { + return new Set() + } +} + +export interface SnapshotAttention { + readonly kind: Extract + readonly agentId: string + readonly value: unknown +} + +function snapshotAttention(agentId: string, snapshot: ViewSnapshot): SnapshotAttention[] { + const conversation = snapshot.state.agent.conversation + const pending: SnapshotAttention[] = [] + if (conversation.pending_permission) { + pending.push({ + kind: 'permission_requested', agentId, value: conversation.pending_permission, + }) + } + if (conversation.pending_question) { + const question = conversation.pending_question + pending.push({ + kind: 'question_requested', agentId, + value: { + id: question.id, + questions: question.questions, + classifier_running: question.classifier_status?.kind === 'running', + classifier_status: question.classifier_status, + }, + }) + } + if (conversation.pending_plan_approval) { + pending.push({ + kind: 'plan_approval_requested', agentId, + value: conversation.pending_plan_approval, + }) + } + return pending +} + +async function loadAgentSnapshots( + host: DesktopHostClient, + main: ViewSnapshot, + topology: Topology, +): Promise> { + const result = new Map([['main', main]]) + const names = topology.agents.map((agent) => agent.name).filter((name) => name !== 'main') + const snapshots = await Promise.all(names.map(async (name) => { + try { + return [name, ViewSnapshotSchema.parse( + await host.request('view/snapshot', { agent: name }), + )] as const + } catch { + return undefined + } + })) + for (const snapshot of snapshots) { + if (snapshot) result.set(snapshot[0], snapshot[1]) + } + return result +} + +function mergeAgents( + snapshots: ReadonlyMap, + topology: Topology, + previous: readonly AgentSummary[], + now: Date, + controllable: ReadonlySet, +): AgentSummary[] { + const old = new Map(previous.map((agent) => [agent.id, agent])) + const active = new Set() + const agents: AgentSummary[] = [] + const main = snapshots.get('main')! + agents.push(withControl(projectAgent( + main, topology.agents.find((agent) => agent.name === 'main'), now, old.get('main'), + ), controllable.has('main'))) + active.add('main') + for (const entry of topology.agents) { + if (entry.name === 'main') continue + const snapshot = snapshots.get(entry.name) + agents.push(withControl(snapshot + ? projectAgent(snapshot, entry, now, old.get(entry.name)) + : topologyAgent(entry, old.get(entry.name)), controllable.has(entry.name))) + active.add(entry.name) + } + for (const agent of previous) { + if (!active.has(agent.id)) agents.push(withControl(retiredAgent(agent), false)) + } + return agents +} + +function withControl(agent: AgentSummary, controllable: boolean): AgentSummary { + return { ...agent, controllable } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-workspaces.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-workspaces.ts new file mode 100644 index 00000000..32cd47ab --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/loopal-session-workspaces.ts @@ -0,0 +1,129 @@ +import { realpath, stat } from 'node:fs/promises' +import { type SessionSummary, type Workspace } from '../../../../shared/contracts' +import { LoopalSessionDirectory } from './loopal-session-directory' +import { + LoopalSessionResumeState, type SessionLocation, +} from './loopal-session-resume-state' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' +import { LoopalWorkspaceCatalog, type WorkspaceCatalogStage } from '../workspace/loopal-workspace-catalog' +import { type PreparedSessionDirectory } from './session-directory-authority' + +export class LoopalSessionWorkspaces { + private readonly catalog: LoopalWorkspaceCatalog + private readonly resume: LoopalSessionResumeState + + constructor(readonly initial: Workspace, cwd: string, statePath?: string) { + this.catalog = new LoopalWorkspaceCatalog(initial) + this.resume = new LoopalSessionResumeState(cwd, statePath) + } + + async load(directory: LoopalSessionDirectory): Promise { + await this.resume.load() + const unavailable: string[] = [] + for (const location of this.resume.locations) { + this.catalog.restore(location) + restoreSession(directory, location) + if (!(await isAvailable(location.cwd))) { + directory.markSessionUnavailable(location.sessionId) + unavailable.push(location.sessionId) + } + } + if (unavailable.length > 0) await this.resume.discardUnavailable(unavailable) + } + + get bootstrapTarget(): { workspaceId: string; cwd: string; resumeSessionId?: string } { + const sessionId = this.resume.resumeSessionId + const location = sessionId ? this.resume.location(sessionId) : undefined + return location + ? { + workspaceId: location.workspaceId, cwd: location.cwd, + resumeSessionId: location.sessionId, + } + : { + workspaceId: this.initial.id, cwd: this.catalog.path(this.initial.id)!, + ...(sessionId ? { resumeSessionId: sessionId } : {}), + } + } + + ensure(target: PreparedSessionDirectory): Workspace { + return this.catalog.ensure(target.path, target.kind) + } + + stage(target: PreparedSessionDirectory): WorkspaceCatalogStage { + return this.catalog.stage(target.path, target.kind) + } + + require(workspaceId: string): Workspace { + const workspace = this.catalog.get(workspaceId) + if (!workspace) throw new Error(`Unknown workspace: ${workspaceId}`) + return workspace + } + + cwd(sessionId: string, workspaceId: string): string { + return this.resume.location(sessionId)?.cwd + ?? this.catalog.path(workspaceId) + ?? this.catalog.path(this.initial.id)! + } + + started( + runtime: SessionRuntimeHandle, session: SessionSummary | undefined, select: boolean, + ): Promise { + return this.resume.started( + runtime.sessionId, select, this.location(runtime.sessionId, runtime.workspaceId, session), + ) + } + + created(sessionId: string, workspaceId: string): Promise { + return this.resume.created(this.location(sessionId, workspaceId)) + } + + private location( + sessionId: string, workspaceId: string, session?: SessionSummary, + ): SessionLocation { + const workspace = this.require(workspaceId) + return { + sessionId, + workspaceId: workspace.id, + cwd: this.catalog.path(workspace.id)!, + name: workspace.name, + kind: normalizedKind(workspace), + ...(session ? summaryFields(session) : {}), + } + } + + select(sessionId: string): Promise { return this.resume.select(sessionId) } + stopped(sessionId: string): Promise { return this.resume.stopped(sessionId) } + flush(): Promise { return this.resume.flush() } + values(): readonly Workspace[] { return this.catalog.values() } + get lifecycle(): LoopalSessionResumeState { return this.resume } +} + +function normalizedKind(workspace: Workspace): 'folder' | 'git_worktree' { + return workspace.kind === 'git_worktree' ? 'git_worktree' : 'folder' +} + +function summaryFields(session: SessionSummary) { + return { + title: session.title, model: session.model, mode: session.mode, + createdAt: session.createdAt, updatedAt: session.updatedAt, + } +} + +function restoreSession(directory: LoopalSessionDirectory, location: SessionLocation): void { + const timestamp = location.updatedAt ?? location.createdAt ?? new Date(0).toISOString() + directory.mergeCatalog([{ + id: location.sessionId, + title: location.title ?? `Loopal session · ${location.name}`, + model: location.model ?? 'loopal-default', mode: location.mode ?? 'agent', + createdAt: location.createdAt ?? timestamp, updatedAt: timestamp, + }], location.workspaceId, false) +} + +async function isAvailable(path: string): Promise { + try { + const [resolved, metadata] = await Promise.all([realpath(path), stat(path)]) + return metadata.isDirectory() && resolved === path + } catch { + return false + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.test.ts new file mode 100644 index 00000000..8d905920 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.test.ts @@ -0,0 +1,165 @@ +import { SessionDirectoryAuthority } from './session-directory-authority' + +describe('SessionDirectoryAuthority', () => { + it('consumes an opaque authorization exactly once', async () => { + const request = vi.fn(async (method: string) => method.endsWith('inspectWorkingDirectory') + ? { path: '/project', name: 'project' } + : { path: '/project/.loopal/worktrees/wt', branch: 'loopal-wt-wt', name: 'wt' }) + const authority = new SessionDirectoryAuthority(request) + const selected = await authority.authorize('/project') + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).resolves.toEqual({ path: '/project', kind: 'folder' }) + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).rejects.toThrow('directory_authorization_invalid') + }) + + it('expires bounded grants and refuses worktrees outside Git', async () => { + let now = 10 + const authority = new SessionDirectoryAuthority( + async () => ({ path: '/project', name: 'project' }), () => now, 5, + ) + const selected = await authority.authorize('/project') + now = 15 + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).rejects.toThrow('directory_authorization_invalid') + + now = 20 + const next = await authority.authorize('/project') + await expect(authority.prepare({ + authorizationId: next.authorizationId, launchMode: 'worktree', worktreeName: 'wt', + })).rejects.toThrow('not_git_repository') + }) + + it('rejects extra fields from the Rust inspection boundary', async () => { + const authority = new SessionDirectoryAuthority(async () => ({ + path: '/project', name: 'project', injected: true, + })) + await expect(authority.authorize('/project')).rejects.toThrow() + }) + + it('revalidates direct directories and restores the grant after rollback', async () => { + let root = '/repo' + const authority = new SessionDirectoryAuthority(async () => ({ + path: '/repo/project', name: 'project', + git: { root, branch: 'main', head: 'a'.repeat(40), dirty: false }, + })) + const selected = await authority.authorize('/repo/project') + root = '/replacement' + await expect(authority.claim({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).rejects.toThrow('working_directory_changed') + root = '/repo' + const claim = await authority.claim({ + authorizationId: selected.authorizationId, launchMode: 'directory', + }) + await claim.rollback() + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).resolves.toMatchObject({ path: '/repo/project', kind: 'folder' }) + }) + + it('keeps the grant after a recoverable worktree error', async () => { + let prepareAttempts = 0 + const authority = new SessionDirectoryAuthority(async (method) => { + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', branch: 'main', dirty: false }, + } + prepareAttempts += 1 + if (prepareAttempts === 1) throw new Error('worktree_exists: choose another name') + return { path: '/project/.loopal/worktrees/next', branch: 'loopal-wt-next', name: 'next' } + }) + const selected = await authority.authorize('/project') + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'used', + })).rejects.toThrow('worktree_exists') + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'next', + })).resolves.toMatchObject({ branch: 'loopal-wt-next' }) + }) + + it('consumes the grant when Rust retains an unsafe prepared worktree', async () => { + const authority = new SessionDirectoryAuthority(async (method) => { + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', branch: 'main', head: 'a'.repeat(40), dirty: false }, + } + throw new Error( + 'worktree_retained: worktree retained at /project/.loopal/worktrees/wt', + ) + }) + const selected = await authority.authorize('/project') + + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'wt', + })).rejects.toThrow('worktree_retained') + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).rejects.toThrow('directory_authorization_invalid') + }) + + it('claims worktree authorization before awaiting and restores it only after failure', async () => { + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + let attempts = 0 + const authority = new SessionDirectoryAuthority(async (method) => { + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', branch: 'main', dirty: false }, + } + attempts += 1 + if (attempts === 1) { + await gate + throw new Error('worktree_exists: retry') + } + return { path: '/project/.loopal/worktrees/retry', branch: 'loopal-wt-retry', name: 'retry' } + }) + const selected = await authority.authorize('/project') + const first = authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'first', + }) + await vi.waitFor(() => expect(attempts).toBe(1)) + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'second', + })).rejects.toThrow('directory_authorization_invalid') + expect(attempts).toBe(1) + const failed = expect(first).rejects.toThrow('worktree_exists') + release() + await failed + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'retry', + })).resolves.toMatchObject({ branch: 'loopal-wt-retry' }) + }) + + it('cleans a prepared worktree before restoring a rolled-back grant', async () => { + const calls: string[] = [] + const params: unknown[] = [] + const authority = new SessionDirectoryAuthority(async (method, input) => { + calls.push(method) + params.push(input) + if (method.endsWith('inspectWorkingDirectory')) return { + path: '/project', name: 'project', + git: { root: '/project', head: 'a'.repeat(40), dirty: false }, + } + if (method.endsWith('prepareWorktree')) return { + path: '/project/.loopal/worktrees/wt', branch: 'loopal-wt-wt', name: 'wt', + } + return { path: '/project/.loopal/worktrees/wt', removed: true } + }) + const selected = await authority.authorize('/project') + expect(selected.git).toEqual({ root: '/project', dirty: false }) + expect(selected.git).not.toHaveProperty('head') + const claim = await authority.claim({ + authorizationId: selected.authorizationId, launchMode: 'worktree', worktreeName: 'wt', + }) + expect(params[1]).toMatchObject({ expectedHead: 'a'.repeat(40) }) + await claim.rollback() + expect(calls).toContain('desktop/cleanupWorktree') + await expect(authority.prepare({ + authorizationId: selected.authorizationId, launchMode: 'directory', + })).resolves.toMatchObject({ path: '/project' }) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.ts new file mode 100644 index 00000000..f3513b29 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-authority.ts @@ -0,0 +1,169 @@ +import { randomUUID } from 'node:crypto' +import { basename } from 'node:path' +import { z } from 'zod' +import { + type CreateSessionInput, type SessionDirectorySelection, + SessionDirectorySelectionSchema, +} from '../../../../shared/contracts' + +const InspectionSchema = z.object({ + path: z.string().min(1), + name: z.string().min(1), + git: z.object({ + root: z.string().min(1), branch: z.string().min(1).optional(), + head: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u).optional(), dirty: z.boolean(), + }).strict().optional(), +}).strict() +const PreparedSchema = z.object({ + path: z.string().min(1), branch: z.string().min(1), name: z.string().min(1), +}).strict() +const CleanupSchema = z.object({ path: z.string().min(1), removed: z.literal(true) }).strict() +type Inspection = z.infer + +interface Authorization { + readonly value: Inspection + readonly expiresAt: number +} + +export interface PreparedSessionDirectory { + readonly path: string + readonly kind: 'folder' | 'git_worktree' + readonly branch?: string +} +export interface SessionDirectoryClaim { + readonly target: PreparedSessionDirectory + commit(): void + rollback(): Promise +} +export type SessionDirectoryRequest = (method: string, params: unknown) => Promise + +export class SessionDirectoryAuthority { + private readonly entries = new Map() + + constructor( + private readonly request: SessionDirectoryRequest, + private readonly now: () => number = Date.now, + private readonly ttlMs = 10 * 60_000, + ) {} + + async authorize(path: string): Promise { + const value = InspectionSchema.parse(await this.request( + 'desktop/inspectWorkingDirectory', { path }, + )) + this.prune() + const authorizationId = randomUUID() + this.entries.set(authorizationId, { value, expiresAt: this.now() + this.ttlMs }) + while (this.entries.size > 32) this.entries.delete(this.entries.keys().next().value!) + const git = value.git ? { + root: value.git.root, dirty: value.git.dirty, + ...(value.git.branch ? { branch: value.git.branch } : {}), + } : undefined + return SessionDirectorySelectionSchema.parse({ + authorizationId, + path: value.path, name: value.name, ...(git ? { git } : {}), + suggestedWorktreeName: suggestedName(value.name, this.now()), + }) + } + + async prepare(input: CreateSessionInput): Promise { + const claim = await this.claim(input) + claim.commit() + return claim.target + } + + async claim(input: CreateSessionInput): Promise { + this.prune() + const entry = this.entries.get(input.authorizationId) + if (!entry) throw coded('directory_authorization_invalid', 'directory authorization expired') + if (input.launchMode === 'worktree' && !entry.value.git) { + throw coded('not_git_repository', 'worktree mode requires Git') + } + this.entries.delete(input.authorizationId) + if (input.launchMode === 'directory') { + try { + await this.revalidate(entry.value) + return this.transaction( + input.authorizationId, entry, { path: entry.value.path, kind: 'folder' }, + ) + } catch (error) { + if (entry.expiresAt > this.now()) this.restore(input.authorizationId, entry) + throw error + } + } + const git = entry.value.git! + try { + const prepared = PreparedSchema.parse(await this.request('desktop/prepareWorktree', { + path: entry.value.path, name: input.worktreeName, + expectedRoot: git.root, expectedHead: git.head ?? 'UNBORN', + })) + return this.transaction(input.authorizationId, entry, { + path: prepared.path, kind: 'git_worktree', branch: prepared.branch, + }, async () => { + CleanupSchema.parse(await this.request('desktop/cleanupWorktree', { + path: entry.value.path, name: prepared.name, expectedPath: prepared.path, + })) + }) + } catch (error) { + if (!isRetainedWorktree(error) && entry.expiresAt > this.now()) { + this.restore(input.authorizationId, entry) + } + throw error + } + } + + private async revalidate(expected: Inspection): Promise { + const current = InspectionSchema.parse(await this.request( + 'desktop/inspectWorkingDirectory', { path: expected.path }, + )) + if (current.path !== expected.path || current.git?.root !== expected.git?.root) { + throw coded( + 'working_directory_changed', 'working directory changed; select it again', + ) + } + } + + private transaction( + id: string, + entry: Authorization, + target: PreparedSessionDirectory, + cleanup?: () => Promise, + ): SessionDirectoryClaim { + let settled = false + return { + target, + commit: () => { settled = true }, + rollback: async () => { + if (settled) return + settled = true + await cleanup?.() + if (entry.expiresAt > this.now()) this.restore(id, entry) + }, + } + } + + private prune(): void { + const now = this.now() + for (const [id, value] of this.entries) { + if (value.expiresAt <= now) this.entries.delete(id) + } + } + + private restore(id: string, entry: Authorization): void { + this.entries.set(id, entry) + while (this.entries.size > 32) this.entries.delete(this.entries.keys().next().value!) + } +} + +function suggestedName(name: string, now: number): string { + const clean = basename(name).replace(/[^A-Za-z0-9_-]+/gu, '-').replace(/^-+/u, '') + const stamp = new Date(now).toISOString().replace(/[-:TZ.]/gu, '').slice(0, 14) + return `${(clean || 'loopal').slice(0, 43)}-${stamp}`.slice(0, 64) +} + +function coded(code: string, message: string): Error { + return new Error(`${code}: ${message}`) +} + +function isRetainedWorktree(error: unknown): boolean { + return error instanceof Error && error.message.startsWith('worktree_retained:') +} diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.test.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.test.ts new file mode 100644 index 00000000..c1b945dc --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.test.ts @@ -0,0 +1,55 @@ +import { createSessionDirectoryCommand, type SessionDirectoryCommandRunner } from './session-directory-command' + +describe('Session directory one-shot command bridge', () => { + it('passes bounded no-shell arguments and a secret-minimized environment', async () => { + const runner = vi.fn(async () => ({ + stdout: JSON.stringify({ ok: true, value: { path: '/project', name: 'project' } }), + })) + const allowedGit = { + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/tmp/gitconfig', + GIT_AUTHOR_NAME: 'Loopal Author', GIT_AUTHOR_EMAIL: 'author@loopal.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', GIT_COMMITTER_NAME: 'Loopal Committer', + GIT_COMMITTER_EMAIL: 'committer@loopal.invalid', + GIT_COMMITTER_DATE: '2000-01-02T00:00:00Z', + } + vi.stubEnv('ANTHROPIC_API_KEY', 'secret') + for (const [key, value] of Object.entries(allowedGit)) vi.stubEnv(key, value) + for (const key of [ + 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0', + ]) vi.stubEnv(key, 'injected') + const request = createSessionDirectoryCommand('/bin/loopal', runner) + await expect(request('desktop/inspectWorkingDirectory', { path: '/project' })) + .resolves.toEqual({ path: '/project', name: 'project' }) + expect(runner).toHaveBeenCalledWith( + '/bin/loopal', ['desktop', 'inspect-directory', '--path', '/project'], + expect.objectContaining({ timeout: 30_000, maxBuffer: 1024 * 1024 }), + ) + const options = runner.mock.calls[0]![2] + expect(options.env.ANTHROPIC_API_KEY).toBeUndefined() + expect(options.env).toMatchObject(allowedGit) + expect(options.env).not.toHaveProperty('GIT_DIR') + expect(options.env).not.toHaveProperty('GIT_WORK_TREE') + expect(options.env).not.toHaveProperty('GIT_CONFIG_COUNT') + expect(options.env).not.toHaveProperty('GIT_CONFIG_KEY_0') + expect(options.env).not.toHaveProperty('GIT_CONFIG_VALUE_0') + expect(options.env.LOOPAL_OTEL_ENABLED).toBe('false') + vi.unstubAllEnvs() + }) + + it('rejects malformed and domain-error envelopes', async () => { + const malformed = createSessionDirectoryCommand('/bin/loopal', async () => ({ + stdout: '{"ok":true,"value":{},"unexpected":true}', + })) + await expect(malformed('desktop/inspectWorkingDirectory', { path: '/project' })) + .rejects.toThrow() + const rejected = createSessionDirectoryCommand('/bin/loopal', async () => ({ + stdout: JSON.stringify({ + ok: false, error: { code: 'not_git_repository', message: 'Git required' }, + }), + })) + await expect(rejected('desktop/prepareWorktree', { + path: '/project', name: 'wt', expectedRoot: '/project', expectedHead: 'abc123', + })) + .rejects.toThrow('not_git_repository: Git required') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.ts b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.ts new file mode 100644 index 00000000..a18fd5b0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/sessions/session-directory-command.ts @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { z } from 'zod' +import { type SessionDirectoryRequest } from './session-directory-authority' + +const execute = promisify(execFile) +const InspectParamsSchema = z.object({ path: z.string().min(1) }).strict() +const PrepareParamsSchema = z.object({ + path: z.string().min(1), name: z.string().min(1), expectedRoot: z.string().min(1), + expectedHead: z.string().min(1), +}).strict() +const CleanupParamsSchema = z.object({ + path: z.string().min(1), name: z.string().min(1), expectedPath: z.string().min(1), +}).strict() +const EnvelopeSchema = z.union([ + z.object({ ok: z.literal(true), value: z.unknown() }).strict(), + z.object({ + ok: z.literal(false), + error: z.object({ code: z.string().min(1), message: z.string() }).strict(), + }).strict(), +]) + +interface CommandOptions { + readonly env: NodeJS.ProcessEnv + readonly encoding: 'utf8' + readonly maxBuffer: number + readonly timeout: number + readonly windowsHide: true +} +export type SessionDirectoryCommandRunner = ( + file: string, args: readonly string[], options: CommandOptions, +) => Promise<{ stdout: string }> + +const runCommand: SessionDirectoryCommandRunner = async (file, args, options) => { + const result = await execute(file, [...args], options) + return { stdout: result.stdout } +} + +export function createSessionDirectoryCommand( + binaryPath: string, + runner: SessionDirectoryCommandRunner = runCommand, +): SessionDirectoryRequest { + return async (method, params) => { + const args = commandArgs(method, params) + const { stdout } = await runner(binaryPath, args, { + env: safeEnvironment(process.env), + encoding: 'utf8', + maxBuffer: 1024 * 1024, + timeout: 30_000, + windowsHide: true, + }) + const envelope = EnvelopeSchema.parse(JSON.parse(stdout.trim())) + if (!envelope.ok) throw new Error(`${envelope.error.code}: ${envelope.error.message}`) + return envelope.value + } +} + +function commandArgs(method: string, params: unknown): string[] { + if (method === 'desktop/inspectWorkingDirectory') { + const input = InspectParamsSchema.parse(params) + return ['desktop', 'inspect-directory', '--path', input.path] + } + if (method === 'desktop/prepareWorktree') { + const input = PrepareParamsSchema.parse(params) + return [ + 'desktop', 'prepare-worktree', '--path', input.path, '--name', input.name, + '--expected-root', input.expectedRoot, '--expected-head', input.expectedHead, + ] + } + if (method === 'desktop/cleanupWorktree') { + const input = CleanupParamsSchema.parse(params) + return [ + 'desktop', 'cleanup-worktree', '--path', input.path, '--name', input.name, + '--expected-path', input.expectedPath, + ] + } + throw new Error(`Unsupported session-directory command: ${method}`) +} + +function safeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = { LOOPAL_OTEL_ENABLED: 'false' } + const exact = new Set([ + 'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'WINDIR', 'USERPROFILE', + 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL', + 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_AUTHOR_DATE', + 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_COMMITTER_DATE', + ]) + for (const [key, value] of Object.entries(env)) { + if (value !== undefined && exact.has(key)) result[key] = value + } + return result +} diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.test.ts b/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.test.ts new file mode 100644 index 00000000..a661230a --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DesktopPreferencesService } from './desktop-preferences-service' + +describe('DesktopPreferencesService', () => { + let root = '' + let path = '' + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'loopal-desktop-preferences-')) + path = join(root, 'nested', 'desktop-preferences.json') + }) + afterEach(async () => rm(root, { recursive: true, force: true })) + + it('defaults to the system locale and restores an atomic private update', async () => { + const service = new DesktopPreferencesService(path) + await expect(service.getDesktopPreferences()).resolves.toEqual({ locale: 'system' }) + await expect(service.updateDesktopPreferences({ locale: 'zh-CN' })).resolves + .toEqual({ locale: 'zh-CN' }) + if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ version: 1, locale: 'zh-CN' }) + + const restored = new DesktopPreferencesService(path) + await expect(restored.getDesktopPreferences()).resolves.toEqual({ locale: 'zh-CN' }) + }) + + it('repairs malformed persisted values and validates updates', async () => { + await writeFile(path, '{"version":1,"locale":"invalid"}', { flag: 'w' }).catch(async () => { + const service = new DesktopPreferencesService(path) + await service.updateDesktopPreferences({ locale: 'en' }) + await writeFile(path, '{"version":1,"locale":"invalid"}') + }) + const service = new DesktopPreferencesService(path) + await expect(service.getDesktopPreferences()).resolves.toEqual({ locale: 'system' }) + await service.flush() + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ version: 1, locale: 'system' }) + await expect(service.updateDesktopPreferences({ locale: 'fr' } as never)).rejects.toThrow() + }) + + it('serializes concurrent updates and returns immutable snapshots', async () => { + const service = new DesktopPreferencesService(path) + const first = service.updateDesktopPreferences({ locale: 'en' }) + const second = service.updateDesktopPreferences({ locale: 'zh-CN' }) + await Promise.all([first, second]) + await service.flush() + expect(await service.getDesktopPreferences()).toEqual({ locale: 'zh-CN' }) + expect(JSON.parse(await readFile(path, 'utf8')).locale).toBe('zh-CN') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.ts b/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.ts new file mode 100644 index 00000000..6222fb51 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/desktop-preferences-service.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { z } from 'zod' +import { + DEFAULT_DESKTOP_PREFERENCES, + DesktopPreferencesSchema, + UpdateDesktopPreferencesInputSchema, + type DesktopPreferences, + type UpdateDesktopPreferencesInput, +} from '../../../../shared/contracts' + +const StoredSchema = DesktopPreferencesSchema.extend({ version: z.literal(1) }) + +export interface DesktopPreferencesOperations { + getDesktopPreferences(): Promise + updateDesktopPreferences(input: UpdateDesktopPreferencesInput): Promise +} + +export class DesktopPreferencesService implements DesktopPreferencesOperations { + private value = DEFAULT_DESKTOP_PREFERENCES + private loaded = false + private loading?: Promise + private writes = Promise.resolve() + + constructor(private readonly path?: string) {} + + async getDesktopPreferences(): Promise { + await this.load() + return { ...this.value } + } + + async updateDesktopPreferences( + input: UpdateDesktopPreferencesInput, + ): Promise { + await this.load() + this.value = UpdateDesktopPreferencesInputSchema.parse(input) + await this.persist() + return { ...this.value } + } + + flush(): Promise { return this.writes } + + private load(): Promise { + if (this.loaded) return Promise.resolve() + this.loading ??= this.loadInner() + return this.loading + } + + private async loadInner(): Promise { + try { + if (this.path) { + const stored = StoredSchema.parse(JSON.parse(await readFile(this.path, 'utf8'))) + this.value = { locale: stored.locale } + } + } catch (error) { + if (!isMissing(error)) await this.persist() + } finally { + this.loaded = true + } + } + + private persist(): Promise { + if (!this.path) return Promise.resolve() + const snapshot = JSON.stringify({ version: 1, ...this.value }) + const write = async (): Promise => { + await mkdir(dirname(this.path!), { recursive: true }) + const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }) + await rename(temporary, this.path!) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } + } + const queued = this.writes.then(write, write) + this.writes = queued + return queued + } +} + +export function bindDesktopPreferences( + service: DesktopPreferencesOperations, +): DesktopPreferencesOperations { + return { + getDesktopPreferences: service.getDesktopPreferences.bind(service), + updateDesktopPreferences: service.updateDesktopPreferences.bind(service), + } +} + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.test.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.test.ts new file mode 100644 index 00000000..08c71283 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken } from '../../../../base/common/cancellation' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { LoopalMcpSettingsService } from './loopal-mcp-settings-service' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +function harness(result: unknown = { workspaceId: 'workspace', servers: [] }) { + const request = vi.fn(async () => result) + const runtime = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 1, + host: { request } as unknown as DesktopHostClient, + } satisfies SessionRuntimeHandle + const service = new LoopalMcpSettingsService({ + workspace: async () => runtime, liveSession: async () => runtime, + }) + return { service, request } +} + +describe('LoopalMcpSettingsService', () => { + it('routes all typed methods through the workspace leader', async () => { + const { service, request } = harness() + const token = CancellationToken.None + await service.listMcpServers('workspace', token) + const server = { + type: 'stdio' as const, name: 'local', command: 'node', args: [], enabled: true, + timeoutMs: 30_000, sharing: 'hub-singleton' as const, cwdIsolation: null, + secretPatches: [], + } + await service.upsertMcpServer({ workspaceId: 'workspace', server }, token) + await service.deleteMcpServer({ workspaceId: 'workspace', name: 'local' }, token) + expect(request.mock.calls.map(([method, input]) => [method, input])).toEqual([ + ['desktop/listMcpServers', { workspaceId: 'workspace' }], + ['desktop/upsertMcpServer', { workspaceId: 'workspace', server }], + ['desktop/deleteMcpServer', { workspaceId: 'workspace', name: 'local' }], + ]) + }) + + it('rejects secret-bearing host responses', async () => { + const { service } = harness({ + workspaceId: 'workspace', servers: [{ + type: 'stdio', name: 'local', source: 'local', command: 'node', args: [], + enabled: true, timeoutMs: 30_000, sharing: 'hub-singleton', cwdIsolation: null, + env: [{ name: 'TOKEN', configured: true, value: 'must-not-cross' }], + }], + }) + await expect(service.listMcpServers('workspace', CancellationToken.None)).rejects.toThrow() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.ts new file mode 100644 index 00000000..d5898ddb --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-mcp-settings-service.ts @@ -0,0 +1,62 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + McpServersResponseSchema, + type DeleteMcpServerInput, + type McpServersResponse, + type UpsertMcpServerInput, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../../common/backend' +import { type CodeWorkbenchRuntimeRouter } from '../workspace/loopal-code-workbench' + +export type LoopalMcpSettingsOperations = Pick< + DesktopBackend, 'listMcpServers' | 'upsertMcpServer' | 'deleteMcpServer' +> + +export class LoopalMcpSettingsService implements LoopalMcpSettingsOperations { + constructor(private readonly router: CodeWorkbenchRuntimeRouter) {} + + async listMcpServers( + workspaceId: string, token: CancellationToken, + ): Promise { + return this.call(workspaceId, 'desktop/listMcpServers', { workspaceId }, token) + } + + async upsertMcpServer( + input: UpsertMcpServerInput, token: CancellationToken, + ): Promise { + return this.call(input.workspaceId, 'desktop/upsertMcpServer', input, token) + } + + async deleteMcpServer( + input: DeleteMcpServerInput, token: CancellationToken, + ): Promise { + return this.call(input.workspaceId, 'desktop/deleteMcpServer', input, token) + } + + private async call( + workspaceId: string, method: string, input: unknown, token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const runtime = await this.router.workspace(workspaceId) + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, input, controller.signal) + throwIfCancelled(token) + return McpServersResponseSchema.parse(result) + } finally { + subscription.dispose() + } + } +} + +export function bindLoopalMcpSettings( + service: LoopalMcpSettingsService, +): LoopalMcpSettingsOperations { + return { + listMcpServers: service.listMcpServers.bind(service), + upsertMcpServer: service.upsertMcpServer.bind(service), + deleteMcpServer: service.deleteMcpServer.bind(service), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.test.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.test.ts new file mode 100644 index 00000000..d242833e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { LoopalSettingsService } from './loopal-settings-service' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +const values = { + model: 'gpt-5', modelRouting: { + default: '', summarization: '', classification: '', refine: '', + }, permissionMode: 'bypass' as const, decisionMode: 'manual' as const, + sandboxPolicy: 'default_write' as const, thinking: { type: 'auto' as const }, + maxContextTokens: 0, memoryEnabled: true, microcompactIdleMinutes: 60, + telemetryEnabled: true, outputStyle: '', +} + +function harness(result: unknown = { + workspaceId: 'workspace', settings: values, configuredProviders: [], + providers: { + anthropic: emptyProvider(), openai: emptyProvider(), google: emptyProvider(), + }, + openaiCompatible: [], + resolvedEntries: [{ key: 'model', value: 'gpt-5' }], settingSources: ['defaults'], +}) { + const request = vi.fn(async () => result) + const runtime = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 1, + host: { request } as unknown as DesktopHostClient, + } satisfies SessionRuntimeHandle + const workspace = vi.fn(async () => runtime) + const service = new LoopalSettingsService({ + workspace, + liveSession: async () => runtime, + }) + return { service, request, workspace } +} + +describe('LoopalSettingsService', () => { + it('routes typed reads and atomic updates through the workspace leader', async () => { + const { service, request } = harness() + await expect(service.getLoopalSettings('workspace', CancellationToken.None)).resolves + .toMatchObject({ settings: { model: 'gpt-5' } }) + await service.updateLoopalSettings({ workspaceId: 'workspace', settings: values }, CancellationToken.None) + expect(request.mock.calls.map(([method, input]) => [method, input])).toEqual([ + ['desktop/getSettings', { workspaceId: 'workspace' }], + ['desktop/updateSettings', { workspaceId: 'workspace', settings: values }], + ]) + }) + + it('honors cancellation and rejects secret-bearing responses', async () => { + const cancelled = harness() + await expect(cancelled.service.getLoopalSettings( + 'workspace', CancellationToken.Cancelled, + )).rejects.toThrow('cancelled') + expect(cancelled.request).not.toHaveBeenCalled() + + const pending = harness() + pending.request.mockImplementationOnce(async (_method, _params, signal) => new Promise( + (_resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('aborted'))), + )) + const source = new CancellationTokenSource() + const request = pending.service.getLoopalSettings('workspace', source.token) + await vi.waitFor(() => expect(pending.request).toHaveBeenCalled()) + source.cancel() + await expect(request).rejects.toThrow('aborted') + source.dispose() + + const unsafe = harness({ + workspaceId: 'workspace', settings: values, configuredProviders: [], + providers: { + anthropic: emptyProvider(), openai: emptyProvider(), google: emptyProvider(), + }, + openaiCompatible: [], + resolvedEntries: [], settingSources: ['defaults'], apiKey: 'secret', + }) + await expect(unsafe.service.getLoopalSettings( + 'workspace', CancellationToken.None, + )).rejects.toThrow() + }) +}) + +function emptyProvider() { + return { enabled: false, baseUrl: '', apiKeyEnv: '', apiKeyConfigured: false } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.ts new file mode 100644 index 00000000..9e0c57f0 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-settings-service.ts @@ -0,0 +1,61 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + LoopalDefaultSettingsSchema, + type LoopalDefaultSettings, + type UpdateLoopalSettingsInput, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../../common/backend' +import { type CodeWorkbenchRuntimeRouter } from '../workspace/loopal-code-workbench' + +export type LoopalSettingsOperations = Pick< + DesktopBackend, 'getLoopalSettings' | 'updateLoopalSettings' +> + +export class LoopalSettingsService implements LoopalSettingsOperations { + constructor(private readonly router: CodeWorkbenchRuntimeRouter) {} + + async getLoopalSettings( + workspaceId: string, + token: CancellationToken, + ): Promise { + return LoopalDefaultSettingsSchema.parse(await this.call( + workspaceId, 'desktop/getSettings', { workspaceId }, token, + )) + } + + async updateLoopalSettings( + input: UpdateLoopalSettingsInput, + token: CancellationToken, + ): Promise { + return LoopalDefaultSettingsSchema.parse(await this.call( + input.workspaceId, 'desktop/updateSettings', input, token, + )) + } + + private async call( + workspaceId: string, + method: string, + input: unknown, + token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const runtime = await this.router.workspace(workspaceId) + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, input, controller.signal) + throwIfCancelled(token) + return result + } finally { + subscription.dispose() + } + } +} + +export function bindLoopalSettings(service: LoopalSettingsService): LoopalSettingsOperations { + return { + getLoopalSettings: service.getLoopalSettings.bind(service), + updateLoopalSettings: service.updateLoopalSettings.bind(service), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.test.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.test.ts new file mode 100644 index 00000000..82b8e358 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.test.ts @@ -0,0 +1,70 @@ +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { LoopalSkillPluginService } from './loopal-skill-plugin-service' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +const revision = 'a'.repeat(64) +const summary = { + name: '/review', description: 'Review', hasArguments: true, + source: 'global', scope: 'global', editable: true, effective: true, revision, +} as const +const detail = { workspaceId: 'workspace', ...summary, body: 'Review $ARGUMENTS' } + +function harness() { + const request = vi.fn(async (method) => { + if (method === 'desktop/listSkills' || method === 'desktop/deleteSkill') { + return { workspaceId: 'workspace', skills: [summary] } + } + if (method === 'desktop/listPlugins') return { workspaceId: 'workspace', plugins: [] } + return detail + }) + const runtime = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime', generation: 1, + host: { request } as unknown as DesktopHostClient, + } satisfies SessionRuntimeHandle + const workspace = vi.fn(async () => runtime) + return { + service: new LoopalSkillPluginService({ workspace, liveSession: async () => runtime }), + request, + } +} + +describe('LoopalSkillPluginService', () => { + it('adapts all five operations to the workspace Host protocol', async () => { + const { service, request } = harness() + const token = CancellationToken.None + await service.listSkills('workspace', token) + await service.getSkill({ workspaceId: 'workspace', name: '/review' }, token) + await service.upsertGlobalSkill({ + workspaceId: 'workspace', name: '/review', description: 'Review', + body: 'Review $ARGUMENTS', expectedRevision: revision, + }, token) + await service.deleteGlobalSkill({ + workspaceId: 'workspace', name: '/review', expectedRevision: revision, + }, token) + await service.listPlugins('workspace', token) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'desktop/listSkills', 'desktop/getSkill', 'desktop/upsertSkill', + 'desktop/deleteSkill', 'desktop/listPlugins', + ]) + }) + + it('validates requests and aborts an in-flight Host request', async () => { + const invalid = harness() + await expect(invalid.service.getSkill({ + workspaceId: 'workspace', name: '../bad', + }, CancellationToken.None)).rejects.toThrow() + expect(invalid.request).not.toHaveBeenCalled() + + const pending = harness() + pending.request.mockImplementationOnce(async (_method, _input, signal) => new Promise( + (_resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('aborted'))), + )) + const source = new CancellationTokenSource() + const result = pending.service.listSkills('workspace', source.token) + await vi.waitFor(() => expect(pending.request).toHaveBeenCalled()) + source.cancel() + await expect(result).rejects.toThrow('aborted') + source.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.ts b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.ts new file mode 100644 index 00000000..c5b24228 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/settings/loopal-skill-plugin-service.ts @@ -0,0 +1,91 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + DeleteGlobalSkillInputSchema, + GetSkillInputSchema, + PluginsResponseSchema, + SkillDetailSchema, + SkillsResponseSchema, + UpsertGlobalSkillInputSchema, + type DeleteGlobalSkillInput, + type GetSkillInput, + type PluginsResponse, + type SkillDetail, + type SkillsResponse, + type UpsertGlobalSkillInput, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../../common/backend' +import { type CodeWorkbenchRuntimeRouter } from '../workspace/loopal-code-workbench' + +export type LoopalSkillPluginOperations = Pick + +export class LoopalSkillPluginService implements LoopalSkillPluginOperations { + constructor(private readonly router: CodeWorkbenchRuntimeRouter) {} + + async listSkills(workspaceId: string, token: CancellationToken): Promise { + return SkillsResponseSchema.parse(await this.call( + workspaceId, 'desktop/listSkills', { workspaceId }, token, + )) + } + + async getSkill(input: GetSkillInput, token: CancellationToken): Promise { + const parsed = GetSkillInputSchema.parse(input) + return SkillDetailSchema.parse(await this.call( + parsed.workspaceId, 'desktop/getSkill', parsed, token, + )) + } + + async upsertGlobalSkill( + input: UpsertGlobalSkillInput, token: CancellationToken, + ): Promise { + const parsed = UpsertGlobalSkillInputSchema.parse(input) + return SkillDetailSchema.parse(await this.call( + parsed.workspaceId, 'desktop/upsertSkill', parsed, token, + )) + } + + async deleteGlobalSkill( + input: DeleteGlobalSkillInput, token: CancellationToken, + ): Promise { + const parsed = DeleteGlobalSkillInputSchema.parse(input) + return SkillsResponseSchema.parse(await this.call( + parsed.workspaceId, 'desktop/deleteSkill', parsed, token, + )) + } + + async listPlugins(workspaceId: string, token: CancellationToken): Promise { + return PluginsResponseSchema.parse(await this.call( + workspaceId, 'desktop/listPlugins', { workspaceId }, token, + )) + } + + private async call( + workspaceId: string, method: string, input: unknown, token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const runtime = await this.router.workspace(workspaceId) + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, input, controller.signal) + throwIfCancelled(token) + return result + } finally { + subscription.dispose() + } + } +} + +export function bindLoopalSkillPlugins( + service: LoopalSkillPluginService, +): LoopalSkillPluginOperations { + return { + listSkills: service.listSkills.bind(service), + getSkill: service.getSkill.bind(service), + upsertGlobalSkill: service.upsertGlobalSkill.bind(service), + deleteGlobalSkill: service.deleteGlobalSkill.bind(service), + listPlugins: service.listPlugins.bind(service), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.test.ts b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.test.ts new file mode 100644 index 00000000..96cf5d51 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { UnavailableDesktopBackend } from './unavailable-backend' + +describe('UnavailableDesktopBackend', () => { + it('reports an empty v2 catalog and rejects session operations', async () => { + const backend = new UnavailableDesktopBackend('host missing') + await expect(backend.bootstrap()).resolves.toEqual({ + protocolVersion: 2, + hostStatus: 'stopped', + workspaces: [], + sessions: [], + runtimes: [], + }) + await expect(backend.openSession('session')).rejects.toThrow('host missing') + await expect(backend.createSession({ + authorizationId: '5d0c638c-d44c-4f47-818b-62e6b599e31c', launchMode: 'directory', + })).rejects.toThrow('host missing') + await expect(backend.stopSession('session')).rejects.toThrow('host missing') + await expect(backend.restartSession('session')).rejects.toThrow('host missing') + await expect(backend.sendMessage('session', 'hello')).rejects.toThrow('host missing') + const target = { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + } + await expect(backend.interruptAgent(target)).rejects.toThrow('host missing') + await expect(backend.controlAgent({ + target, command: { type: 'clear' }, + })).rejects.toThrow('host missing') + await expect(backend.readFile({ workspaceId: 'w', path: 'a.ts' }, CancellationToken.None)) + .rejects.toThrow('host missing') + await expect(backend.gitStage({ workspaceId: 'w', path: 'a.ts' }, CancellationToken.None)) + .rejects.toThrow('host missing') + backend.dispose() + }) + + it('honors cancellation for every operation', async () => { + const backend = new UnavailableDesktopBackend('host missing') + const source = new CancellationTokenSource() + source.cancel() + await expect(backend.bootstrap(source.token)).rejects.toThrow('cancelled') + await expect(backend.openSession('session', source.token)).rejects.toThrow('cancelled') + await expect(backend.sendMessage('session', 'hello', source.token)).rejects.toThrow('cancelled') + const target = { + sessionId: 'session', runtimeId: 'runtime', generation: 1, agentId: 'main', + } + await expect(backend.interruptAgent(target, source.token)).rejects.toThrow('cancelled') + await expect(backend.controlAgent({ + target, command: { type: 'clear' }, + }, source.token)).rejects.toThrow('cancelled') + await expect(backend.gitStatus('w', source.token)).rejects.toThrow('cancelled') + backend.dispose() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.ts b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.ts new file mode 100644 index 00000000..208695ea --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-backend.ts @@ -0,0 +1,117 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { Emitter } from '../../../../base/common/event' +import { type IDisposable } from '../../../../base/common/lifecycle' +import { + type AgentControlInput, + type AgentControlTarget, + type CreateSessionInput, + type DesktopImageAttachment, + type DesktopEvent, + type RuntimeSummary, + type SessionDetail, + type SessionDirectorySelection, + type WorkbenchBootstrap, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../../common/backend' +import { type CodeWorkbenchOperations } from '../workspace/loopal-code-workbench' +import { bindUnavailableCodeWorkbench } from './unavailable-code-workbench' +import { bindUnavailableMetaHub } from './unavailable-metahub' +import { type FakeMetaHubOperations } from '../fake/fake-metahub' +import { bindUnavailableLoopalSettings } from '../fake/fake-loopal-settings' +import { type LoopalSettingsOperations } from '../settings/loopal-settings-service' +import { bindUnavailableMcpSettings } from '../fake/fake-mcp-settings' +import { type LoopalMcpSettingsOperations } from '../settings/loopal-mcp-settings-service' +import { bindUnavailableSkillPlugins } from '../fake/fake-skill-plugin-settings' +import { type LoopalSkillPluginOperations } from '../settings/loopal-skill-plugin-service' +import { + bindDesktopPreferences, DesktopPreferencesService, type DesktopPreferencesOperations, +} from '../settings/desktop-preferences-service' + +export interface UnavailableDesktopBackend extends CodeWorkbenchOperations, FakeMetaHubOperations, + LoopalSettingsOperations, LoopalMcpSettingsOperations, LoopalSkillPluginOperations, + DesktopPreferencesOperations {} +export class UnavailableDesktopBackend implements DesktopBackend, IDisposable { + private readonly emitter = new Emitter() + readonly onEvent = this.emitter.event + + constructor(private readonly reason: string) { + Object.assign(this, bindUnavailableCodeWorkbench(reason)) + Object.assign(this, bindUnavailableMetaHub(reason)) + Object.assign(this, bindUnavailableLoopalSettings(reason)) + Object.assign(this, bindUnavailableMcpSettings(reason)) + Object.assign(this, bindUnavailableSkillPlugins(reason)) + Object.assign(this, bindDesktopPreferences(new DesktopPreferencesService())) + } + + async bootstrap(token = CancellationToken.None): Promise { + throwIfCancelled(token) + return { + protocolVersion: 2, + hostStatus: 'stopped', + workspaces: [], + sessions: [], + runtimes: [], + } + } + + async openSession(_sessionId: string, token = CancellationToken.None): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async createSession( + _input: CreateSessionInput, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async authorizeSessionDirectory(_path: string): Promise { + throw new Error(this.reason) + } + + async stopSession(_sessionId: string, token = CancellationToken.None): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async restartSession( + _sessionId: string, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async sendMessage( + _sessionId: string, + _text: string, + token = CancellationToken.None, + _agentId?: string, + _images?: readonly DesktopImageAttachment[], + ): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async interruptAgent( + _input: AgentControlTarget, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + async controlAgent( + _input: AgentControlInput, + token = CancellationToken.None, + ): Promise { + throwIfCancelled(token) + throw new Error(this.reason) + } + + dispose(): void { + this.emitter.dispose() + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-code-workbench.ts b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-code-workbench.ts new file mode 100644 index 00000000..60afb99e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-code-workbench.ts @@ -0,0 +1,25 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { type CodeWorkbenchOperations } from '../workspace/loopal-code-workbench' + +export function bindUnavailableCodeWorkbench(reason: string): CodeWorkbenchOperations { + const unavailable = async (...args: unknown[]): Promise => { + throwIfCancelled(args.at(-1) as CancellationToken) + throw new Error(reason) + } + return { + listDirectory: unavailable, + readFile: unavailable, + writeFile: unavailable, + searchWorkspace: unavailable, + gitStatus: unavailable, + gitDiff: unavailable, + gitStage: unavailable, + gitUnstage: unavailable, + listWorktrees: unavailable, + createWorktree: unavailable, + removeWorktree: unavailable, + respondPermission: unavailable, + respondQuestion: unavailable, + respondPlanApproval: unavailable, + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-metahub.ts b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-metahub.ts new file mode 100644 index 00000000..1dafc600 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/unavailable/unavailable-metahub.ts @@ -0,0 +1,27 @@ +import { CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { type FakeMetaHubOperations } from '../fake/fake-metahub' + +export function bindUnavailableMetaHub(reason: string): FakeMetaHubOperations { + const fail = async (token = CancellationToken.None): Promise => { + throwIfCancelled(token) + throw new Error(reason) + } + return { + getMetaHubSettings: async (token = CancellationToken.None) => { + throwIfCancelled(token) + return { + address: '', hubName: 'loopal-desktop', joinOnStart: false, + startLocalOnLaunch: false, tokenConfigured: false, + } + }, + updateMetaHubSettings: async (_input, token) => fail(token), + getMetaHubStatus: async (_target, token) => fail(token), + joinMetaHub: async (_input, token) => fail(token), + disconnectMetaHub: async (_target, token) => fail(token), + getLocalMetaHubStatus: async (token = CancellationToken.None) => { + throwIfCancelled(token); return { state: 'stopped' } + }, + startLocalMetaHub: async (_input, token) => fail(token), + stopLocalMetaHub: async (token) => fail(token), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.test.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.test.ts new file mode 100644 index 00000000..abe0e201 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.test.ts @@ -0,0 +1,19 @@ +import { projectCodeWorkbenchEvent } from './loopal-code-events' + +describe('code workbench event projection', () => { + it('maps workspace notifications onto Desktop events', () => { + expect(projectCodeWorkbenchEvent('workspace/fileChanged', { + workspaceId: 'w', path: 'src/main.rs', kind: 'changed', + })).toEqual({ + type: 'file_changed', workspaceId: 'w', path: 'src/main.rs', kind: 'changed', + }) + expect(projectCodeWorkbenchEvent('workspace/gitChanged', { workspaceId: 'w' })) + .toEqual({ type: 'git_changed', workspaceId: 'w' }) + }) + + it('drops unknown and malformed notifications', () => { + expect(projectCodeWorkbenchEvent('unknown', {})).toBeUndefined() + expect(projectCodeWorkbenchEvent('workspace/fileChanged', null)).toBeUndefined() + expect(projectCodeWorkbenchEvent('workspace/fileChanged', {})).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.ts new file mode 100644 index 00000000..83a33907 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-events.ts @@ -0,0 +1,21 @@ +import { DesktopEventSchema, type DesktopEvent } from '../../../../shared/contracts' + +const eventTypes: Readonly> = { + 'workspace/fileChanged': 'file_changed', + 'workspace/gitChanged': 'git_changed', + 'workspace/resyncRequired': 'workspace_resync_required', +} + +export function projectCodeWorkbenchEvent( + method: string, + params: unknown, +): DesktopEvent | undefined { + const type = eventTypes[method] + if (!type || !isRecord(params)) return undefined + const parsed = DesktopEventSchema.safeParse({ type, ...params }) + return parsed.success ? parsed.data : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench-bind.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench-bind.ts new file mode 100644 index 00000000..3dbf0d72 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench-bind.ts @@ -0,0 +1,23 @@ +import { + type CodeWorkbenchOperations, + LoopalCodeWorkbench, +} from './loopal-code-workbench' + +export function bindCodeWorkbench(service: LoopalCodeWorkbench): CodeWorkbenchOperations { + return { + listDirectory: service.listDirectory.bind(service), + readFile: service.readFile.bind(service), + writeFile: service.writeFile.bind(service), + searchWorkspace: service.searchWorkspace.bind(service), + gitStatus: service.gitStatus.bind(service), + gitDiff: service.gitDiff.bind(service), + gitStage: service.gitStage.bind(service), + gitUnstage: service.gitUnstage.bind(service), + listWorktrees: service.listWorktrees.bind(service), + createWorktree: service.createWorktree.bind(service), + removeWorktree: service.removeWorktree.bind(service), + respondPermission: service.respondPermission.bind(service), + respondQuestion: service.respondQuestion.bind(service), + respondPlanApproval: service.respondPlanApproval.bind(service), + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.test.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.test.ts new file mode 100644 index 00000000..e1f1b52c --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { bindCodeWorkbench } from './loopal-code-workbench-bind' +import { + LoopalCodeWorkbench, + type CodeWorkbenchRuntimeRouter, +} from './loopal-code-workbench' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +function response(method: string, params: unknown): unknown { + const input = params as Record + if (method === 'workspace/listDirectory') { + return { workspaceId: input.workspaceId, path: input.path, entries: [] } + } + if (method === 'workspace/readFile' || method === 'workspace/writeFile') { + return { + workspaceId: input.workspaceId, path: input.path, content: input.content ?? 'text', + version: 'v1', languageId: 'rust', readonly: false, + } + } + if (method === 'workspace/search') return { matches: [], truncated: false } + if (method === 'workspace/gitStatus') return { branch: 'main', ahead: 0, behind: 0, changes: [] } + if (method === 'workspace/gitDiff') return { path: input.path, patch: '', original: '', modified: '' } + if (method === 'workspace/listWorktrees') return [] + if (method === 'workspace/createWorktree') { + return { id: input.name, path: '/tmp/wt', branch: 'feature', head: 'abc', isMain: false, hasChanges: false } + } + return { ok: true } +} + +function harness() { + const request = vi.fn(async (method, params) => response(method, params)) + const host = { request } as unknown as DesktopHostClient + const runtime: SessionRuntimeHandle = { + workspaceId: 'workspace', sessionId: 'session', runtimeId: 'runtime-1', + generation: 1, host, + } + let live: SessionRuntimeHandle | undefined = runtime + const router: CodeWorkbenchRuntimeRouter = { + workspace: vi.fn(async () => runtime), + liveSession: vi.fn(async () => live), + } + const setLive = (value?: SessionRuntimeHandle): void => { + live = value + } + return { code: new LoopalCodeWorkbench(router), request, router, runtime, setLive } +} + +describe('LoopalCodeWorkbench routing', () => { + it('maps workspace operations and binds their schemas', async () => { + const { code, request } = harness() + const token = CancellationToken.None + await expect(code.listDirectory({ workspaceId: 'workspace', path: '' }, token)) + .resolves.toMatchObject({ workspaceId: 'workspace' }) + await expect(code.readFile({ workspaceId: 'workspace', path: 'a.rs' }, token)) + .resolves.toMatchObject({ languageId: 'rust' }) + await code.writeFile({ + workspaceId: 'workspace', path: 'a.rs', content: 'next', expectedVersion: 'v0', + }, token) + await code.searchWorkspace({ workspaceId: 'workspace', query: 'next' }, token) + await code.gitStatus('workspace', token) + await code.gitDiff({ workspaceId: 'workspace', path: 'a.rs' }, token) + await code.gitStage({ workspaceId: 'workspace', path: 'a.rs' }, token) + await code.gitUnstage({ workspaceId: 'workspace', path: 'a.rs' }, token) + await code.listWorktrees('workspace', token) + await code.createWorktree({ workspaceId: 'workspace', name: 'feature' }, token) + await code.removeWorktree({ workspaceId: 'workspace', name: 'feature', force: false }, token) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'workspace/listDirectory', 'workspace/readFile', 'workspace/writeFile', 'workspace/search', + 'workspace/gitStatus', 'workspace/gitDiff', 'workspace/gitStage', 'workspace/gitUnstage', + 'workspace/listWorktrees', 'workspace/createWorktree', 'workspace/removeWorktree', + ]) + + request.mockResolvedValueOnce({ invalid: true }) + await expect(bindCodeWorkbench(code).readFile({ + workspaceId: 'workspace', path: 'bad.rs', + }, token)).rejects.toThrow() + }) + + it('responds only through the exact live generation', async () => { + const { code, request, setLive } = harness() + const permission = { + sessionId: 'session', runtimeId: 'runtime-1', generation: 1, + agentId: 'worker', requestId: 'permission', decision: 'allow_once' as const, + } + await code.respondPermission(permission, CancellationToken.None) + expect(request).toHaveBeenLastCalledWith('hub/permission_response', { + agent_name: 'worker', tool_call_id: 'permission', allow: true, + }, expect.any(AbortSignal)) + await code.respondPermission({ ...permission, requestId: 'deny', decision: 'deny' }, CancellationToken.None) + expect(request).toHaveBeenLastCalledWith('hub/permission_response', { + agent_name: 'worker', tool_call_id: 'deny', allow: false, + }, expect.any(AbortSignal)) + const question = { + sessionId: 'session', runtimeId: 'runtime-1', generation: 1, + agentId: 'worker', requestId: 'question', + } + await code.respondQuestion({ ...question, answers: ['yes'] }, CancellationToken.None) + expect(request).toHaveBeenLastCalledWith('hub/question_response', { + agent_name: 'worker', question_id: 'question', + response: { kind: 'answered', question_id: 'question', answers: ['yes'] }, + }, expect.any(AbortSignal)) + await code.respondQuestion( + { ...question, requestId: 'cancelled', cancelled: true }, CancellationToken.None, + ) + expect(request).toHaveBeenLastCalledWith('hub/question_response', { + agent_name: 'worker', question_id: 'cancelled', + response: { kind: 'cancelled', question_id: 'cancelled' }, + }, expect.any(AbortSignal)) + await expect(code.respondPermission({ ...permission, generation: 2 }, CancellationToken.None)) + .rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + setLive() + await expect(code.respondQuestion({ ...question, answers: ['yes'] }, CancellationToken.None)) + .rejects.toMatchObject({ code: 'RUNTIME_GONE' }) + }) + + it('checks cancellation before and after router resolution and during RPC', async () => { + const { code, router, request, runtime } = harness() + await expect(code.gitStatus('workspace', CancellationToken.Cancelled)) + .rejects.toThrow('cancelled') + expect(router.workspace).not.toHaveBeenCalled() + + let release!: (runtime: SessionRuntimeHandle) => void + router.workspace = vi.fn(() => new Promise((resolve) => { + release = resolve + })) + const source = new CancellationTokenSource() + const pending = code.gitStatus('workspace', source.token) + source.cancel() + release(runtime) + await expect(pending).rejects.toThrow('cancelled') + expect(request).not.toHaveBeenCalled() + + router.workspace = vi.fn(async () => runtime) + request.mockImplementation(() => new Promise((_resolve, reject) => { + const signal = request.mock.calls.at(-1)?.[2] + signal?.addEventListener('abort', () => reject(new Error('aborted'))) + })) + const during = new CancellationTokenSource() + const inFlight = code.gitStatus('workspace', during.token) + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + during.cancel() + await expect(inFlight).rejects.toThrow('aborted') + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.ts new file mode 100644 index 00000000..5649ac8f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-code-workbench.ts @@ -0,0 +1,132 @@ +import { type CancellationToken, throwIfCancelled } from '../../../../base/common/cancellation' +import { + DirectoryListingSchema, + FileDocumentSchema, + GitDiffSchema, + GitStatusSchema, + WorkspaceSearchResultSchema, + WorktreeListSchema, + WorktreeSchema, + type CreateWorktreeInput, + type GitStageInput, + type GitUnstageInput, + type ListDirectoryInput, + type PermissionResponseInput, + type PlanApprovalResponseInput, + type QuestionResponseInput, + type ReadFileInput, + type RemoveWorktreeInput, + type WorkspaceSearchInput, + type WriteFileInput, +} from '../../../../shared/contracts' +import { type DesktopBackend } from '../../common/backend' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' +import { + respondPermission, respondPlanApproval, respondQuestion, +} from '../attention/loopal-code-attention' +export type CodeWorkbenchOperations = Pick + +export interface CodeWorkbenchRuntimeRouter { + workspace(workspaceId: string): Promise + liveSession(sessionId: string): Promise +} + +export class LoopalCodeWorkbench implements CodeWorkbenchOperations { + constructor(private readonly router: CodeWorkbenchRuntimeRouter) {} + async listDirectory(input: ListDirectoryInput, token: CancellationToken) { + return DirectoryListingSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/listDirectory', input, token, + )) + } + async readFile(input: ReadFileInput, token: CancellationToken) { + return FileDocumentSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/readFile', input, token, + )) + } + async writeFile(input: WriteFileInput, token: CancellationToken) { + return FileDocumentSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/writeFile', input, token, + )) + } + async searchWorkspace(input: WorkspaceSearchInput, token: CancellationToken) { + return WorkspaceSearchResultSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/search', input, token, + )) + } + async gitStatus(workspaceId: string, token: CancellationToken) { + return GitStatusSchema.parse(await this.workspaceCall( + workspaceId, 'workspace/gitStatus', { workspaceId }, token, + )) + } + async gitDiff(input: ReadFileInput, token: CancellationToken) { + return GitDiffSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/gitDiff', input, token, + )) + } + async gitStage(input: GitStageInput, token: CancellationToken) { + await this.workspaceCall(input.workspaceId, 'workspace/gitStage', input, token) + } + async gitUnstage(input: GitUnstageInput, token: CancellationToken) { + await this.workspaceCall(input.workspaceId, 'workspace/gitUnstage', input, token) + } + async listWorktrees(workspaceId: string, token: CancellationToken) { + return WorktreeListSchema.parse(await this.workspaceCall( + workspaceId, 'workspace/listWorktrees', { workspaceId }, token, + )) + } + async createWorktree(input: CreateWorktreeInput, token: CancellationToken) { + return WorktreeSchema.parse(await this.workspaceCall( + input.workspaceId, 'workspace/createWorktree', input, token, + )) + } + async removeWorktree(input: RemoveWorktreeInput, token: CancellationToken) { + await this.workspaceCall(input.workspaceId, 'workspace/removeWorktree', input, token) + } + async respondPermission(input: PermissionResponseInput, token: CancellationToken) { + await respondPermission(this.router, input, token) + } + async respondQuestion(input: QuestionResponseInput, token: CancellationToken) { + await respondQuestion(this.router, input, token) + } + async respondPlanApproval(input: PlanApprovalResponseInput, token: CancellationToken) { + await respondPlanApproval(this.router, input, token) + } + private async workspaceCall( + workspaceId: string, + method: string, + params: unknown, + token: CancellationToken, + ): Promise { + const runtime = await this.resolve(() => this.router.workspace(workspaceId), token) + return this.call(runtime, method, params, token) + } + private async resolve(factory: () => Promise, token: CancellationToken): Promise { + throwIfCancelled(token) + const resolved = await factory() + throwIfCancelled(token) + return resolved + } + + private async call( + runtime: Pick, + method: string, + params: unknown, + token: CancellationToken, + ): Promise { + throwIfCancelled(token) + const controller = new AbortController() + const subscription = token.onCancellationRequested(() => controller.abort()) + try { + const result = await runtime.host.request(method, params, controller.signal) + throwIfCancelled(token) + return result + } finally { + subscription.dispose() + } + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.test.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.test.ts new file mode 100644 index 00000000..eb46094d --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.test.ts @@ -0,0 +1,39 @@ +import { pathToFileURL } from 'node:url' +import { type Workspace } from '../../../../shared/contracts' +import { LoopalWorkspaceCatalog } from './loopal-workspace-catalog' + +const initial: Workspace = { + id: 'local-workspace', name: 'Initial', + rootUri: pathToFileURL('/initial').href, kind: 'folder', +} + +describe('LoopalWorkspaceCatalog staging', () => { + it('keeps pre-start workspaces invisible and shares a concurrent identity', () => { + const catalog = new LoopalWorkspaceCatalog(initial) + const failed = catalog.stage('/project', 'folder') + const successful = catalog.stage('/project', 'folder') + + expect(failed.workspace.id).toBe(successful.workspace.id) + expect(catalog.values()).toEqual([initial]) + successful.commit() + + expect(catalog.values()).toEqual([ + initial, + expect.objectContaining({ id: successful.workspace.id, name: 'project' }), + ]) + }) + + it('reuses a workspace restored from a persisted Session location', () => { + const catalog = new LoopalWorkspaceCatalog(initial) + const restored = catalog.restore({ + sessionId: 'session-1', workspaceId: 'persisted-workspace', + cwd: '/persisted', name: 'Persisted', kind: 'git_worktree', + }) + + const staged = catalog.stage('/persisted', 'folder') + staged.commit() + + expect(staged.workspace).toBe(restored) + expect(catalog.values()).toEqual([initial, restored]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.ts new file mode 100644 index 00000000..ab76853e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-catalog.ts @@ -0,0 +1,53 @@ +import { createHash } from 'node:crypto' +import { basename } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { type Workspace } from '../../../../shared/contracts' +import { type SessionLocation } from '../sessions/loopal-session-resume-state' + +export interface WorkspaceCatalogStage { + readonly workspace: Workspace + commit(): void +} + +export class LoopalWorkspaceCatalog { + private readonly entries = new Map() + + constructor(readonly initial: Workspace) { + this.entries.set(initial.id, initial) + } + + ensure(path: string, kind: Workspace['kind'], name = basename(path) || 'Workspace'): Workspace { + const stage = this.stage(path, kind, name) + stage.commit() + return stage.workspace + } + + stage( + path: string, kind: Workspace['kind'], name = basename(path) || 'Workspace', + ): WorkspaceCatalogStage { + const rootUri = pathToFileURL(path).href + const existing = [...this.entries.values()].find((value) => value.rootUri === rootUri) + if (existing) return { workspace: existing, commit: () => undefined } + const id = `local-${createHash('sha256').update(rootUri).digest('hex').slice(0, 16)}` + const workspace = { id, name, rootUri, kind } + return { workspace, commit: () => this.entries.set(id, workspace) } + } + + restore(location: SessionLocation): Workspace { + const workspace: Workspace = { + id: location.workspaceId, + name: location.name, + rootUri: pathToFileURL(location.cwd).href, + kind: location.kind, + } + this.entries.set(workspace.id, workspace) + return workspace + } + + get(id: string): Workspace | undefined { return this.entries.get(id) } + path(id: string): string | undefined { + const workspace = this.get(id) + return workspace ? fileURLToPath(workspace.rootUri) : undefined + } + values(): readonly Workspace[] { return [...this.entries.values()] } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.test.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.test.ts new file mode 100644 index 00000000..b7f1de5e --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' +import { LoopalWorkspaceLeaders } from './loopal-workspace-leaders' + +function runtime(id: string, workspaceId = 'workspace'): SessionRuntimeHandle { + return { + workspaceId, sessionId: `session-${id}`, runtimeId: id, generation: 1, + host: { currentStatus: 'ready' } as DesktopHostClient, + } +} + +describe('LoopalWorkspaceLeaders', () => { + it('handles leader, follower, retiring, direct terminal, and missing transitions', () => { + const leaders = new LoopalWorkspaceLeaders() + const first = runtime('first') + const second = runtime('second') + expect(leaders.current('missing')).toBeUndefined() + expect(leaders.add(first)).toBe(true) + expect(leaders.add(second)).toBe(false) + expect(leaders.transition('second', 'workspace', 'stopping')).toEqual([]) + expect(leaders.transition('missing', 'workspace', 'ready')).toEqual([]) + expect(leaders.transition('first', 'workspace', 'ready')).toEqual(['ready']) + expect(leaders.transition('first', 'workspace', 'stopping')).toEqual(['stopping']) + expect(leaders.transition('first', 'workspace', 'stopped')).toEqual(['stopped']) + + const direct = runtime('direct', 'other') + expect(leaders.add(direct)).toBe(true) + expect(leaders.transition('direct', 'other', 'crashed')).toEqual(['crashed']) + expect(leaders.transition('direct', 'other', 'stopped')).toEqual([]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.ts new file mode 100644 index 00000000..dcb6d70f --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/loopal-workspace-leaders.ts @@ -0,0 +1,68 @@ +import { type HostStatus } from '../../../../shared/contracts' +import { type SessionRuntimeHandle } from '../runtime/session-runtime-registry' + +export class LoopalWorkspaceLeaders { + private readonly members = new Map>() + private readonly runtimeWorkspaces = new Map() + private readonly retiring = new Map() + + add(runtime: SessionRuntimeHandle): boolean { + const becameLeader = !this.current(runtime.workspaceId) + let workspace = this.members.get(runtime.workspaceId) + if (!workspace) { + workspace = new Map() + this.members.set(runtime.workspaceId, workspace) + } + workspace.set(runtime.runtimeId, runtime) + this.runtimeWorkspaces.set(runtime.runtimeId, runtime.workspaceId) + if (becameLeader) this.retiring.delete(runtime.workspaceId) + return becameLeader + } + + current(workspaceId: string): SessionRuntimeHandle | undefined { + return this.members.get(workspaceId)?.values().next().value + } + + isLeader(runtimeId: string, workspaceId: string): boolean { + return this.current(workspaceId)?.runtimeId === runtimeId + } + + transition(runtimeId: string, workspaceId: string, status: HostStatus): readonly HostStatus[] { + if (status === 'stopping') { + const transition = this.stopping(runtimeId, workspaceId) + return transition.wasLeader + ? [status, ...(transition.next ? [transition.next.host.currentStatus] : [])] + : [] + } + if (status === 'stopped' || status === 'crashed') { + const transition = this.finished(runtimeId, workspaceId) + return transition.publish ? [transition.next?.host.currentStatus ?? status] : [] + } + return this.isLeader(runtimeId, workspaceId) ? [status] : [] + } + + private stopping(runtimeId: string, workspaceId: string) { + const wasLeader = this.isLeader(runtimeId, workspaceId) + const next = this.remove(runtimeId) + if (wasLeader && !next) this.retiring.set(workspaceId, runtimeId) + return { wasLeader, next } + } + + private finished(runtimeId: string, workspaceId: string) { + const wasLeader = this.isLeader(runtimeId, workspaceId) + const next = this.remove(runtimeId) + const wasRetiring = this.retiring.get(workspaceId) === runtimeId + if (wasRetiring) this.retiring.delete(workspaceId) + return { publish: wasLeader || wasRetiring, next } + } + + private remove(runtimeId: string): SessionRuntimeHandle | undefined { + const workspaceId = this.runtimeWorkspaces.get(runtimeId) + if (!workspaceId) return undefined + this.runtimeWorkspaces.delete(runtimeId) + const workspace = this.members.get(workspaceId) + workspace?.delete(runtimeId) + if (workspace?.size === 0) this.members.delete(workspaceId) + return this.current(workspaceId) + } +} diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.test.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.test.ts new file mode 100644 index 00000000..cb447749 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.test.ts @@ -0,0 +1,26 @@ +import { WorkspaceScopedHost } from './workspace-scoped-host' +import { FakeHost } from '../backend/loopal-backend.test-fixtures' + +describe('WorkspaceScopedHost', () => { + it('translates app workspace IDs at every Host request and event boundary', async () => { + const raw = new FakeHost('session-1', []) + raw.request.mockImplementation(async (_method, params) => ({ + workspaceId: (params as { workspaceId: string }).workspaceId, + })) + const host = new WorkspaceScopedHost(raw, 'local-dynamic') + const activate = vi.fn(async () => undefined) + await host.start(activate) + expect(raw.start).toHaveBeenCalledWith(activate) + await expect(host.request('workspace/listDirectory', { + workspaceId: 'local-dynamic', path: '', + })).resolves.toEqual({ workspaceId: 'local-dynamic' }) + expect(raw.request).toHaveBeenCalledWith( + 'workspace/listDirectory', { workspaceId: 'local-workspace', path: '' }, undefined, + ) + + const events: unknown[] = [] + host.onNotification((event) => events.push(event.params)) + raw.notification('workspace/fileChanged', { workspaceId: 'local-workspace', path: 'a.ts' }) + expect(events).toEqual([{ workspaceId: 'local-dynamic', path: 'a.ts' }]) + }) +}) diff --git a/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.ts b/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.ts new file mode 100644 index 00000000..965f3409 --- /dev/null +++ b/apps/desktop/src/platform/loopal-backend/node/workspace/workspace-scoped-host.ts @@ -0,0 +1,44 @@ +import { type Event } from '../../../../base/common/event' +import { type HostStatus } from '../../../../shared/contracts' +import { type DesktopHostClient } from '../backend/loopal-backend-types' +import { + type DesktopHostActivation, type DesktopHostSession, +} from '../../../desktop-host/node/host/desktop-host' +import { type JsonRpcNotification } from '../../../desktop-host/node/rpc/jsonrpc-client' + +const HOST_WORKSPACE_ID = 'local-workspace' + +export class WorkspaceScopedHost implements DesktopHostClient { + readonly onStatus: Event + readonly onNotification: Event + + constructor( + private readonly host: DesktopHostClient, + private readonly workspaceId: string, + ) { + this.onStatus = host.onStatus + this.onNotification = (listener) => host.onNotification((event) => listener({ + ...event, params: remap(event.params, HOST_WORKSPACE_ID, workspaceId), + })) + } + + get currentStatus(): HostStatus { return this.host.currentStatus } + start(activate?: DesktopHostActivation): Promise { + return this.host.start(activate) + } + stop(): Promise { return this.host.stop() } + dispose(): void { this.host.dispose() } + + async request(method: string, params?: unknown, signal?: AbortSignal): Promise { + const value = await this.host.request( + method, remap(params, this.workspaceId, HOST_WORKSPACE_ID), signal, + ) + return remap(value, HOST_WORKSPACE_ID, this.workspaceId) + } +} + +function remap(value: unknown, from: string, to: string): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + const record = value as Record + return record.workspaceId === from ? { ...record, workspaceId: to } : value +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 00000000..1f26c185 --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,108 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { ChannelClientImpl } from '../platform/ipc/common/channel' +import { MessagePortTransport } from '../platform/ipc/common/transport' +import { DesktopBackendClient } from '../platform/loopal-backend/common/clients/backend-client' +import { + RENDERER_CONNECT_CHANNEL, + RENDERER_PORT_CHANNEL, + RENDERER_PROTOCOL_VERSION, + SELECT_IMAGES_CHANNEL, + SELECT_SESSION_DIRECTORY_CHANNEL, +} from '../shared/protocol/renderer-protocol' +import { + DesktopImageAttachmentListSchema, + type DesktopEvent, + type LoopalDesktopAPI, + SessionDirectorySelectionSchema, +} from '../shared/contracts' + +let apiPromise: Promise | undefined + +function connect(): Promise { + apiPromise ??= new Promise((resolve, reject) => { + ipcRenderer.once(RENDERER_PORT_CHANNEL, (event, metadata: unknown) => { + if ( + typeof metadata !== 'object' || + metadata === null || + !('protocolVersion' in metadata) || + metadata.protocolVersion !== RENDERER_PROTOCOL_VERSION || + !event.ports[0] + ) { + reject(new Error('Invalid LoopalDesktop renderer connection')) + return + } + const client = new ChannelClientImpl(new MessagePortTransport(event.ports[0])) + resolve(new DesktopBackendClient(client, async () => DesktopImageAttachmentListSchema.parse( + await ipcRenderer.invoke(SELECT_IMAGES_CHANNEL), + ))) + }) + ipcRenderer.send(RENDERER_CONNECT_CHANNEL) + }) + return apiPromise +} + +const api: LoopalDesktopAPI = { + bootstrap: async () => (await connect()).bootstrap(), + openSession: async (sessionId) => (await connect()).openSession(sessionId), + createSession: async (input) => (await connect()).createSession(input), + selectSessionDirectory: async () => SessionDirectorySelectionSchema.optional().parse( + await ipcRenderer.invoke(SELECT_SESSION_DIRECTORY_CHANNEL), + ), + stopSession: async (sessionId) => (await connect()).stopSession(sessionId), + restartSession: async (sessionId) => (await connect()).restartSession(sessionId), + selectImages: async () => (await connect()).selectImages(), + sendMessage: async (sessionId, text, agentId, images) => ( + await connect() + ).sendMessage(sessionId, text, agentId, images), + interruptAgent: async (input) => (await connect()).interruptAgent(input), + controlAgent: async (input) => (await connect()).controlAgent(input), + getDesktopPreferences: async () => (await connect()).getDesktopPreferences(), + updateDesktopPreferences: async (input) => (await connect()).updateDesktopPreferences(input), + getLoopalSettings: async (workspaceId) => (await connect()).getLoopalSettings(workspaceId), + updateLoopalSettings: async (input) => (await connect()).updateLoopalSettings(input), + listMcpServers: async (workspaceId) => (await connect()).listMcpServers(workspaceId), + upsertMcpServer: async (input) => (await connect()).upsertMcpServer(input), + deleteMcpServer: async (input) => (await connect()).deleteMcpServer(input), + listSkills: async (workspaceId) => (await connect()).listSkills(workspaceId), + getSkill: async (input) => (await connect()).getSkill(input), + upsertGlobalSkill: async (input) => (await connect()).upsertGlobalSkill(input), + deleteGlobalSkill: async (input) => (await connect()).deleteGlobalSkill(input), + listPlugins: async (workspaceId) => (await connect()).listPlugins(workspaceId), + getMetaHubSettings: async () => (await connect()).getMetaHubSettings(), + updateMetaHubSettings: async (input) => (await connect()).updateMetaHubSettings(input), + getMetaHubStatus: async (target) => (await connect()).getMetaHubStatus(target), + joinMetaHub: async (input) => (await connect()).joinMetaHub(input), + disconnectMetaHub: async (target) => (await connect()).disconnectMetaHub(target), + getLocalMetaHubStatus: async () => (await connect()).getLocalMetaHubStatus(), + startLocalMetaHub: async (input) => (await connect()).startLocalMetaHub(input), + stopLocalMetaHub: async () => (await connect()).stopLocalMetaHub(), + listDirectory: async (input) => (await connect()).listDirectory(input), + readFile: async (input) => (await connect()).readFile(input), + writeFile: async (input) => (await connect()).writeFile(input), + searchWorkspace: async (input) => (await connect()).searchWorkspace(input), + gitStatus: async (workspaceId) => (await connect()).gitStatus(workspaceId), + gitDiff: async (input) => (await connect()).gitDiff(input), + gitStage: async (input) => (await connect()).gitStage(input), + gitUnstage: async (input) => (await connect()).gitUnstage(input), + listWorktrees: async (workspaceId) => (await connect()).listWorktrees(workspaceId), + createWorktree: async (input) => (await connect()).createWorktree(input), + removeWorktree: async (input) => (await connect()).removeWorktree(input), + respondPermission: async (input) => (await connect()).respondPermission(input), + respondQuestion: async (input) => (await connect()).respondQuestion(input), + respondPlanApproval: async (input) => (await connect()).respondPlanApproval(input), + onEvent: (listener: (event: DesktopEvent) => void) => { + let unsubscribe: (() => void) | undefined + let disposed = false + void connect().then((client) => { + if (!disposed) { + unsubscribe = client.onEvent(listener) + } + }) + return () => { + disposed = true + unsubscribe?.() + } + }, +} + +contextBridge.exposeInMainWorld('loopalDesktop', api) diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html new file mode 100644 index 00000000..caf8d91f --- /dev/null +++ b/apps/desktop/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + + Loopal Desktop + + +
+ + + diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx new file mode 100644 index 00000000..280f8a09 --- /dev/null +++ b/apps/desktop/src/renderer/index.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { Workbench } from '../workbench/browser/workbench' +import { I18nProvider } from '../workbench/browser/i18n-context' +import { detectRendererPlatform } from './renderer-platform' +import '../workbench/browser/workbench.css' + +document.documentElement.dataset.platform = detectRendererPlatform(navigator.platform) +const root = document.getElementById('root') +if (!root) { + throw new Error('Missing #root element') +} + +ReactDOM.createRoot(root).render( + + + + + , +) diff --git a/apps/desktop/src/renderer/renderer-platform.test.ts b/apps/desktop/src/renderer/renderer-platform.test.ts new file mode 100644 index 00000000..5dea28f5 --- /dev/null +++ b/apps/desktop/src/renderer/renderer-platform.test.ts @@ -0,0 +1,12 @@ +import { detectRendererPlatform } from './renderer-platform' + +describe('detectRendererPlatform', () => { + it.each([ + ['MacIntel', 'darwin'], + ['macOS', 'darwin'], + ['Win32', 'windows'], + ['Linux x86_64', 'linux'], + ] as const)('maps %s to %s', (value, expected) => { + expect(detectRendererPlatform(value)).toBe(expected) + }) +}) diff --git a/apps/desktop/src/renderer/renderer-platform.ts b/apps/desktop/src/renderer/renderer-platform.ts new file mode 100644 index 00000000..8f5c3fc5 --- /dev/null +++ b/apps/desktop/src/renderer/renderer-platform.ts @@ -0,0 +1,8 @@ +export type RendererPlatform = 'darwin' | 'windows' | 'linux' + +export function detectRendererPlatform(value: string): RendererPlatform { + const platform = value.toLowerCase() + if (platform.startsWith('mac')) return 'darwin' + if (platform.startsWith('win')) return 'windows' + return 'linux' +} diff --git a/apps/desktop/src/shared/contracts/attention-contracts.test.ts b/apps/desktop/src/shared/contracts/attention-contracts.test.ts new file mode 100644 index 00000000..e9179ecd --- /dev/null +++ b/apps/desktop/src/shared/contracts/attention-contracts.test.ts @@ -0,0 +1,58 @@ +import { + AttentionDesktopEventSchema, + PermissionRequestSchema, + PermissionResponseInputSchema, + QuestionRequestSchema, + QuestionResponseInputSchema, +} from './attention-contracts' + +describe('attention contracts', () => { + it('validates permission and multi-question lifecycles', () => { + const permission = PermissionRequestSchema.parse({ + id: 'p', sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', tool: 'bash', title: 'Allow bash', + detail: '{}', risk: 'high', createdAt: '2026-07-11T12:00:00.000Z', + }) + expect(permission.risk).toBe('high') + expect(PermissionResponseInputSchema.parse({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', requestId: 'p', decision: 'allow_session', + }).decision).toBe('allow_session') + const question = QuestionRequestSchema.parse({ + id: 'q', sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', classifierRunning: true, + createdAt: '2026-07-11T12:00:00.000Z', + questions: [{ + question: 'Continue?', header: 'Decision', allowMultiple: false, + options: [{ label: 'Yes', description: 'Continue work' }], + }], + }) + expect(question.questions[0]?.options[0]?.label).toBe('Yes') + expect(QuestionResponseInputSchema.parse({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', requestId: 'q', answers: ['Yes'], + }).answers).toEqual(['Yes']) + expect(QuestionResponseInputSchema.parse({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', requestId: 'q', cancelled: true, + }).cancelled).toBe(true) + expect(() => QuestionResponseInputSchema.parse({ + sessionId: 'session', runtimeId: 'runtime', generation: 1, + agentId: 'main', requestId: 'q', answers: ['Yes'], cancelled: true, + })).toThrow('Provide answers or cancel') + expect(AttentionDesktopEventSchema.parse({ + type: 'permission_requested', request: permission, + }).type).toBe('permission_requested') + expect(AttentionDesktopEventSchema.parse({ + type: 'permission_resolved', sessionId: 'session', runtimeId: 'runtime', + generation: 1, agentId: 'main', requestId: 'p', + }).type).toBe('permission_resolved') + expect(AttentionDesktopEventSchema.parse({ + type: 'question_requested', request: question, + }).type).toBe('question_requested') + expect(AttentionDesktopEventSchema.parse({ + type: 'question_resolved', sessionId: 'session', runtimeId: 'runtime', + generation: 1, agentId: 'main', requestId: 'q', + }).type).toBe('question_resolved') + }) +}) diff --git a/apps/desktop/src/shared/contracts/attention-contracts.ts b/apps/desktop/src/shared/contracts/attention-contracts.ts new file mode 100644 index 00000000..a6831fac --- /dev/null +++ b/apps/desktop/src/shared/contracts/attention-contracts.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' + +export const PermissionRequestSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + tool: z.string().min(1), + title: z.string().min(1), + detail: z.string(), + risk: z.enum(['low', 'medium', 'high']), + createdAt: z.string().datetime(), +}) +export type PermissionRequest = z.infer + +export const QuestionItemSchema = z.object({ + question: z.string().min(1), + header: z.string().optional(), + options: z.array(z.object({ label: z.string().min(1), description: z.string() })), + allowMultiple: z.boolean(), +}) +export const QuestionClassifierStatusSchema = z.object({ + kind: z.enum(['none', 'running', 'failed', 'completed']), + elapsedMs: z.number().int().nonnegative().optional(), + reason: z.string().optional(), + answers: z.array(z.string()).optional(), +}) +export const QuestionRequestSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + questions: z.array(QuestionItemSchema).min(1).max(20), + classifierRunning: z.boolean(), + classifierStatus: QuestionClassifierStatusSchema.optional(), + createdAt: z.string().datetime(), +}) +export type QuestionRequest = z.infer + +export const PlanApprovalRequestSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + planContent: z.string().min(1).max(1_000_000), + planPath: z.string().min(1).max(10_000), + createdAt: z.string().datetime(), +}) +export type PlanApprovalRequest = z.infer + +export const PermissionResponseInputSchema = z.object({ + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + requestId: z.string().min(1), + decision: z.enum(['allow_once', 'allow_session', 'deny']), +}) +export const QuestionResponseInputSchema = z.object({ + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + requestId: z.string().min(1), + answers: z.array(z.string().max(100_000)).min(1).max(20).optional(), + cancelled: z.literal(true).optional(), +}).superRefine((input, context) => { + if (Boolean(input.cancelled) === Boolean(input.answers?.length)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide answers or cancel the question, but not both', + }) + } +}) +export type PermissionResponseInput = z.infer +export type QuestionResponseInput = z.infer +export const PlanApprovalResponseInputSchema = z.object({ + sessionId: z.string().min(1), runtimeId: z.string().min(1), + generation: z.number().int().positive(), agentId: z.string().min(1), + requestId: z.string().min(1), + decision: z.enum(['approve', 'reject', 'approve_with_edits']), + editedPlan: z.string().max(1_000_000).optional(), +}).superRefine((input, context) => { + if (input.decision === 'approve_with_edits' && input.editedPlan === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'Edited plan is required' }) + } +}) +export type PlanApprovalResponseInput = z.infer + +export const AttentionDesktopEventSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('permission_requested'), request: PermissionRequestSchema }), + z.object({ + type: z.literal('permission_resolved'), + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + requestId: z.string().min(1), + }), + z.object({ type: z.literal('question_requested'), request: QuestionRequestSchema }), + z.object({ + type: z.literal('question_resolved'), + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), + agentId: z.string().min(1), + requestId: z.string().min(1), + }), + z.object({ type: z.literal('plan_approval_requested'), request: PlanApprovalRequestSchema }), + z.object({ + type: z.literal('plan_approval_resolved'), + sessionId: z.string().min(1), runtimeId: z.string().min(1), + generation: z.number().int().positive(), agentId: z.string().min(1), + requestId: z.string().min(1), + }), +]) diff --git a/apps/desktop/src/shared/contracts/contracts.test.ts b/apps/desktop/src/shared/contracts/contracts.test.ts new file mode 100644 index 00000000..d8fa2629 --- /dev/null +++ b/apps/desktop/src/shared/contracts/contracts.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + CreateSessionInputSchema, DesktopEventSchema, DesktopImageAttachmentListSchema, + SendMessageInputSchema, SessionDetailSchema, + SessionOperationInputSchema, WorkbenchBootstrapSchema, +} from './' + +describe('desktop session contracts', () => { + const now = '2026-07-11T12:00:00.000Z' + const session = { + id: 'session-1', workspaceId: 'workspace-1', title: 'Session', + model: 'gpt-5', mode: 'agent', status: 'running' as const, + createdAt: now, updatedAt: now, activeRuntimeId: 'runtime-1', + } + const runtime = { + id: 'runtime-1', sessionId: session.id, workspaceId: session.workspaceId, + generation: 1, state: 'ready' as const, rootAgent: 'main', startedAt: now, + } + + it('validates protocol v2 session and runtime catalogs', () => { + const bootstrap = WorkbenchBootstrapSchema.parse({ + protocolVersion: 2, hostStatus: 'ready', + workspaces: [{ + id: 'workspace-1', name: 'Loopal', rootUri: 'file:///loopal', kind: 'folder', + }], + sessions: [session], runtimes: [runtime], activeSessionId: session.id, + }) + expect(bootstrap.sessions).toHaveLength(1) + expect(bootstrap.runtimes).toEqual([runtime]) + expect(SessionDetailSchema.parse({ + session, conversation: [], agents: [], artifacts: [], + }).session).toEqual(session) + }) + + it('validates scoped session and runtime events', () => { + const detail = { session, conversation: [], agents: [], artifacts: [] } + const events = [ + { type: 'host_status', status: 'ready' }, + { type: 'session_updated', session }, + { type: 'runtime_updated', runtime }, + { type: 'session_detail_replaced', detail }, + { + type: 'conversation_entry', sessionId: session.id, + entry: { id: 'message-1', role: 'assistant', text: 'done', createdAt: now }, + }, + { + type: 'artifact_created', + artifact: { + id: 'artifact-1', sessionId: session.id, title: 'Report', kind: 'report', + uri: 'loopal-artifact://report', mediaType: 'text/markdown', + producerAgentId: 'agent-1', createdAt: now, + }, + }, + ] + for (const event of events) expect(DesktopEventSchema.parse(event)).toEqual(event) + }) + + it('rejects obsolete task-shaped and malformed inputs', () => { + expect(SessionOperationInputSchema.safeParse({ sessionId: '' }).success).toBe(false) + expect(SendMessageInputSchema.safeParse({ sessionId: 'x', text: ' ' }).success).toBe(false) + expect(SendMessageInputSchema.parse({ + sessionId: 'x', text: 'hello', agentId: 'worker', + })).toMatchObject({ agentId: 'worker' }) + expect(SendMessageInputSchema.safeParse({ + sessionId: 'x', text: 'hello', agentId: '', + }).success).toBe(false) + const image = { + name: 'pixel.png', mediaType: 'image/png', data: 'iVBORw==', sizeBytes: 4, + } + expect(SendMessageInputSchema.parse({ sessionId: 'x', text: '', images: [image] })) + .toMatchObject({ images: [image] }) + expect(DesktopImageAttachmentListSchema.safeParse([ + { ...image, sizeBytes: 5 }, + ]).success).toBe(false) + expect(WorkbenchBootstrapSchema.safeParse({ + protocolVersion: 1, workspaces: [], tasks: [], + }).success).toBe(false) + expect(DesktopEventSchema.safeParse({ type: 'task_updated', task: session }).success) + .toBe(false) + }) + + it('accepts only strict opaque session-directory launch inputs', () => { + const authorizationId = '5d0c638c-d44c-4f47-818b-62e6b599e31c' + expect(CreateSessionInputSchema.safeParse({ + authorizationId, launchMode: 'directory', + }).success).toBe(true) + expect(CreateSessionInputSchema.safeParse({ + authorizationId, launchMode: 'worktree', worktreeName: 'desktop-1', + }).success).toBe(true) + for (const input of [ + { authorizationId, launchMode: 'directory', cwd: '/injected' }, + { authorizationId, launchMode: 'directory', path: '/injected' }, + { authorizationId, launchMode: 'worktree', worktreeName: 'wt', cwd: '/injected' }, + { workspaceId: 'workspace' }, + { workspaceId: 'workspace', cwd: '/injected' }, + ]) expect(CreateSessionInputSchema.safeParse(input).success).toBe(false) + }) +}) diff --git a/apps/desktop/src/shared/contracts/control-contracts.test.ts b/apps/desktop/src/shared/contracts/control-contracts.test.ts new file mode 100644 index 00000000..2d8282b7 --- /dev/null +++ b/apps/desktop/src/shared/contracts/control-contracts.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + AgentControlCommandSchema, + AgentControlInputSchema, + AgentControlTargetSchema, + ThinkingConfigSchema, +} from './control-contracts' + +const target = { + sessionId: 'session', runtimeId: 'runtime', generation: 2, agentId: 'main', +} + +describe('desktop agent control contracts', () => { + it('accepts only an exact live-runtime target', () => { + expect(AgentControlTargetSchema.parse(target)).toEqual(target) + for (const invalid of [ + { ...target, sessionId: '' }, + { ...target, runtimeId: '' }, + { ...target, generation: 0 }, + { ...target, generation: 1.5 }, + { ...target, agentId: '' }, + { ...target, extra: true }, + ]) expect(AgentControlTargetSchema.safeParse(invalid).success).toBe(false) + }) + + it('validates every exposed command without exposing session hot-swap', () => { + const commands = [ + { type: 'mode', mode: 'act' }, { type: 'mode', mode: 'plan' }, + { type: 'clear' }, { type: 'compact' }, { type: 'compact', instructions: 'keep tests' }, + { type: 'model', model: 'gpt-5' }, { type: 'rewind', turnIndex: 0 }, + { type: 'thinking', config: { type: 'auto' } }, + { type: 'permission', mode: 'ask_dangerous' }, + { type: 'decision', mode: 'classifier' }, + { type: 'sandbox', policy: 'read_only' }, + { type: 'suspend' }, { type: 'unsuspend' }, + { type: 'mcp_status' }, + { type: 'mcp_reconnect', server: 'filesystem' }, + { type: 'mcp_disconnect', server: 'filesystem' }, + { type: 'background_task_kill', id: 'bg-1' }, { type: 'cron_delete', id: 'cron-1' }, + ] + for (const command of commands) { + expect(AgentControlInputSchema.safeParse({ target, command }).success).toBe(true) + } + expect(AgentControlCommandSchema.safeParse({ type: 'resume_session', sessionId: 'other' }).success) + .toBe(false) + }) + + it('bounds enums, payloads, thinking JSON, and extra fields', () => { + for (const config of [ + { type: 'auto' }, { type: 'disabled' }, { type: 'effort', level: 'max' }, + { type: 'budget', tokens: 4_096 }, + ]) expect(ThinkingConfigSchema.safeParse(config).success).toBe(true) + for (const command of [ + { type: 'mode', mode: 'agent' }, + { type: 'rewind', turnIndex: -1 }, + { type: 'thinking', config: { type: 'effort', level: 'extreme' } }, + { type: 'permission', mode: 'always' }, + { type: 'decision', mode: 'auto' }, + { type: 'sandbox', policy: 'workspace_write' }, + { type: 'clear', surprise: true }, + { type: 'goal_create', objective: 'not a Desktop control' }, + { type: 'goal_pause' }, { type: 'goal_resume' }, { type: 'goal_complete' }, + { type: 'goal_reopen' }, { type: 'goal_clear' }, + ]) expect(AgentControlCommandSchema.safeParse(command).success).toBe(false) + }) +}) diff --git a/apps/desktop/src/shared/contracts/control-contracts.ts b/apps/desktop/src/shared/contracts/control-contracts.ts new file mode 100644 index 00000000..9384f133 --- /dev/null +++ b/apps/desktop/src/shared/contracts/control-contracts.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' + +const IdentifierSchema = z.string().trim().min(1).max(512) +const ValueSchema = z.string().trim().min(1).max(4_096) + +export const AgentControlTargetSchema = z.object({ + sessionId: IdentifierSchema, + runtimeId: IdentifierSchema, + generation: z.number().int().positive(), + agentId: IdentifierSchema, +}).strict() +export type AgentControlTarget = z.infer + +export const ThinkingConfigSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('auto') }).strict(), + z.object({ type: z.literal('disabled') }).strict(), + z.object({ + type: z.literal('effort'), + level: z.enum(['low', 'medium', 'high', 'max']), + }).strict(), + z.object({ + type: z.literal('budget'), + tokens: z.number().int().positive().max(10_000_000), + }).strict(), +]) +export type ThinkingConfig = z.infer + +export const AgentControlCommandSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('mode'), mode: z.enum(['act', 'plan']) }).strict(), + z.object({ type: z.literal('clear') }).strict(), + z.object({ + type: z.literal('compact'), + instructions: ValueSchema.optional(), + }).strict(), + z.object({ type: z.literal('model'), model: ValueSchema }).strict(), + z.object({ + type: z.literal('rewind'), + turnIndex: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }).strict(), + z.object({ type: z.literal('thinking'), config: ThinkingConfigSchema }).strict(), + z.object({ + type: z.literal('permission'), + mode: z.enum(['bypass', 'ask_dangerous', 'ask_any_write']), + }).strict(), + z.object({ + type: z.literal('decision'), + mode: z.enum(['manual', 'classifier', 'agent']), + }).strict(), + z.object({ + type: z.literal('sandbox'), + policy: z.enum(['disabled', 'default_write', 'read_only']), + }).strict(), + z.object({ type: z.literal('suspend') }).strict(), + z.object({ type: z.literal('unsuspend') }).strict(), + z.object({ type: z.literal('mcp_status') }).strict(), + z.object({ type: z.literal('mcp_reconnect'), server: IdentifierSchema }).strict(), + z.object({ type: z.literal('mcp_disconnect'), server: IdentifierSchema }).strict(), + z.object({ type: z.literal('background_task_kill'), id: IdentifierSchema }).strict(), + z.object({ type: z.literal('cron_delete'), id: IdentifierSchema }).strict(), +]) +export type AgentControlCommand = z.infer + +export const AgentControlInputSchema = z.object({ + target: AgentControlTargetSchema, + command: AgentControlCommandSchema, +}).strict() +export type AgentControlInput = z.infer diff --git a/apps/desktop/src/shared/contracts/desktop-preferences-contracts.test.ts b/apps/desktop/src/shared/contracts/desktop-preferences-contracts.test.ts new file mode 100644 index 00000000..c324b75e --- /dev/null +++ b/apps/desktop/src/shared/contracts/desktop-preferences-contracts.test.ts @@ -0,0 +1,16 @@ +import { + DEFAULT_DESKTOP_PREFERENCES, + DesktopLocalePreferenceSchema, + DesktopPreferencesSchema, +} from './desktop-preferences-contracts' + +describe('desktop preference contracts', () => { + it('accepts only the supported application locale preferences', () => { + for (const locale of ['system', 'en', 'zh-CN']) { + expect(DesktopPreferencesSchema.parse({ locale })).toEqual({ locale }) + } + expect(DesktopLocalePreferenceSchema.safeParse('zh-TW').success).toBe(false) + expect(DesktopPreferencesSchema.safeParse({ locale: 'en', extra: true }).success).toBe(false) + expect(DEFAULT_DESKTOP_PREFERENCES).toEqual({ locale: 'system' }) + }) +}) diff --git a/apps/desktop/src/shared/contracts/desktop-preferences-contracts.ts b/apps/desktop/src/shared/contracts/desktop-preferences-contracts.ts new file mode 100644 index 00000000..c329d33d --- /dev/null +++ b/apps/desktop/src/shared/contracts/desktop-preferences-contracts.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +export const DesktopLocalePreferenceSchema = z.enum(['system', 'en', 'zh-CN']) +export type DesktopLocalePreference = z.infer + +export const DesktopPreferencesSchema = z.object({ + locale: DesktopLocalePreferenceSchema, +}).strict() +export type DesktopPreferences = z.infer + +export const UpdateDesktopPreferencesInputSchema = DesktopPreferencesSchema +export type UpdateDesktopPreferencesInput = z.infer< + typeof UpdateDesktopPreferencesInputSchema +> + +export const DEFAULT_DESKTOP_PREFERENCES: DesktopPreferences = { locale: 'system' } diff --git a/apps/desktop/src/shared/contracts/image-contracts.ts b/apps/desktop/src/shared/contracts/image-contracts.ts new file mode 100644 index 00000000..adb0ac6d --- /dev/null +++ b/apps/desktop/src/shared/contracts/image-contracts.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +export const DESKTOP_IMAGE_MAX_COUNT = 4 +export const DESKTOP_IMAGE_MAX_BYTES = 10 * 1024 * 1024 +export const DESKTOP_IMAGE_MAX_TOTAL_BYTES = 20 * 1024 * 1024 + +export const DesktopImageMediaTypeSchema = z.enum([ + 'image/png', 'image/jpeg', 'image/gif', 'image/webp', +]) + +const base64 = /^[A-Za-z0-9+/]+={0,2}$/ +const maxBase64Length = Math.ceil(DESKTOP_IMAGE_MAX_BYTES / 3) * 4 + +export const DesktopImageAttachmentSchema = z.object({ + name: z.string().min(1).max(255), + mediaType: DesktopImageMediaTypeSchema, + data: z.string().min(4).max(maxBase64Length).regex(base64), + sizeBytes: z.number().int().positive().max(DESKTOP_IMAGE_MAX_BYTES), +}).superRefine((image, context) => { + if (base64ByteLength(image.data) !== image.sizeBytes) { + context.addIssue({ code: 'custom', message: 'Image size does not match its data' }) + } +}) + +export const DesktopImageAttachmentListSchema = z.array(DesktopImageAttachmentSchema) + .max(DESKTOP_IMAGE_MAX_COUNT) + .superRefine((images, context) => { + const total = images.reduce((sum, image) => sum + image.sizeBytes, 0) + if (total > DESKTOP_IMAGE_MAX_TOTAL_BYTES) { + context.addIssue({ code: 'custom', message: 'Image attachments exceed the total size limit' }) + } + }) + +export type DesktopImageAttachment = z.infer + +function base64ByteLength(value: string): number { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0 + return (value.length * 3) / 4 - padding +} diff --git a/apps/desktop/src/shared/contracts/index.ts b/apps/desktop/src/shared/contracts/index.ts new file mode 100644 index 00000000..5e962b43 --- /dev/null +++ b/apps/desktop/src/shared/contracts/index.ts @@ -0,0 +1,164 @@ +import { z } from 'zod' +import { + type AgentControlInput, + type AgentControlTarget, +} from './control-contracts' +import { + AttentionDesktopEventSchema, + type PermissionResponseInput, + type PlanApprovalResponseInput, + type QuestionResponseInput, +} from './attention-contracts' +import { + type CreateSessionInput, + RuntimeSummarySchema, + SessionDetailSchema, + SessionSummarySchema, + type RuntimeSummary, + type SessionDetail, + type SessionDirectorySelection, + ConversationEntrySchema, + ArtifactSchema, +} from './session-contracts' +import { + type CreateWorktreeInput, type DirectoryListing, type FileDocument, type GitDiff, + type GitStageInput, type GitStatus, type GitUnstageInput, type ListDirectoryInput, + type ReadFileInput, type RemoveWorktreeInput, type WorkspaceSearchInput, + type WorkspaceSearchResult, type Worktree, type WriteFileInput, + WorkspaceDesktopEventSchema, +} from './workspace-contracts' +import { + type JoinMetaHubInput, + type LocalMetaHubStatus, + type MetaHubRuntimeState, + type MetaHubRuntimeTarget, + type MetaHubSettings, + type StartLocalMetaHubInput, + type UpdateMetaHubSettingsInput, +} from './metahub-contracts' +import { + type LoopalDefaultSettings, + type UpdateLoopalSettingsInput, +} from './loopal-settings-contracts' +import { + type DeleteMcpServerInput, + type McpServersResponse, + type UpsertMcpServerInput, +} from './mcp-settings-contracts' +import { + type DeleteGlobalSkillInput, + type GetSkillInput, + type PluginsResponse, + type SkillDetail, + type SkillsResponse, + type UpsertGlobalSkillInput, +} from './skill-plugin-contracts' +import { type DesktopImageAttachment } from './image-contracts' +import { + type DesktopPreferences, + type UpdateDesktopPreferencesInput, +} from './desktop-preferences-contracts' + +export * from './attention-contracts' +export * from './control-contracts' +export * from './desktop-preferences-contracts' +export * from './image-contracts' +export * from './metahub-contracts' +export * from './loopal-settings-contracts' +export * from './mcp-settings-contracts' +export * from './skill-plugin-contracts' +export * from './session-contracts' +export * from './workspace-contracts' + +export const HostStatusSchema = z.enum([ + 'stopped', 'spawning', 'alive', 'registering', 'ready', 'stopping', 'crashed', +]) +export type HostStatus = z.infer + +export const WorkspaceSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + rootUri: z.string().min(1), + kind: z.enum(['folder', 'git_worktree', 'remote', 'cloud']), +}) +export type Workspace = z.infer + +export const WorkbenchBootstrapSchema = z.object({ + protocolVersion: z.literal(2), + hostStatus: HostStatusSchema, + workspaces: z.array(WorkspaceSchema), + sessions: z.array(SessionSummarySchema), + runtimes: z.array(RuntimeSummarySchema), + activeSessionId: z.string().optional(), +}) +export type WorkbenchBootstrap = z.infer + +const CoreDesktopEventSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('host_status'), status: HostStatusSchema }), + z.object({ type: z.literal('session_updated'), session: SessionSummarySchema }), + z.object({ type: z.literal('runtime_updated'), runtime: RuntimeSummarySchema }), + z.object({ type: z.literal('session_detail_replaced'), detail: SessionDetailSchema }), + z.object({ + type: z.literal('conversation_entry'), + sessionId: z.string().min(1), + entry: ConversationEntrySchema, + }), + z.object({ type: z.literal('artifact_created'), artifact: ArtifactSchema }), +]) +export const DesktopEventSchema = z.union([ + CoreDesktopEventSchema, + WorkspaceDesktopEventSchema, + AttentionDesktopEventSchema, +]) +export type DesktopEvent = z.infer + +export interface LoopalDesktopAPI { + bootstrap(): Promise + openSession(sessionId: string): Promise + createSession(input: CreateSessionInput): Promise + selectSessionDirectory(): Promise + stopSession(sessionId: string): Promise + restartSession(sessionId: string): Promise + selectImages(): Promise + sendMessage( + sessionId: string, text: string, agentId?: string, + images?: readonly DesktopImageAttachment[], + ): Promise + interruptAgent(target: AgentControlTarget): Promise + controlAgent(input: AgentControlInput): Promise + getDesktopPreferences(): Promise + updateDesktopPreferences(input: UpdateDesktopPreferencesInput): Promise + getLoopalSettings(workspaceId: string): Promise + updateLoopalSettings(input: UpdateLoopalSettingsInput): Promise + listMcpServers(workspaceId: string): Promise + upsertMcpServer(input: UpsertMcpServerInput): Promise + deleteMcpServer(input: DeleteMcpServerInput): Promise + listSkills(workspaceId: string): Promise + getSkill(input: GetSkillInput): Promise + upsertGlobalSkill(input: UpsertGlobalSkillInput): Promise + deleteGlobalSkill(input: DeleteGlobalSkillInput): Promise + listPlugins(workspaceId: string): Promise + getMetaHubSettings(): Promise + updateMetaHubSettings(input: UpdateMetaHubSettingsInput): Promise + getMetaHubStatus(target: MetaHubRuntimeTarget): Promise + joinMetaHub(input: JoinMetaHubInput): Promise + disconnectMetaHub(target: MetaHubRuntimeTarget): Promise + getLocalMetaHubStatus(): Promise + startLocalMetaHub(input: StartLocalMetaHubInput): Promise + stopLocalMetaHub(): Promise + listDirectory(input: ListDirectoryInput): Promise + readFile(input: ReadFileInput): Promise + writeFile(input: WriteFileInput): Promise + searchWorkspace(input: WorkspaceSearchInput): Promise + gitStatus(workspaceId: string): Promise + gitDiff(input: ReadFileInput): Promise + gitStage(input: GitStageInput): Promise + gitUnstage(input: GitUnstageInput): Promise + listWorktrees(workspaceId: string): Promise + createWorktree(input: CreateWorktreeInput): Promise + removeWorktree(input: RemoveWorktreeInput): Promise + respondPermission(input: PermissionResponseInput): Promise + respondQuestion(input: QuestionResponseInput): Promise + respondPlanApproval(input: PlanApprovalResponseInput): Promise + onEvent(listener: (event: DesktopEvent) => void): () => void +} diff --git a/apps/desktop/src/shared/contracts/loopal-settings-contracts.test.ts b/apps/desktop/src/shared/contracts/loopal-settings-contracts.test.ts new file mode 100644 index 00000000..beff034e --- /dev/null +++ b/apps/desktop/src/shared/contracts/loopal-settings-contracts.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + LoopalDefaultSettingsSchema, + LoopalSettingsValuesSchema, + UpdateLoopalSettingsInputSchema, +} from './loopal-settings-contracts' + +const settings = { + model: 'claude-opus-4-8', modelRouting: { + default: '', summarization: 'claude-haiku', classification: '', refine: '', + }, permissionMode: 'ask_dangerous' as const, + decisionMode: 'classifier' as const, sandboxPolicy: 'read_only' as const, + thinking: { type: 'effort' as const, level: 'high' as const }, + maxContextTokens: 200_000, memoryEnabled: true, microcompactIdleMinutes: 30, + telemetryEnabled: false, outputStyle: 'engineer', +} +const provider = { enabled: true, baseUrl: 'https://api.example.test/v1', + apiKeyEnv: 'SAFE_API_KEY', apiKeyConfigured: true } +const projection = { + providers: { anthropic: provider, openai: { ...provider, enabled: false }, google: provider }, + openaiCompatible: [], + resolvedEntries: [{ key: 'providers.anthropic.api_key', value: '********' }], + settingSources: ['project local overrides'], +} + +describe('Loopal default settings contracts', () => { + it('accepts the public redacted settings projection', () => { + expect(LoopalDefaultSettingsSchema.parse({ + workspaceId: 'workspace', settings, configuredProviders: ['anthropic'], ...projection, + })).toEqual({ workspaceId: 'workspace', settings, configuredProviders: ['anthropic'], + ...projection }) + }) + + it('strictly rejects secrets, unknown modes, and invalid numeric bounds', () => { + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings: { ...settings, apiKey: 'secret' }, + }).success).toBe(false) + expect(LoopalSettingsValuesSchema.safeParse({ + ...settings, permissionMode: 'unrestricted', + }).success).toBe(false) + expect(LoopalSettingsValuesSchema.safeParse({ + ...settings, microcompactIdleMinutes: 1441, + }).success).toBe(false) + expect(LoopalSettingsValuesSchema.safeParse({ + ...settings, thinking: { type: 'budget', tokens: 0 }, + }).success).toBe(false) + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings, + providerUpdates: { anthropic: { apiKey: 'write-only-secret' } }, + }).success).toBe(true) + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings, + providerUpdates: { openaiCompatible: [{ + name: 'local', baseUrl: 'https://local.example.test/v1', + modelPrefix: 'local/', apiKey: 'write-only-secret', + }] }, + }).success).toBe(true) + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings, + providerUpdates: { anthropic: { apiKey: 'secret', clearApiKey: true } }, + }).success).toBe(false) + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings, + providerUpdates: { openaiCompatible: [{ + name: 'local', apiKey: 'secret', clearApiKey: true, + }] }, + }).success).toBe(false) + expect(UpdateLoopalSettingsInputSchema.safeParse({ + workspaceId: 'workspace', settings, + providerUpdates: { openai: { baseUrl: 'https://user:pass@example.test/?key=secret' } }, + }).success).toBe(false) + expect(LoopalDefaultSettingsSchema.safeParse({ + workspaceId: 'workspace', settings, configuredProviders: [], ...projection, + providers: { ...projection.providers, anthropic: { ...provider, apiKey: 'secret' } }, + }).success).toBe(false) + }) +}) diff --git a/apps/desktop/src/shared/contracts/loopal-settings-contracts.ts b/apps/desktop/src/shared/contracts/loopal-settings-contracts.ts new file mode 100644 index 00000000..a86488e3 --- /dev/null +++ b/apps/desktop/src/shared/contracts/loopal-settings-contracts.ts @@ -0,0 +1,164 @@ +import { z } from 'zod' + +const SafeTextSchema = (max: number, allowEmpty = false) => z.string().max(max).refine( + (value) => (allowEmpty || value.trim().length > 0) && !/[\u0000-\u001f\u007f]/.test(value), + 'Invalid settings text', +) +const PublicBaseUrlSchema = SafeTextSchema(2048, true).refine( + isSafePublicBaseUrl, 'Base URL must be a public http(s) URL without credentials, query, or fragment', +) + +export const LoopalThinkingSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('auto') }).strict(), + z.object({ type: z.literal('disabled') }).strict(), + z.object({ + type: z.literal('effort'), + level: z.enum(['low', 'medium', 'high', 'max']), + }).strict(), + z.object({ + type: z.literal('budget'), + tokens: z.number().int().min(1).max(4_294_967_295), + }).strict(), +]) +export type LoopalThinking = z.infer + +export const LoopalModelRoutingSchema = z.object({ + default: SafeTextSchema(256, true), + summarization: SafeTextSchema(256, true), + classification: SafeTextSchema(256, true), + refine: SafeTextSchema(256, true), +}).strict() +export type LoopalModelRouting = z.infer + +export const LoopalSettingsValuesSchema = z.object({ + model: SafeTextSchema(256), + modelRouting: LoopalModelRoutingSchema, + permissionMode: z.enum(['bypass', 'ask_dangerous', 'ask_any_write']), + decisionMode: z.enum(['manual', 'classifier', 'agent']), + sandboxPolicy: z.enum(['disabled', 'default_write', 'read_only']), + thinking: LoopalThinkingSchema, + maxContextTokens: z.number().int().min(0).max(4_294_967_295), + memoryEnabled: z.boolean(), + microcompactIdleMinutes: z.number().int().min(0).max(1440), + telemetryEnabled: z.boolean(), + outputStyle: SafeTextSchema(128, true), +}).strict() +export type LoopalSettingsValues = z.infer + +export const LoopalProviderSettingsSchema = z.object({ + enabled: z.boolean(), + baseUrl: PublicBaseUrlSchema, + apiKeyEnv: SafeTextSchema(128, true), + apiKeyConfigured: z.boolean(), +}).strict() +export type LoopalProviderSettings = z.infer + +export const LoopalBuiltInProvidersSchema = z.object({ + anthropic: LoopalProviderSettingsSchema, + openai: LoopalProviderSettingsSchema, + google: LoopalProviderSettingsSchema, +}).strict() +export type LoopalBuiltInProviders = z.infer + +export const LoopalOpenAiCompatibleSettingsSchema = z.object({ + name: SafeTextSchema(96), + baseUrl: PublicBaseUrlSchema, + apiKeyEnv: SafeTextSchema(128, true), + modelPrefix: SafeTextSchema(128, true), + apiKeyConfigured: z.boolean(), +}).strict() +export type LoopalOpenAiCompatibleSettings = z.infer< + typeof LoopalOpenAiCompatibleSettingsSchema +> + +export const LoopalResolvedSettingEntrySchema = z.object({ + key: z.string().min(1).max(512), + value: z.string().max(512), +}).strict() + +export const LoopalDefaultSettingsSchema = z.object({ + workspaceId: z.string().min(1), + settings: LoopalSettingsValuesSchema, + configuredProviders: z.array(z.string().max(128)).max(64), + providers: LoopalBuiltInProvidersSchema, + openaiCompatible: z.array(LoopalOpenAiCompatibleSettingsSchema).max(61), + resolvedEntries: z.array(LoopalResolvedSettingEntrySchema).max(1024), + settingSources: z.array(z.string().min(1).max(128)).max(32), +}).strict() +export type LoopalDefaultSettings = z.infer + +export const LoopalSettingsWorkspaceInputSchema = z.object({ + workspaceId: z.string().min(1), +}).strict() +export type LoopalSettingsWorkspaceInput = z.infer + +export const LoopalProviderUpdateSchema = z.object({ + enabled: z.boolean().optional(), + remove: z.boolean().optional(), + baseUrl: PublicBaseUrlSchema.optional(), + apiKeyEnv: SafeTextSchema(128, true).regex(/^$|^[A-Za-z_][A-Za-z0-9_]*$/).optional(), + apiKey: SafeTextSchema(8192).optional(), + clearApiKey: z.boolean().optional(), +}).strict().superRefine((value, context) => { + const changes = value.baseUrl !== undefined || value.apiKeyEnv !== undefined + || value.apiKey !== undefined || value.clearApiKey === true + if (value.remove && (value.enabled !== undefined || changes)) { + context.addIssue({ code: 'custom', message: 'Remove cannot be combined with other changes' }) + } + if (value.enabled === false && changes) { + context.addIssue({ code: 'custom', message: 'Disable cannot be combined with field changes' }) + } + if (value.apiKey !== undefined && value.clearApiKey) { + context.addIssue({ code: 'custom', message: 'API key cannot be set and cleared together' }) + } +}) +export type LoopalProviderUpdate = z.infer + +export const LoopalOpenAiCompatibleUpdateSchema = z.object({ + name: SafeTextSchema(96), + remove: z.boolean().optional(), + baseUrl: PublicBaseUrlSchema.optional(), + apiKeyEnv: SafeTextSchema(128, true).regex(/^$|^[A-Za-z_][A-Za-z0-9_]*$/).optional(), + modelPrefix: SafeTextSchema(128, true).optional(), + apiKey: SafeTextSchema(8192).optional(), + clearApiKey: z.boolean().optional(), +}).strict().superRefine((value, context) => { + const changes = value.baseUrl !== undefined || value.apiKeyEnv !== undefined + || value.modelPrefix !== undefined || value.apiKey !== undefined || value.clearApiKey === true + if (value.remove && changes) { + context.addIssue({ code: 'custom', message: 'Remove cannot include field changes' }) + } + if (value.apiKey !== undefined && value.clearApiKey) { + context.addIssue({ code: 'custom', message: 'API key cannot be set and cleared together' }) + } +}) +export type LoopalOpenAiCompatibleUpdate = z.infer< + typeof LoopalOpenAiCompatibleUpdateSchema +> + +export const LoopalProviderUpdatesSchema = z.object({ + anthropic: LoopalProviderUpdateSchema.optional(), + openai: LoopalProviderUpdateSchema.optional(), + google: LoopalProviderUpdateSchema.optional(), + openaiCompatible: z.array(LoopalOpenAiCompatibleUpdateSchema).max(61).optional(), +}).strict() +export type LoopalProviderUpdates = z.infer + +export const UpdateLoopalSettingsInputSchema = z.object({ + workspaceId: z.string().min(1), + settings: LoopalSettingsValuesSchema, + providerUpdates: LoopalProviderUpdatesSchema.optional(), +}).strict() +export type UpdateLoopalSettingsInput = z.infer + +function isSafePublicBaseUrl(value: string): boolean { + if (value === '') return true + try { + const url = new URL(value) + return (url.protocol === 'http:' || url.protocol === 'https:') + && url.username === '' && url.password === '' && url.search === '' && url.hash === '' + && !value.includes('@') && !value.includes('?') && !value.includes('#') + } catch { + return false + } +} diff --git a/apps/desktop/src/shared/contracts/mcp-settings-contracts.test.ts b/apps/desktop/src/shared/contracts/mcp-settings-contracts.test.ts new file mode 100644 index 00000000..0b061da6 --- /dev/null +++ b/apps/desktop/src/shared/contracts/mcp-settings-contracts.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + McpServersResponseSchema, UpsertMcpServerInputSchema, +} from './mcp-settings-contracts' + +const http = { + workspaceId: 'workspace', + server: { + type: 'streamable-http', name: 'remote', url: 'https://example.test/mcp', + enabled: true, timeoutMs: 30_000, sharing: 'hub-singleton', secretPatches: [], + }, +} + +describe('MCP Settings contracts', () => { + it('accepts typed stdio and public HTTP definitions', () => { + expect(UpsertMcpServerInputSchema.parse(http)).toEqual(http) + expect(UpsertMcpServerInputSchema.parse({ + workspaceId: 'workspace', server: { + type: 'stdio', name: 'local', command: 'node', args: ['server.js'], + enabled: false, timeoutMs: 100, sharing: 'per-agent', cwdIsolation: null, + secretPatches: [{ target: 'env', name: 'TOKEN', operation: 'remove' }], + }, + }).server.type).toBe('stdio') + }) + + it('rejects URL credentials, queries, invalid secret targets, and response values', () => { + for (const url of [ + 'file:///tmp/mcp', 'https://user:secret@example.test/mcp', + 'https://example.test/mcp?token=secret', 'https://example.test/mcp#secret', + ]) { + expect(() => UpsertMcpServerInputSchema.parse({ + ...http, server: { ...http.server, url }, + })).toThrow() + } + expect(() => UpsertMcpServerInputSchema.parse({ + ...http, server: { ...http.server, secretPatches: [{ + target: 'env', name: 'TOKEN', operation: 'set', value: 'secret', + }] }, + })).toThrow() + expect(() => UpsertMcpServerInputSchema.parse({ + ...http, server: { ...http.server, secretPatches: [ + { target: 'header', name: 'Authorization', operation: 'remove' }, + { target: 'header', name: 'authorization', operation: 'remove' }, + ] }, + })).toThrow() + expect(() => McpServersResponseSchema.parse({ + workspaceId: 'workspace', servers: [{ + type: 'streamable-http', name: 'remote', source: 'local', + url: 'https://example.test/mcp', enabled: true, timeoutMs: 30_000, + sharing: 'hub-singleton', headers: [{ + name: 'Authorization', configured: true, value: 'must-not-cross', + }], + }], + })).toThrow() + }) +}) diff --git a/apps/desktop/src/shared/contracts/mcp-settings-contracts.ts b/apps/desktop/src/shared/contracts/mcp-settings-contracts.ts new file mode 100644 index 00000000..e92148dc --- /dev/null +++ b/apps/desktop/src/shared/contracts/mcp-settings-contracts.ts @@ -0,0 +1,137 @@ +import { z } from 'zod' + +const IdentifierSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/) +const SafeTextSchema = (max: number, allowEmpty = false) => z.string().max(max).refine( + (value) => (allowEmpty || value.trim().length > 0) && !/[\u0000-\u001f\u007f]/.test(value), + 'Invalid MCP text', +) +const TimeoutSchema = z.number().int().min(100).max(600_000) +export const McpSharingSchema = z.enum(['hub-singleton', 'per-agent', 'spawn-tree']) + +export const McpSecretStatusSchema = z.object({ + name: z.string().min(1).max(128), + configured: z.boolean(), +}).strict() +export type McpSecretStatus = z.infer + +const SecretPatchBaseSchema = z.object({ + target: z.enum(['env', 'header']), + name: z.string().min(1).max(128).refine((name) => !name.includes('.')), +}) +const SetSecretPatchSchema = SecretPatchBaseSchema.extend({ + operation: z.literal('set'), + value: z.string().min(1).max(8192).refine((value) => !value.includes('\0')), +}).strict() +const RemoveSecretPatchSchema = SecretPatchBaseSchema.extend({ + operation: z.literal('remove'), +}).strict() +export const McpSecretPatchSchema = z.discriminatedUnion('operation', [ + SetSecretPatchSchema, RemoveSecretPatchSchema, +]) +export type McpSecretPatch = z.infer + +export const McpCwdIsolationSchema = z.object({ + arg: SafeTextSchema(128).refine((value) => value.startsWith('-')), + cacheSubdir: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/).optional(), +}).strict() +export type McpCwdIsolation = z.infer + +const CommonDefinitionShape = { + name: IdentifierSchema, + source: z.enum(['global', 'plugin', 'project', 'local', 'environment', 'cli']), + enabled: z.boolean(), + timeoutMs: TimeoutSchema, + sharing: McpSharingSchema, +} +export const StdioMcpServerDefinitionSchema = z.object({ + type: z.literal('stdio'), + ...CommonDefinitionShape, + command: SafeTextSchema(1024), + args: z.array(SafeTextSchema(4096, true)).max(128), + cwdIsolation: McpCwdIsolationSchema.nullable(), + env: z.array(McpSecretStatusSchema), +}).strict() +export const HttpMcpServerDefinitionSchema = z.object({ + type: z.literal('streamable-http'), + ...CommonDefinitionShape, + url: SafeTextSchema(2048).refine(isPublicHttpUrl), + headers: z.array(McpSecretStatusSchema), +}).strict() +export const McpServerDefinitionSchema = z.discriminatedUnion('type', [ + StdioMcpServerDefinitionSchema, HttpMcpServerDefinitionSchema, +]) +export type McpServerDefinition = z.infer + +const CommonInputShape = { + name: IdentifierSchema, + enabled: z.boolean(), + timeoutMs: TimeoutSchema, + sharing: McpSharingSchema, + secretPatches: z.array(McpSecretPatchSchema).max(256), +} +export const StdioMcpServerInputSchema = z.object({ + type: z.literal('stdio'), + ...CommonInputShape, + command: SafeTextSchema(1024), + args: z.array(SafeTextSchema(4096, true)).max(128), + cwdIsolation: McpCwdIsolationSchema.nullable(), +}).strict().superRefine((value, context) => { + validateSecretTargets(value.secretPatches, 'env', context) +}) +export const HttpMcpServerInputSchema = z.object({ + type: z.literal('streamable-http'), + ...CommonInputShape, + url: SafeTextSchema(2048).refine(isPublicHttpUrl), +}).strict().superRefine((value, context) => { + validateSecretTargets(value.secretPatches, 'header', context) + value.secretPatches.forEach((patch, index) => { + if (patch.operation === 'set' && /[\u0000-\u001f\u007f]/.test(patch.value)) { + context.addIssue({ code: 'custom', path: ['secretPatches', index], message: 'Invalid header' }) + } + }) +}) +export const McpServerInputSchema = z.union([ + StdioMcpServerInputSchema, HttpMcpServerInputSchema, +]) +export type McpServerInput = z.infer + +export const McpServersResponseSchema = z.object({ + workspaceId: z.string().min(1), + servers: z.array(McpServerDefinitionSchema), +}).strict() +export type McpServersResponse = z.infer + +export const ListMcpServersInputSchema = z.object({ workspaceId: z.string().min(1) }).strict() +export const UpsertMcpServerInputSchema = z.object({ + workspaceId: z.string().min(1), server: McpServerInputSchema, +}).strict() +export type UpsertMcpServerInput = z.infer +export const DeleteMcpServerInputSchema = z.object({ + workspaceId: z.string().min(1), name: IdentifierSchema, +}).strict() +export type DeleteMcpServerInput = z.infer + +function isPublicHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return (url.protocol === 'http:' || url.protocol === 'https:') + && !url.username && !url.password && !url.search && !url.hash + } catch { + return false + } +} + +function validateSecretTargets( + patches: readonly McpSecretPatch[], target: McpSecretPatch['target'], context: z.RefinementCtx, +): void { + const seen = new Set() + patches.forEach((patch, index) => { + const identity = target === 'header' ? patch.name.toLowerCase() : patch.name + if (patch.target !== target || seen.has(identity)) { + context.addIssue({ + code: 'custom', path: ['secretPatches', index], message: `Invalid ${target} patch`, + }) + } + seen.add(identity) + }) +} diff --git a/apps/desktop/src/shared/contracts/metahub-contracts.test.ts b/apps/desktop/src/shared/contracts/metahub-contracts.test.ts new file mode 100644 index 00000000..751e7d3d --- /dev/null +++ b/apps/desktop/src/shared/contracts/metahub-contracts.test.ts @@ -0,0 +1,58 @@ +import { + JoinMetaHubInputSchema, + LocalMetaHubStatusSchema, + MetaHubInfoSchema, + MetaHubRuntimeStateSchema, + MetaHubRuntimeTargetSchema, + MetaHubSettingsSchema, + MetaHubTopologyAgentSchema, + StartLocalMetaHubInputSchema, + UpdateMetaHubSettingsInputSchema, +} from './metahub-contracts' + +describe('MetaHub contracts', () => { + const target = { sessionId: 'session', runtimeId: 'runtime', generation: 1 } + + it('parses settings, runtime targets, join inputs, and managed coordinator defaults', () => { + expect(MetaHubSettingsSchema.parse({ + address: '', hubName: 'desktop-a', joinOnStart: false, + startLocalOnLaunch: false, tokenConfigured: false, + })).toMatchObject({ hubName: 'desktop-a' }) + expect(UpdateMetaHubSettingsInputSchema.parse({ + address: ' 127.0.0.1:9 ', hubName: ' desktop-a ', joinOnStart: true, + startLocalOnLaunch: false, token: 'secret', clearToken: false, + })).toMatchObject({ address: '127.0.0.1:9', hubName: 'desktop-a' }) + expect(MetaHubRuntimeTargetSchema.parse(target)).toEqual(target) + expect(JoinMetaHubInputSchema.parse({ ...target, address: 'meta:9' })).toMatchObject(target) + expect(StartLocalMetaHubInputSchema.parse({})).toEqual({ bindAddress: '127.0.0.1:0' }) + expect(LocalMetaHubStatusSchema.parse({ state: 'failed', error: 'port busy' })) + .toMatchObject({ state: 'failed' }) + }) + + it('parses Hub inventory, qualified topology, and connected runtime state', () => { + const hub = MetaHubInfoSchema.parse({ + name: 'hub-a', status: 'degraded', agentCount: 2, capabilities: ['desktop'], + }) + const agent = MetaHubTopologyAgentSchema.parse({ + id: 'hub-a/main', name: 'main', hub: 'hub-a', hubPath: ['hub-a'], + parentId: 'outer/root', children: ['hub-a/child'], lifecycle: 'failed', + model: 'gpt-5', error: 'stopped', + }) + expect(MetaHubRuntimeStateSchema.parse({ + state: 'connected', address: 'meta:9', hubName: 'hub-a', hubs: [hub], + topology: [agent], refreshedAt: '2026-01-01T00:00:00.000Z', + })).toMatchObject({ state: 'connected', hubs: [hub] }) + }) + + it('rejects unsafe or incomplete values', () => { + expect(() => UpdateMetaHubSettingsInputSchema.parse({ + address: 'meta:9', hubName: 'bad/name', joinOnStart: false, + startLocalOnLaunch: false, + })).toThrow("cannot contain '/'") + expect(() => MetaHubRuntimeTargetSchema.parse({ ...target, generation: 0 })).toThrow() + expect(() => JoinMetaHubInputSchema.parse({ ...target, token: '' })).toThrow() + expect(() => MetaHubTopologyAgentSchema.parse({ + id: 'x', name: 'x', hub: 'x', hubPath: [], children: [], lifecycle: 'running', + })).toThrow() + }) +}) diff --git a/apps/desktop/src/shared/contracts/metahub-contracts.ts b/apps/desktop/src/shared/contracts/metahub-contracts.ts new file mode 100644 index 00000000..5030e338 --- /dev/null +++ b/apps/desktop/src/shared/contracts/metahub-contracts.ts @@ -0,0 +1,80 @@ +import { z } from 'zod' + +export const MetaHubSettingsSchema = z.object({ + address: z.string().max(512), + hubName: z.string().min(1).max(128), + joinOnStart: z.boolean(), + startLocalOnLaunch: z.boolean(), + tokenConfigured: z.boolean(), +}) +export type MetaHubSettings = z.infer + +export const UpdateMetaHubSettingsInputSchema = z.object({ + address: z.string().trim().max(512), + hubName: z.string().trim().min(1).max(128).refine((value) => !value.includes('/'), { + message: "Hub name cannot contain '/'", + }), + joinOnStart: z.boolean(), + startLocalOnLaunch: z.boolean(), + token: z.string().max(4096).optional(), + clearToken: z.boolean().optional(), +}) +export type UpdateMetaHubSettingsInput = z.infer + +export const MetaHubRuntimeTargetSchema = z.object({ + sessionId: z.string().min(1), + runtimeId: z.string().min(1), + generation: z.number().int().positive(), +}) +export type MetaHubRuntimeTarget = z.infer + +export const MetaHubInfoSchema = z.object({ + name: z.string().min(1), + status: z.enum(['connected', 'degraded', 'disconnected']), + agentCount: z.number().int().nonnegative(), + capabilities: z.array(z.string()), +}) +export type MetaHubInfo = z.infer + +export const MetaHubTopologyAgentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + hub: z.string().min(1), + hubPath: z.array(z.string().min(1)).min(1), + parentId: z.string().optional(), + children: z.array(z.string()), + lifecycle: z.enum(['spawning', 'running', 'finished', 'failed']), + model: z.string().optional(), + error: z.string().optional(), +}) +export type MetaHubTopologyAgent = z.infer + +export const MetaHubRuntimeStateSchema = z.object({ + state: z.enum(['disconnected', 'connected', 'error']), + address: z.string().optional(), + hubName: z.string().optional(), + hubs: z.array(MetaHubInfoSchema), + topology: z.array(MetaHubTopologyAgentSchema), + error: z.string().optional(), + refreshedAt: z.string().datetime(), +}) +export type MetaHubRuntimeState = z.infer + +export const JoinMetaHubInputSchema = MetaHubRuntimeTargetSchema.extend({ + address: z.string().trim().min(1).max(512).optional(), + hubName: z.string().trim().min(1).max(128).optional(), + token: z.string().min(1).max(4096).optional(), +}) +export type JoinMetaHubInput = z.infer + +export const LocalMetaHubStatusSchema = z.object({ + state: z.enum(['stopped', 'starting', 'running', 'failed']), + address: z.string().optional(), + error: z.string().optional(), +}) +export type LocalMetaHubStatus = z.infer + +export const StartLocalMetaHubInputSchema = z.object({ + bindAddress: z.string().trim().min(1).max(512).default('127.0.0.1:0'), +}) +export type StartLocalMetaHubInput = z.infer diff --git a/apps/desktop/src/shared/contracts/metahub-identity.ts b/apps/desktop/src/shared/contracts/metahub-identity.ts new file mode 100644 index 00000000..4ab7ea24 --- /dev/null +++ b/apps/desktop/src/shared/contracts/metahub-identity.ts @@ -0,0 +1,20 @@ +import { type MetaHubRuntimeTarget } from './metahub-contracts' + +export function federationHubName( + base: string, + target: MetaHubRuntimeTarget, +): string { + const session = target.sessionId.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') + const identity = `${session.slice(0, 24) || 'session'}-g${target.generation}-${hash(target.runtimeId)}` + const prefix = base.slice(0, Math.max(1, 127 - identity.length)) + return `${prefix}-${identity}` +} + +function hash(value: string): string { + let result = 2166136261 + for (const character of value) { + result ^= character.charCodeAt(0) + result = Math.imul(result, 16777619) + } + return (result >>> 0).toString(36) +} diff --git a/apps/desktop/src/shared/contracts/session-contracts.ts b/apps/desktop/src/shared/contracts/session-contracts.ts new file mode 100644 index 00000000..8a206f80 --- /dev/null +++ b/apps/desktop/src/shared/contracts/session-contracts.ts @@ -0,0 +1,164 @@ +import { z } from 'zod' +import { + AgentTelemetrySchema, + SessionViewSchema, + ToolInvocationSchema, +} from './session-view-contracts' +import { MetaHubRuntimeStateSchema } from './metahub-contracts' +import { + DesktopImageAttachmentListSchema, +} from './image-contracts' + +export * from './session-view-contracts' + +export const SessionStatusSchema = z.enum([ + 'stopped', + 'starting', + 'running', + 'waiting', + 'failed', + 'archived', +]) +export type SessionStatus = z.infer + +export const SessionSummarySchema = z.object({ + id: z.string().min(1), + workspaceId: z.string().min(1), + title: z.string().min(1), + model: z.string().min(1), + mode: z.string().min(1), + status: SessionStatusSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + activeRuntimeId: z.string().min(1).optional(), + attention: z.enum(['permission', 'question', 'plan', 'failure', 'completed']).optional(), +}) +export type SessionSummary = z.infer + +export const RuntimeSummarySchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + workspaceId: z.string().min(1), + generation: z.number().int().positive(), + state: z.enum(['starting', 'ready', 'stopping', 'stopped', 'crashed']), + rootAgent: z.string().min(1), + startedAt: z.string().datetime().optional(), +}) +export type RuntimeSummary = z.infer + +export const RuntimeEventNoticeKindSchema = z.enum([ + 'mode_changed', + 'model_changed', + 'thinking_changed', + 'permission_mode_changed', + 'decision_mode_changed', + 'sandbox_policy_changed', + 'conversation_cleared', + 'conversation_rewound', + 'context_compacted', +]) +export const RuntimeEventNoticeSchema = z.object({ + kind: RuntimeEventNoticeKindSchema, + values: z.record(z.string(), z.union([z.string(), z.number()])).optional(), +}).strict() +export type RuntimeEventNotice = z.infer + +export const ConversationEntrySchema = z.object({ + id: z.string().min(1), + role: z.enum(['user', 'assistant', 'system', 'thinking', 'error', 'welcome']), + text: z.string(), + createdAt: z.string().datetime(), + agentId: z.string().optional(), + imageCount: z.number().int().nonnegative().optional(), + toolCalls: z.array(ToolInvocationSchema).optional(), + skill: z.object({ name: z.string(), userArgs: z.string() }).optional(), + inbox: z.object({ source: z.string(), summary: z.string().optional() }).optional(), + streaming: z.boolean().optional(), + thinkingTokens: z.number().int().nonnegative().optional(), + eventNotice: z.union([z.boolean(), RuntimeEventNoticeSchema]).optional(), +}) +export type ConversationEntry = z.infer + +export const AgentSummarySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + status: z.enum([ + 'starting', 'idle', 'running', 'waiting', 'suspended', 'completed', 'failed', + ]), + parentId: z.string().optional(), + children: z.array(z.string()).optional(), + model: z.string().optional(), + mode: z.string().optional(), + thinkingConfig: z.string().optional(), + permissionMode: z.string().optional(), + decisionMode: z.string().optional(), + sandboxPolicy: z.string().optional(), + lastTool: z.string().optional(), + telemetry: AgentTelemetrySchema.optional(), + conversation: z.array(ConversationEntrySchema).optional(), + view: SessionViewSchema.optional(), + controllable: z.boolean().optional(), + hubPath: z.array(z.string().min(1)).optional(), + qualifiedName: z.string().min(1).optional(), + shadow: z.boolean().optional(), + error: z.string().optional(), +}) +export type AgentSummary = z.infer + +export const ArtifactSchema = z.object({ + id: z.string().min(1), + sessionId: z.string().min(1), + title: z.string().min(1), + kind: z.enum(['document', 'code', 'image', 'report', 'web_app', 'other']), + uri: z.string().min(1), + mediaType: z.string().min(1), + producerAgentId: z.string().min(1), + createdAt: z.string().datetime(), +}) +export type Artifact = z.infer + +export const SessionDetailSchema = z.object({ + session: SessionSummarySchema, + conversation: z.array(ConversationEntrySchema), + agents: z.array(AgentSummarySchema), + artifacts: z.array(ArtifactSchema), + view: SessionViewSchema.optional(), + metaHub: MetaHubRuntimeStateSchema.optional(), +}) +export type SessionDetail = z.infer + +export const SessionOperationInputSchema = z.object({ sessionId: z.string().min(1) }) +export const SessionDirectorySelectionSchema = z.object({ + authorizationId: z.string().uuid(), + path: z.string().min(1), + name: z.string().min(1), + git: z.object({ + root: z.string().min(1), branch: z.string().min(1).optional(), dirty: z.boolean(), + }).strict().optional(), + suggestedWorktreeName: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u), +}).strict() +export type SessionDirectorySelection = z.infer + +const WorktreeNameSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u) +export const CreateSessionInputSchema = z.discriminatedUnion('launchMode', [ + z.object({ + authorizationId: z.string().uuid(), + launchMode: z.literal('directory'), + worktreeName: z.never().optional(), + }).strict(), + z.object({ + authorizationId: z.string().uuid(), + launchMode: z.literal('worktree'), + worktreeName: WorktreeNameSchema, + }).strict(), +]) +export const SendMessageInputSchema = z.object({ + sessionId: z.string().min(1), + text: z.string().trim().max(100_000), + agentId: z.string().min(1).max(512).optional(), + images: DesktopImageAttachmentListSchema.optional(), +}).refine((input) => input.text.length > 0 || Boolean(input.images?.length), { + message: 'A message needs text or an image', +}) + +export type CreateSessionInput = z.infer diff --git a/apps/desktop/src/shared/contracts/session-lifecycle.test.ts b/apps/desktop/src/shared/contracts/session-lifecycle.test.ts new file mode 100644 index 00000000..539f1e7e --- /dev/null +++ b/apps/desktop/src/shared/contracts/session-lifecycle.test.ts @@ -0,0 +1,27 @@ +import { type SessionSummary } from './session-contracts' +import { canRestartSession, isSessionLive } from './session-lifecycle' + +const base: SessionSummary = { + id: 'session', workspaceId: 'workspace', title: 'Session', model: 'model', mode: 'agent', + status: 'waiting', activeRuntimeId: 'runtime', + createdAt: '2026-07-12T12:00:00.000Z', updatedAt: '2026-07-12T12:00:00.000Z', +} + +describe('session lifecycle predicates', () => { + it('requires both a live status and active runtime', () => { + for (const status of ['starting', 'running', 'waiting', 'failed'] as const) { + expect(isSessionLive({ ...base, status })).toBe(true) + } + const { activeRuntimeId: _runtime, ...inactive } = base + expect(isSessionLive(inactive)).toBe(false) + for (const status of ['stopped', 'archived'] as const) { + expect(isSessionLive({ ...base, status })).toBe(false) + } + }) + + it('only prevents archived sessions from restarting', () => { + expect(canRestartSession({ ...base, status: 'failed' })).toBe(true) + expect(canRestartSession({ ...base, status: 'stopped' })).toBe(true) + expect(canRestartSession({ ...base, status: 'archived' })).toBe(false) + }) +}) diff --git a/apps/desktop/src/shared/contracts/session-lifecycle.ts b/apps/desktop/src/shared/contracts/session-lifecycle.ts new file mode 100644 index 00000000..eaf26294 --- /dev/null +++ b/apps/desktop/src/shared/contracts/session-lifecycle.ts @@ -0,0 +1,13 @@ +import { type SessionSummary } from './session-contracts' + +const LIVE_STATUSES = new Set([ + 'starting', 'running', 'waiting', 'failed', +]) + +export function isSessionLive(session: SessionSummary): boolean { + return Boolean(session.activeRuntimeId) && LIVE_STATUSES.has(session.status) +} + +export function canRestartSession(session: SessionSummary): boolean { + return session.status !== 'archived' +} diff --git a/apps/desktop/src/shared/contracts/session-view-contracts.ts b/apps/desktop/src/shared/contracts/session-view-contracts.ts new file mode 100644 index 00000000..139462db --- /dev/null +++ b/apps/desktop/src/shared/contracts/session-view-contracts.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' + +export const ToolInvocationSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + summary: z.string(), + status: z.enum(['pending', 'running', 'succeeded', 'failed', 'stale', 'cancelled']), + input: z.unknown().optional(), + output: z.string().optional(), + progress: z.string().optional(), + detail: z.string().optional(), + durationMs: z.number().nonnegative().optional(), + batchId: z.string().optional(), +}) +export type ToolInvocation = z.infer + +export const AgentTelemetrySchema = z.object({ + turnCount: z.number().int().nonnegative(), + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + cacheCreationTokens: z.number().int().nonnegative(), + cacheReadTokens: z.number().int().nonnegative(), + thinkingTokens: z.number().int().nonnegative(), + contextWindow: z.number().int().nonnegative(), + toolsInFlight: z.number().int().nonnegative(), + toolCount: z.number().int().nonnegative(), +}) +export type AgentTelemetry = z.infer + +export const TaskSummarySchema = z.object({ + id: z.string().min(1), + subject: z.string().min(1), + description: z.string(), + activeForm: z.string().optional(), + status: z.enum(['pending', 'in_progress', 'completed']), + blockedBy: z.array(z.string()), + blocks: z.array(z.string()), +}) +export type TaskSummary = z.infer + +export const BackgroundTaskSchema = z.object({ + id: z.string().min(1), + description: z.string(), + status: z.enum(['running', 'completed', 'failed', 'killed']), + exitCode: z.number().int().nullable(), + output: z.string(), + createdAt: z.string().datetime(), +}) +export type BackgroundTask = z.infer + +export const CronJobSchema = z.object({ + id: z.string().min(1), + schedule: z.string(), + prompt: z.string(), + recurring: z.boolean(), + durable: z.boolean(), + nextFireAt: z.string().datetime().optional(), +}) +export type CronJob = z.infer + +export const McpServerSchema = z.object({ + name: z.string().min(1), + transport: z.string(), + source: z.string(), + status: z.string(), + toolCount: z.number().int().nonnegative(), + resourceCount: z.number().int().nonnegative(), + promptCount: z.number().int().nonnegative(), + errors: z.array(z.string()), +}) +export type McpServer = z.infer + +export const ThreadGoalSchema = z.object({ + id: z.string().min(1), + objective: z.string().min(1), + status: z.enum(['active', 'paused', 'complete', 'infeasible']), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) +export type ThreadGoal = z.infer + +export const SessionViewSchema = z.object({ + revision: z.number().int().nonnegative(), + historyTruncated: z.boolean(), + streamingText: z.string(), + streamingThinking: z.string(), + thinkingActive: z.boolean(), + retryBanner: z.string().nullable(), + compactBanner: z.string().nullable(), + tasks: z.array(TaskSummarySchema), + backgroundTasks: z.array(BackgroundTaskSchema), + crons: z.array(CronJobSchema), + mcpServers: z.array(McpServerSchema), + goal: ThreadGoalSchema.optional(), + hubDegradedSince: z.string().datetime().optional(), +}) +export type SessionView = z.infer diff --git a/apps/desktop/src/shared/contracts/skill-plugin-contracts.test.ts b/apps/desktop/src/shared/contracts/skill-plugin-contracts.test.ts new file mode 100644 index 00000000..b6f762b2 --- /dev/null +++ b/apps/desktop/src/shared/contracts/skill-plugin-contracts.test.ts @@ -0,0 +1,68 @@ +import { + PluginSummarySchema, + SkillDetailSchema, + SkillSummarySchema, + UpsertGlobalSkillInputSchema, +} from './skill-plugin-contracts' + +const revision = 'a'.repeat(64) + +describe('Skill and Plugin contracts', () => { + it('keeps legacy Unicode names displayable but not writable', () => { + expect(SkillSummarySchema.safeParse({ + name: '/代码 审计', description: 'Legacy skill', hasArguments: false, + source: 'project', scope: 'project', editable: false, effective: true, + }).success).toBe(true) + expect(UpsertGlobalSkillInputSchema.safeParse({ + workspaceId: 'workspace', name: '/代码 审计', description: '', body: 'Audit', + }).success).toBe(false) + expect(SkillSummarySchema.safeParse({ + name: '/nested/name', description: '', hasArguments: false, + source: 'global', scope: 'global', editable: false, effective: true, + }).success).toBe(false) + }) + + it('requires canonical managed names, bounded content, and exact CAS revisions', () => { + const value = { + workspaceId: 'workspace', name: '/review_code', description: 'Review code', + body: 'Review $ARGUMENTS', expectedRevision: revision, + } + expect(UpsertGlobalSkillInputSchema.parse(value)).toEqual(value) + expect(UpsertGlobalSkillInputSchema.safeParse({ ...value, extra: true }).success).toBe(false) + expect(UpsertGlobalSkillInputSchema.safeParse({ + ...value, description: 'two\nlines', + }).success).toBe(false) + expect(UpsertGlobalSkillInputSchema.safeParse({ ...value, body: '' }).success).toBe(false) + expect(UpsertGlobalSkillInputSchema.safeParse({ + ...value, body: '中'.repeat(35_000), + }).success).toBe(false) + expect(UpsertGlobalSkillInputSchema.safeParse({ + ...value, expectedRevision: 'short', + }).success).toBe(false) + const { expectedRevision: _expectedRevision, ...detail } = value + expect(SkillDetailSchema.safeParse({ + ...detail, hasArguments: true, source: 'global', scope: 'global', + editable: true, effective: true, revision, + }).success).toBe(true) + expect(SkillDetailSchema.safeParse({ + ...detail, body: '', hasArguments: false, source: 'global', scope: 'global', + editable: true, effective: true, revision, + }).success).toBe(true) + expect(SkillDetailSchema.safeParse({ + ...detail, body: '\0', hasArguments: false, source: 'global', scope: 'global', + editable: true, effective: true, revision, + }).success).toBe(true) + expect(UpsertGlobalSkillInputSchema.safeParse({ ...value, body: '\0' }).success).toBe(false) + }) + + it('accepts Unicode Plugin names but rejects path and control characters', () => { + const plugin = { + name: '质量工具', source: 'plugin:质量工具', skills: ['/代码 审计'], + mcpServers: ['review'], hookCount: 1, + hasSettings: true, hasInstructions: false, hasMemory: false, + } + expect(PluginSummarySchema.parse(plugin)).toEqual(plugin) + expect(PluginSummarySchema.safeParse({ ...plugin, name: '../escape' }).success).toBe(false) + expect(PluginSummarySchema.safeParse({ ...plugin, name: 'bad\nname' }).success).toBe(false) + }) +}) diff --git a/apps/desktop/src/shared/contracts/skill-plugin-contracts.ts b/apps/desktop/src/shared/contracts/skill-plugin-contracts.ts new file mode 100644 index 00000000..1230f9c5 --- /dev/null +++ b/apps/desktop/src/shared/contracts/skill-plugin-contracts.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' + +const WorkspaceIdSchema = z.string().min(1).max(256) +const ManagedSkillNameSchema = z.string() + .regex(/^\/[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/, 'Invalid canonical skill name') +const SkillDisplayNameSchema = z.string().min(2).max(256).refine( + (value) => value.startsWith('/') && !/[\\/\u0000-\u001f\u007f]/.test(value.slice(1)), + 'Invalid skill display name', +) +const DescriptionSchema = z.string().max(512).refine( + (value) => !/[\u0000-\u001f\u007f]/.test(value), + 'Invalid skill description', +) +const ManagedDescriptionSchema = DescriptionSchema.refine( + (value) => value.trim().length > 0, 'Skill description is required', +) +const SkillBodySchema = z.string().min(1).max(100 * 1024).refine( + (value) => !value.includes('\0'), 'Skill body must not contain NUL', +) +const LegacySkillBodySchema = z.string().max(100 * 1024) +const RevisionSchema = z.string().regex(/^[a-f0-9]{64}$/, 'Invalid skill revision') +const SourceSchema = z.string().min(1).max(256).refine( + (value) => !/[\u0000-\u001f\u007f]/.test(value), 'Invalid configuration source', +) + +export const SkillScopeSchema = z.enum(['global', 'project', 'plugin']) + +export const SkillSummarySchema = z.object({ + name: SkillDisplayNameSchema, + description: DescriptionSchema, + hasArguments: z.boolean(), + source: SourceSchema, + scope: SkillScopeSchema, + editable: z.boolean(), + effective: z.boolean(), + revision: RevisionSchema.optional(), +}).strict() +export type SkillSummary = z.infer + +export const SkillsResponseSchema = z.object({ + workspaceId: WorkspaceIdSchema, + skills: z.array(SkillSummarySchema).max(2048), +}).strict() +export type SkillsResponse = z.infer + +export const SkillDetailSchema = SkillSummarySchema.extend({ + name: ManagedSkillNameSchema, + workspaceId: WorkspaceIdSchema, + body: LegacySkillBodySchema, + revision: RevisionSchema, +}).strict() +export type SkillDetail = z.infer + +export const ListSkillPluginsInputSchema = z.object({ + workspaceId: WorkspaceIdSchema, +}).strict() + +export const GetSkillInputSchema = z.object({ + workspaceId: WorkspaceIdSchema, + name: ManagedSkillNameSchema, +}).strict() +export type GetSkillInput = z.infer + +export const UpsertGlobalSkillInputSchema = z.object({ + workspaceId: WorkspaceIdSchema, + name: ManagedSkillNameSchema, + description: ManagedDescriptionSchema, + body: SkillBodySchema, + expectedRevision: RevisionSchema.optional(), +}).strict().superRefine((value, context) => { + const serialized = `---\ndescription: ${value.description}\n---\n${value.body}` + if (new TextEncoder().encode(serialized).byteLength > 100 * 1024) { + context.addIssue({ + code: 'custom', path: ['body'], message: 'Serialized skill exceeds 100 KiB', + }) + } +}) +export type UpsertGlobalSkillInput = z.infer + +export const DeleteGlobalSkillInputSchema = z.object({ + workspaceId: WorkspaceIdSchema, + name: ManagedSkillNameSchema, + expectedRevision: RevisionSchema, +}).strict() +export type DeleteGlobalSkillInput = z.infer + +export const PluginSummarySchema = z.object({ + name: z.string().min(1).max(128).refine( + (value) => ![/[\\/]/, /[\u0000-\u001f\u007f]/].some((pattern) => pattern.test(value)), + 'Invalid Plugin name', + ), + source: SourceSchema, + skills: z.array(SkillDisplayNameSchema).max(1024), + mcpServers: z.array(z.string().min(1).max(128)).max(1024), + hookCount: z.number().int().min(0).max(65_535), + hasSettings: z.boolean(), + hasInstructions: z.boolean(), + hasMemory: z.boolean(), +}).strict() +export type PluginSummary = z.infer + +export const PluginsResponseSchema = z.object({ + workspaceId: WorkspaceIdSchema, + plugins: z.array(PluginSummarySchema).max(1024), +}).strict() +export type PluginsResponse = z.infer diff --git a/apps/desktop/src/shared/contracts/workspace-contracts.test.ts b/apps/desktop/src/shared/contracts/workspace-contracts.test.ts new file mode 100644 index 00000000..4159bb93 --- /dev/null +++ b/apps/desktop/src/shared/contracts/workspace-contracts.test.ts @@ -0,0 +1,75 @@ +import { + CreateWorktreeInputSchema, + DirectoryListingSchema, + FileDocumentSchema, + GitDiffSchema, + GitStageInputSchema, + GitStatusSchema, + RelativePathSchema, + RemoveWorktreeInputSchema, + GitUnstageInputSchema, + WorkspaceDesktopEventSchema, + WorkspaceSearchInputSchema, + WorkspaceSearchResultSchema, + WorktreeListSchema, + WriteFileInputSchema, +} from './workspace-contracts' + +describe('workspace contracts', () => { + it('accepts normalized relative paths and rejects escape forms', () => { + expect(RelativePathSchema.parse('')).toBe('') + expect(RelativePathSchema.parse('src/main.rs')).toBe('src/main.rs') + expect(() => RelativePathSchema.parse('/etc/passwd')).toThrow() + expect(() => RelativePathSchema.parse('../secret')).toThrow() + expect(() => RelativePathSchema.parse('src\\..\\secret')).toThrow() + }) + + it('validates files, listings, CAS writes, and search defaults', () => { + expect(DirectoryListingSchema.parse({ + workspaceId: 'w', path: '', entries: [{ path: 'src', name: 'src', kind: 'directory', size: 0 }], + }).entries).toHaveLength(1) + expect(FileDocumentSchema.parse({ + workspaceId: 'w', path: 'a.ts', content: '', version: 'v1', + languageId: 'typescript', readonly: false, + }).version).toBe('v1') + expect(WriteFileInputSchema.parse({ + workspaceId: 'w', path: 'a.ts', content: 'x', expectedVersion: null, + }).expectedVersion).toBeNull() + expect(WorkspaceSearchInputSchema.parse({ workspaceId: 'w', query: 'needle' }).maxResults) + .toBe(200) + expect(WorkspaceSearchResultSchema.parse({ + matches: [{ path: 'a.ts', line: 1, column: 2, preview: 'needle' }], truncated: false, + }).matches[0]?.line).toBe(1) + }) + + it('validates Git, worktree, and workspace events', () => { + expect(GitStatusSchema.parse({ + branch: null, ahead: 1, behind: 2, + changes: [{ path: 'a.ts', indexStatus: 'M', worktreeStatus: ' ' }], + }).ahead).toBe(1) + expect(GitDiffSchema.parse({ + path: 'a.ts', patch: '@@', original: 'a', modified: 'b', + }).modified).toBe('b') + expect(GitStageInputSchema.parse({ workspaceId: 'w', path: 'a.ts' }).path).toBe('a.ts') + expect(GitUnstageInputSchema.parse({ workspaceId: 'w', path: 'a.ts' }).path).toBe('a.ts') + expect(WorktreeListSchema.parse([{ + id: 'main', path: '/tmp/repo', branch: 'main', head: 'abc', + isMain: true, hasChanges: false, + }])).toHaveLength(1) + expect(CreateWorktreeInputSchema.parse({ workspaceId: 'w', name: 'feature_1' }).name) + .toBe('feature_1') + expect(RemoveWorktreeInputSchema.parse({ + workspaceId: 'w', name: 'feature', force: true, + }).force).toBe(true) + expect(() => CreateWorktreeInputSchema.parse({ workspaceId: 'w', name: '../escape' })).toThrow() + expect(WorkspaceDesktopEventSchema.parse({ + type: 'file_changed', workspaceId: 'w', path: 'a.ts', kind: 'changed', + }).type).toBe('file_changed') + expect(WorkspaceDesktopEventSchema.parse({ type: 'git_changed', workspaceId: 'w' }).type) + .toBe('git_changed') + const resync = WorkspaceDesktopEventSchema.parse({ + type: 'workspace_resync_required', workspaceId: 'w', reason: 'overflow', + }) + expect(resync.type === 'workspace_resync_required' && resync.reason).toBe('overflow') + }) +}) diff --git a/apps/desktop/src/shared/contracts/workspace-contracts.ts b/apps/desktop/src/shared/contracts/workspace-contracts.ts new file mode 100644 index 00000000..1061e735 --- /dev/null +++ b/apps/desktop/src/shared/contracts/workspace-contracts.ts @@ -0,0 +1,131 @@ +import { z } from 'zod' + +export const RelativePathSchema = z.string().max(4_096).refine( + (value) => !value.startsWith('/') && !value.split(/[\\/]/).includes('..'), + 'path must stay inside its workspace', +) + +export const FileEntrySchema = z.object({ + path: RelativePathSchema, + name: z.string().min(1).max(255), + kind: z.enum(['file', 'directory', 'symlink']), + size: z.number().int().nonnegative(), + modifiedAt: z.string().datetime().optional(), +}) +export type FileEntry = z.infer + +export const DirectoryListingSchema = z.object({ + workspaceId: z.string().min(1), + path: RelativePathSchema, + entries: z.array(FileEntrySchema).max(10_000), +}) +export type DirectoryListing = z.infer + +export const FileDocumentSchema = z.object({ + workspaceId: z.string().min(1), + path: RelativePathSchema, + content: z.string().max(10_000_000), + version: z.string().min(1), + languageId: z.string().min(1), + readonly: z.boolean(), +}) +export type FileDocument = z.infer + +export const ListDirectoryInputSchema = z.object({ + workspaceId: z.string().min(1), + path: RelativePathSchema, +}) +export type ListDirectoryInput = z.infer + +export const ReadFileInputSchema = ListDirectoryInputSchema.extend({ + path: RelativePathSchema.refine((value) => value.length > 0, 'file path is required'), +}) +export type ReadFileInput = z.infer + +export const WriteFileInputSchema = ReadFileInputSchema.extend({ + content: z.string().max(10_000_000), + expectedVersion: z.string().min(1).nullable(), +}) +export type WriteFileInput = z.infer + +export const SearchMatchSchema = z.object({ + path: RelativePathSchema, + line: z.number().int().positive(), + column: z.number().int().positive(), + preview: z.string().max(4_000), +}) +export type SearchMatch = z.infer + +export const WorkspaceSearchInputSchema = z.object({ + workspaceId: z.string().min(1), + query: z.string().min(1).max(1_000), + glob: z.string().max(1_000).optional(), + maxResults: z.number().int().positive().max(2_000).default(200), +}) +export const WorkspaceSearchResultSchema = z.object({ + matches: z.array(SearchMatchSchema).max(2_000), + truncated: z.boolean(), +}) +export type WorkspaceSearchInput = z.input +export type WorkspaceSearchResult = z.infer + +export const GitChangeSchema = z.object({ + path: RelativePathSchema, + indexStatus: z.string().max(2), + worktreeStatus: z.string().max(2), +}) +export const GitStatusSchema = z.object({ + branch: z.string().nullable(), + ahead: z.number().int().nonnegative(), + behind: z.number().int().nonnegative(), + changes: z.array(GitChangeSchema).max(100_000), +}) +export type GitChange = z.infer +export type GitStatus = z.infer + +export const WorkspaceOperationInputSchema = z.object({ workspaceId: z.string().min(1) }) +export const GitDiffInputSchema = ReadFileInputSchema +export const GitStageInputSchema = ReadFileInputSchema +export const GitUnstageInputSchema = ReadFileInputSchema +export const GitDiffSchema = z.object({ + path: RelativePathSchema, + patch: z.string().max(8 * 1024 * 1024), + original: z.string().max(8 * 1024 * 1024), + modified: z.string().max(8 * 1024 * 1024), +}) +export type GitDiff = z.infer +export type GitStageInput = z.infer +export type GitUnstageInput = z.infer + +export const WorktreeSchema = z.object({ + id: z.string().min(1), + path: z.string().min(1), + branch: z.string().nullable(), + head: z.string(), + isMain: z.boolean(), + hasChanges: z.boolean(), +}) +export const WorktreeListSchema = z.array(WorktreeSchema).max(10_000) +export const CreateWorktreeInputSchema = z.object({ + workspaceId: z.string().min(1), + name: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), +}) +export const RemoveWorktreeInputSchema = CreateWorktreeInputSchema.extend({ force: z.boolean() }) +export type Worktree = z.infer +export type CreateWorktreeInput = z.infer +export type RemoveWorktreeInput = z.infer + +export const WorkspaceDesktopEventSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('file_changed'), + workspaceId: z.string(), + path: RelativePathSchema, + kind: z.enum(['created', 'changed', 'deleted']), + }), + z.object({ type: z.literal('git_changed'), workspaceId: z.string() }), + z.object({ + type: z.literal('workspace_resync_required'), + workspaceId: z.string().min(1), + reason: z.string().min(1), + }), +]) diff --git a/apps/desktop/src/shared/global.d.ts b/apps/desktop/src/shared/global.d.ts new file mode 100644 index 00000000..a7e9c204 --- /dev/null +++ b/apps/desktop/src/shared/global.d.ts @@ -0,0 +1,9 @@ +import { type LoopalDesktopAPI } from './contracts' + +declare global { + interface Window { + readonly loopalDesktop: LoopalDesktopAPI + } +} + +export {} diff --git a/apps/desktop/src/shared/i18n/i18n-catalog-en.ts b/apps/desktop/src/shared/i18n/i18n-catalog-en.ts new file mode 100644 index 00000000..b6fe7048 --- /dev/null +++ b/apps/desktop/src/shared/i18n/i18n-catalog-en.ts @@ -0,0 +1,29 @@ +import { ACTIVITY_EN } from './messages-activity-en' +import { COMMON_EN } from './messages-common-en' +import { SHELL_EN } from './messages-shell-en' +import { SETTINGS_CORE_EN } from './messages-settings-core-en' +import { SETTINGS_LOOPAL_EN } from './messages-settings-loopal-en' +import { SETTINGS_MCP_EN } from './messages-settings-mcp-en' +import { SETTINGS_SKILLS_EN } from './messages-settings-skills-en' +import { SETTINGS_METAHUB_EN } from './messages-settings-metahub-en' +import { PANELS_EN } from './messages-panels-en' +import { CODE_EN } from './messages-code-en' +import { FEDERATION_EN } from './messages-federation-en' +import { SESSION_CREATE_EN } from './messages-session-create-en' + +export const EN_MESSAGES = { + ...COMMON_EN, + ...ACTIVITY_EN, + ...SHELL_EN, + ...SETTINGS_CORE_EN, + ...SETTINGS_LOOPAL_EN, + ...SETTINGS_MCP_EN, + ...SETTINGS_SKILLS_EN, + ...SETTINGS_METAHUB_EN, + ...PANELS_EN, + ...CODE_EN, + ...FEDERATION_EN, + ...SESSION_CREATE_EN, +} as const + +export type MessageKey = keyof typeof EN_MESSAGES diff --git a/apps/desktop/src/shared/i18n/i18n-catalog-zh-cn.ts b/apps/desktop/src/shared/i18n/i18n-catalog-zh-cn.ts new file mode 100644 index 00000000..9b9e5b81 --- /dev/null +++ b/apps/desktop/src/shared/i18n/i18n-catalog-zh-cn.ts @@ -0,0 +1,28 @@ +import { type MessageKey } from './i18n-catalog-en' +import { ACTIVITY_ZH_CN } from './messages-activity-zh-cn' +import { COMMON_ZH_CN } from './messages-common-zh-cn' +import { SHELL_ZH_CN } from './messages-shell-zh-cn' +import { SETTINGS_CORE_ZH_CN } from './messages-settings-core-zh-cn' +import { SETTINGS_LOOPAL_ZH_CN } from './messages-settings-loopal-zh-cn' +import { SETTINGS_MCP_ZH_CN } from './messages-settings-mcp-zh-cn' +import { SETTINGS_SKILLS_ZH_CN } from './messages-settings-skills-zh-cn' +import { SETTINGS_METAHUB_ZH_CN } from './messages-settings-metahub-zh-cn' +import { PANELS_ZH_CN } from './messages-panels-zh-cn' +import { CODE_ZH_CN } from './messages-code-zh-cn' +import { FEDERATION_ZH_CN } from './messages-federation-zh-cn' +import { SESSION_CREATE_ZH_CN } from './messages-session-create-zh-cn' + +export const ZH_CN_MESSAGES = { + ...COMMON_ZH_CN, + ...ACTIVITY_ZH_CN, + ...SHELL_ZH_CN, + ...SETTINGS_CORE_ZH_CN, + ...SETTINGS_LOOPAL_ZH_CN, + ...SETTINGS_MCP_ZH_CN, + ...SETTINGS_SKILLS_ZH_CN, + ...SETTINGS_METAHUB_ZH_CN, + ...PANELS_ZH_CN, + ...CODE_ZH_CN, + ...FEDERATION_ZH_CN, + ...SESSION_CREATE_ZH_CN, +} as const satisfies Readonly> diff --git a/apps/desktop/src/shared/i18n/i18n.test.ts b/apps/desktop/src/shared/i18n/i18n.test.ts new file mode 100644 index 00000000..3ba6d8c4 --- /dev/null +++ b/apps/desktop/src/shared/i18n/i18n.test.ts @@ -0,0 +1,29 @@ +import { resolveLocale, translate, type MessageKey } from './' + +describe('desktop internationalization', () => { + it('resolves explicit and system locales with an English fallback', () => { + expect(resolveLocale('zh-CN', ['en-US'])).toBe('zh-CN') + expect(resolveLocale('en', ['zh-CN'])).toBe('en') + expect(resolveLocale('system', ['zh-Hans-CN', 'en-US'])).toBe('zh-CN') + expect(resolveLocale('system', ['ZH_tw'])).toBe('zh-CN') + expect(resolveLocale('system', ['fr-FR'])).toBe('en') + expect(resolveLocale('system', [])).toBe('en') + }) + + it('provides complete typed English and Chinese catalogs', () => { + const keys: MessageKey[] = [ + 'activity.conversation', 'activity.federation', 'activity.settings', + 'language.system', 'language.en', 'language.zh-CN', 'settings.language', + 'settings.languageHint', 'common.close', 'common.save', 'common.cancel', + 'common.loading', 'common.error', + 'settings.skills.navigation', 'settings.skills.editor.save', + 'settings.plugins.restart', + ] + for (const key of keys) { + expect(translate('en', key)).toBeTruthy() + expect(translate('zh-CN', key)).toBeTruthy() + } + expect(translate('zh-CN', 'settings.language')).toBe('显示语言') + expect(translate('zh-CN', 'settings.skills.navigation')).toBe('Skills 与 Plugins') + }) +}) diff --git a/apps/desktop/src/shared/i18n/index.ts b/apps/desktop/src/shared/i18n/index.ts new file mode 100644 index 00000000..07f8b839 --- /dev/null +++ b/apps/desktop/src/shared/i18n/index.ts @@ -0,0 +1,42 @@ +import { type DesktopLocalePreference } from '../contracts/desktop-preferences-contracts' +import { EN_MESSAGES, type MessageKey } from './i18n-catalog-en' +import { ZH_CN_MESSAGES } from './i18n-catalog-zh-cn' + +export { type MessageKey } from './i18n-catalog-en' + +export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const +export type SupportedLocale = typeof SUPPORTED_LOCALES[number] + +type Catalog = Readonly> +const catalogs: Readonly> = { + en: EN_MESSAGES, + 'zh-CN': ZH_CN_MESSAGES, +} + +export function resolveLocale( + preference: DesktopLocalePreference, + systemLocales: readonly string[] | string = systemLanguages(), +): SupportedLocale { + if (preference !== 'system') return preference + const values = typeof systemLocales === 'string' ? [systemLocales] : systemLocales + return values.some((value) => normalizeTag(value).startsWith('zh')) ? 'zh-CN' : 'en' +} + +export function translate( + locale: SupportedLocale, + key: MessageKey, + values: Readonly> = {}, +): string { + const message = catalogs[locale][key] ?? catalogs.en[key] ?? key + return message.replace(/\{([^}]+)\}/g, (match, name: string) => ( + Object.hasOwn(values, name) ? String(values[name]) : match + )) +} + +function systemLanguages(): readonly string[] { + return typeof navigator === 'undefined' ? [] : navigator.languages +} + +function normalizeTag(value: string): string { + return value.trim().replaceAll('_', '-').toLowerCase() +} diff --git a/apps/desktop/src/shared/i18n/messages-activity-en.ts b/apps/desktop/src/shared/i18n/messages-activity-en.ts new file mode 100644 index 00000000..b8137962 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-activity-en.ts @@ -0,0 +1,5 @@ +export const ACTIVITY_EN = { + 'activity.sessions': 'Sessions', + 'activity.workspaces': 'Workspaces', + 'activity.settings': 'Settings', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-activity-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-activity-zh-cn.ts new file mode 100644 index 00000000..aaa53472 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-activity-zh-cn.ts @@ -0,0 +1,5 @@ +export const ACTIVITY_ZH_CN = { + 'activity.sessions': '会话', + 'activity.workspaces': '工作区', + 'activity.settings': '设置', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-code-en.ts b/apps/desktop/src/shared/i18n/messages-code-en.ts new file mode 100644 index 00000000..62373d6f --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-code-en.ts @@ -0,0 +1,42 @@ +export const CODE_EN = { + 'conversation.historyTruncated': 'Earlier history is not loaded in this view.', + 'conversation.skill': 'Skill · {name}', + 'conversation.from': 'From · {source}', + 'conversation.tokens': '{count} tokens', + 'conversation.images': '{count} image attachment(s)', + 'conversation.streaming': 'Streaming', + 'conversation.role.thinking': 'Thinking', + 'conversation.role.error': 'Error', + 'conversation.role.user': 'User', + 'tool.status.pending': 'Queued', + 'tool.status.running': 'Running', + 'tool.status.succeeded': 'Completed', + 'tool.status.failed': 'Failed', + 'tool.status.stale': 'Stale', + 'tool.status.cancelled': 'Cancelled', + 'tool.input': 'Input', + 'slash.command.act': 'Switch the agent to Act mode.', + 'slash.command.plan': 'Switch the agent to Plan mode.', + 'slash.command.clear': 'Clear the agent conversation context.', + 'slash.command.compact': 'Compact context, optionally with instructions.', + 'slash.command.model': 'Change the model used by this agent.', + 'slash.command.rewind': 'Rewind to a zero-based conversation turn.', + 'slash.command.permission': 'Set the permission prompt mode.', + 'slash.command.decision': 'Set the runtime decision mode.', + 'slash.command.sandbox': 'Set the runtime sandbox policy.', + 'slash.command.suspend': 'Suspend this agent.', + 'slash.command.unsuspend': 'Resume a suspended agent.', + 'slash.command.mcp': 'Inspect or reconnect an MCP server.', + 'slash.command.help': 'Browse commands and effective Skills.', + 'slash.source.runtime': 'Runtime', + 'slash.source.skill': 'Skill', + 'slash.skillNoDescription': 'Project Skill', + 'slash.label': 'Slash commands', + 'slash.empty': 'No matching commands.', + 'slash.error.unexpectedArguments': '{command} does not accept arguments. Usage: {usage}', + 'slash.error.requiredArgument': '{command} requires an argument. Usage: {usage}', + 'slash.error.invalidValue': 'Invalid value for {command}. Usage: {usage}', + 'slash.error.valueTooLong': 'The argument for {command} is too long. Usage: {usage}', + 'slash.error.skillsUnavailable': 'Effective Skills could not be loaded.', + 'slash.error.controlWithImages': 'Remove pending images before running a Runtime command.', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-code-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-code-zh-cn.ts new file mode 100644 index 00000000..9a49a1a5 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-code-zh-cn.ts @@ -0,0 +1,42 @@ +export const CODE_ZH_CN = { + 'conversation.historyTruncated': '当前视图未加载更早的对话历史。', + 'conversation.skill': '技能 · {name}', + 'conversation.from': '来自 · {source}', + 'conversation.tokens': '{count} 个 token', + 'conversation.images': '{count} 个图片附件', + 'conversation.streaming': '正在流式输出', + 'conversation.role.thinking': '思考', + 'conversation.role.error': '错误', + 'conversation.role.user': '用户', + 'tool.status.pending': '已排队', + 'tool.status.running': '正在运行', + 'tool.status.succeeded': '已完成', + 'tool.status.failed': '失败', + 'tool.status.stale': '已过期', + 'tool.status.cancelled': '已取消', + 'tool.input': '输入', + 'slash.command.act': '将 Agent 切换到执行模式。', + 'slash.command.plan': '将 Agent 切换到规划模式。', + 'slash.command.clear': '清空 Agent 的会话上下文。', + 'slash.command.compact': '压缩上下文,可附带压缩指令。', + 'slash.command.model': '切换当前 Agent 使用的模型。', + 'slash.command.rewind': '回退到从零开始计数的会话轮次。', + 'slash.command.permission': '设置权限确认模式。', + 'slash.command.decision': '设置 Runtime 的决策模式。', + 'slash.command.sandbox': '设置 Runtime 的沙箱策略。', + 'slash.command.suspend': '挂起当前 Agent。', + 'slash.command.unsuspend': '恢复已挂起的 Agent。', + 'slash.command.mcp': '查看或重新连接 MCP 服务。', + 'slash.command.help': '浏览命令和当前生效的 Skills。', + 'slash.source.runtime': '运行时', + 'slash.source.skill': '技能', + 'slash.skillNoDescription': '项目 Skill', + 'slash.label': '斜杠命令', + 'slash.empty': '没有匹配的命令。', + 'slash.error.unexpectedArguments': '{command} 不接受参数。用法:{usage}', + 'slash.error.requiredArgument': '{command} 缺少必填参数。用法:{usage}', + 'slash.error.invalidValue': '{command} 的参数无效。用法:{usage}', + 'slash.error.valueTooLong': '{command} 的参数过长。用法:{usage}', + 'slash.error.skillsUnavailable': '无法加载当前生效的 Skills。', + 'slash.error.controlWithImages': '执行 Runtime 命令前,请先移除待发送的图片。', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-common-en.ts b/apps/desktop/src/shared/i18n/messages-common-en.ts new file mode 100644 index 00000000..eeab951c --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-common-en.ts @@ -0,0 +1,12 @@ +export const COMMON_EN = { + 'language.system': 'System default', + 'language.en': 'English', + 'language.zh-CN': 'Simplified Chinese', + 'settings.language': 'Display language', + 'settings.languageHint': 'Choose the language used by Loopal Desktop.', + 'common.close': 'Close', + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.loading': 'Loading…', + 'common.error': 'Something went wrong', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-common-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-common-zh-cn.ts new file mode 100644 index 00000000..d2494bcf --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-common-zh-cn.ts @@ -0,0 +1,12 @@ +export const COMMON_ZH_CN = { + 'language.system': '跟随系统', + 'language.en': 'English', + 'language.zh-CN': '简体中文', + 'settings.language': '显示语言', + 'settings.languageHint': '选择 Loopal Desktop 使用的界面语言。', + 'common.close': '关闭', + 'common.save': '保存', + 'common.cancel': '取消', + 'common.loading': '正在加载…', + 'common.error': '出现了问题', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-federation-en.ts b/apps/desktop/src/shared/i18n/messages-federation-en.ts new file mode 100644 index 00000000..e4d838d0 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-federation-en.ts @@ -0,0 +1,40 @@ +export const FEDERATION_EN = { + 'federation.title': 'Federation', + 'federation.subtitle': 'Coordinate work across Loopal hubs and remote Agents.', + 'federation.disconnected': 'Federation is not connected for this session.', + 'federation.disconnectedHint': 'Configure MetaHub or start a local coordinator to discover remote Agents.', + 'federation.notRunning': 'Start a Federation for your Loopal sessions.', + 'federation.notRunningHint': 'Loopal Desktop will start and remember a local MetaHub coordinator.', + 'federation.runningEmpty': 'Federation is running.', + 'federation.runningEmptyHint': 'Right-click a session in the workbench to join or leave this Federation.', + 'federation.start': 'Start Federation', + 'federation.restart': 'Restart Federation', + 'federation.refresh': 'Refresh', + 'federation.manage': 'Manage federation', + 'federation.connection': 'Federation connection', + 'federation.summary': '{hubs} hubs · {agents} Agents', + 'federation.sessions': '{count} sessions joined', + 'federation.updated': 'Updated {value}', + 'federation.hubs': 'Hubs', + 'federation.allHubs': 'All hubs', + 'federation.hubList': 'Federation hubs', + 'federation.agentMap': 'Agent routes', + 'federation.agentMapHint': 'Select an Agent to inspect its route and conversation availability.', + 'federation.agents': '{count} Agents', + 'federation.capabilities': 'Capabilities: {value}', + 'federation.noCapabilities': 'No capabilities reported', + 'federation.noAgents': 'No Agents are visible in this scope.', + 'federation.agentDetails': 'Federated Agent details', + 'federation.route': 'Route', + 'federation.parent': 'Parent', + 'federation.root': 'Root Agent', + 'federation.model': 'Model', + 'federation.lifecycle': 'Lifecycle', + 'federation.lifecycle.spawning': 'spawning', + 'federation.lifecycle.running': 'running', + 'federation.lifecycle.finished': 'finished', + 'federation.lifecycle.failed': 'failed', + 'federation.openConversation': 'Open conversation', + 'federation.conversationUnavailable': 'This topology node has no projected conversation yet.', + 'federation.error': 'Federation error: {error}', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-federation-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-federation-zh-cn.ts new file mode 100644 index 00000000..436396fe --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-federation-zh-cn.ts @@ -0,0 +1,40 @@ +export const FEDERATION_ZH_CN = { + 'federation.title': '联邦', + 'federation.subtitle': '跨 Loopal Hub 和远程 Agent 协同工作。', + 'federation.disconnected': '当前会话尚未连接联邦。', + 'federation.disconnectedHint': '配置 MetaHub 或启动本地协调器,以发现远程 Agent。', + 'federation.notRunning': '为 Loopal 会话启动联邦。', + 'federation.notRunningHint': 'Loopal Desktop 会启动并记住一个本地 MetaHub 协调器。', + 'federation.runningEmpty': '联邦正在运行。', + 'federation.runningEmptyHint': '在工作台中右键会话,即可加入或退出该联邦。', + 'federation.start': '启动联邦', + 'federation.restart': '重启联邦', + 'federation.refresh': '刷新', + 'federation.manage': '管理联邦', + 'federation.connection': '联邦连接', + 'federation.summary': '{hubs} 个 Hub · {agents} 个 Agent', + 'federation.sessions': '已加入 {count} 个会话', + 'federation.updated': '更新于 {value}', + 'federation.hubs': 'Hub', + 'federation.allHubs': '全部 Hub', + 'federation.hubList': '联邦 Hub', + 'federation.agentMap': 'Agent 路由', + 'federation.agentMapHint': '选择 Agent,查看其路由与对话可用性。', + 'federation.agents': '{count} 个 Agent', + 'federation.capabilities': '能力:{value}', + 'federation.noCapabilities': '未上报能力', + 'federation.noAgents': '当前范围内没有可见 Agent。', + 'federation.agentDetails': '联邦 Agent 详情', + 'federation.route': '路由', + 'federation.parent': '父 Agent', + 'federation.root': '根 Agent', + 'federation.model': '模型', + 'federation.lifecycle': '生命周期', + 'federation.lifecycle.spawning': '启动中', + 'federation.lifecycle.running': '运行中', + 'federation.lifecycle.finished': '已完成', + 'federation.lifecycle.failed': '失败', + 'federation.openConversation': '打开对话', + 'federation.conversationUnavailable': '该拓扑节点尚未投影出可用对话。', + 'federation.error': '联邦错误:{error}', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-panels-en.ts b/apps/desktop/src/shared/i18n/messages-panels-en.ts new file mode 100644 index 00000000..e93d03db --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-panels-en.ts @@ -0,0 +1,138 @@ +export const PANELS_EN = { + 'panels.label': 'Session panels', + 'panels.expand': 'Expand session panel', + 'panels.collapse': 'Collapse session panel', + 'panels.agents': 'Agents', + 'panels.tasks': 'Tasks', + 'panels.background': 'Background', + 'panels.scheduled': 'Scheduled', + 'panels.artifacts': 'Artifacts', + 'panels.mcp': 'MCP', + 'panels.metahub': 'MetaHub', + 'panels.diagnostics': 'Diagnostics', + 'topology.label': 'Agent topology', + 'topology.counts': '{active} active · {retained} retained', + 'topology.filter': 'Agent topology filter', + 'topology.active': 'Active', + 'topology.all': 'All', + 'topology.empty': 'No agents in this session.', + 'topology.childOf': 'child of {name}', + 'topology.root': 'root', + 'topology.retained': 'retained', + 'topology.unavailable': 'unavailable', + 'agent.controls': 'Agent controls', + 'agent.applying': 'Applying…', + 'agent.interrupt': 'Interrupt', + 'agent.suspend': 'Suspend', + 'agent.unsuspend': 'Unsuspend', + 'agent.clear': 'Clear', + 'agent.mode': 'Mode', + 'agent.agentMode': 'Agent mode', + 'agent.model': 'Model', + 'agent.agentModel': 'Agent model', + 'agent.applyModel': 'Apply agent model', + 'agent.apply': 'Apply', + 'agent.thinking': 'Thinking', + 'agent.thinkingConfig': 'Thinking configuration', + 'agent.permission': 'Permission', + 'agent.permissionMode': 'Permission mode', + 'agent.decision': 'Decision', + 'agent.decisionMode': 'Decision mode', + 'agent.sandbox': 'Sandbox', + 'agent.sandboxPolicy': 'Sandbox policy', + 'agent.compact': 'Compact', + 'agent.compactInstructions': 'Compact instructions', + 'agent.optionalInstructions': 'Optional instructions', + 'agent.rewind': 'Rewind', + 'agent.rewindTurn': 'Rewind turn index', + 'agent.observed': 'Observed: {value}', + 'agent.unsupportedMode': 'Unsupported: Agent mode (observed)', + 'agent.unavailable': 'Unavailable', + 'runtimeNotice.modeChanged': 'Agent mode changed to {value}.', + 'runtimeNotice.modelChanged': 'Model changed to {value}.', + 'runtimeNotice.thinkingChanged': 'Thinking configuration changed.', + 'runtimeNotice.permissionChanged': 'Permission mode changed to {value}.', + 'runtimeNotice.decisionChanged': 'Decision mode changed to {value}.', + 'runtimeNotice.sandboxChanged': 'Sandbox policy changed to {value}.', + 'runtimeNotice.cleared': 'Conversation cleared.', + 'runtimeNotice.rewound': 'Conversation rewound; {remaining} turns remain.', + 'runtimeNotice.compacted': 'Context compacted: {tokensBefore} → {tokensAfter} tokens.', + 'tasks.goal': 'Current objective', + 'tasks.observed': 'Reported by the Loopal runtime', + 'tasks.goalActive': 'Active', + 'tasks.goalPaused': 'Paused', + 'tasks.goalComplete': 'Complete', + 'tasks.goalInfeasible': 'Infeasible', + 'tasks.planProgress': 'Plan · {completed}/{total}', + 'tasks.blockedBy': 'Blocked by {ids}', + 'tasks.blocks': 'Blocks {ids}', + 'tasks.background': 'Background tasks', + 'tasks.killBackground': 'Kill background task {name}', + 'tasks.kill': 'Kill', + 'tasks.exitCode': 'Exit code {code}', + 'tasks.scheduled': 'Scheduled work', + 'tasks.recurring': 'Recurring', + 'tasks.oneShot': 'One shot', + 'tasks.deleteScheduled': 'Delete scheduled work {name}', + 'tasks.delete': 'Delete', + 'tasks.next': 'Next · {time}', + 'tasks.exhausted': 'Exhausted', + 'tasks.empty': 'No Agent plan or background work yet.', + 'diagnostics.hubDegraded': 'Hub degraded since {time}', + 'diagnostics.historyTruncated': 'Conversation history was truncated for this snapshot.', + 'diagnostics.agentFailed': 'Agent failed: {error}', + 'diagnostics.providerFailed': 'Provider/runtime failure: {error}', + 'diagnostics.desktopHost': 'Desktop Host', + 'diagnostics.rendererProtocol': 'Renderer protocol', + 'diagnostics.rendererSecurity': 'Renderer security', + 'diagnostics.sandboxed': 'Sandboxed', + 'diagnostics.selectedAgent': 'Selected Agent', + 'diagnostics.runtimeConfig': 'Runtime configuration', + 'diagnostics.usage': 'Usage', + 'diagnostics.turns': 'Turns', + 'diagnostics.inputTokens': 'Input tokens', + 'diagnostics.outputTokens': 'Output tokens', + 'diagnostics.cacheRead': 'Cache read', + 'diagnostics.thinkingTokens': 'Thinking tokens', + 'diagnostics.tools': 'Tools', + 'diagnostics.toolsValue': '{active} active · {total} total', + 'artifact.kind': 'Kind', + 'artifact.producer': 'Producer', + 'artifact.uri': 'URI', + 'artifact.empty': 'Artifacts produced by this session will appear here.', + 'mcp.servers': 'MCP servers · {count}', + 'mcp.refreshStatus': 'Refresh MCP status', + 'mcp.refresh': 'Refresh', + 'mcp.summary': '{tools} tools · {resources} resources · {prompts} prompts', + 'mcp.disconnectServer': 'Disconnect MCP server {name}', + 'mcp.disconnect': 'Disconnect', + 'mcp.reconnectServer': 'Reconnect MCP server {name}', + 'mcp.reconnect': 'Reconnect', + 'mcp.empty': 'No MCP servers reported.', + 'metahub.joinHint': 'Join a MetaHub to discover hubs and remote Agents.', + 'metahub.summary': '{hubs} Hubs · {agents} remote Agents', + 'metahub.manage': 'Manage MetaHub', + 'metahub.filter': 'Filter MetaHub topology', + 'metahub.agentCount': '{count} Agents', + 'attention.requests': 'Agent requests', + 'attention.risk': '{risk} risk', + 'attention.riskAgent': '{risk} risk · Agent {agent}', + 'attention.deny': 'Deny', + 'attention.allow': 'Allow', + 'attention.allowSession': 'Allow for session', + 'attention.noApprovals': 'No pending approvals.', + 'attention.question': 'Agent question', + 'attention.questionAgent': 'Agent question · {agent}', + 'attention.other': 'Other', + 'attention.otherAnswer': 'Other answer for {prompt}', + 'attention.customAnswer': 'Type a custom answer', + 'attention.submit': 'Submit answers', + 'attention.noQuestions': 'No unanswered questions.', + 'plan.kind': 'Plan approval · Agent {agent}', + 'plan.review': 'Review implementation plan', + 'plan.edit': 'Edit before approval', + 'plan.edited': 'Edited plan', + 'plan.reject': 'Reject', + 'plan.approve': 'Approve', + 'plan.approveEdits': 'Approve with edits', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-panels-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-panels-zh-cn.ts new file mode 100644 index 00000000..949e7a67 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-panels-zh-cn.ts @@ -0,0 +1,138 @@ +export const PANELS_ZH_CN = { + 'panels.label': '会话面板', + 'panels.expand': '展开会话面板', + 'panels.collapse': '折叠会话面板', + 'panels.agents': 'Agent', + 'panels.tasks': '任务', + 'panels.background': '后台任务', + 'panels.scheduled': '定时任务', + 'panels.artifacts': '产物', + 'panels.mcp': 'MCP', + 'panels.metahub': 'MetaHub', + 'panels.diagnostics': '诊断', + 'topology.label': 'Agent 拓扑', + 'topology.counts': '{active} 个活跃 · {retained} 个已保留', + 'topology.filter': 'Agent 拓扑筛选', + 'topology.active': '活跃', + 'topology.all': '全部', + 'topology.empty': '此会话中暂无 Agent。', + 'topology.childOf': '{name} 的子 Agent', + 'topology.root': '根 Agent', + 'topology.retained': '已保留', + 'topology.unavailable': '不可用', + 'agent.controls': 'Agent 控制', + 'agent.applying': '正在应用…', + 'agent.interrupt': '中断', + 'agent.suspend': '挂起', + 'agent.unsuspend': '恢复', + 'agent.clear': '清除', + 'agent.mode': '模式', + 'agent.agentMode': 'Agent 模式', + 'agent.model': '模型', + 'agent.agentModel': 'Agent 模型', + 'agent.applyModel': '应用 Agent 模型', + 'agent.apply': '应用', + 'agent.thinking': '思考', + 'agent.thinkingConfig': '思考配置', + 'agent.permission': '权限', + 'agent.permissionMode': '权限模式', + 'agent.decision': '决策', + 'agent.decisionMode': '决策模式', + 'agent.sandbox': '沙箱', + 'agent.sandboxPolicy': '沙箱策略', + 'agent.compact': '压缩上下文', + 'agent.compactInstructions': '压缩指令', + 'agent.optionalInstructions': '可选指令', + 'agent.rewind': '回退', + 'agent.rewindTurn': '回退轮次索引', + 'agent.observed': '已观测:{value}', + 'agent.unsupportedMode': '不支持:Agent 模式(已观测)', + 'agent.unavailable': '不可用', + 'runtimeNotice.modeChanged': 'Agent 模式已切换为“{value}”。', + 'runtimeNotice.modelChanged': '模型已切换为“{value}”。', + 'runtimeNotice.thinkingChanged': '思考配置已更新。', + 'runtimeNotice.permissionChanged': '权限模式已切换为“{value}”。', + 'runtimeNotice.decisionChanged': '决策模式已切换为“{value}”。', + 'runtimeNotice.sandboxChanged': '沙箱策略已切换为“{value}”。', + 'runtimeNotice.cleared': '对话已清除。', + 'runtimeNotice.rewound': '对话已回退,保留 {remaining} 轮。', + 'runtimeNotice.compacted': '上下文已压缩:{tokensBefore} → {tokensAfter} token。', + 'tasks.goal': '当前目标', + 'tasks.observed': '由 Loopal 运行时上报', + 'tasks.goalActive': '执行中', + 'tasks.goalPaused': '已暂停', + 'tasks.goalComplete': '已完成', + 'tasks.goalInfeasible': '不可执行', + 'tasks.planProgress': '计划 · {completed}/{total}', + 'tasks.blockedBy': '被 {ids} 阻塞', + 'tasks.blocks': '阻塞 {ids}', + 'tasks.background': '后台任务', + 'tasks.killBackground': '终止后台任务 {name}', + 'tasks.kill': '终止', + 'tasks.exitCode': '退出码 {code}', + 'tasks.scheduled': '定时任务', + 'tasks.recurring': '循环执行', + 'tasks.oneShot': '单次执行', + 'tasks.deleteScheduled': '删除定时任务 {name}', + 'tasks.delete': '删除', + 'tasks.next': '下次 · {time}', + 'tasks.exhausted': '已结束', + 'tasks.empty': '暂无 Agent 计划或后台任务。', + 'diagnostics.hubDegraded': 'Hub 自 {time} 起处于降级状态', + 'diagnostics.historyTruncated': '此快照的对话历史已被截断。', + 'diagnostics.agentFailed': 'Agent 失败:{error}', + 'diagnostics.providerFailed': '模型供应商/运行时失败:{error}', + 'diagnostics.desktopHost': 'Desktop Host', + 'diagnostics.rendererProtocol': '渲染器协议', + 'diagnostics.rendererSecurity': '渲染器安全', + 'diagnostics.sandboxed': '已启用沙箱', + 'diagnostics.selectedAgent': '当前 Agent', + 'diagnostics.runtimeConfig': '运行时配置', + 'diagnostics.usage': '用量', + 'diagnostics.turns': '轮次', + 'diagnostics.inputTokens': '输入 token', + 'diagnostics.outputTokens': '输出 token', + 'diagnostics.cacheRead': '缓存读取', + 'diagnostics.thinkingTokens': '思考 token', + 'diagnostics.tools': '工具', + 'diagnostics.toolsValue': '{active} 个活跃 · {total} 个总计', + 'artifact.kind': '类型', + 'artifact.producer': '生成者', + 'artifact.uri': 'URI', + 'artifact.empty': '此会话生成的产物将显示在这里。', + 'mcp.servers': 'MCP 服务器 · {count}', + 'mcp.refreshStatus': '刷新 MCP 状态', + 'mcp.refresh': '刷新', + 'mcp.summary': '{tools} 个工具 · {resources} 个资源 · {prompts} 个提示词', + 'mcp.disconnectServer': '断开 MCP 服务器 {name}', + 'mcp.disconnect': '断开', + 'mcp.reconnectServer': '重连 MCP 服务器 {name}', + 'mcp.reconnect': '重连', + 'mcp.empty': '未上报 MCP 服务器。', + 'metahub.joinHint': '加入 MetaHub 以发现 Hub 和远程 Agent。', + 'metahub.summary': '{hubs} 个 Hub · {agents} 个远程 Agent', + 'metahub.manage': '管理 MetaHub', + 'metahub.filter': '筛选 MetaHub 拓扑', + 'metahub.agentCount': '{count} 个 Agent', + 'attention.requests': 'Agent 请求', + 'attention.risk': '{risk} 风险', + 'attention.riskAgent': '{risk} 风险 · Agent {agent}', + 'attention.deny': '拒绝', + 'attention.allow': '允许', + 'attention.allowSession': '在此会话中允许', + 'attention.noApprovals': '暂无待审批请求。', + 'attention.question': 'Agent 问题', + 'attention.questionAgent': 'Agent 问题 · {agent}', + 'attention.other': '其他', + 'attention.otherAnswer': '针对“{prompt}”的其他回答', + 'attention.customAnswer': '输入自定义回答', + 'attention.submit': '提交回答', + 'attention.noQuestions': '暂无未回答问题。', + 'plan.kind': '计划审批 · Agent {agent}', + 'plan.review': '审查实施计划', + 'plan.edit': '批准前编辑', + 'plan.edited': '编辑后的计划', + 'plan.reject': '拒绝', + 'plan.approve': '批准', + 'plan.approveEdits': '批准修改后的计划', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-session-create-en.ts b/apps/desktop/src/shared/i18n/messages-session-create-en.ts new file mode 100644 index 00000000..bd0edfa4 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-session-create-en.ts @@ -0,0 +1,25 @@ +export const SESSION_CREATE_EN = { + 'session.create.title': 'New session', + 'session.create.subtitle': 'Choose where Loopal will run this session.', + 'session.create.directory': 'Working directory', + 'session.create.directoryHint': 'Loopal reads and changes files inside this directory.', + 'session.create.chooseDirectory': 'Choose directory…', + 'session.create.changeDirectory': 'Change…', + 'session.create.noDirectory': 'Choose a directory before creating the session.', + 'session.create.gitRepository': 'Git repository', + 'session.create.gitBranch': 'Branch {branch}', + 'session.create.plainDirectory': 'Regular directory', + 'session.create.launchMode': 'Session isolation', + 'session.create.direct': 'Use this directory', + 'session.create.directHint': 'Run Loopal directly in the selected directory.', + 'session.create.worktree': 'Create a Git worktree', + 'session.create.worktreeHint': 'Create an isolated branch and working tree for this session.', + 'session.create.dirtyWorktree': 'Created from the current HEAD; uncommitted changes are not copied.', + 'session.create.worktreeName': 'Worktree name', + 'session.create.worktreePlaceholder': 'desktop-task', + 'session.create.worktreeNameHint': 'Use letters, numbers, hyphens, or underscores.', + 'session.create.invalidWorktreeName': 'Enter a valid worktree name.', + 'session.create.confirm': 'Create session', + 'session.create.creating': 'Creating…', + 'session.create.error': 'Could not create the session: {message}', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-session-create-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-session-create-zh-cn.ts new file mode 100644 index 00000000..551df50f --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-session-create-zh-cn.ts @@ -0,0 +1,25 @@ +export const SESSION_CREATE_ZH_CN = { + 'session.create.title': '新建会话', + 'session.create.subtitle': '选择 Loopal 运行本次会话的位置。', + 'session.create.directory': '运行目录', + 'session.create.directoryHint': 'Loopal 将在这个目录中读取和修改文件。', + 'session.create.chooseDirectory': '选择目录…', + 'session.create.changeDirectory': '更改…', + 'session.create.noDirectory': '请先选择运行目录,再创建会话。', + 'session.create.gitRepository': 'Git 仓库', + 'session.create.gitBranch': '分支 {branch}', + 'session.create.plainDirectory': '普通目录', + 'session.create.launchMode': '会话隔离方式', + 'session.create.direct': '直接使用此目录', + 'session.create.directHint': '让 Loopal 直接在所选目录中运行。', + 'session.create.worktree': '创建 Git worktree', + 'session.create.worktreeHint': '为本次会话创建隔离的分支和工作目录。', + 'session.create.dirtyWorktree': 'Worktree 从当前 HEAD 创建,不会复制未提交的改动。', + 'session.create.worktreeName': 'Worktree 名称', + 'session.create.worktreePlaceholder': 'desktop-task', + 'session.create.worktreeNameHint': '可使用字母、数字、连字符或下划线。', + 'session.create.invalidWorktreeName': '请输入有效的 worktree 名称。', + 'session.create.confirm': '创建会话', + 'session.create.creating': '正在创建…', + 'session.create.error': '无法创建会话:{message}', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-core-en.ts b/apps/desktop/src/shared/i18n/messages-settings-core-en.ts new file mode 100644 index 00000000..e27b9240 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-core-en.ts @@ -0,0 +1,36 @@ +export const SETTINGS_CORE_EN = { + 'settings.title': 'Settings', + 'settings.close': 'Close settings', + 'settings.search': 'Search settings', + 'settings.sections': 'Settings sections', + 'settings.noResults': 'No settings sections found.', + 'settings.category.desktop': 'Desktop', + 'settings.category.loopal': 'Loopal', + 'settings.category.session': 'Session', + 'settings.category.federation': 'Federation', + 'settings.appearance': 'Desktop appearance', + 'settings.loopal.defaults.navigation': 'Defaults', + 'settings.loopal.providers.navigation': 'Model providers', + 'settings.mcp.navigation': 'MCP servers', + 'settings.metahub.navigation': 'MetaHub', + 'settings.scope.desktop': 'Desktop · User', + 'settings.scope.loopal': 'Loopal · User · ~/.loopal/settings.json', + 'settings.scope.mcp': 'MCP · Current session directory', + 'settings.scope.metahub': 'MetaHub · Application', + 'settings.scope.session': 'Session · Current session', + 'settings.panelDensity': 'Panel density', + 'settings.comfortable': 'Comfortable', + 'settings.compact': 'Compact', + 'settings.conversationFont': 'Conversation font · {size}px', + 'settings.conversationFontSize': 'Conversation font size', + 'settings.showTopology': 'Show agent topology', + 'settings.showTopologyHint': 'Show agent topology in the session panel', + 'settings.currentAgent': 'Current Agent (live)', + 'settings.session': 'Session', + 'settings.status': 'Status', + 'settings.host': 'Host', + 'settings.configureAgent': 'Configure Agent', + 'settings.settingsAgent': 'Settings agent', + 'settings.selectLive': 'Select a live session to configure its Agent.', + 'settings.runtimeMcp': 'Runtime and MCP', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-core-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-settings-core-zh-cn.ts new file mode 100644 index 00000000..9b0079f8 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-core-zh-cn.ts @@ -0,0 +1,36 @@ +export const SETTINGS_CORE_ZH_CN = { + 'settings.title': '设置', + 'settings.close': '关闭设置', + 'settings.search': '搜索设置', + 'settings.sections': '设置分类', + 'settings.noResults': '没有匹配的设置分类。', + 'settings.category.desktop': '桌面', + 'settings.category.loopal': 'Loopal', + 'settings.category.session': '会话', + 'settings.category.federation': '联邦', + 'settings.appearance': '桌面外观', + 'settings.loopal.defaults.navigation': '默认值', + 'settings.loopal.providers.navigation': '模型提供商', + 'settings.mcp.navigation': 'MCP 服务器', + 'settings.metahub.navigation': 'MetaHub', + 'settings.scope.desktop': '桌面 · 用户', + 'settings.scope.loopal': 'Loopal · 用户 · ~/.loopal/settings.json', + 'settings.scope.mcp': 'MCP · 当前会话目录', + 'settings.scope.metahub': 'MetaHub · 应用', + 'settings.scope.session': '会话 · 当前会话', + 'settings.panelDensity': '面板密度', + 'settings.comfortable': '宽松', + 'settings.compact': '紧凑', + 'settings.conversationFont': '对话字号 · {size}px', + 'settings.conversationFontSize': '对话字号', + 'settings.showTopology': '显示 Agent 拓扑', + 'settings.showTopologyHint': '在会话面板中显示 Agent 拓扑结构', + 'settings.currentAgent': '当前 Agent(实时)', + 'settings.session': '会话', + 'settings.status': '状态', + 'settings.host': '宿主', + 'settings.configureAgent': '配置 Agent', + 'settings.settingsAgent': '设置的 Agent', + 'settings.selectLive': '选择一个活跃会话以配置 Agent。', + 'settings.runtimeMcp': '运行时与 MCP', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-loopal-en.ts b/apps/desktop/src/shared/i18n/messages-settings-loopal-en.ts new file mode 100644 index 00000000..036c224f --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-loopal-en.ts @@ -0,0 +1,80 @@ +export const SETTINGS_LOOPAL_EN = { + 'settings.loopal.defaultModel': 'Default model', + 'settings.loopal.routing': 'Model routing · empty uses the default model', + 'settings.loopal.routing.conversation': 'Conversation override', + 'settings.loopal.routing.summarization': 'Summarization override', + 'settings.loopal.routing.classification': 'Classification override', + 'settings.loopal.routing.refine': 'Tool-output refine override', + 'settings.loopal.permission': 'Default permission mode', + 'settings.loopal.permission.bypass': 'Bypass', + 'settings.loopal.permission.dangerous': 'Ask for dangerous actions', + 'settings.loopal.permission.write': 'Ask for any write', + 'settings.loopal.decision': 'Default decision mode', + 'settings.loopal.decision.manual': 'Manual', + 'settings.loopal.decision.classifier': 'Classifier', + 'settings.loopal.decision.agent': 'Agent', + 'settings.loopal.sandbox': 'Default sandbox policy', + 'settings.loopal.sandbox.write': 'Default write', + 'settings.loopal.sandbox.readOnly': 'Read only', + 'settings.loopal.disabled': 'Disabled', + 'settings.loopal.thinking': 'Default thinking', + 'settings.loopal.auto': 'Auto', + 'settings.loopal.thinking.low': 'Effort · low', + 'settings.loopal.thinking.medium': 'Effort · medium', + 'settings.loopal.thinking.high': 'Effort · high', + 'settings.loopal.thinking.max': 'Effort · max', + 'settings.loopal.thinking.budget': 'Token budget', + 'settings.loopal.thinking.tokens': 'Thinking budget tokens', + 'settings.loopal.contextTokens': 'Max context tokens · 0 uses the model default', + 'settings.loopal.microcompact': 'Microcompact idle minutes · 0 disables it', + 'settings.loopal.memory': 'Enable project memory', + 'settings.loopal.telemetry': 'Enable telemetry', + 'settings.loopal.outputStyle': 'Output style', + 'settings.loopal.defaults.title': 'Loopal defaults (new/restarted Sessions)', + 'settings.loopal.defaults.storage': 'Stored by Loopal in ~/.loopal/settings.json for this user.', + 'settings.loopal.defaults.openWorkspace': 'Open a live Session to edit Loopal defaults.', + 'settings.loopal.defaults.loading': 'Loading Loopal defaults…', + 'settings.loopal.defaults.saved': 'Saved. These defaults apply to new or restarted Sessions.', + 'settings.loopal.defaults.restarted': 'Current Session restarted with the saved Loopal defaults.', + 'settings.loopal.defaults.save': 'Save Loopal defaults', + 'settings.loopal.defaults.restart': 'Restart current Session', + 'settings.loopal.providers.configured': 'Configured providers (credentials hidden): {providers}', + 'settings.loopal.providers.none': 'none', + 'settings.loopal.providers.title': 'Built-in providers', + 'settings.loopal.providers.sectionTitle': 'Model providers', + 'settings.loopal.providers.save': 'Save provider settings', + 'settings.loopal.providers.help': 'Stored API keys are write-only; configured refers to a direct key. Environment variable names are shown separately.', + 'settings.loopal.providers.enable': 'Enable {provider}', + 'settings.loopal.providers.enabled': 'Enabled', + 'settings.loopal.providers.disabled': 'Disabled', + 'settings.loopal.providers.baseUrl': '{provider} base URL', + 'settings.loopal.providers.apiKeyEnv': '{provider} API key environment', + 'settings.loopal.providers.apiKey': '{provider} API key', + 'settings.loopal.providers.default': 'Provider default', + 'settings.loopal.providers.configuredValue': 'Configured', + 'settings.loopal.providers.notConfigured': 'Not configured', + 'settings.loopal.providers.clearKey': 'Clear API key', + 'settings.loopal.providers.undoRemove': 'Undo remove', + 'settings.loopal.providers.removeOverride': 'Remove local override', + 'settings.loopal.compatible.title': 'OpenAI-compatible endpoints', + 'settings.loopal.compatible.add': 'Add endpoint', + 'settings.loopal.compatible.help': 'Custom Chat Completions endpoints can be selected by their model prefix.', + 'settings.loopal.compatible.providerName': 'Provider name', + 'settings.loopal.compatible.providerNameAria': 'Compatible provider name', + 'settings.loopal.compatible.baseUrl': 'Base URL', + 'settings.loopal.compatible.baseUrlAria': 'Compatible base URL', + 'settings.loopal.compatible.modelPrefix': 'Model prefix', + 'settings.loopal.compatible.modelPrefixAria': 'Compatible model prefix', + 'settings.loopal.compatible.apiKeyEnv': 'API key environment', + 'settings.loopal.compatible.apiKeyEnvAria': 'Compatible API key environment', + 'settings.loopal.compatible.apiKey': 'API key', + 'settings.loopal.compatible.apiKeyAria': 'Compatible API key', + 'settings.loopal.compatible.remove': 'Remove provider', + 'settings.loopal.advanced.title': 'Advanced resolved config', + 'settings.loopal.advanced.help': 'Read-only merged values. Sensitive fields are redacted by Loopal.', + 'settings.loopal.advanced.sources': 'Sources: {sources}', + 'settings.loopal.advanced.defaults': 'defaults', + 'settings.loopal.advanced.search': 'Search resolved config', + 'settings.loopal.advanced.table': 'Resolved Loopal config', + 'settings.loopal.advanced.empty': 'No matching settings.', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-loopal-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-settings-loopal-zh-cn.ts new file mode 100644 index 00000000..a44e1911 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-loopal-zh-cn.ts @@ -0,0 +1,80 @@ +export const SETTINGS_LOOPAL_ZH_CN = { + 'settings.loopal.defaultModel': '默认模型', + 'settings.loopal.routing': '模型路由 · 留空则使用默认模型', + 'settings.loopal.routing.conversation': '对话模型覆盖', + 'settings.loopal.routing.summarization': '摘要模型覆盖', + 'settings.loopal.routing.classification': '分类模型覆盖', + 'settings.loopal.routing.refine': '工具输出精炼模型覆盖', + 'settings.loopal.permission': '默认权限模式', + 'settings.loopal.permission.bypass': '直接执行', + 'settings.loopal.permission.dangerous': '危险操作时询问', + 'settings.loopal.permission.write': '任何写入时询问', + 'settings.loopal.decision': '默认决策模式', + 'settings.loopal.decision.manual': '手动', + 'settings.loopal.decision.classifier': '分类器', + 'settings.loopal.decision.agent': 'Agent', + 'settings.loopal.sandbox': '默认沙箱策略', + 'settings.loopal.sandbox.write': '默认可写', + 'settings.loopal.sandbox.readOnly': '只读', + 'settings.loopal.disabled': '已禁用', + 'settings.loopal.thinking': '默认思考模式', + 'settings.loopal.auto': '自动', + 'settings.loopal.thinking.low': '思考强度 · 低', + 'settings.loopal.thinking.medium': '思考强度 · 中', + 'settings.loopal.thinking.high': '思考强度 · 高', + 'settings.loopal.thinking.max': '思考强度 · 最高', + 'settings.loopal.thinking.budget': 'Token 预算', + 'settings.loopal.thinking.tokens': '思考 Token 预算', + 'settings.loopal.contextTokens': '最大上下文 Token · 0 表示使用模型默认值', + 'settings.loopal.microcompact': '微压缩空闲分钟数 · 0 表示禁用', + 'settings.loopal.memory': '启用项目记忆', + 'settings.loopal.telemetry': '启用遥测', + 'settings.loopal.outputStyle': '输出样式', + 'settings.loopal.defaults.title': 'Loopal 默认设置(新建/重启的会话)', + 'settings.loopal.defaults.storage': '由 Loopal 保存在当前用户的 ~/.loopal/settings.json 中。', + 'settings.loopal.defaults.openWorkspace': '打开一个活跃会话后可编辑 Loopal 默认设置。', + 'settings.loopal.defaults.loading': '正在加载 Loopal 默认设置…', + 'settings.loopal.defaults.saved': '已保存。这些默认设置将应用于新建或重启的会话。', + 'settings.loopal.defaults.restarted': '当前会话已使用保存的 Loopal 默认设置重启。', + 'settings.loopal.defaults.save': '保存 Loopal 默认设置', + 'settings.loopal.defaults.restart': '重启当前会话', + 'settings.loopal.providers.configured': '已配置的提供商(凭据已隐藏):{providers}', + 'settings.loopal.providers.none': '无', + 'settings.loopal.providers.title': '内置提供商', + 'settings.loopal.providers.sectionTitle': '模型提供商', + 'settings.loopal.providers.save': '保存提供商设置', + 'settings.loopal.providers.help': '已保存的 API 密钥只可写入;“已配置”表示已设置直接密钥。环境变量名会单独显示。', + 'settings.loopal.providers.enable': '启用 {provider}', + 'settings.loopal.providers.enabled': '已启用', + 'settings.loopal.providers.disabled': '已禁用', + 'settings.loopal.providers.baseUrl': '{provider} 基础 URL', + 'settings.loopal.providers.apiKeyEnv': '{provider} API 密钥环境变量', + 'settings.loopal.providers.apiKey': '{provider} API 密钥', + 'settings.loopal.providers.default': '提供商默认值', + 'settings.loopal.providers.configuredValue': '已配置', + 'settings.loopal.providers.notConfigured': '未配置', + 'settings.loopal.providers.clearKey': '清除 API 密钥', + 'settings.loopal.providers.undoRemove': '撤销移除', + 'settings.loopal.providers.removeOverride': '移除本地覆盖', + 'settings.loopal.compatible.title': 'OpenAI 兼容端点', + 'settings.loopal.compatible.add': '添加端点', + 'settings.loopal.compatible.help': '可通过模型前缀选择自定义 Chat Completions 端点。', + 'settings.loopal.compatible.providerName': '提供商名称', + 'settings.loopal.compatible.providerNameAria': '兼容提供商名称', + 'settings.loopal.compatible.baseUrl': '基础 URL', + 'settings.loopal.compatible.baseUrlAria': '兼容提供商基础 URL', + 'settings.loopal.compatible.modelPrefix': '模型前缀', + 'settings.loopal.compatible.modelPrefixAria': '兼容提供商模型前缀', + 'settings.loopal.compatible.apiKeyEnv': 'API 密钥环境变量', + 'settings.loopal.compatible.apiKeyEnvAria': '兼容提供商 API 密钥环境变量', + 'settings.loopal.compatible.apiKey': 'API 密钥', + 'settings.loopal.compatible.apiKeyAria': '兼容提供商 API 密钥', + 'settings.loopal.compatible.remove': '移除提供商', + 'settings.loopal.advanced.title': '高级解析配置', + 'settings.loopal.advanced.help': '合并后的只读配置。敏感字段由 Loopal 隐藏。', + 'settings.loopal.advanced.sources': '来源:{sources}', + 'settings.loopal.advanced.defaults': '默认值', + 'settings.loopal.advanced.search': '搜索解析配置', + 'settings.loopal.advanced.table': 'Loopal 解析配置', + 'settings.loopal.advanced.empty': '没有匹配的设置。', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-mcp-en.ts b/apps/desktop/src/shared/i18n/messages-settings-mcp-en.ts new file mode 100644 index 00000000..12b00ebc --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-mcp-en.ts @@ -0,0 +1,68 @@ +export const SETTINGS_MCP_EN = { + 'settings.mcp.title': 'Loopal MCP servers (new/restarted Sessions)', + 'settings.mcp.help': 'Typed local definitions. Secret values are write-only and never returned.', + 'settings.mcp.add': 'Add MCP server', + 'settings.mcp.openWorkspace': 'Open a live Session to manage MCP servers for its directory.', + 'settings.mcp.loading': 'Loading MCP definitions…', + 'settings.mcp.empty': 'No MCP servers configured.', + 'settings.mcp.enabled': 'Enabled', + 'settings.mcp.disabled': 'Disabled', + 'settings.mcp.edit': 'Edit', + 'settings.mcp.delete': 'Delete', + 'settings.mcp.deleteAria': 'Delete MCP server {name}', + 'settings.mcp.saved': 'Saved. Restart Sessions to load this MCP definition.', + 'settings.mcp.deleted': 'Deleted {name}. Restart Sessions to unload it.', + 'settings.mcp.transport': 'MCP transport', + 'settings.mcp.serverName': 'MCP server name', + 'settings.mcp.sharing': 'MCP sharing', + 'settings.mcp.sharing.singleton': 'Hub singleton', + 'settings.mcp.sharing.agent': 'Per Agent', + 'settings.mcp.sharing.tree': 'Spawn tree', + 'settings.mcp.timeout': 'MCP timeout milliseconds', + 'settings.mcp.enable': 'Enable MCP server', + 'settings.mcp.enableHint': 'Enabled for new or restarted Sessions', + 'settings.mcp.restrictedSecrets': 'Global, plugin, environment, or CLI secrets cannot be copied into project settings. Replace or remove every configured secret below before saving other changes.', + 'settings.mcp.command': 'MCP command', + 'settings.mcp.arguments': 'MCP arguments (one per line)', + 'settings.mcp.argumentsAria': 'MCP arguments', + 'settings.mcp.argumentsHelp': 'Command and arguments are visible configuration. An empty textarea means no arguments; otherwise every line is preserved, including blank lines. Put credentials only in environment secrets.', + 'settings.mcp.cwdIsolation': 'Use MCP cwd isolation', + 'settings.mcp.cwdIsolationHint': 'Isolate this MCP server by the Session working directory', + 'settings.mcp.cwdArgument': 'Cwd isolation argument', + 'settings.mcp.cwdCache': 'Cwd isolation cache subdirectory', + 'settings.mcp.httpUrl': 'MCP HTTP URL', + 'settings.mcp.httpHelp': 'URLs cannot contain credentials, query strings, or fragments. Put credentials only in write-only headers.', + 'settings.mcp.save': 'Save MCP server', + 'settings.mcp.secrets.env': 'Environment variables', + 'settings.mcp.secrets.headers': 'HTTP headers', + 'settings.mcp.secrets.target.env': 'env', + 'settings.mcp.secrets.target.header': 'header', + 'settings.mcp.secrets.legend': '{label} · values are write-only', + 'settings.mcp.secrets.help': 'Blank means keep the configured value. Replace or remove entries individually.', + 'settings.mcp.secrets.configured': 'configured · preserved', + 'settings.mcp.secrets.notConfigured': 'not configured', + 'settings.mcp.secrets.pending.set': 'set', + 'settings.mcp.secrets.pending.remove': 'remove', + 'settings.mcp.secrets.keep': 'Leave blank to keep', + 'settings.mcp.secrets.value': 'Secret value {name}', + 'settings.mcp.secrets.remove': 'Remove secret {name}', + 'settings.mcp.secrets.undo': 'Undo', + 'settings.mcp.secrets.removeButton': 'Remove', + 'settings.mcp.secrets.willConfigure': 'will be configured', + 'settings.mcp.secrets.newName': 'New {target} name', + 'settings.mcp.secrets.newValue': 'New {target} value', + 'settings.mcp.secrets.namePlaceholder': 'Name', + 'settings.mcp.secrets.valuePlaceholder': 'Write-only value', + 'settings.mcp.secrets.set': 'Set secret', + 'settings.mcp.runtime.title': 'MCP servers · {count}', + 'settings.mcp.runtime.refresh': 'Refresh MCP status', + 'settings.mcp.runtime.refreshButton': 'Refresh', + 'settings.mcp.runtime.tools': '{count} tools', + 'settings.mcp.runtime.resources': '{count} resources', + 'settings.mcp.runtime.prompts': '{count} prompts', + 'settings.mcp.runtime.disconnect': 'Disconnect MCP server {name}', + 'settings.mcp.runtime.disconnectButton': 'Disconnect', + 'settings.mcp.runtime.reconnect': 'Reconnect MCP server {name}', + 'settings.mcp.runtime.reconnectButton': 'Reconnect', + 'settings.mcp.runtime.empty': 'No MCP servers reported.', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-mcp-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-settings-mcp-zh-cn.ts new file mode 100644 index 00000000..d81e70a2 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-mcp-zh-cn.ts @@ -0,0 +1,68 @@ +export const SETTINGS_MCP_ZH_CN = { + 'settings.mcp.title': 'Loopal MCP 服务器(新建/重启的会话)', + 'settings.mcp.help': '类型化本地定义。敏感值只可写入,绝不会返回。', + 'settings.mcp.add': '添加 MCP 服务器', + 'settings.mcp.openWorkspace': '打开活跃会话后可管理其目录下的 MCP 服务器。', + 'settings.mcp.loading': '正在加载 MCP 定义…', + 'settings.mcp.empty': '尚未配置 MCP 服务器。', + 'settings.mcp.enabled': '已启用', + 'settings.mcp.disabled': '已禁用', + 'settings.mcp.edit': '编辑', + 'settings.mcp.delete': '删除', + 'settings.mcp.deleteAria': '删除 MCP 服务器 {name}', + 'settings.mcp.saved': '已保存。重启会话以加载此 MCP 定义。', + 'settings.mcp.deleted': '已删除 {name}。重启会话以卸载它。', + 'settings.mcp.transport': 'MCP 传输方式', + 'settings.mcp.serverName': 'MCP 服务器名称', + 'settings.mcp.sharing': 'MCP 共享方式', + 'settings.mcp.sharing.singleton': 'Hub 单例', + 'settings.mcp.sharing.agent': '每个 Agent', + 'settings.mcp.sharing.tree': '派生树', + 'settings.mcp.timeout': 'MCP 超时毫秒数', + 'settings.mcp.enable': '启用 MCP 服务器', + 'settings.mcp.enableHint': '对新建或重启的会话启用', + 'settings.mcp.restrictedSecrets': '全局、插件、环境或 CLI 敏感信息无法复制到项目设置中。请先替换或移除下方所有已配置敏感信息,再保存其他更改。', + 'settings.mcp.command': 'MCP 命令', + 'settings.mcp.arguments': 'MCP 参数(每行一个)', + 'settings.mcp.argumentsAria': 'MCP 参数', + 'settings.mcp.argumentsHelp': '命令和参数属于可见配置。文本区为空表示无参数;否则每行都会保留,包括空行。凭据只应放在环境变量密钥中。', + 'settings.mcp.cwdIsolation': '使用 MCP 当前目录隔离', + 'settings.mcp.cwdIsolationHint': '按会话运行目录隔离此 MCP 服务器', + 'settings.mcp.cwdArgument': '当前目录隔离参数', + 'settings.mcp.cwdCache': '当前目录隔离缓存子目录', + 'settings.mcp.httpUrl': 'MCP HTTP URL', + 'settings.mcp.httpHelp': 'URL 不能包含凭据、查询字符串或片段。凭据只应放在只写请求头中。', + 'settings.mcp.save': '保存 MCP 服务器', + 'settings.mcp.secrets.env': '环境变量', + 'settings.mcp.secrets.headers': 'HTTP 请求头', + 'settings.mcp.secrets.target.env': '环境变量', + 'settings.mcp.secrets.target.header': '请求头', + 'settings.mcp.secrets.legend': '{label} · 值只可写入', + 'settings.mcp.secrets.help': '留空表示保留已配置的值。可逐项替换或移除。', + 'settings.mcp.secrets.configured': '已配置 · 将保留', + 'settings.mcp.secrets.notConfigured': '未配置', + 'settings.mcp.secrets.pending.set': '将设置', + 'settings.mcp.secrets.pending.remove': '将移除', + 'settings.mcp.secrets.keep': '留空以保留', + 'settings.mcp.secrets.value': '密钥值 {name}', + 'settings.mcp.secrets.remove': '移除密钥 {name}', + 'settings.mcp.secrets.undo': '撤销', + 'settings.mcp.secrets.removeButton': '移除', + 'settings.mcp.secrets.willConfigure': '将被配置', + 'settings.mcp.secrets.newName': '新建 {target} 名称', + 'settings.mcp.secrets.newValue': '新建 {target} 值', + 'settings.mcp.secrets.namePlaceholder': '名称', + 'settings.mcp.secrets.valuePlaceholder': '只写值', + 'settings.mcp.secrets.set': '设置密钥', + 'settings.mcp.runtime.title': 'MCP 服务器 · {count}', + 'settings.mcp.runtime.refresh': '刷新 MCP 状态', + 'settings.mcp.runtime.refreshButton': '刷新', + 'settings.mcp.runtime.tools': '{count} 个工具', + 'settings.mcp.runtime.resources': '{count} 个资源', + 'settings.mcp.runtime.prompts': '{count} 个提示词', + 'settings.mcp.runtime.disconnect': '断开 MCP 服务器 {name}', + 'settings.mcp.runtime.disconnectButton': '断开', + 'settings.mcp.runtime.reconnect': '重连 MCP 服务器 {name}', + 'settings.mcp.runtime.reconnectButton': '重连', + 'settings.mcp.runtime.empty': '未报告 MCP 服务器。', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-metahub-en.ts b/apps/desktop/src/shared/i18n/messages-settings-metahub-en.ts new file mode 100644 index 00000000..79ceb0e3 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-metahub-en.ts @@ -0,0 +1,45 @@ +export const SETTINGS_METAHUB_EN = { + 'settings.metahub.title': 'MetaHub federation', + 'settings.metahub.refresh': 'Refresh', + 'settings.metahub.address': 'Coordinator address', + 'settings.metahub.addressAria': 'MetaHub address', + 'settings.metahub.hubName': 'This Hub name', + 'settings.metahub.hubNameAria': 'MetaHub hub name', + 'settings.metahub.token': 'Authentication token', + 'settings.metahub.tokenAria': 'MetaHub token', + 'settings.metahub.tokenConfigured': 'Configured · replace token', + 'settings.metahub.tokenRequired': 'Required', + 'settings.metahub.joinOnStartAria': 'Join MetaHub on session start', + 'settings.metahub.joinOnStart': 'Join this MetaHub when a session starts', + 'settings.metahub.startLocalAria': 'Start local MetaHub on launch', + 'settings.metahub.startLocal': 'Start a managed local coordinator when Loopal Desktop launches', + 'settings.metahub.clearToken': 'Clear stored token', + 'settings.metahub.join': 'Join / Reconnect', + 'settings.metahub.disconnect': 'Disconnect', + 'settings.metahub.clearFailed': 'Clear failed local MetaHub', + 'settings.metahub.stopLocal': 'Stop local MetaHub', + 'settings.metahub.restartLocal': 'Restart local & join', + 'settings.metahub.startLocalAndJoin': 'Start local & join', + 'settings.metahub.error.join': 'Select a live session before joining MetaHub', + 'settings.metahub.error.refresh': 'Select a live session to refresh MetaHub', + 'settings.metahub.error.disconnect': 'Select a live session to disconnect MetaHub', + 'settings.metahub.status.summary': '{cluster} · local coordinator {local}', + 'settings.metahub.status.noSession': 'no live session', + 'settings.metahub.status.disconnected': 'disconnected', + 'settings.metahub.status.connected': 'connected', + 'settings.metahub.status.degraded': 'degraded', + 'settings.metahub.status.error': 'error', + 'settings.metahub.status.stopped': 'stopped', + 'settings.metahub.status.starting': 'starting', + 'settings.metahub.status.running': 'running', + 'settings.metahub.status.failed': 'failed', + 'settings.metahub.topology.empty': 'Join a MetaHub to discover hubs and remote Agents.', + 'settings.metahub.topology.summary': '{hubs} Hubs · {agents} remote Agents', + 'settings.metahub.topology.manage': 'Manage MetaHub', + 'settings.metahub.topology.filter': 'Filter MetaHub topology', + 'settings.metahub.topology.agents': '{count} Agents', + 'settings.metahub.topology.lifecycle.spawning': 'spawning', + 'settings.metahub.topology.lifecycle.running': 'running', + 'settings.metahub.topology.lifecycle.finished': 'finished', + 'settings.metahub.topology.lifecycle.failed': 'failed', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-metahub-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-settings-metahub-zh-cn.ts new file mode 100644 index 00000000..38546b76 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-metahub-zh-cn.ts @@ -0,0 +1,45 @@ +export const SETTINGS_METAHUB_ZH_CN = { + 'settings.metahub.title': 'MetaHub 联邦', + 'settings.metahub.refresh': '刷新', + 'settings.metahub.address': '协调器地址', + 'settings.metahub.addressAria': 'MetaHub 地址', + 'settings.metahub.hubName': '当前 Hub 名称', + 'settings.metahub.hubNameAria': 'MetaHub Hub 名称', + 'settings.metahub.token': '身份验证 Token', + 'settings.metahub.tokenAria': 'MetaHub Token', + 'settings.metahub.tokenConfigured': '已配置 · 替换 Token', + 'settings.metahub.tokenRequired': '必填', + 'settings.metahub.joinOnStartAria': '会话启动时加入 MetaHub', + 'settings.metahub.joinOnStart': '会话启动时加入此 MetaHub', + 'settings.metahub.startLocalAria': 'Loopal Desktop 启动时启动本地 MetaHub', + 'settings.metahub.startLocal': 'Loopal Desktop 启动时启动受管理的本地协调器', + 'settings.metahub.clearToken': '清除已保存的 Token', + 'settings.metahub.join': '加入 / 重连', + 'settings.metahub.disconnect': '断开连接', + 'settings.metahub.clearFailed': '清理失败的本地 MetaHub', + 'settings.metahub.stopLocal': '停止本地 MetaHub', + 'settings.metahub.restartLocal': '重启本地 MetaHub 并加入', + 'settings.metahub.startLocalAndJoin': '启动本地 MetaHub 并加入', + 'settings.metahub.error.join': '请先选择一个活跃会话,再加入 MetaHub', + 'settings.metahub.error.refresh': '请选择活跃会话以刷新 MetaHub', + 'settings.metahub.error.disconnect': '请选择活跃会话以断开 MetaHub', + 'settings.metahub.status.summary': '{cluster} · 本地协调器 {local}', + 'settings.metahub.status.noSession': '无活跃会话', + 'settings.metahub.status.disconnected': '已断开', + 'settings.metahub.status.connected': '已连接', + 'settings.metahub.status.degraded': '部分可用', + 'settings.metahub.status.error': '错误', + 'settings.metahub.status.stopped': '已停止', + 'settings.metahub.status.starting': '正在启动', + 'settings.metahub.status.running': '运行中', + 'settings.metahub.status.failed': '失败', + 'settings.metahub.topology.empty': '加入 MetaHub 后可发现 Hub 和远程 Agent。', + 'settings.metahub.topology.summary': '{hubs} 个 Hub · {agents} 个远程 Agent', + 'settings.metahub.topology.manage': '管理 MetaHub', + 'settings.metahub.topology.filter': '筛选 MetaHub 拓扑', + 'settings.metahub.topology.agents': '{count} 个 Agent', + 'settings.metahub.topology.lifecycle.spawning': '正在派生', + 'settings.metahub.topology.lifecycle.running': '运行中', + 'settings.metahub.topology.lifecycle.finished': '已完成', + 'settings.metahub.topology.lifecycle.failed': '失败', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-skills-en.ts b/apps/desktop/src/shared/i18n/messages-settings-skills-en.ts new file mode 100644 index 00000000..35851774 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-skills-en.ts @@ -0,0 +1,48 @@ +export const SETTINGS_SKILLS_EN = { + 'settings.skills.navigation': 'Skills & Plugins', + 'settings.skills.scope': 'Loopal · User Skills and current project sources', + 'settings.skills.title': 'Skills & Plugins', + 'settings.skills.help': 'Manage global Skills and inspect what the current Session directory resolves.', + 'settings.skills.openWorkspace': 'Open a live Session to inspect its project Skills and Plugins.', + 'settings.skills.loading': 'Loading Skills and Plugins…', + 'settings.skills.global.title': 'Global Skills', + 'settings.skills.global.help': 'Stored in ~/.loopal/skills and shared by Loopal CLI, TUI, and Desktop.', + 'settings.skills.global.empty': 'No global Skills configured.', + 'settings.skills.global.create': 'New Skill', + 'settings.skills.global.edit': 'Edit {name}', + 'settings.skills.global.effective': 'Effective for this Session', + 'settings.skills.global.overridden': 'Overridden by this project', + 'settings.skills.effective.title': 'Effective Skills', + 'settings.skills.effective.help': 'The final definitions after Plugin, global, and project precedence.', + 'settings.skills.effective.empty': 'No Skills resolve for this Session directory.', + 'settings.skills.source': 'Source · {source}', + 'settings.skills.arguments': 'Accepts arguments', + 'settings.skills.noArguments': 'No arguments', + 'settings.skills.nextCall': 'Skill edits apply to the next /name invocation.', + 'settings.skills.editor.create': 'Create global Skill', + 'settings.skills.editor.edit': 'Edit global Skill', + 'settings.skills.editor.name': 'Skill name', + 'settings.skills.editor.nameHint': 'Use / followed by letters, numbers, _ or -.', + 'settings.skills.editor.description': 'Description', + 'settings.skills.editor.body': 'Prompt template', + 'settings.skills.editor.bodyHint': 'Use $ARGUMENTS where invocation arguments should appear.', + 'settings.skills.editor.save': 'Save Skill', + 'settings.skills.editor.cancel': 'Cancel', + 'settings.skills.editor.delete': 'Delete Skill', + 'settings.skills.editor.confirmDelete': 'Delete {name}? This removes the global Skill file.', + 'settings.skills.editor.confirm': 'Delete permanently', + 'settings.skills.saved': 'Saved {name}. The next invocation uses this definition.', + 'settings.skills.deleted': 'Deleted {name}.', + 'settings.plugins.title': 'Plugin configuration directories', + 'settings.plugins.help': 'Read-only summary of ~/.loopal/plugins directories loaded by Loopal.', + 'settings.plugins.empty': 'No Plugin configuration directories found.', + 'settings.plugins.skills': 'Skills · {items}', + 'settings.plugins.mcp': 'MCP servers · {items}', + 'settings.plugins.hooks': 'Hooks · {count}', + 'settings.plugins.files': 'Configuration · {items}', + 'settings.plugins.none': 'none', + 'settings.plugins.settings': 'settings.json', + 'settings.plugins.instructions': 'LOOPAL.md', + 'settings.plugins.memory': 'memory/MEMORY.md', + 'settings.plugins.restart': 'Plugin settings, MCP, hooks, instructions, and memory require a Session restart.', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-settings-skills-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-settings-skills-zh-cn.ts new file mode 100644 index 00000000..b0f4d2d6 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-settings-skills-zh-cn.ts @@ -0,0 +1,48 @@ +export const SETTINGS_SKILLS_ZH_CN = { + 'settings.skills.navigation': 'Skills 与 Plugins', + 'settings.skills.scope': 'Loopal · 用户 Skills 与当前项目来源', + 'settings.skills.title': 'Skills 与 Plugins', + 'settings.skills.help': '管理全局 Skills,并查看当前会话目录最终解析出的定义。', + 'settings.skills.openWorkspace': '打开一个活跃会话以查看其项目 Skills 和 Plugins。', + 'settings.skills.loading': '正在加载 Skills 和 Plugins…', + 'settings.skills.global.title': '全局 Skills', + 'settings.skills.global.help': '存储于 ~/.loopal/skills,由 Loopal CLI、TUI 和 Desktop 共享。', + 'settings.skills.global.empty': '尚未配置全局 Skill。', + 'settings.skills.global.create': '新建 Skill', + 'settings.skills.global.edit': '编辑 {name}', + 'settings.skills.global.effective': '对当前会话生效', + 'settings.skills.global.overridden': '被当前项目覆盖', + 'settings.skills.effective.title': '有效 Skills', + 'settings.skills.effective.help': '按 Plugin、全局和项目优先级解析后的最终定义。', + 'settings.skills.effective.empty': '当前会话目录没有解析出 Skill。', + 'settings.skills.source': '来源 · {source}', + 'settings.skills.arguments': '支持参数', + 'settings.skills.noArguments': '不支持参数', + 'settings.skills.nextCall': 'Skill 修改会在下一次 /name 调用时生效。', + 'settings.skills.editor.create': '新建全局 Skill', + 'settings.skills.editor.edit': '编辑全局 Skill', + 'settings.skills.editor.name': 'Skill 名称', + 'settings.skills.editor.nameHint': '使用 / 开头,后接字母、数字、_ 或 -。', + 'settings.skills.editor.description': '描述', + 'settings.skills.editor.body': '提示词模板', + 'settings.skills.editor.bodyHint': '用 $ARGUMENTS 标记调用参数应插入的位置。', + 'settings.skills.editor.save': '保存 Skill', + 'settings.skills.editor.cancel': '取消', + 'settings.skills.editor.delete': '删除 Skill', + 'settings.skills.editor.confirmDelete': '删除 {name}?这会移除对应的全局 Skill 文件。', + 'settings.skills.editor.confirm': '永久删除', + 'settings.skills.saved': '已保存 {name},下一次调用将使用此定义。', + 'settings.skills.deleted': '已删除 {name}。', + 'settings.plugins.title': 'Plugin 配置目录', + 'settings.plugins.help': '只读展示 Loopal 从 ~/.loopal/plugins 加载的配置目录。', + 'settings.plugins.empty': '没有找到 Plugin 配置目录。', + 'settings.plugins.skills': 'Skills · {items}', + 'settings.plugins.mcp': 'MCP 服务器 · {items}', + 'settings.plugins.hooks': 'Hooks · {count}', + 'settings.plugins.files': '配置 · {items}', + 'settings.plugins.none': '无', + 'settings.plugins.settings': 'settings.json', + 'settings.plugins.instructions': 'LOOPAL.md', + 'settings.plugins.memory': 'memory/MEMORY.md', + 'settings.plugins.restart': 'Plugin 的设置、MCP、Hooks、指令和记忆需要重启会话后生效。', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-shell-en.ts b/apps/desktop/src/shared/i18n/messages-shell-en.ts new file mode 100644 index 00000000..881e5696 --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-shell-en.ts @@ -0,0 +1,84 @@ +export const SHELL_EN = { + 'activity.primary': 'Primary activity', + 'activity.productAreas': 'Product areas', + 'activity.conversation': 'Conversation', + 'activity.federation': 'Federation', + 'activity.toggleSidebar': 'Toggle sidebar', + 'activity.pendingRequests': '{count} pending requests', + 'navigator.workbench': 'WORKBENCH', + 'navigator.sessions': 'Sessions', + 'navigator.newSession': 'New Session', + 'navigator.search': 'Search sessions', + 'navigator.searchPlaceholder': 'Search all sessions ⌘K', + 'navigator.empty': 'No matching sessions', + 'navigator.currentSessions': 'Current sessions', + 'navigator.historySessions': 'History', + 'navigator.noCurrentSessions': 'No active sessions. Create one to start working.', + 'navigator.noSearchResults': 'No sessions match “{query}”.', + 'navigator.sessionActions': 'Actions for {title}', + 'navigator.joinFederation': 'Join Federation', + 'navigator.leaveFederation': 'Leave Federation', + 'navigator.joiningFederation': 'Joining Federation…', + 'navigator.leavingFederation': 'Leaving Federation…', + 'navigator.federationUnavailable': 'Federation unavailable', + 'session.status.stopped': 'Stopped', + 'session.status.starting': 'Starting', + 'session.status.running': 'Ready', + 'session.status.waiting': 'Waiting', + 'session.status.failed': 'Failed', + 'session.status.archived': 'Archived', + 'workspace.selectSession': 'Select a session', + 'workspace.stop': 'Stop', + 'workspace.restart': 'Restart', + 'workspace.details': 'Session details', + 'workspace.session': 'Session', + 'workspace.model': 'Model', + 'workspace.mode': 'Mode', + 'workspace.status': 'Status', + 'workspace.conversation': 'Conversation', + 'workspace.viewingAgent': 'Viewing {name} · {status}', + 'workspace.connecting': 'Connecting to Loopal Desktop Host…', + 'workspace.messageLoopal': 'Message Loopal', + 'workspace.messageAgent': 'Message {name}', + 'composer.retired': '{name} is {status}; retained conversation is read-only.', + 'composer.notReady': '{name} is not ready for messages yet.', + 'composer.placeholder': 'Ask {name} to work in this session…', + 'composer.removeImage': 'Remove {name}', + 'composer.attachImages': 'Attach images', + 'composer.image': '+ Image', + 'composer.mode': 'Mode', + 'composer.agentMode': 'Agent mode', + 'composer.act': 'Act', + 'composer.plan': 'Plan', + 'composer.modeUnavailable': 'Mode unavailable', + 'composer.running': 'Running…', + 'composer.send': 'Send', + 'composer.stopSession': 'Stop session', + 'composer.restartSession': 'Restart session', + 'status.running': '{count} running', + 'status.attention': '{count} need attention', + 'status.openFederation': 'Open Federation', + 'status.federation.disconnected': 'Federation offline', + 'status.federation.running': 'Federation running', + 'status.federation.connected': 'Federation connected', + 'status.federation.error': 'Federation needs attention', + 'runtime.turn': '{count} turn', + 'runtime.turns': '{count} turns', + 'runtime.tokens': '{used} / {total} tokens', + 'runtime.context': 'Context {percent}%', + 'runtime.thinking': 'Thinking', + 'runtime.streaming': 'Streaming', + 'runtime.compacting': 'Compacting', + 'runtime.waitPermission': 'Waiting for permission', + 'runtime.waitAnswer': 'Waiting for answer', + 'runtime.waitPlan': 'Waiting for plan approval', + 'runtime.failed': 'Failed', + 'runtime.working': 'Working', + 'runtime.agentWorking': '{count} Agent working', + 'runtime.agentsWorking': '{count} Agents working', + 'runtime.ready': 'Ready for input', + 'runtime.starting': 'Starting', + 'runtime.suspended': 'Suspended', + 'runtime.completed': 'Completed', + 'runtime.idle': 'Idle', +} as const diff --git a/apps/desktop/src/shared/i18n/messages-shell-zh-cn.ts b/apps/desktop/src/shared/i18n/messages-shell-zh-cn.ts new file mode 100644 index 00000000..d0d7651f --- /dev/null +++ b/apps/desktop/src/shared/i18n/messages-shell-zh-cn.ts @@ -0,0 +1,84 @@ +export const SHELL_ZH_CN = { + 'activity.primary': '主功能区', + 'activity.productAreas': '产品功能', + 'activity.conversation': '对话', + 'activity.federation': '联邦', + 'activity.toggleSidebar': '切换侧边栏', + 'activity.pendingRequests': '{count} 个待处理请求', + 'navigator.workbench': '工作台', + 'navigator.sessions': '会话', + 'navigator.newSession': '新建会话', + 'navigator.search': '搜索会话', + 'navigator.searchPlaceholder': '搜索全部会话 ⌘K', + 'navigator.empty': '没有匹配的会话', + 'navigator.currentSessions': '当前会话', + 'navigator.historySessions': '历史会话', + 'navigator.noCurrentSessions': '暂无活跃会话。新建会话后即可开始工作。', + 'navigator.noSearchResults': '没有与“{query}”匹配的会话。', + 'navigator.sessionActions': '{title} 的操作', + 'navigator.joinFederation': '加入联邦', + 'navigator.leaveFederation': '退出联邦', + 'navigator.joiningFederation': '正在加入联邦…', + 'navigator.leavingFederation': '正在退出联邦…', + 'navigator.federationUnavailable': '联邦不可用', + 'session.status.stopped': '已停止', + 'session.status.starting': '正在启动', + 'session.status.running': '就绪', + 'session.status.waiting': '等待中', + 'session.status.failed': '失败', + 'session.status.archived': '已归档', + 'workspace.selectSession': '选择一个会话', + 'workspace.stop': '停止', + 'workspace.restart': '重启', + 'workspace.details': '会话详情', + 'workspace.session': '会话', + 'workspace.model': '模型', + 'workspace.mode': '模式', + 'workspace.status': '状态', + 'workspace.conversation': '对话', + 'workspace.viewingAgent': '正在查看 {name} · {status}', + 'workspace.connecting': '正在连接 Loopal Desktop Host…', + 'workspace.messageLoopal': '给 Loopal 发消息', + 'workspace.messageAgent': '给 {name} 发消息', + 'composer.retired': '{name} 状态为 {status};保留的对话仅可读。', + 'composer.notReady': '{name} 暂时无法接收消息。', + 'composer.placeholder': '请 {name} 在此会话中执行任务…', + 'composer.removeImage': '移除 {name}', + 'composer.attachImages': '添加图片', + 'composer.image': '+ 图片', + 'composer.mode': '模式', + 'composer.agentMode': 'Agent 模式', + 'composer.act': '执行', + 'composer.plan': '规划', + 'composer.modeUnavailable': '模式不可用', + 'composer.running': '正在运行…', + 'composer.send': '发送', + 'composer.stopSession': '停止会话', + 'composer.restartSession': '重启会话', + 'status.running': '{count} 个正在运行', + 'status.attention': '{count} 个需要处理', + 'status.openFederation': '打开联邦', + 'status.federation.disconnected': '联邦离线', + 'status.federation.running': '联邦运行中', + 'status.federation.connected': '联邦已连接', + 'status.federation.error': '联邦需要处理', + 'runtime.turn': '{count} 轮', + 'runtime.turns': '{count} 轮', + 'runtime.tokens': '{used} / {total} token', + 'runtime.context': '上下文 {percent}%', + 'runtime.thinking': '正在思考', + 'runtime.streaming': '正在流式输出', + 'runtime.compacting': '正在压缩上下文', + 'runtime.waitPermission': '等待权限确认', + 'runtime.waitAnswer': '等待回答', + 'runtime.waitPlan': '等待计划审批', + 'runtime.failed': '失败', + 'runtime.working': '正在工作', + 'runtime.agentWorking': '{count} 个 Agent 正在工作', + 'runtime.agentsWorking': '{count} 个 Agent 正在工作', + 'runtime.ready': '等待输入', + 'runtime.starting': '正在启动', + 'runtime.suspended': '已挂起', + 'runtime.completed': '已完成', + 'runtime.idle': '空闲', +} as const diff --git a/apps/desktop/src/shared/protocol/renderer-protocol.ts b/apps/desktop/src/shared/protocol/renderer-protocol.ts new file mode 100644 index 00000000..3f874b5a --- /dev/null +++ b/apps/desktop/src/shared/protocol/renderer-protocol.ts @@ -0,0 +1,5 @@ +export const RENDERER_CONNECT_CHANNEL = 'loopal-desktop:connect' +export const RENDERER_PORT_CHANNEL = 'loopal-desktop:port' +export const RENDERER_PROTOCOL_VERSION = 2 +export const SELECT_IMAGES_CHANNEL = 'loopal-desktop:select-images' +export const SELECT_SESSION_DIRECTORY_CHANNEL = 'loopal-desktop:select-session-directory' diff --git a/apps/desktop/src/workbench/browser/activity-bar.tsx b/apps/desktop/src/workbench/browser/activity-bar.tsx new file mode 100644 index 00000000..de275c02 --- /dev/null +++ b/apps/desktop/src/workbench/browser/activity-bar.tsx @@ -0,0 +1,69 @@ +import { useI18n } from './i18n-context' +import { WorkbenchIcon, type WorkbenchIconName } from './workbench-icon' +import { type WorkbenchArea } from './workbench-view-state' + +interface ActivityBarProps { + readonly activeArea: WorkbenchArea + readonly sidebarVisible: boolean + readonly attentionCount: number + readonly onActivate: (area: WorkbenchArea) => void + readonly onToggleSidebar: () => void + readonly onOpenAttention: () => void + readonly settingsOpen: boolean + readonly onOpenSettings: () => void +} + +const productAreas = [ + ['conversation', 'activity.conversation', 'conversation'], + ['federation', 'activity.federation', 'federation'], +] as const + +export function ActivityBar(props: ActivityBarProps): React.JSX.Element { + const { t } = useI18n() + return ( + + ) +} + +function ActivityGroup(props: ActivityBarProps & { + readonly label: string + readonly items: readonly (readonly [ + WorkbenchArea, Parameters['t']>[0], WorkbenchIconName, + ])[] +}): React.JSX.Element { + const { t } = useI18n() + return
+ {props.items.map(([id, key, icon]) => ( + + ))} +
+} diff --git a/apps/desktop/src/workbench/browser/desktop-event-projector.test.ts b/apps/desktop/src/workbench/browser/desktop-event-projector.test.ts new file mode 100644 index 00000000..daa38fdc --- /dev/null +++ b/apps/desktop/src/workbench/browser/desktop-event-projector.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { sessionDetail, sessionOne, sessionTwo, updatedAt } from '../../../test/support/workbench/api-stub' +import { projectDesktopEvent, type DesktopProjection } from './desktop-event-projector' + +const runtime = { + id: 'runtime-1', sessionId: sessionOne.id, workspaceId: sessionOne.workspaceId, + generation: 1, state: 'ready' as const, rootAgent: 'main', startedAt: updatedAt, +} + +function projection(): DesktopProjection { + return { + hostStatus: 'ready', sessions: [sessionOne, sessionTwo], runtimes: [runtime], + detail: sessionDetail(sessionOne), + } +} + +describe('desktop event projection', () => { + it('keeps conversations and artifacts isolated by session', () => { + const current = projection() + const entry = { id: 'other', role: 'assistant' as const, text: 'other', createdAt: updatedAt } + const ignored = projectDesktopEvent(current, { + type: 'conversation_entry', sessionId: sessionTwo.id, entry, + }) + expect(ignored.detail?.conversation).toEqual(current.detail?.conversation) + const accepted = projectDesktopEvent(current, { + type: 'conversation_entry', sessionId: sessionOne.id, entry, + }) + expect(accepted.detail?.conversation.at(-1)).toEqual(entry) + const artifact = { + id: 'file', sessionId: sessionOne.id, title: 'main.rs', kind: 'code' as const, + uri: 'loopal-workspace://src%2Fmain.rs', mediaType: 'text/plain', + producerAgentId: 'worker', createdAt: updatedAt, + } + const withArtifact = projectDesktopEvent(current, { type: 'artifact_created', artifact }) + const repeated = projectDesktopEvent(withArtifact, { type: 'artifact_created', artifact }) + expect(repeated.detail?.artifacts).toEqual([artifact]) + expect(projectDesktopEvent(current, { + type: 'artifact_created', artifact: { ...artifact, sessionId: sessionTwo.id }, + })).toBe(current) + }) + + it('bounds retired runtimes and ignores late obsolete generations', () => { + let current = projectDesktopEvent(projection(), { + type: 'runtime_updated', runtime: { ...runtime, state: 'stopped' }, + }) + const next = { + ...runtime, id: 'runtime-2', generation: 2, state: 'ready' as const, + } + current = projectDesktopEvent(current, { type: 'runtime_updated', runtime: next }) + expect(current.runtimes).toEqual([next]) + current = projectDesktopEvent(current, { + type: 'runtime_updated', runtime: { ...runtime, state: 'crashed' }, + }) + expect(current.runtimes).toEqual([next]) + }) +}) diff --git a/apps/desktop/src/workbench/browser/desktop-event-projector.ts b/apps/desktop/src/workbench/browser/desktop-event-projector.ts new file mode 100644 index 00000000..6bc2a2eb --- /dev/null +++ b/apps/desktop/src/workbench/browser/desktop-event-projector.ts @@ -0,0 +1,95 @@ +import { + type DesktopEvent, + type HostStatus, + type RuntimeSummary, + type SessionDetail, + type SessionSummary, +} from '../../shared/contracts' + +export interface DesktopProjection { + readonly hostStatus: HostStatus + readonly sessions: readonly SessionSummary[] + readonly runtimes: readonly RuntimeSummary[] + readonly detail?: SessionDetail +} + +export const initialDesktopProjection: DesktopProjection = { + hostStatus: 'spawning', + sessions: [], + runtimes: [], +} + +export function projectDesktopEvent( + current: DesktopProjection, + event: DesktopEvent, +): DesktopProjection { + switch (event.type) { + case 'host_status': + return { ...current, hostStatus: event.status } + case 'session_updated': + if (current.detail?.session.id !== event.session.id) return { + ...current, + sessions: replaceOrAppend(current.sessions, event.session), + } + return { + ...current, + sessions: replaceOrAppend(current.sessions, event.session), + detail: { ...current.detail, session: event.session }, + } + case 'runtime_updated': + return { ...current, runtimes: projectRuntime(current.runtimes, event.runtime) } + case 'session_detail_replaced': + return current.detail?.session.id === event.detail.session.id + ? { + ...current, + sessions: replaceOrAppend(current.sessions, event.detail.session), + detail: event.detail, + } + : current + case 'conversation_entry': + return current.detail?.session.id === event.sessionId + ? { + ...current, + detail: { + ...current.detail, + conversation: [...current.detail.conversation, event.entry], + }, + } + : current + case 'artifact_created': + return current.detail?.session.id === event.artifact.sessionId + ? { + ...current, + detail: { + ...current.detail, + artifacts: replaceOrAppend(current.detail.artifacts, event.artifact), + }, + } + : current + default: + return current + } +} + +function projectRuntime( + runtimes: readonly RuntimeSummary[], + runtime: RuntimeSummary, +): readonly RuntimeSummary[] { + const newer = runtimes.some((candidate) => candidate.sessionId === runtime.sessionId + && candidate.generation > runtime.generation) + if (newer && (runtime.state === 'stopped' || runtime.state === 'crashed')) return runtimes + const retained = runtimes.filter((candidate) => candidate.sessionId !== runtime.sessionId + || candidate.id === runtime.id + || candidate.generation > runtime.generation + || (candidate.state !== 'stopped' && candidate.state !== 'crashed')) + return replaceOrAppend(retained, runtime) +} + +function replaceOrAppend( + values: readonly T[], + value: T, +): T[] { + return values.some((item) => item.id === value.id) + ? values.map((item) => item.id === value.id ? value : item) + : [...values, value] +} diff --git a/apps/desktop/src/workbench/browser/fallback-context.test.ts b/apps/desktop/src/workbench/browser/fallback-context.test.ts new file mode 100644 index 00000000..b5c89444 --- /dev/null +++ b/apps/desktop/src/workbench/browser/fallback-context.test.ts @@ -0,0 +1,26 @@ +import { buildFallbackContext } from './fallback-context' +import { sessionOne } from '../../../test/support/workbench/api-stub' + +describe('buildFallbackContext', () => { + it('projects active workspaces, sessions, and matching runtime generations', () => { + const context = buildFallbackContext({ + workspaces: [{ id: 'workspace', name: 'Loopal', rootUri: '/loopal', kind: 'folder' }], + sessions: [sessionOne, { ...sessionOne, id: 'stopped', activeRuntimeId: undefined }], + runtimes: [{ + id: 'runtime-1', sessionId: sessionOne.id, workspaceId: 'workspace', generation: 3, + state: 'ready', rootAgent: 'main', + }], + activeWorkspaceId: 'workspace', activeSessionId: sessionOne.id, + }) + expect(context.workspaces).toEqual([{ id: 'workspace', name: 'Loopal', detail: '/loopal' }]) + expect(context.sessions[0]).toMatchObject({ runtimeId: 'runtime-1', runtimeGeneration: 3 }) + expect(context.sessions[1]).not.toHaveProperty('runtimeId') + expect(context).toMatchObject({ activeWorkspaceId: 'workspace', activeSessionId: sessionOne.id }) + }) + + it('omits absent active selections', () => { + expect(buildFallbackContext({ workspaces: [], sessions: [], runtimes: [] })).toEqual({ + workspaces: [], sessions: [], + }) + }) +}) diff --git a/apps/desktop/src/workbench/browser/fallback-context.ts b/apps/desktop/src/workbench/browser/fallback-context.ts new file mode 100644 index 00000000..67eb2fa4 --- /dev/null +++ b/apps/desktop/src/workbench/browser/fallback-context.ts @@ -0,0 +1,32 @@ +import { + type RuntimeSummary, type SessionSummary, type Workspace, +} from '../../shared/contracts' +import { type Stage2WorkbenchModel } from './stage2-view-model' + +export function buildFallbackContext(input: { + readonly workspaces: readonly Workspace[] + readonly sessions: readonly SessionSummary[] + readonly runtimes: readonly RuntimeSummary[] + readonly activeWorkspaceId?: string + readonly activeSessionId?: string +}): Stage2WorkbenchModel['context'] { + return { + workspaces: input.workspaces.map((workspace) => ({ + id: workspace.id, name: workspace.name, detail: workspace.rootUri, + })), + ...(input.activeWorkspaceId !== undefined + ? { activeWorkspaceId: input.activeWorkspaceId } + : {}), + sessions: input.sessions.map((session) => { + const runtime = input.runtimes.find((candidate) => candidate.id === session.activeRuntimeId) + return { + id: session.id, workspaceId: session.workspaceId, + title: session.title, state: session.status, + ...(runtime ? { runtimeId: runtime.id, runtimeGeneration: runtime.generation } : {}), + } + }), + ...(input.activeSessionId !== undefined + ? { activeSessionId: input.activeSessionId } + : {}), + } +} diff --git a/apps/desktop/src/workbench/browser/i18n-context.test.tsx b/apps/desktop/src/workbench/browser/i18n-context.test.tsx new file mode 100644 index 00000000..727d196f --- /dev/null +++ b/apps/desktop/src/workbench/browser/i18n-context.test.tsx @@ -0,0 +1,49 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { type ReactNode } from 'react' +import { I18nProvider, useI18n } from './i18n-context' + +describe('I18nProvider', () => { + it('loads, renders, persists, and applies the selected locale', async () => { + let locale = 'zh-CN' as const + const api = { + getDesktopPreferences: vi.fn(async () => ({ locale })), + updateDesktopPreferences: vi.fn(async (input: { locale: 'system' | 'en' | 'zh-CN' }) => { + locale = input.locale as 'zh-CN' + return { locale: input.locale } + }), + } + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const result = renderHook(useI18n, { wrapper }).result + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.locale).toBe('zh-CN') + expect(result.current.t('activity.sessions')).toBe('会话') + expect(document.documentElement.lang).toBe('zh-CN') + + await act(() => result.current.setPreference('en')) + expect(api.updateDesktopPreferences).toHaveBeenCalledWith({ locale: 'en' }) + expect(result.current.t('activity.sessions')).toBe('Sessions') + expect(document.documentElement.lang).toBe('en') + }) + + it('uses system language changes and survives preference read failure', async () => { + const api = { + getDesktopPreferences: vi.fn(async () => { throw new Error('unavailable') }), + updateDesktopPreferences: vi.fn(async () => ({ locale: 'system' as const })), + } + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + const result = renderHook(useI18n, { wrapper }).result + await waitFor(() => expect(result.current.ready).toBe(true)) + expect(result.current.preference).toBe('system') + expect(result.current.locale).toBe('zh-CN') + }) + + it('provides an English fallback to independently rendered components', () => { + const result = renderHook(useI18n).result + expect(result.current.locale).toBe('en') + expect(result.current.t('activity.sessions')).toBe('Sessions') + }) +}) diff --git a/apps/desktop/src/workbench/browser/i18n-context.tsx b/apps/desktop/src/workbench/browser/i18n-context.tsx new file mode 100644 index 00000000..4276c276 --- /dev/null +++ b/apps/desktop/src/workbench/browser/i18n-context.tsx @@ -0,0 +1,89 @@ +import { + createContext, useCallback, useContext, useEffect, useMemo, useState, + type ReactNode, +} from 'react' +import { + DEFAULT_DESKTOP_PREFERENCES, + type DesktopLocalePreference, + type DesktopPreferences, + type LoopalDesktopAPI, +} from '../../shared/contracts' +import { + resolveLocale, translate, type MessageKey, type SupportedLocale, +} from '../../shared/i18n' + +type TranslationValues = Readonly> + +export interface I18nContextValue { + readonly locale: SupportedLocale + readonly preference: DesktopLocalePreference + readonly ready: boolean + readonly t: (key: MessageKey, values?: TranslationValues) => string + readonly setPreference: (preference: DesktopLocalePreference) => Promise +} + +const fallbackContext: I18nContextValue = { + locale: 'en', preference: 'system', ready: true, + t: (key, values) => translate('en', key, values), + setPreference: async () => undefined, +} +const I18nContext = createContext(fallbackContext) + +export function I18nProvider({ + children, + api = window.loopalDesktop, + systemLocales, +}: { + readonly children: ReactNode + readonly api?: Pick + readonly systemLocales?: readonly string[] +}) { + const [preferences, setPreferences] = useState(DEFAULT_DESKTOP_PREFERENCES) + const [ready, setReady] = useState(false) + const [languageRevision, setLanguageRevision] = useState(0) + + useEffect(() => { + let active = true + void api.getDesktopPreferences().then((value) => { + if (active) setPreferences(value) + }).catch(() => undefined).finally(() => { + if (active) setReady(true) + }) + return () => { active = false } + }, [api]) + + useEffect(() => { + if (systemLocales || typeof window === 'undefined') return undefined + const update = (): void => setLanguageRevision((value) => value + 1) + window.addEventListener('languagechange', update) + return () => window.removeEventListener('languagechange', update) + }, [systemLocales]) + + const locale = resolveLocale( + preferences.locale, + systemLocales ?? (typeof navigator === 'undefined' ? [] : navigator.languages), + ) + void languageRevision + + useEffect(() => { + document.documentElement.lang = locale + }, [locale]) + + const setPreference = useCallback(async (preference: DesktopLocalePreference) => { + const persisted = await api.updateDesktopPreferences({ locale: preference }) + setPreferences(persisted) + }, [api]) + const t = useCallback( + (key: MessageKey, values?: TranslationValues) => translate(locale, key, values), + [locale], + ) + const value = useMemo(() => ({ + locale, preference: preferences.locale, ready, setPreference, t, + }), [locale, preferences.locale, ready, setPreference, t]) + + return {children} +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext) +} diff --git a/apps/desktop/src/workbench/browser/runtime-controller-utils.test.ts b/apps/desktop/src/workbench/browser/runtime-controller-utils.test.ts new file mode 100644 index 00000000..1ef13fcb --- /dev/null +++ b/apps/desktop/src/workbench/browser/runtime-controller-utils.test.ts @@ -0,0 +1,8 @@ +import { errorMessage } from './runtime-controller-utils' + +describe('Workbench runtime helpers', () => { + it('normalizes operation failures', () => { + expect(errorMessage('plain failure')).toBe('plain failure') + expect(errorMessage(new Error('runtime failed'))).toBe('runtime failed') + }) +}) diff --git a/apps/desktop/src/workbench/browser/runtime-controller-utils.ts b/apps/desktop/src/workbench/browser/runtime-controller-utils.ts new file mode 100644 index 00000000..0d462b19 --- /dev/null +++ b/apps/desktop/src/workbench/browser/runtime-controller-utils.ts @@ -0,0 +1,3 @@ +export function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} diff --git a/apps/desktop/src/workbench/browser/session-workspace.tsx b/apps/desktop/src/workbench/browser/session-workspace.tsx new file mode 100644 index 00000000..60cd3cbf --- /dev/null +++ b/apps/desktop/src/workbench/browser/session-workspace.tsx @@ -0,0 +1,159 @@ +import { + type AgentControlCommand, + type DesktopImageAttachment, + type SessionDetail, +} from '../../shared/contracts' +import { canRestartSession, isSessionLive } from '../../shared/contracts/session-lifecycle' +import { ConversationView } from '../contrib/conversation/browser/conversation-view' +import { type FederationConversationTarget } from '../contrib/federation/browser/federation-model' +import { FederationWorkspace } from '../contrib/federation/browser/federation-workspace' +import { SessionRuntimeStatus } from '../contrib/sessions/browser/session-runtime-status' +import { MessageComposer } from '../contrib/conversation/browser/message-composer' +import { SessionToolbar } from '../contrib/sessions/browser/session-toolbar' +import { type SlashCommandItem } from '../contrib/conversation/browser/slash-command-model' +import { useI18n } from './i18n-context' +import { type useFederationController } from '../contrib/federation/browser/use-federation-controller' + +interface SessionWorkspaceProps { + readonly detail?: SessionDetail + readonly error?: string + readonly draft: string + readonly sending: boolean + readonly images: readonly DesktopImageAttachment[] + readonly onDraftChange: (draft: string) => void + readonly onSelectImages: () => Promise + readonly onRemoveImage: (index: number) => void + readonly onSend: (agentId?: string) => Promise + readonly onStop: () => Promise + readonly onRestart: () => Promise + readonly lifecycleBusy: boolean + readonly selectedAgentId: string + readonly controlBusy: boolean + readonly controlAvailable: (agentId: string) => boolean + readonly onControl: (agentId: string, command: AgentControlCommand) => Promise + readonly commands: readonly SlashCommandItem[] + readonly commandError?: string + readonly commandHelpQuery?: string + readonly onRequestCommands: () => void + readonly onDismissCommandHelp: () => void + readonly onExecuteCommand: (command: string, agentId: string) => void + readonly surface: 'conversation' | 'federation' + readonly onOpenAgentConversation: (target: FederationConversationTarget) => void + readonly onOpenSettings: () => void + readonly federation: ReturnType + readonly panel?: React.ReactNode + readonly attention?: React.ReactNode +} + +export function SessionWorkspace(props: SessionWorkspaceProps): React.JSX.Element { + const { t } = useI18n() + const selectedAgent = props.detail?.agents.find((agent) => agent.id === props.selectedAgentId) + ?? props.detail?.agents.find((agent) => agent.id === 'main') + ?? props.detail?.agents[0] + const isRootAgent = selectedAgent !== undefined && !selectedAgent.parentId + const isRootConversation = selectedAgent === undefined || isRootAgent + const conversation = selectedAgent && !isRootAgent + ? selectedAgent.conversation ?? [] + : props.detail?.conversation ?? [] + const conversationView = selectedAgent?.view + ?? (isRootConversation ? props.detail?.view : undefined) + const controlAvailable = selectedAgent !== undefined + && props.controlAvailable(selectedAgent.id) + const canControl = controlAvailable && !props.controlBusy + const mode = selectedAgent?.mode ?? props.detail?.session.mode + const agentCanReceive = selectedAgent === undefined + || (selectedAgent.controllable !== false + && (['starting', 'idle', 'running', 'waiting', 'suspended'].includes(selectedAgent.status) + || (isRootAgent && selectedAgent.status === 'failed'))) + const retiredAgent = selectedAgent?.status === 'completed' || selectedAgent?.status === 'failed' + const sessionLive = Boolean(props.detail && isSessionLive(props.detail.session)) + const composeDisabled = !props.detail || props.sending + || !sessionLive || !agentCanReceive + const composerPlaceholder = !agentCanReceive + ? retiredAgent + ? t('composer.retired', { + name: selectedAgent?.name ?? 'Agent', status: selectedAgent?.status ?? '', + }) + : t('composer.notReady', { name: selectedAgent?.name ?? 'Agent' }) + : t('composer.placeholder', { name: selectedAgent?.name ?? 'Loopal' }) + const control = (command: AgentControlCommand): void => { + if (selectedAgent) void props.onControl(selectedAgent.id, command) + } + return ( +
+ {props.surface === 'federation' ? ( + + ) : ( + <> + +
+ {selectedAgent && !isRootAgent && ( +
+ {t('workspace.viewingAgent', { + name: selectedAgent.name, status: selectedAgent.status, + })} + {selectedAgent.error ? ` · ${selectedAgent.error}` : ''} +
+ )} + {props.detail && ( + + )} + {!props.detail && !props.error && ( +

{t('workspace.connecting')}

+ )} + {props.error &&
{props.error}
} +
+ {props.panel} + {props.attention} + + ) : undefined} + commands={props.commands} + {...(props.commandError ? { commandError: props.commandError } : {})} + {...(props.commandHelpQuery !== undefined + ? { commandHelpQuery: props.commandHelpQuery } : {})} + onRequestCommands={props.onRequestCommands} + onDismissCommandHelp={props.onDismissCommandHelp} + onExecuteCommand={(command) => props.onExecuteCommand( + command, selectedAgent?.id ?? props.selectedAgentId, + )} + onDraftChange={props.onDraftChange} + onSelectImages={() => void props.onSelectImages()} + onRemoveImage={props.onRemoveImage} + onSend={() => void props.onSend(selectedAgent?.id ?? props.selectedAgentId)} + onModeChange={(value) => control({ type: 'mode', mode: value })} + onStopSession={() => void props.onStop()} + onRestartSession={() => void props.onRestart()} + /> + + )} +
+ ) +} diff --git a/apps/desktop/src/workbench/browser/stage2-view-model.ts b/apps/desktop/src/workbench/browser/stage2-view-model.ts new file mode 100644 index 00000000..4c9cc61a --- /dev/null +++ b/apps/desktop/src/workbench/browser/stage2-view-model.ts @@ -0,0 +1,90 @@ +import { type PlanApprovalItem } from '../contrib/attention/browser/plan-approval-view-model' + +export interface WorkspaceContextItem { + readonly id: string + readonly name: string + readonly detail: string +} + +export interface SessionContextItem { + readonly id: string + readonly workspaceId: string + readonly title: string + readonly state: string + readonly runtimeId?: string + readonly runtimeGeneration?: number +} + +export interface PermissionItem { + readonly id: string + readonly agentId?: string + readonly title: string + readonly description: string + readonly risk: 'low' | 'medium' | 'high' + readonly command?: string +} + +export interface QuestionChoice { + readonly id: string + readonly label: string + readonly description?: string +} + +export interface QuestionItem { + readonly id: string + readonly agentId?: string + readonly prompt: string + readonly allowMultiple: boolean + readonly selectedChoiceIds: readonly string[] + readonly otherText?: string + readonly choices: readonly QuestionChoice[] + readonly classifier?: { + readonly kind: 'running' | 'failed' | 'completed' + readonly label: string + } + readonly submit?: { + readonly requestId: string + readonly enabled: boolean + } +} + +export interface Stage2WorkbenchModel { + readonly error?: string + readonly context: { + readonly workspaces: readonly WorkspaceContextItem[] + readonly activeWorkspaceId?: string + readonly sessions: readonly SessionContextItem[] + readonly activeSessionId?: string + } + readonly permissions: readonly PermissionItem[] + readonly questions: readonly QuestionItem[] + readonly planApprovals: readonly PlanApprovalItem[] +} + +export interface Stage2WorkbenchCallbacks { + readonly onWorkspaceChange?: (id: string) => void + readonly onSessionChange?: (id: string) => void + readonly onResolvePermission?: ( + id: string, decision: 'allow' | 'allow_session' | 'deny' + ) => void + readonly onAnswerQuestion?: (id: string, choiceId: string) => void + readonly onQuestionFreeTextChange?: (id: string, value: string) => void + readonly onSubmitQuestionAnswers?: (requestId: string) => void + readonly onCancelQuestion?: (requestId: string) => void + readonly onPlanApprovalEdit?: (id: string, value: string) => void + readonly onResolvePlanApproval?: ( + id: string, decision: 'approve' | 'reject' | 'approve_with_edits' + ) => void +} + +export interface Stage2WorkbenchBinding { + readonly model: Stage2WorkbenchModel + readonly callbacks: Stage2WorkbenchCallbacks +} + +export const emptyStage2WorkbenchModel: Stage2WorkbenchModel = { + context: { workspaces: [], sessions: [] }, + permissions: [], + questions: [], + planApprovals: [], +} diff --git a/apps/desktop/src/workbench/browser/stage2-workbench.css b/apps/desktop/src/workbench/browser/stage2-workbench.css new file mode 100644 index 00000000..6e2e53ec --- /dev/null +++ b/apps/desktop/src/workbench/browser/stage2-workbench.css @@ -0,0 +1,37 @@ +.workbench-center { position: relative; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.workbench-center > .session-workspace { flex: 1; min-height: 0; } +.stage2-error { padding: 7px 12px; border-bottom: 1px solid #713f47; background: #321d22; color: #f0aeb7; font-size: 10px; } + +.activity-badge { + min-width: 17px; + height: 17px; + display: grid; + place-items: center; + border-radius: 9px; + background: #be784f; + color: #fff; + font-size: 9px; +} + +.attention-pane { display: flex; flex-direction: column; gap: 9px; } +.attention-card { padding: 11px; border: 1px solid #303543; border-radius: 8px; background: #171a22; } +.attention-card.risk-high { border-color: #704149; } +.attention-kind { color: #c09055; font-size: 8px; letter-spacing: .08em; text-transform: uppercase; } +.classifier-status { display: block; margin-top: 5px; color: #8fa8e8; font-size: 9px; } +.classifier-failed { color: #dd7d89; } +.classifier-completed { color: #79be8d; } +.attention-card h3 { margin: 7px 0; color: #d8dce7; font-size: 11px; } +.attention-card p { color: #8991a6; font-size: 10px; line-height: 1.5; } +.attention-card > code { display: block; overflow: auto; padding: 7px; border-radius: 5px; background: #0e1016; color: #d2a778; font-size: 9px; } +.attention-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 10px; } +.attention-actions button, .question-choices button { border: 1px solid #363b49; border-radius: 6px; background: #20242e; color: #bdc3d2; font-size: 9px; cursor: pointer; } +.attention-actions button { padding: 5px 10px; } +.attention-actions .primary { border-color: #8590be; background: #7885b6; color: #fff; } +.question-choices { display: flex; flex-direction: column; gap: 6px; } +.question-choices button { display: flex; flex-direction: column; gap: 3px; padding: 8px; text-align: left; } +.question-choices button.selected { border-color: #8590be; background: #30384e; color: #fff; } +.attention-actions button:disabled { opacity: .4; cursor: default; } +.question-choices small { color: #7e879a; } +.question-other { display: grid; gap: 5px; margin-top: 8px; color: #8991a6; font-size: 9px; } +.question-other input { min-width: 0; padding: 7px 8px; border: 1px solid #363b49; border-radius: 6px; outline: none; background: #10131a; color: #d8dce7; font: inherit; } +.question-other input:focus { border-color: #8590be; } diff --git a/apps/desktop/src/workbench/browser/status-bar.test.tsx b/apps/desktop/src/workbench/browser/status-bar.test.tsx new file mode 100644 index 00000000..f651c251 --- /dev/null +++ b/apps/desktop/src/workbench/browser/status-bar.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { sessionOne, sessionTwo } from '../../../test/support/workbench/api-stub' +import { type FederationSnapshot } from '../contrib/federation/browser/federation-model' +import { StatusBar } from './status-bar' + +const federation: FederationSnapshot = { + local: { state: 'stopped' }, + network: { + state: 'disconnected', hubs: [], topology: [], + refreshedAt: '2026-07-11T12:00:00.000Z', + }, + connections: [], topology: [], memberships: {}, +} + +describe('StatusBar', () => { + it('counts every live Runtime, including sessions waiting for input', () => { + const stopped = { + ...sessionOne, id: 'history', status: 'stopped' as const, + activeRuntimeId: undefined, + } + render() + + expect(screen.getByText('2 running')).toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/browser/status-bar.tsx b/apps/desktop/src/workbench/browser/status-bar.tsx new file mode 100644 index 00000000..88dc54ea --- /dev/null +++ b/apps/desktop/src/workbench/browser/status-bar.tsx @@ -0,0 +1,36 @@ +import { type SessionSummary } from '../../shared/contracts' +import { isSessionLive } from '../../shared/contracts/session-lifecycle' +import { type FederationSnapshot } from '../contrib/federation/browser/federation-model' +import { useI18n } from './i18n-context' + +export function StatusBar({ sessions, federation, onOpenFederation }: { + readonly sessions: readonly SessionSummary[] + readonly federation: FederationSnapshot + readonly onOpenFederation: () => void +}): React.JSX.Element { + const { t } = useI18n() + const running = sessions.filter(isSessionLive).length + const attention = sessions.filter((session) => ( + session.attention && session.attention !== 'completed' + )).length + const federationState = federation.network.state === 'connected' + || federation.network.state === 'error' + ? federation.network.state + : federation.local.state === 'running' ? 'running' : 'disconnected' + return ( +
+ Loopal Desktop + {t('status.running', { count: running })} + {t('status.attention', { count: attention })} + +
+ ) +} diff --git a/apps/desktop/src/workbench/browser/use-workbench-controller.test.tsx b/apps/desktop/src/workbench/browser/use-workbench-controller.test.tsx new file mode 100644 index 00000000..3bca022e --- /dev/null +++ b/apps/desktop/src/workbench/browser/use-workbench-controller.test.tsx @@ -0,0 +1,145 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { + createTestAPI, sessionDetail, sessionOne, +} from '../../../test/support/workbench/api-stub' +import { useWorkbenchController } from './use-workbench-controller' + +describe('useWorkbenchController session lifecycle guards', () => { + const createInput = { + authorizationId: 'd10f67f2-f471-44ea-b6d1-e1b963e11228', + launchMode: 'directory' as const, + } + it('creates without a workspace while runtime lifecycle needs a selected session', async () => { + const createSession = vi.fn(async () => sessionDetail(sessionOne)) + const stopSession = vi.fn(async () => undefined) + const restartSession = vi.fn() + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, hostStatus: 'stopped', workspaces: [], + sessions: [], runtimes: [], + }), + createSession, stopSession, restartSession, + }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.projection.hostStatus).toBe('stopped')) + await act(async () => { + await hook.result.current.stopSession() + await hook.result.current.restartSession() + }) + expect(stopSession).not.toHaveBeenCalled() + expect(restartSession).not.toHaveBeenCalled() + expect(hook.result.current.canCreate).toBe(true) + await act(async () => { await hook.result.current.createSession(createInput) }) + expect(createSession).toHaveBeenCalledWith(createInput) + }) + + it('reports create and runtime lifecycle failures', async () => { + const createSession = vi.fn(async () => { throw new Error('create failed') }) + const stopSession = vi.fn(async () => { throw new Error('stop failed') }) + const restartSession = vi.fn(async () => { throw new Error('restart failed') }) + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, hostStatus: 'ready', + workspaces: [{ + id: 'workspace', name: 'Loopal', rootUri: '/loopal', kind: 'folder', + }], + sessions: [sessionOne], runtimes: [], activeSessionId: sessionOne.id, + }), + createSession, stopSession, restartSession, + }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.activeSessionId).toBe(sessionOne.id)) + await act(async () => { await hook.result.current.createSession(createInput) }) + expect(hook.result.current.error).toBe('create failed') + await act(async () => hook.result.current.stopSession()) + expect(hook.result.current.error).toBe('stop failed') + await act(async () => hook.result.current.restartSession()) + expect(hook.result.current.error).toBe('restart failed') + }) + + it('refreshes dynamic workspaces and selects the created session', async () => { + const initial = { + id: 'workspace', name: 'Loopal', rootUri: '/loopal', kind: 'folder' as const, + } + const selected = { + id: 'local-new', name: 'feature', rootUri: '/work/feature', kind: 'git_worktree' as const, + } + const created = { + ...sessionOne, id: 'session-new', workspaceId: selected.id, title: 'Feature session', + } + const bootstrap = vi.fn() + .mockResolvedValueOnce({ + protocolVersion: 2, hostStatus: 'ready', workspaces: [initial], + sessions: [sessionOne], runtimes: [], activeSessionId: sessionOne.id, + }) + .mockResolvedValue({ + protocolVersion: 2, hostStatus: 'ready', workspaces: [initial, selected], + sessions: [sessionOne, created], runtimes: [], activeSessionId: created.id, + }) + const { api } = createTestAPI({ + bootstrap, createSession: async () => sessionDetail(created), + }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.activeSessionId).toBe(sessionOne.id)) + await act(async () => { await hook.result.current.createSession(createInput) }) + + expect(hook.result.current.activeWorkspaceId).toBe(selected.id) + expect(hook.result.current.workspaces).toContainEqual(selected) + expect(hook.result.current.currentSessions.map(({ id }) => id)) + .toEqual(['session-1', 'session-new']) + expect(hook.result.current.searchResults).toEqual([]) + expect(bootstrap).toHaveBeenCalledTimes(2) + }) + + it('does not auto-open history but keeps it available to cross-workspace search', async () => { + const history = { + ...sessionOne, id: 'history', workspaceId: 'workspace-history', + title: 'Legacy desktop audit', status: 'stopped' as const, + activeRuntimeId: undefined, + } + const openSession = vi.fn(async () => sessionDetail(history)) + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, hostStatus: 'ready', workspaces: [], + sessions: [history], runtimes: [], + }), + openSession, + }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.projection.hostStatus).toBe('ready')) + expect(hook.result.current.activeSessionId).toBeUndefined() + expect(hook.result.current.activeWorkspaceId).toBeUndefined() + expect(hook.result.current.currentSessions).toEqual([]) + expect(openSession).not.toHaveBeenCalled() + act(() => hook.result.current.setQuery('desktop')) + expect(hook.result.current.searchResults.map(({ id }) => id)).toEqual(['history']) + }) + + it('honors an explicit active historical session and restores its workspace scope', async () => { + const history = { + ...sessionOne, id: 'history-active', workspaceId: 'workspace-history', + status: 'stopped' as const, activeRuntimeId: undefined, + } + const openSession = vi.fn(async () => sessionDetail(history)) + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, hostStatus: 'ready', workspaces: [], + sessions: [history], runtimes: [], activeSessionId: history.id, + }), + openSession, + }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.activeSessionId).toBe(history.id)) + expect(openSession).toHaveBeenCalledWith(history.id) + expect(hook.result.current.activeWorkspaceId).toBe(history.workspaceId) + }) + + it('opens a detail even when an event adds no matching summary first', async () => { + const openSession = vi.fn(async () => sessionDetail(sessionOne)) + const { api } = createTestAPI({ openSession }) + const hook = renderHook(() => useWorkbenchController(api)) + await waitFor(() => expect(hook.result.current.activeSessionId).toBe(sessionOne.id)) + await act(async () => hook.result.current.openSession('unknown')) + expect(openSession).toHaveBeenLastCalledWith('unknown') + }) +}) diff --git a/apps/desktop/src/workbench/browser/use-workbench-controller.ts b/apps/desktop/src/workbench/browser/use-workbench-controller.ts new file mode 100644 index 00000000..cebbb2cd --- /dev/null +++ b/apps/desktop/src/workbench/browser/use-workbench-controller.ts @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + type CreateSessionInput, type DesktopEvent, type LoopalDesktopAPI, + type SessionDetail, type Workspace, +} from '../../shared/contracts' +import { isSessionLive } from '../../shared/contracts/session-lifecycle' +import { createWorkbenchRegistries } from '../workbench-registries' +import { + initialDesktopProjection, projectDesktopEvent, type DesktopProjection, +} from './desktop-event-projector' +import { preferredSessionId, sessionCatalogModel } from '../contrib/sessions/browser/session-catalog-model' +import { useImageAttachments } from '../contrib/conversation/browser/use-image-attachments' +import { useTargetDrafts } from '../contrib/conversation/browser/use-target-drafts' +export function useWorkbenchController(api: LoopalDesktopAPI) { + const registries = useMemo(createWorkbenchRegistries, []) + const [projection, setProjection] = useState(initialDesktopProjection) + const [activeSessionId, setActiveSessionId] = useState() + const [workspaces, setWorkspaces] = useState([]) + const [activeWorkspaceId, setActiveWorkspaceId] = useState() + const [query, setQuery] = useState('') + const [sending, setSending] = useState(false) + const [lifecycleBusy, setLifecycleBusy] = useState(false) + const [error, setError] = useState() + const searchRef = useRef(null) + const selectionVersion = useRef(0) + const imageAttachments = useImageAttachments(api, setError) + const targetDrafts = useTargetDrafts(activeSessionId) + useEffect(() => { + let disposed = false + let live = false + const buffered: DesktopEvent[] = [] + const unsubscribe = api.onEvent((event) => { + if (disposed) return + if (!live) buffered.push(event) + else setProjection((current) => projectDesktopEvent(current, event)) + }) + void api.bootstrap().then(async (bootstrap) => { + if (disposed) return + const base = buffered.reduce(projectDesktopEvent, { + hostStatus: bootstrap.hostStatus, + sessions: bootstrap.sessions, + runtimes: bootstrap.runtimes, + }) + buffered.length = 0 + live = true + setProjection(base) + setWorkspaces(bootstrap.workspaces) + const selected = preferredSessionId(bootstrap.sessions, bootstrap.activeSessionId) + const session = bootstrap.sessions.find((candidate) => candidate.id === selected) + setActiveWorkspaceId(session?.workspaceId) + setActiveSessionId(selected) + if (selected) { + const version = ++selectionVersion.current + const detail = await api.openSession(selected) + if (!disposed && version === selectionVersion.current) { + setProjection((current) => ({ ...current, detail })) + } + } + }).catch((reason: unknown) => { + if (!disposed) setError(errorMessage(reason)) + }) + return () => { + disposed = true + selectionVersion.current++ + unsubscribe() + registries.contributions.dispose() + } + }, [api, registries]) + useEffect(() => { + const listener = (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + searchRef.current?.focus() + } + } + window.addEventListener('keydown', listener) + return () => window.removeEventListener('keydown', listener) + }, []) + const catalog = useMemo( + () => sessionCatalogModel(projection.sessions, query), + [projection.sessions, query], + ) + const selectDetail = (sessionId: string, detail: SessionDetail): void => { + setActiveSessionId(sessionId) + setActiveWorkspaceId(detail.session.workspaceId) + setProjection((current) => ({ + ...current, + sessions: current.sessions.some((session) => session.id === sessionId) + ? current.sessions.map((session) => session.id === sessionId ? detail.session : session) + : [...current.sessions, detail.session], + detail, + })) + } + const openSession = async (sessionId: string): Promise => { + const version = ++selectionVersion.current + const session = projection.sessions.find((candidate) => candidate.id === sessionId) + setActiveSessionId(sessionId) + if (session) setActiveWorkspaceId(session.workspaceId) + setProjection(withoutDetail) + setError(undefined) + try { + const detail = await api.openSession(sessionId) + if (version !== selectionVersion.current) return + selectDetail(sessionId, detail) + return detail + } catch (reason) { + if (version === selectionVersion.current) setError(errorMessage(reason)) + } + } + const createSession = async (input: CreateSessionInput): Promise => { + if (lifecycleBusy) return 'SESSION_CREATE_UNAVAILABLE' + const version = ++selectionVersion.current + setLifecycleBusy(true) + setError(undefined) + try { + const detail = await api.createSession(input) + if (version === selectionVersion.current) selectDetail(detail.session.id, detail) + try { + const refreshed = await api.bootstrap() + setWorkspaces(refreshed.workspaces) + } catch (reason) { + setError(`SESSION_WORKSPACE_REFRESH_FAILED: ${errorMessage(reason)}`) + } + } catch (reason) { + const message = errorMessage(reason) + if (version === selectionVersion.current) setError(message) + return message + } finally { + setLifecycleBusy(false) + } + } + const runLifecycle = async (restart: boolean): Promise => { + if (!activeSessionId || lifecycleBusy) return + setLifecycleBusy(true) + setError(undefined) + try { + if (restart) { + const runtime = await api.restartSession(activeSessionId) + setProjection((current) => projectDesktopEvent(current, { + type: 'runtime_updated', runtime, + })) + } else await api.stopSession(activeSessionId) + } catch (reason) { + setError(errorMessage(reason)) + } finally { + setLifecycleBusy(false) + } + } + const send = async (agentId?: string): Promise => { + const text = targetDrafts.get(agentId).trim() + if (!activeSessionId || (!text && imageAttachments.images.length === 0) + || sending || !projection.detail + || !isSessionLive(projection.detail.session)) return + const images = imageAttachments.clearImages() + targetDrafts.set(agentId, '') + setSending(true) + setError(undefined) + try { + if (images.length) await api.sendMessage(activeSessionId, text, agentId, images) + else await api.sendMessage(activeSessionId, text, agentId) + } + catch (reason) { + targetDrafts.set(agentId, text) + imageAttachments.restoreImages(images) + setError(errorMessage(reason)) + } finally { setSending(false) } + } + return { + registries, projection, workspaces, activeWorkspaceId, activeSessionId, + currentSessions: catalog.currentSessions, searchResults: catalog.searchResults, + query, setQuery, searchRef, + openSession, createSession, stopSession: () => runLifecycle(false), + restartSession: () => runLifecycle(true), + canCreate: !lifecycleBusy, + lifecycleBusy, + draftFor: targetDrafts.get, + setDraftFor: targetDrafts.set, + sending, error, send, + images: imageAttachments.images, selectImages: imageAttachments.selectImages, + removeImage: imageAttachments.removeImage, + } +} +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} +function withoutDetail({ detail: _detail, ...projection }: DesktopProjection): DesktopProjection { + return projection +} diff --git a/apps/desktop/src/workbench/browser/use-workbench-runtime-controller.ts b/apps/desktop/src/workbench/browser/use-workbench-runtime-controller.ts new file mode 100644 index 00000000..1f219c7d --- /dev/null +++ b/apps/desktop/src/workbench/browser/use-workbench-runtime-controller.ts @@ -0,0 +1,36 @@ +import { useState } from 'react' +import { type LoopalDesktopAPI } from '../../shared/contracts' +import { + type Stage2WorkbenchBinding, type Stage2WorkbenchModel, +} from './stage2-view-model' +import { useAttentionController } from '../contrib/attention/browser/use-attention-controller' +import { usePlanApprovalController } from '../contrib/attention/browser/use-plan-approval-controller' + +export function useWorkbenchRuntimeController( + api: LoopalDesktopAPI, + context: Stage2WorkbenchModel['context'], + enabled: boolean, +): Stage2WorkbenchBinding { + const [error, setError] = useState() + const sessionId = context.activeSessionId + const attention = useAttentionController( + api, context.sessions, sessionId, enabled, setError, + ) + const plans = usePlanApprovalController( + api, context.sessions, sessionId, enabled, setError, + ) + + return { + model: { + context, + ...(error !== undefined ? { error } : {}), + permissions: attention.permissions, + questions: attention.questions, + planApprovals: plans.planApprovals, + }, + callbacks: { + ...attention.callbacks, + ...plans.callbacks, + }, + } +} diff --git a/apps/desktop/src/workbench/browser/use-workbench-shortcuts.ts b/apps/desktop/src/workbench/browser/use-workbench-shortcuts.ts new file mode 100644 index 00000000..fbd0601d --- /dev/null +++ b/apps/desktop/src/workbench/browser/use-workbench-shortcuts.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' +import { type WorkbenchArea } from './workbench-view-state' + +export function useWorkbenchShortcuts(actions: { + readonly selectArea: (area: WorkbenchArea) => void + readonly toggleSidebar: () => void + readonly toggleSettings: () => void +}): void { + useEffect(() => { + const listener = (event: KeyboardEvent): void => { + if (event.isComposing) return + if (!event.metaKey && !event.ctrlKey) return + const key = event.key.toLowerCase() + const action = key === '1' ? () => actions.selectArea('conversation') + : key === '2' ? () => actions.selectArea('federation') + : key === 'b' ? actions.toggleSidebar + : key === ',' ? actions.toggleSettings : undefined + if (!action) return + event.preventDefault() + action() + } + window.addEventListener('keydown', listener) + return () => window.removeEventListener('keydown', listener) + }, [actions]) +} diff --git a/apps/desktop/src/workbench/browser/window-drag-region.css b/apps/desktop/src/workbench/browser/window-drag-region.css new file mode 100644 index 00000000..2122efd4 --- /dev/null +++ b/apps/desktop/src/workbench/browser/window-drag-region.css @@ -0,0 +1,26 @@ +html[data-platform="darwin"] :where( + .activity-bar, + .navigator-header, + .session-toolbar, + .settings-header +) { + -webkit-app-region: drag; + user-select: none; +} + +html[data-platform="darwin"] .activity-bar { + padding-top: 42px; +} + +html[data-platform="darwin"] :where( + .activity-bar, + .navigator-header, + .session-toolbar, + .settings-header +) :where( + button, input, textarea, select, a, + [contenteditable="true"], [role="button"], [role="tab"], + .toolbar-actions +) { + -webkit-app-region: no-drag; +} diff --git a/apps/desktop/src/workbench/browser/workbench-chrome.css b/apps/desktop/src/workbench/browser/workbench-chrome.css new file mode 100644 index 00000000..916d7335 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-chrome.css @@ -0,0 +1,29 @@ +.status-bar { + grid-column: 1 / -1; + grid-row: 2; + display: flex; + align-items: center; + gap: 18px; + padding: 0 10px; + border-top: 1px solid var(--wb-border-subtle); + background: var(--wb-surface-2); + color: var(--wb-text-muted); + font-size: 9px; +} +.status-bar > button { border: 0; background: transparent; color: inherit; cursor: pointer; } +.status-federation { display: flex; align-items: center; gap: 6px; margin-left: auto; } +.status-federation > span { width: 6px; height: 6px; border-radius: 50%; background: var(--wb-text-muted); } +.status-federation.state-connected > span { background: var(--wb-success); } +.status-federation.state-running > span { background: var(--wb-warning); } +.status-federation.state-error > span { background: var(--wb-danger); } +.status-federation small { color: var(--wb-text-muted); font-size: 9px; } + +.settings-overlay { + position: absolute; + z-index: var(--wb-overlay); + inset: 0; + display: flex; + background: rgba(8, 10, 14, .82); + backdrop-filter: blur(8px); +} +.settings-overlay > .settings-view { width: 100%; box-shadow: var(--wb-shadow); } diff --git a/apps/desktop/src/workbench/browser/workbench-context.ts b/apps/desktop/src/workbench/browser/workbench-context.ts new file mode 100644 index 00000000..9a6a4669 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-context.ts @@ -0,0 +1,18 @@ +import { buildFallbackContext } from './fallback-context' +import { type useWorkbenchController } from './use-workbench-controller' + +export function buildControllerContext( + controller: ReturnType, +): ReturnType { + return buildFallbackContext({ + workspaces: controller.workspaces, + sessions: controller.projection.sessions, + runtimes: controller.projection.runtimes, + ...(controller.activeWorkspaceId !== undefined + ? { activeWorkspaceId: controller.activeWorkspaceId } + : {}), + ...(controller.activeSessionId !== undefined + ? { activeSessionId: controller.activeSessionId } + : {}), + }) +} diff --git a/apps/desktop/src/workbench/browser/workbench-icon.tsx b/apps/desktop/src/workbench/browser/workbench-icon.tsx new file mode 100644 index 00000000..7bde4318 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-icon.tsx @@ -0,0 +1,30 @@ +export type WorkbenchIconName = + | 'conversation' | 'federation' | 'settings' | 'sidebar' + +const paths: Record = { + conversation: [ + 'M4.5 4.5h15v10h-8l-4.5 4v-4H4.5z', + 'M8 8h8M8 11h6', + ], + federation: [ + 'M12 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z', + 'M5 17a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm14 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z', + 'M10.2 4.8 6.8 12m7-7.2 3.4 7.2M7.5 15h9', + ], + settings: [ + 'M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z', + 'M19 13.2v-2.4l2-1.5-2-3.4-2.3 1a8 8 0 0 0-2-1.2L14.4 3h-4.8l-.3 2.7a8 8 0 0 0-2 1.2l-2.3-1-2 3.4 2 1.5v2.4l-2 1.5 2 3.4 2.3-1a8 8 0 0 0 2 1.2l.3 2.7h4.8l.3-2.7a8 8 0 0 0 2-1.2l2.3 1 2-3.4z', + ], + sidebar: ['M4 4h16v16H4z', 'M9 4v16'], +} + +export function WorkbenchIcon(props: { + readonly name: WorkbenchIconName +}): React.JSX.Element { + return ( + + {paths[props.name].map((path) => )} + + ) +} diff --git a/apps/desktop/src/workbench/browser/workbench-sidebar.tsx b/apps/desktop/src/workbench/browser/workbench-sidebar.tsx new file mode 100644 index 00000000..d41691f0 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-sidebar.tsx @@ -0,0 +1,22 @@ +import { SessionNavigator } from '../contrib/sessions/browser/session-navigator' +import { type useWorkbenchController } from './use-workbench-controller' +import { type useFederationController } from '../contrib/federation/browser/use-federation-controller' + +export function WorkbenchSidebar(props: { + readonly controller: ReturnType + readonly federation: ReturnType + readonly onRequestCreate: () => void +}): React.JSX.Element { + const controller = props.controller + return +} diff --git a/apps/desktop/src/workbench/browser/workbench-tokens.css b/apps/desktop/src/workbench/browser/workbench-tokens.css new file mode 100644 index 00000000..8d0d4f64 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-tokens.css @@ -0,0 +1,44 @@ +:root { + --wb-bg: #090b10; + --wb-surface-1: #0d1016; + --wb-surface-2: #121620; + --wb-surface-3: #181d28; + --wb-surface-raised: #1d2330; + --wb-border-subtle: #202632; + --wb-border: #2b3342; + --wb-border-strong: #3c475b; + --wb-text: #e8ebf2; + --wb-text-secondary: #a9b1c1; + --wb-text-muted: #747e92; + --wb-accent: #94a7ff; + --wb-accent-soft: #202943; + --wb-focus: #91a5ff; + --wb-success: #70d6a7; + --wb-warning: #e0ad58; + --wb-danger: #ee787f; + --wb-info: #7f9ee8; + --wb-space-1: 4px; + --wb-space-2: 8px; + --wb-space-3: 12px; + --wb-space-4: 16px; + --wb-space-5: 24px; + --wb-control-sm: 28px; + --wb-control: 32px; + --wb-radius-sm: 6px; + --wb-radius: 9px; + --wb-radius-lg: 13px; + --wb-shadow: 0 18px 48px rgba(0, 0, 0, .32); + --wb-rail-width: 48px; + --wb-sidebar-width: 272px; + --wb-status-height: 24px; + --wb-overlay: 30; +} + +:where(button, input, textarea, select, [tabindex]):focus-visible { + outline: 2px solid var(--wb-focus); + outline-offset: 2px; +} + +@media (max-width: 1279px) { + :root { --wb-sidebar-width: 240px; } +} diff --git a/apps/desktop/src/workbench/browser/workbench-view-state.test.ts b/apps/desktop/src/workbench/browser/workbench-view-state.test.ts new file mode 100644 index 00000000..34acc3aa --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-view-state.test.ts @@ -0,0 +1,34 @@ +import { + createWorkbenchViewState, reduceWorkbenchView, +} from './workbench-view-state' + +describe('workbench view state', () => { + it('starts in the conversation area', () => { + expect(createWorkbenchViewState()).toEqual({ + area: 'conversation', sidebarVisible: true, + settingsOpen: false, + }) + }) + + it('keeps utilities orthogonal while area changes close settings', () => { + let state = createWorkbenchViewState() + state = reduceWorkbenchView(state, { type: 'toggle_sidebar' }) + state = reduceWorkbenchView(state, { type: 'open_settings' }) + state = reduceWorkbenchView(state, { type: 'select_area', area: 'federation' }) + expect(state).toEqual({ + area: 'federation', sidebarVisible: false, + settingsOpen: false, + }) + }) + + it('toggles settings without losing the underlying area', () => { + const federation = reduceWorkbenchView( + createWorkbenchViewState(), + { type: 'select_area', area: 'federation' }, + ) + const open = reduceWorkbenchView(federation, { type: 'toggle_settings' }) + expect(open).toMatchObject({ area: 'federation', settingsOpen: true }) + expect(reduceWorkbenchView(open, { type: 'close_settings' })) + .toMatchObject({ area: 'federation', settingsOpen: false }) + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench-view-state.ts b/apps/desktop/src/workbench/browser/workbench-view-state.ts new file mode 100644 index 00000000..a4f742d8 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench-view-state.ts @@ -0,0 +1,40 @@ +export type WorkbenchArea = 'conversation' | 'federation' + +export interface WorkbenchViewState { + readonly area: WorkbenchArea + readonly sidebarVisible: boolean + readonly settingsOpen: boolean +} + +export type WorkbenchViewAction = + | { readonly type: 'select_area'; readonly area: WorkbenchArea } + | { readonly type: 'toggle_sidebar' } + | { readonly type: 'toggle_settings' } + | { readonly type: 'open_settings' } + | { readonly type: 'close_settings' } + +export function createWorkbenchViewState(): WorkbenchViewState { + return { + area: 'conversation', + sidebarVisible: true, + settingsOpen: false, + } +} + +export function reduceWorkbenchView( + state: WorkbenchViewState, + action: WorkbenchViewAction, +): WorkbenchViewState { + switch (action.type) { + case 'select_area': + return { ...state, area: action.area, settingsOpen: false } + case 'toggle_sidebar': + return { ...state, sidebarVisible: !state.sidebarVisible } + case 'toggle_settings': + return { ...state, settingsOpen: !state.settingsOpen } + case 'open_settings': + return { ...state, settingsOpen: true } + case 'close_settings': + return { ...state, settingsOpen: false } + } +} diff --git a/apps/desktop/src/workbench/browser/workbench.context.test.tsx b/apps/desktop/src/workbench/browser/workbench.context.test.tsx new file mode 100644 index 00000000..600607e3 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.context.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { + createTestAPI, + sessionDetail, + sessionOne, + updatedAt, +} from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +const docsSession = { + ...sessionOne, + id: 'docs-session', + workspaceId: 'docs', + title: 'Write the guide', + updatedAt, +} + +const workspaces = [ + { id: 'workspace', name: 'Loopal', rootUri: '/work/loopal', kind: 'folder' as const }, + { id: 'docs', name: 'Docs', rootUri: '/work/docs', kind: 'git_worktree' as const }, +] + +describe('Workbench session catalog', () => { + it('opens live sessions across workspace scopes from one catalog', async () => { + const openSession = vi.fn(async (sessionId: string) => ( + sessionDetail(sessionId === docsSession.id ? docsSession : sessionOne) + )) + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, + hostStatus: 'ready', + workspaces, + sessions: [sessionOne, docsSession], + runtimes: [], + activeSessionId: sessionOne.id, + }), + openSession, + }) + render() + await screen.findByText('Conversation for Build the desktop workbench') + expect(screen.queryByLabelText('Active workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Active session')).not.toBeInTheDocument() + const list = within(screen.getByTestId('session-list')) + expect(list.getByText('Build the desktop workbench')).toBeInTheDocument() + expect(list.getByText('Write the guide')).toBeInTheDocument() + + fireEvent.click(list.getByText('Write the guide')) + await waitFor(() => expect(openSession).toHaveBeenCalledWith(docsSession.id)) + expect(screen.getByTestId('active-session-title')).toHaveTextContent('Write the guide') + expect(screen.getByText('Conversation for Write the guide')).toBeInTheDocument() + }) + + it('allows session creation without a workspace or active session', async () => { + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, + hostStatus: 'ready', + workspaces: [], + sessions: [], + runtimes: [], + }), + }) + render() + await screen.findByText('No active sessions. Create one to start working.') + expect(screen.queryByLabelText('Active workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Active session')).not.toBeInTheDocument() + const create = screen.getByRole('button', { name: 'New Session' }) + expect(create).toBeEnabled() + fireEvent.click(create) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.css b/apps/desktop/src/workbench/browser/workbench.css new file mode 100644 index 00000000..8c523e2e --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.css @@ -0,0 +1,194 @@ +@import './workbench-tokens.css'; +@import './workbench-chrome.css'; +@import './stage2-workbench.css'; +@import '../contrib/conversation/browser/conversation-rich.css'; +@import '../contrib/conversation/browser/slash-command-menu.css'; +@import '../contrib/session-panels/browser/inspector-rich.css'; +@import '../contrib/agents/browser/agent-controls.css'; +@import '../contrib/session-panels/browser/session-panel-deck.css'; +@import '../contrib/session-panels/browser/session-panels.css'; +@import '../contrib/federation/browser/federation-workspace.css'; +@import './window-drag-region.css'; +@import '../contrib/sessions/browser/session-context-menu.css'; +@import '../contrib/sessions/browser/session-create-dialog.css'; +@import '../contrib/sessions/browser/session-navigator.css'; + +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0b0d12; + color: #e9ebf1; + font-synthesis: none; +} + +* { box-sizing: border-box; } +html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; } +button, input, textarea { font: inherit; } +button { color: inherit; } + +.workbench { + display: grid; + grid-template-columns: var(--wb-rail-width) var(--wb-sidebar-width) minmax(560px, 1fr); + grid-template-rows: minmax(0, 1fr) var(--wb-status-height); + height: 100%; + background: + radial-gradient(circle at 55% -20%, rgba(71, 92, 165, 0.14), transparent 36%), + #0b0d12; +} +.workbench.sidebar-collapsed { grid-template-columns: var(--wb-rail-width) minmax(0, 1fr); } + +.activity-bar, .session-navigator, .session-workspace { min-height: 0; } +.activity-bar { + grid-row: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--wb-space-2); + padding: 10px 6px; + background: #090b0f; + border-right: 1px solid #20232d; +} +.activity-group { display: flex; flex-direction: column; gap: 5px; } +.brand-mark { + width: 31px; + height: 31px; + display: grid; + place-items: center; + border-radius: 9px; + margin-bottom: 12px; + background: linear-gradient(135deg, #f0f2ff, #8f9bcf); + color: #111421; + font-weight: 900; +} +.activity { + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: transparent; + color: #747b91; + cursor: pointer; +} +.activity svg { width: 18px; height: 18px; } +.activity:hover, .activity.active { background: #202432; color: #dfe3f4; } +.activity-spacer { flex: 1; } + +.session-navigator { + grid-row: 1; + display: flex; + flex-direction: column; + background: rgba(15, 17, 23, 0.96); + border-right: 1px solid #252935; +} +.navigator-header, .session-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 16px 12px; +} +.eyebrow, .session-path { color: #697087; font-size: 10px; letter-spacing: 0.12em; } +.navigator-header h1, .session-toolbar h2 { margin: 3px 0 0; font-size: 17px; } +.new-session, .toolbar-actions button, .composer-lifecycle { + border: 1px solid #323746; + background: #1a1e28; + border-radius: 7px; + height: 30px; + min-width: 30px; + cursor: pointer; +} +.session-search { + margin: 2px 12px 10px; + display: flex; + gap: 7px; + align-items: center; + padding: 7px 9px; + border: 1px solid #282d39; + border-radius: 7px; + background: #11141b; + color: #697087; +} +.session-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: #dfe2ec; font-size: 12px; } +.session-list { overflow: auto; padding: 0 7px 18px; } +.session-card { + width: 100%; + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 9px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + text-align: left; + cursor: pointer; +} +.session-card:hover { background: #171a22; } +.session-card.selected { background: #1d2230; border-color: #30384e; } +.session-copy { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 4px; } +.session-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 590; } +.session-copy small, .resource-row small, .artifact-card small { color: #747b91; font-size: 10px; text-transform: capitalize; } +.status-dot, .agent-state { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: #5b6275; } +.status-running, .agent-running { background: #72dbb0; box-shadow: 0 0 8px rgba(114, 219, 176, 0.45); } +.status-waiting, .agent-waiting { background: #e7b65f; } +.status-stopped, .status-archived, .agent-completed { background: #7585ca; } +.status-failed, .agent-failed { background: #ef7272; } +.attention { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: #9c7432; font-size: 10px; } + +.session-workspace { grid-row: 1; display: flex; flex-direction: column; background: rgba(12, 14, 19, 0.68); } +.session-toolbar { min-height: 64px; border-bottom: 1px solid #242834; } +.session-identity { min-width: 0; } +.session-title-row { min-width: 0; display: flex; align-items: baseline; gap: 9px; } +.session-title-row h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-agent-route { display: flex; align-items: baseline; gap: 5px; color: var(--wb-text-secondary); font-size: 10px; white-space: nowrap; } +.session-agent-route > span, .session-agent-route small { color: var(--wb-text-muted); } +.toolbar-actions { display: flex; align-items: center; gap: 9px; } +.session-details { border-bottom: 1px solid #242834; background: #11141b; } +.session-metadata { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 10px max(28px, 8vw); } +.session-more-actions { display: flex; align-items: center; gap: 8px; padding: 0 max(28px, 8vw) 10px; } +.session-more-actions strong { margin-right: auto; color: #7e869b; font-size: 10px; } +.session-more-actions button, .agent-mode-picker select { border: 1px solid #303645; border-radius: 5px; background: #171b24; color: #cbd0dc; font-size: 10px; } +.session-metadata div, .artifact-metadata div { min-width: 0; } +.session-metadata dt, .artifact-metadata dt { color: #697087; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; } +.session-metadata dd, .artifact-metadata dd { overflow: hidden; margin: 3px 0 0; color: #cdd2df; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.conversation { min-height: 120px; flex: 1; overflow: auto; padding: 26px max(28px, 8vw); } +.message { margin: 0 auto 22px; max-width: 760px; } +.message-role { display: block; margin-bottom: 7px; color: #717990; font-size: 9px; letter-spacing: 0.11em; text-transform: uppercase; } +.message p { margin: 0; line-height: 1.65; color: #d5d9e5; font-size: var(--conversation-font-size, 13px); white-space: pre-wrap; } +.message-user { padding: 12px 14px; border: 1px solid #292f3e; border-radius: 10px; background: #171b25; } +.message-system p { color: #8c93a8; font-style: italic; } +.composer { flex: 0 0 auto; margin: 0 max(24px, 7vw) 22px; border: 1px solid #343a4c; border-radius: 11px; background: #151922; box-shadow: 0 14px 45px rgba(0,0,0,.25); } +.composer textarea { width: 100%; min-height: 64px; max-height: 160px; resize: vertical; padding: 13px; border: 0; outline: 0; background: transparent; color: #e6e9f2; } +.composer-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 8px 8px 12px; border-top: 1px solid #242936; color: #737b91; font-size: 10px; } +.composer-tools, .composer-actions, .pending-images { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.pending-images { padding: 9px 12px 0; } +.pending-image { display: flex; align-items: center; gap: 5px; max-width: 220px; padding: 4px 7px; border: 1px solid #303748; border-radius: 7px; color: #aeb6ca; } +.pending-image > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pending-image button, .attach-images { border: 0; background: transparent; color: #9099b0; cursor: pointer; } +.agent-mode-picker { display: flex; align-items: center; gap: 5px; } +.send-button { border: 0; border-radius: 7px; padding: 6px 13px; background: #d9def5; color: #171a24; cursor: pointer; font-weight: 700; } +.send-button:disabled, .composer-lifecycle:disabled { opacity: .35; cursor: default; } + +.inspector-content { flex: 1; overflow: auto; padding: 13px; } +.artifact-card, .resource-row { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid #292e3b; border-radius: 8px; background: #14171f; text-align: left; } +.artifact-card { cursor: pointer; margin-bottom: 8px; } +.artifact-item { margin-bottom: 8px; } +.artifact-item .artifact-card { margin-bottom: 0; } +.artifact-metadata { margin: 0; padding: 9px 10px; border: 1px solid #292e3b; border-top: 0; border-radius: 0 0 8px 8px; background: #10131a; } +.artifact-metadata div + div { margin-top: 7px; } +.artifact-card span:last-child, .resource-row div { display: flex; flex-direction: column; gap: 3px; } +.artifact-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 6px; background: #242a3b; color: #aab5e4; } +.resource-row { border: 0; border-bottom: 1px solid #252a35; border-radius: 0; background: transparent; } +.agent-node { transition: padding-left 120ms ease; } +.empty-inspector { height: 100%; display: grid; place-content: center; justify-items: center; color: #687086; text-align: center; font-size: 12px; } +.empty-inspector > span { font-size: 28px; } +.empty-inspector p { max-width: 190px; line-height: 1.5; } +.metric { display: flex; justify-content: space-between; padding: 12px 4px; border-bottom: 1px solid #252a35; } +.metric strong { font-size: 11px; text-transform: capitalize; } +.metric span { color: #747b90; font-size: 10px; } +.error-banner { max-width: 760px; margin: auto; padding: 12px; border: 1px solid #713f47; border-radius: 8px; background: #321d22; color: #f0aeb7; } +.loading, .empty-state { color: #737b90; text-align: center; font-size: 12px; } + +@media (max-width: 1050px) { + .workbench { grid-template-columns: var(--wb-rail-width) var(--wb-sidebar-width) minmax(0, 1fr); } + .workbench.sidebar-collapsed { grid-template-columns: var(--wb-rail-width) minmax(0, 1fr); } + .session-agent-route small { display: none; } +} diff --git a/apps/desktop/src/workbench/browser/workbench.events.test.tsx b/apps/desktop/src/workbench/browser/workbench.events.test.tsx new file mode 100644 index 00000000..6c948f31 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.events.test.tsx @@ -0,0 +1,127 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + createTestAPI, + sessionOne, + sessionTwo, + updatedAt, +} from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Workbench events', () => { + it('renders live host, session, conversation, and artifact events', async () => { + const sendMessage = vi.fn(async () => undefined) + const { api, events } = createTestAPI({ sendMessage }) + render() + await screen.findByText('Conversation for Build the desktop workbench') + + await act(async () => { + events.fire({ type: 'host_status', status: 'alive' }) + events.fire({ + type: 'session_updated', + session: { ...sessionOne, status: 'running', attention: 'completed' }, + }) + events.fire({ + type: 'conversation_entry', + sessionId: sessionOne.id, + entry: { + id: 'message-live', + role: 'assistant', + text: 'Live response', + createdAt: updatedAt, + }, + }) + events.fire({ + type: 'artifact_created', + artifact: { + id: 'artifact-live', + sessionId: sessionOne.id, + title: 'Live artifact.md', + kind: 'report', + uri: 'loopal-artifact://live', + mediaType: 'text/markdown', + producerAgentId: 'agent-session-1', + createdAt: updatedAt, + }, + }) + events.fire({ type: 'session_updated', session: { ...sessionTwo, status: 'failed' } }) + }) + + expect(screen.queryByTestId('host-status')).not.toBeInTheDocument() + expect(screen.getByTestId('session-list') + .querySelector(`[data-session-id="${sessionOne.id}"] .attention`)).toBeNull() + expect(screen.getByText('Live response')).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: /Artifacts/ })) + const artifact = screen.getByRole('button', { name: /Live artifact\.md/ }) + fireEvent.click(artifact) + expect(artifact).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('loopal-artifact://live')).toBeInTheDocument() + fireEvent.click(artifact) + expect(screen.queryByText('loopal-artifact://live')).not.toBeInTheDocument() + + act(() => { + events.fire({ + type: 'session_detail_replaced', + detail: { + session: { ...sessionOne, status: 'waiting', attention: 'question' }, + conversation: [{ + id: 'snapshot-message', + role: 'assistant', + text: 'Resynchronized response', + createdAt: updatedAt, + }], + agents: [], + artifacts: [], + }, + }) + }) + expect(screen.getByText('Resynchronized response')).toBeInTheDocument() + expect(screen.queryByText('Live response')).not.toBeInTheDocument() + + const input = screen.getByLabelText('Message Loopal') + fireEvent.change(input, { target: { value: 'Ship it' } }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }) + await waitFor(() => expect(sendMessage).toHaveBeenCalledWith( + sessionOne.id, 'Ship it', 'main', + )) + expect(input).toHaveValue('') + }) + + it('ignores events belonging to another active session', async () => { + const { api, events } = createTestAPI() + render() + await screen.findByText('Conversation for Build the desktop workbench') + + act(() => { + events.fire({ + type: 'conversation_entry', + sessionId: sessionTwo.id, + entry: { + id: 'other', + role: 'assistant', + text: 'Other session event', + createdAt: updatedAt, + }, + }) + events.fire({ + type: 'artifact_created', + artifact: { + id: 'other-artifact', + sessionId: sessionTwo.id, + title: 'Other artifact', + kind: 'other', + uri: 'loopal-artifact://other', + mediaType: 'text/plain', + producerAgentId: 'agent-other', + createdAt: updatedAt, + }, + }) + events.fire({ + type: 'session_detail_replaced', + detail: { session: sessionTwo, conversation: [], agents: [], artifacts: [] }, + }) + }) + + expect(screen.queryByText('Other session event')).not.toBeInTheDocument() + expect(screen.queryByText('Other artifact')).not.toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.failures.test.tsx b/apps/desktop/src/workbench/browser/workbench.failures.test.tsx new file mode 100644 index 00000000..70fbd2e7 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.failures.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + createTestAPI, + sessionDetail, + sessionTwo, +} from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Workbench failures and sending', () => { + it('surfaces Federation failures from the Conversation workspace', async () => { + const { api } = createTestAPI({ + getMetaHubSettings: async () => { throw new Error('federation unavailable') }, + }) + render() + expect(await screen.findByRole('alert')).toHaveTextContent('federation unavailable') + }) + + it('shows bootstrap, open-session, and send failures without losing input', async () => { + const bootstrapFailure = createTestAPI({ + bootstrap: async () => { + throw new Error('bootstrap failed') + }, + }) + const first = render() + expect(await screen.findByRole('alert')).toHaveTextContent('bootstrap failed') + first.unmount() + + const failedOpenSession = vi.fn(async () => { + throw new Error('open failed') + }) + const openFailure = createTestAPI({ openSession: failedOpenSession }) + const second = render() + expect(await screen.findByRole('alert')).toHaveTextContent('open failed') + fireEvent.click(screen.getByText('Version the protocol')) + await waitFor(() => expect(failedOpenSession).toHaveBeenCalledTimes(2)) + second.unmount() + + const sendFailure = createTestAPI({ + sendMessage: async () => { + throw 'send failed' + }, + }) + render() + await screen.findByText('Conversation for Build the desktop workbench') + const input = screen.getByLabelText('Message Loopal') + fireEvent.change(input, { target: { value: 'Retry me' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(await screen.findByRole('alert')).toHaveTextContent('send failed') + expect(input).toHaveValue('Retry me') + }) + + it('uses the first session fallback and guards empty or concurrent sends', async () => { + let finishSend: (() => void) | undefined + const sendMessage = vi.fn( + () => + new Promise((resolve) => { + finishSend = resolve + }), + ) + const openSession = vi.fn(async () => sessionDetail(sessionTwo)) + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, + hostStatus: 'ready', + workspaces: [], + sessions: [sessionTwo], + runtimes: [], + }), + openSession, + sendMessage, + }) + render() + const input = await screen.findByLabelText('Message Loopal') + await waitFor(() => expect(openSession).toHaveBeenCalledWith(sessionTwo.id)) + + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }) + fireEvent.keyDown(input, { key: 'x' }) + expect(sendMessage).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: 'Run once' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Running…' })).toBeDisabled()) + fireEvent.change(input, { target: { value: 'Do not run twice' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(sendMessage).toHaveBeenCalledTimes(1) + + finishSend?.() + await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument()) + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.lifecycle.test.tsx b/apps/desktop/src/workbench/browser/workbench.lifecycle.test.tsx new file mode 100644 index 00000000..aec8017c --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.lifecycle.test.tsx @@ -0,0 +1,111 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { type DesktopEvent, type WorkbenchBootstrap } from '../../shared/contracts' +import { + createTestAPI, + sessionDetail, + sessionOne, + sessionTwo, +} from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Workbench lifecycle', () => { + it('ignores bootstrap and host events after disposal', async () => { + let resolveBootstrap: ((value: WorkbenchBootstrap) => void) | undefined + let eventListener: ((event: DesktopEvent) => void) | undefined + const unsubscribe = vi.fn() + const openSession = vi.fn(async () => sessionDetail(sessionOne)) + const bootstrap = new Promise((resolve) => { + resolveBootstrap = resolve + }) + const { api } = createTestAPI({ + bootstrap: () => bootstrap, + openSession, + onEvent: (listener) => { + eventListener = listener + return unsubscribe + }, + }) + const view = render() + + expect(screen.queryByTestId('session-panel-zone')).not.toBeInTheDocument() + view.unmount() + + await act(async () => { + eventListener?.({ type: 'host_status', status: 'ready' }) + resolveBootstrap?.({ + protocolVersion: 2, + hostStatus: 'ready', + workspaces: [], + sessions: [sessionOne], + runtimes: [], + activeSessionId: sessionOne.id, + }) + await bootstrap + }) + expect(unsubscribe).toHaveBeenCalledTimes(3) + expect(openSession).not.toHaveBeenCalled() + }) + + it('ignores late bootstrap failures and initial session results', async () => { + let rejectBootstrap: ((reason: Error) => void) | undefined + const bootstrap = new Promise((_resolve, reject) => { + rejectBootstrap = reject + }) + const failed = createTestAPI({ bootstrap: () => bootstrap }) + const first = render() + first.unmount() + await act(async () => rejectBootstrap?.(new Error('late failure'))) + + let resolveOpen: (() => void) | undefined + const openSession = vi.fn(() => new Promise>((resolve) => { + resolveOpen = () => resolve(sessionDetail(sessionOne)) + })) + const pending = createTestAPI({ openSession }) + const second = render() + await waitFor(() => expect(openSession).toHaveBeenCalledOnce()) + second.unmount() + await act(async () => resolveOpen?.()) + }) + + it('ignores an open failure after the user selects another session', async () => { + let rejectSessionTwo: ((reason: Error) => void) | undefined + const openSession = vi.fn((sessionId: string) => { + if (sessionId === sessionTwo.id) { + return new Promise>((_resolve, reject) => { + rejectSessionTwo = reject + }) + } + return Promise.resolve(sessionDetail(sessionOne)) + }) + const { api } = createTestAPI({ openSession }) + render() + await screen.findByText(`Conversation for ${sessionOne.title}`) + + const sessionList = within(screen.getByTestId('session-list')) + fireEvent.click(sessionList.getByText(sessionTwo.title)) + await waitFor(() => expect(openSession).toHaveBeenCalledWith(sessionTwo.id)) + fireEvent.click(sessionList.getByText(sessionOne.title)) + await screen.findByText(`Conversation for ${sessionOne.title}`) + await act(async () => rejectSessionTwo?.(new Error('stale failure'))) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('replays events received while bootstrap is pending', async () => { + let resolveBootstrap: ((value: WorkbenchBootstrap) => void) | undefined + const pending = new Promise((resolve) => { resolveBootstrap = resolve }) + const { api, events } = createTestAPI({ bootstrap: () => pending }) + render() + act(() => { + events.fire({ type: 'host_status', status: 'alive' }) + events.fire({ type: 'session_updated', session: sessionTwo }) + }) + await act(async () => resolveBootstrap?.({ + protocolVersion: 2, hostStatus: 'ready', workspaces: [], + sessions: [sessionOne], runtimes: [], activeSessionId: sessionOne.id, + })) + await screen.findByText(`Conversation for ${sessionOne.title}`) + expect(screen.queryByTestId('host-status')).not.toBeInTheDocument() + expect(screen.getByTestId('session-list')).toHaveTextContent(sessionTwo.title) + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.navigation.test.tsx b/apps/desktop/src/workbench/browser/workbench.navigation.test.tsx new file mode 100644 index 00000000..31a3c2a6 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.navigation.test.tsx @@ -0,0 +1,178 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { + createTestAPI, + sessionDetail, + sessionOne, + sessionTwo, + updatedAt, +} from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Workbench navigation', () => { + it('loads, filters, switches sessions, and navigates inspector panes', async () => { + const { api } = createTestAPI() + render() + + expect(await screen.findByTestId('active-session-title')).toHaveTextContent( + 'Build the desktop workbench', + ) + expect(screen.queryByTestId('host-status')).not.toBeInTheDocument() + expect(screen.queryByTestId('session-panel-zone')).not.toBeInTheDocument() + expect(screen.queryByTestId('inspector')).not.toBeInTheDocument() + + const search = screen.getByLabelText('Search sessions') + const sessionList = screen.getByTestId('session-list') + fireEvent.change(search, { target: { value: 'protocol' } }) + expect(within(sessionList).queryByText('Build the desktop workbench')).not.toBeInTheDocument() + expect(within(sessionList).getByText('Version the protocol')).toBeInTheDocument() + fireEvent.change(search, { target: { value: '' } }) + + fireEvent.click(screen.getByText('Version the protocol')) + await waitFor(() => { + expect(screen.getByTestId('active-session-title')).toHaveTextContent('Version the protocol') + }) + expect(screen.getByText('Protocol.md')).toBeInTheDocument() + + expect(screen.getByRole('tab', { name: 'Artifacts' })).toHaveAttribute( + 'aria-selected', 'true', + ) + expect(screen.getByTestId('artifacts-pane')).toHaveTextContent('Protocol.md') + expect(screen.queryByRole('tab', { name: 'Diagnostics' })).not.toBeInTheDocument() + + fireEvent.keyDown(window, { key: 'k', metaKey: true }) + expect(search).toHaveFocus() + search.blur() + fireEvent.keyDown(window, { key: 'K', ctrlKey: true }) + expect(search).toHaveFocus() + fireEvent.keyDown(window, { key: 'x' }) + }) + + it('renders an empty session catalog safely', async () => { + const { api } = createTestAPI({ + bootstrap: async () => ({ + protocolVersion: 2, + hostStatus: 'stopped', + workspaces: [], + sessions: [], + runtimes: [], + }), + }) + render() + + await waitFor(() => expect(screen.getByTestId('active-session-title')) + .toHaveTextContent('Select a session')) + expect(screen.getByTestId('active-session-title')).toHaveTextContent('Select a session') + expect(within(screen.getByTestId('session-list')).queryAllByRole('button')).toHaveLength(0) + fireEvent.keyDown(screen.getByLabelText('Message Loopal'), { key: 'Enter' }) + }) + + it('renders Federation as an application workspace without session chrome', async () => { + const { api } = createTestAPI() + render() + await screen.findByTestId('active-session-title') + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Federation' })) + await Promise.resolve() + }) + const workspace = screen.getByTestId('primary-workspace') + expect(workspace).toHaveAttribute('data-workspace', 'federation') + expect(within(workspace).queryByTestId('active-session-title')).not.toBeInTheDocument() + expect(within(workspace).queryByText('Workspace', { exact: true })).not.toBeInTheDocument() + expect(within(workspace).queryByText('Session', { exact: true })).not.toBeInTheDocument() + expect(within(workspace).queryByRole('button', { name: 'Stop session' })) + .not.toBeInTheDocument() + expect(within(workspace).queryByRole('button', { name: 'Restart session' })) + .not.toBeInTheDocument() + expect(screen.getByRole('heading', { + name: 'Start a Federation for your Loopal sessions.', + })).toBeInTheDocument() + }) + + it('opens a non-active Federation owner and keeps its qualified Agent selected', async () => { + const address = '127.0.0.1:39000' + const topology = [ + { id: 'one/main', name: 'main', hub: 'one', hubPath: ['one'], children: [], + lifecycle: 'running' as const }, + { id: 'two/reviewer', name: 'reviewer', hub: 'two', hubPath: ['two'], + parentId: 'two/main', children: [], lifecycle: 'running' as const }, + ] + const state = (hubName: string) => ({ + state: 'connected' as const, address, hubName, + hubs: ['one', 'two'].map((name) => ({ + name, status: 'connected' as const, agentCount: 1, capabilities: [], + })), topology, refreshedAt: updatedAt, + }) + const openSession = vi.fn(async (sessionId: string) => sessionId === sessionTwo.id + ? { ...sessionDetail(sessionTwo), metaHub: state('two'), agents: [{ + id: 'agent-session-2', name: 'Loopal', status: 'waiting' as const, + }, { + id: 'shadow-reviewer', name: 'reviewer', status: 'running' as const, + parentId: 'agent-session-2', qualifiedName: 'two/reviewer', conversation: [], + }] } + : { ...sessionDetail(sessionOne), metaHub: state('one') }) + const { api } = createTestAPI({ + openSession, + getLocalMetaHubStatus: async () => ({ state: 'running', address }), + getMetaHubSettings: async () => ({ + address, hubName: 'desktop', joinOnStart: false, + startLocalOnLaunch: true, tokenConfigured: true, + }), + getMetaHubStatus: async (target) => state( + target.sessionId === sessionTwo.id ? 'two' : 'one', + ), + }) + render() + await screen.findByTestId('active-session-title') + fireEvent.click(screen.getByRole('button', { name: 'Federation' })) + await screen.findByTestId('federation-connection') + const card = screen.getByTestId('federation-agent-list').querySelector( + `[data-owner-session-id="${sessionTwo.id}"][data-agent-id="two/reviewer"]`, + ) + expect(card).not.toBeNull() + fireEvent.click(card!) + fireEvent.click(screen.getByRole('button', { name: 'Open conversation' })) + await waitFor(() => expect(screen.getByTestId('primary-workspace')) + .toHaveAttribute('data-workspace', 'conversation')) + expect(screen.getByTestId('active-session-title')).toHaveTextContent(sessionTwo.title) + expect(screen.getByText(/Viewing reviewer/)).toBeInTheDocument() + expect(openSession).toHaveBeenLastCalledWith(sessionTwo.id) + }) + + it('does not let a stale session request replace the latest selection', async () => { + let initial = true + let resolveOne: (() => void) | undefined + let resolveTwo: (() => void) | undefined + const one = { ...sessionDetail(sessionOne), conversation: [{ + ...sessionDetail(sessionOne).conversation[0]!, text: 'Latest selection', + }] } + const two = { ...sessionDetail(sessionTwo), conversation: [{ + ...sessionDetail(sessionTwo).conversation[0]!, text: 'Stale selection', + }] } + const openSession = vi.fn((sessionId: string) => { + if (initial) { + initial = false + return Promise.resolve(sessionDetail(sessionOne)) + } + return new Promise((resolve) => { + const finish = (): void => resolve(sessionId === sessionOne.id ? one : two) + if (sessionId === sessionOne.id) resolveOne = finish + else resolveTwo = finish + }) + }) + const { api } = createTestAPI({ openSession }) + render() + await screen.findByText('Conversation for Build the desktop workbench') + + const list = within(screen.getByTestId('session-list')) + fireEvent.click(list.getByText('Version the protocol')) + fireEvent.click(list.getByText('Build the desktop workbench')) + await waitFor(() => { + expect(resolveOne).toBeTypeOf('function') + expect(resolveTwo).toBeTypeOf('function') + }) + await act(async () => resolveOne?.()) + expect(await screen.findByText('Latest selection')).toBeInTheDocument() + await act(async () => resolveTwo?.()) + expect(screen.queryByText('Stale selection')).not.toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.stage2-empty.test.tsx b/apps/desktop/src/workbench/browser/workbench.stage2-empty.test.tsx new file mode 100644 index 00000000..aea7c4dd --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.stage2-empty.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { stage2Model } from '../../../test/fixtures/workbench/attention' +import { createTestAPI } from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Stage 2 empty surfaces', () => { + it('keeps conversation usable without runtime resources', async () => { + const { api } = createTestAPI() + const model = { + ...stage2Model, + context: { workspaces: [], sessions: [] }, + } + render() + await screen.findByText('Conversation for Build the desktop workbench') + + expect(screen.queryByLabelText('Active workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Active session')).not.toBeInTheDocument() + expect(screen.getByTestId('active-session-title')).toHaveTextContent('Build') + expect(screen.queryByRole('button', { name: 'Terminal' })).not.toBeInTheDocument() + expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.stage2-navigation.test.tsx b/apps/desktop/src/workbench/browser/workbench.stage2-navigation.test.tsx new file mode 100644 index 00000000..5ea69a7a --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.stage2-navigation.test.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + createStage2Callbacks, + stage2Model, +} from '../../../test/fixtures/workbench/attention' +import { createTestAPI } from '../../../test/support/workbench/api-stub' +import { Workbench } from './workbench' + +describe('Stage 2 workbench navigation', () => { + it('navigates context, settings, and conversation', async () => { + const callbacks = createStage2Callbacks() + const { api } = createTestAPI() + render() + await screen.findByText('Conversation for Build the desktop workbench') + + expect(screen.getByLabelText('3 pending requests')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + fireEvent.click(screen.getByTestId('settings-navigation') + .querySelector('[data-section="runtime"]')!) + expect(screen.getByTestId('diagnostics-pane')).toBeInTheDocument() + expect(screen.queryByLabelText('Active workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Active session')).not.toBeInTheDocument() + expect(callbacks.onWorkspaceChange).not.toHaveBeenCalled() + expect(callbacks.onSessionChange).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Close settings' })) + fireEvent.click(screen.getByRole('button', { name: 'Conversation' })) + expect(screen.getByTestId('session-list')).toBeInTheDocument() + await waitFor(() => expect(screen.getByTestId('active-session-title')).toHaveTextContent('Build')) + }) +}) diff --git a/apps/desktop/src/workbench/browser/workbench.tsx b/apps/desktop/src/workbench/browser/workbench.tsx new file mode 100644 index 00000000..0072a176 --- /dev/null +++ b/apps/desktop/src/workbench/browser/workbench.tsx @@ -0,0 +1,186 @@ +import { useEffect, useReducer, useRef, useState } from 'react' +import { type LoopalDesktopAPI } from '../../shared/contracts' +import { ActivityBar } from './activity-bar' +import { useDesktopPreferences } from '../contrib/settings/browser/desktop-preferences' +import { openFederationConversation } from '../contrib/federation/browser/federation-conversation' +import { SessionAttention } from '../contrib/attention/browser/session-attention' +import { SessionCreateDialog } from '../contrib/sessions/browser/session-create-dialog' +import { SessionPanelZone } from '../contrib/session-panels/browser/session-panel-zone' +import { SessionWorkspace } from './session-workspace' +import { type Stage2WorkbenchBinding } from './stage2-view-model' +import { StatusBar } from './status-bar' +import { useFederationController } from '../contrib/federation/browser/use-federation-controller' +import { useWorkbenchController } from './use-workbench-controller' +import { useWorkbenchRuntimeController } from './use-workbench-runtime-controller' +import { useAgentControl } from '../contrib/agents/browser/use-agent-control' +import { useSlashCommands } from '../contrib/conversation/browser/use-slash-commands' +import { useWorkbenchShortcuts } from './use-workbench-shortcuts' +import { WorkbenchSettingsOverlay } from '../contrib/settings/browser/workbench-settings-overlay' +import { WorkbenchSidebar } from './workbench-sidebar' +import { buildControllerContext } from './workbench-context' +import { + createWorkbenchViewState, reduceWorkbenchView, type WorkbenchArea, +} from './workbench-view-state' +interface WorkbenchProps { readonly api: LoopalDesktopAPI; readonly stage2?: Stage2WorkbenchBinding } +export function Workbench({ api, stage2 }: WorkbenchProps): React.JSX.Element { + const controller = useWorkbenchController(api) + const federation = useFederationController( + api, controller.projection.sessions, controller.projection.runtimes, + ) + const agentControl = useAgentControl(api, controller.projection) + const slash = useSlashCommands(api, controller.activeWorkspaceId) + const fallbackContext = buildControllerContext(controller) + const runtime = useWorkbenchRuntimeController(api, fallbackContext, stage2 === undefined) + const binding = stage2 ?? runtime + const model = binding.model + const callbacks = binding.callbacks + const activeError = controller.error + const [viewState, dispatchView] = useReducer( + reduceWorkbenchView, + createWorkbenchViewState(), + ) + const [selectedAgentId, setSelectedAgentId] = useState('main') + const [creatingSession, setCreatingSession] = useState(false) + const [preferences, updatePreferences] = useDesktopPreferences() + const previousSessionId = useRef(undefined) + useEffect(() => { + if (previousSessionId.current !== undefined + && previousSessionId.current !== controller.activeSessionId) { + setSelectedAgentId('main') + dispatchView({ type: 'close_settings' }) + } + previousSessionId.current = controller.activeSessionId + }, [controller.activeSessionId]) + const selectedAgent = controller.projection.detail?.agents.find( + (agent) => agent.id === selectedAgentId, + ) ?? controller.projection.detail?.agents.find((agent) => agent.id === 'main') + ?? controller.projection.detail?.agents[0] + const activeAgentId = selectedAgent?.id ?? selectedAgentId + const submit = async (agentId = activeAgentId): Promise => { + const result = await slash.execute( + controller.draftFor(agentId), agentId, agentControl.control, controller.images.length > 0, + ) + if (result === 'message') await controller.send(agentId) + else if (result === 'handled') controller.setDraftFor(agentId, '') + } + const executeCommand = async (command: string, agentId: string): Promise => { + const result = await slash.execute( + command, agentId, agentControl.control, controller.images.length > 0, + ) + if (result === 'handled') controller.setDraftFor(agentId, '') + } + const canControlAgent = selectedAgent !== undefined + && agentControl.available(activeAgentId) + const activateArea = (area: WorkbenchArea): void => { + dispatchView({ type: 'select_area', area }) + } + const selectAgent = (agentId: string): void => { + setSelectedAgentId(agentId) + } + const showSessions = (): void => activateArea('conversation') + const openAttention = (): void => { + dispatchView({ type: 'close_settings' }) + showSessions() + requestAnimationFrame(() => document.querySelector('[data-testid="session-attention"]') + ?.scrollIntoView({ block: 'nearest' })) + } + useWorkbenchShortcuts({ + selectArea: activateArea, + toggleSidebar: () => dispatchView({ type: 'toggle_sidebar' }), + toggleSettings: () => dispatchView({ type: 'toggle_settings' }), + }) + return ( +
+ dispatchView({ type: 'toggle_sidebar' })} + onOpenAttention={openAttention} + settingsOpen={viewState.settingsOpen} + onOpenSettings={() => dispatchView({ type: 'toggle_settings' })} + /> + {viewState.sidebarVisible && viewState.area !== 'federation' && ( + setCreatingSession(true)} /> + )} +
+ {model.error &&
{model.error}
} + {agentControl.error && ( +
{agentControl.error}
+ )} + {federation.error && viewState.area !== 'federation' && ( +
{federation.error}
+ )} + { + slash.clearFeedback() + controller.setDraftFor(activeAgentId, value) + }} + onSelectImages={controller.selectImages} + onRemoveImage={controller.removeImage} + onSend={submit} + onStop={controller.stopSession} + onRestart={controller.restartSession} + lifecycleBusy={controller.lifecycleBusy} + selectedAgentId={activeAgentId} + controlBusy={agentControl.busy} + controlAvailable={agentControl.available} + onControl={agentControl.control} + commands={slash.items} + {...(slash.error ? { commandError: slash.error } : {})} + {...(slash.helpQuery !== undefined ? { commandHelpQuery: slash.helpQuery } : {})} + onRequestCommands={slash.refresh} + onDismissCommandHelp={slash.dismissHelp} + onExecuteCommand={(command, agentId) => void executeCommand(command, agentId)} + surface={viewState.area === 'federation' ? 'federation' : 'conversation'} + federation={federation} + onOpenAgentConversation={(target) => void openFederationConversation( + target, federation.snapshot, controller.openSession, selectAgent, showSessions, + (sessionId) => { previousSessionId.current = sessionId }, + )} + onOpenSettings={() => dispatchView({ type: 'open_settings' })} + panel={( + void agentControl.control(activeAgentId, command)} + showTopology={preferences.showAgentTopology} + /> + )} + attention={} + /> + {viewState.settingsOpen && dispatchView({ type: 'close_settings' })} />} +
+ activateArea('federation')} /> + {creatingSession && setCreatingSession(false)} />} +
+ ) +} diff --git a/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.test.tsx b/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.test.tsx new file mode 100644 index 00000000..7f1a6fbd --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.test.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { type AgentSummary } from '../../../../shared/contracts' +import { AgentControlPanel } from './agent-control-panel' + +const agent: AgentSummary = { + id: 'main', name: 'Loopal', status: 'running', model: 'gpt-5', + mode: 'act', + thinkingConfig: 'auto', permissionMode: 'ask_dangerous', + decisionMode: 'classifier', sandboxPolicy: 'default_write', + telemetry: { + turnCount: 4, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, + cacheReadTokens: 0, thinkingTokens: 0, contextWindow: 0, + toolsInFlight: 0, toolCount: 0, + }, +} + +describe('AgentControlPanel', () => { + it('exposes every agent configuration command with structured values', () => { + const onControl = vi.fn() + const onInterrupt = vi.fn() + const { rerender } = render( + , + ) + expect(screen.getByLabelText('Rewind turn index')).toHaveValue(3) + fireEvent.click(screen.getByRole('button', { name: 'Interrupt' })) + fireEvent.click(screen.getByRole('button', { name: 'Suspend' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + fireEvent.change(screen.getByLabelText('Agent mode'), { target: { value: 'plan' } }) + fireEvent.change(screen.getByLabelText('Agent model'), { target: { value: 'gpt-5.1' } }) + fireEvent.click(screen.getByRole('button', { name: 'Apply agent model' })) + for (const [label, value] of ([ + ['Thinking configuration', 'high'], + ['Permission mode', 'bypass'], + ['Decision mode', 'manual'], + ['Sandbox policy', 'read_only'], + ] as const)) fireEvent.change(screen.getByLabelText(label), { target: { value } }) + fireEvent.change(screen.getByLabelText('Compact instructions'), { + target: { value: 'Preserve tool results' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Compact' })) + fireEvent.change(screen.getByLabelText('Rewind turn index'), { target: { value: '2' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rewind' })) + + expect(onInterrupt).toHaveBeenCalledOnce() + expect(onControl.mock.calls.map(([command]) => command)).toEqual([ + { type: 'suspend' }, { type: 'clear' }, { type: 'mode', mode: 'plan' }, + { type: 'model', model: 'gpt-5.1' }, + { type: 'thinking', config: { type: 'effort', level: 'high' } }, + { type: 'permission', mode: 'bypass' }, + { type: 'decision', mode: 'manual' }, + { type: 'sandbox', policy: 'read_only' }, + { type: 'compact', instructions: 'Preserve tool results' }, + { type: 'rewind', turnIndex: 2 }, + ]) + expect(screen.getAllByRole('button').every((button) => button.hasAttribute('aria-label'))).toBe(true) + + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Unsuspend' })) + expect(onControl).toHaveBeenLastCalledWith({ type: 'unsuspend' }) + }) + + it('disables actions while unavailable or busy and rejects invalid rewind', () => { + const onControl = vi.fn() + const { rerender } = render( + , + ) + expect(screen.getAllByRole('button').every((button) => button.hasAttribute('disabled'))).toBe(true) + rerender( + , + ) + expect(screen.getAllByRole('button').every((button) => button.hasAttribute('disabled'))).toBe(true) + rerender( + , + ) + fireEvent.change(screen.getByLabelText('Rewind turn index'), { target: { value: '-1' } }) + expect(screen.getByRole('button', { name: 'Rewind' })).toBeDisabled() + + rerender( + , + ) + expect(screen.getByLabelText('Thinking configuration')).toHaveValue('adaptive') + expect(screen.getByRole('option', { name: 'Observed: adaptive' })).toBeDisabled() + + rerender( + , + ) + expect(screen.getByRole('option', { + name: 'Unsupported: Agent mode (observed)', + })).toBeDisabled() + + rerender( + , + ) + expect(screen.getByLabelText('Rewind turn index')).toHaveValue(0) + expect(screen.getByRole('button', { name: 'Rewind' })).toBeDisabled() + }) +}) diff --git a/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.tsx b/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.tsx new file mode 100644 index 00000000..87348fde --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/agent-control-panel.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react' +import { + type AgentControlCommand, + type AgentSummary, +} from '../../../../shared/contracts' +import { useI18n } from '../../../browser/i18n-context' + +interface AgentControlPanelProps { + readonly agent: AgentSummary + readonly disabled: boolean + readonly busy: boolean + readonly onInterrupt: () => void + readonly onControl: (command: AgentControlCommand) => void +} + +const THINKING = ['auto', 'disabled', 'low', 'medium', 'high', 'max'] as const +const MODE = ['act', 'plan'] as const +const PERMISSION = ['ask_dangerous', 'ask_any_write', 'bypass'] as const +const DECISION = ['manual', 'classifier'] as const +const SANDBOX = ['default_write', 'read_only', 'disabled'] as const + +export function AgentControlPanel(props: AgentControlPanelProps): React.JSX.Element { + const { t } = useI18n() + const [model, setModel] = useState(props.agent.model ?? '') + const [compactInstructions, setCompactInstructions] = useState('') + const [turnIndex, setTurnIndex] = useState(defaultTurn(props.agent.telemetry?.turnCount)) + const disabled = props.disabled || props.busy + useEffect(() => { + setModel(props.agent.model ?? '') + setCompactInstructions('') + setTurnIndex(defaultTurn(props.agent.telemetry?.turnCount)) + }, [props.agent.id, props.agent.model, props.agent.telemetry?.turnCount]) + const control = (command: AgentControlCommand): void => props.onControl(command) + return ( +
+
+ {props.agent.name} + {props.busy ? t('agent.applying') : props.agent.status} +
+
+ + + +
+ control({ type: 'mode', mode })} + /> + + setModel(event.target.value)} + /> + + + control(value === 'auto' || value === 'disabled' + ? { type: 'thinking', config: { type: value } } + : { type: 'thinking', config: { type: 'effort', level: value } })} + /> + control({ type: 'permission', mode })} + /> + control({ type: 'decision', mode })} + /> + control({ type: 'sandbox', policy })} + /> + + setCompactInstructions(event.target.value)} + /> + + + + setTurnIndex(event.target.value)} + /> + + +
+ ) +} + +function ControlRow(props: { + readonly label: string + readonly children: React.ReactNode +}): React.JSX.Element { + return +} + +function ControlSelect(props: { + readonly label: string + readonly ariaLabel: string + readonly value: string + readonly options: readonly T[] + readonly disabled: boolean + readonly onChange: (value: T) => void +}): React.JSX.Element { + const { t } = useI18n() + const observed = !props.options.some((option) => option === props.value) + return ( + + ) +} + +function defaultTurn(turnCount: number | undefined): string { + return String(Math.max(0, (turnCount ?? 1) - 1)) +} +function validTurn(value: string, turnCount: number | undefined): boolean { + const turn = Number(value) + return Number.isSafeInteger(turn) && turn >= 0 + && (turnCount === undefined || turn < turnCount) +} +function label(value: string): string { + return value.replaceAll('_', ' ').replace(/^./, (first) => first.toUpperCase()) +} diff --git a/apps/desktop/src/workbench/contrib/agents/browser/agent-controls.css b/apps/desktop/src/workbench/contrib/agents/browser/agent-controls.css new file mode 100644 index 00000000..85130332 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/agent-controls.css @@ -0,0 +1,30 @@ +.session-more-actions { display: block; } +.agent-control-panel { display: grid; width: 100%; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px; } +.control-panel-heading { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; } +.control-panel-heading strong { margin: 0; color: #cbd0dc; font-size: 11px; } +.control-panel-heading span { color: #788196; font-size: 9px; text-transform: capitalize; } +.control-quick-actions { display: flex; align-items: end; gap: 6px; } +.agent-control-row { display: grid; min-width: 0; grid-template-columns: 1fr auto; gap: 5px; align-items: end; } +.agent-control-row > span { grid-column: 1 / -1; color: #777f93; font-size: 9px; } +.agent-control-row input, .agent-control-row select, .goal-create input { + min-width: 0; height: 27px; border: 1px solid #303645; border-radius: 5px; + padding: 0 7px; outline: 0; background: #171b24; color: #cbd0dc; font-size: 10px; +} +.agent-control-row select { grid-column: 1 / -1; } +.agent-control-panel button, .resource-actions button, .resource-heading button, +.inspector-section-heading button, .goal-create button { + min-height: 27px; border: 1px solid #303645; border-radius: 5px; + padding: 0 8px; background: #171b24; color: #cbd0dc; font-size: 9px; cursor: pointer; +} +.agent-control-panel button:disabled, .resource-actions button:disabled, +.resource-heading button:disabled, .inspector-section-heading button:disabled, +.goal-create button:disabled { opacity: .4; cursor: default; } +.resource-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.resource-heading, .inspector-section-heading { display: flex; align-items: center; gap: 6px; } +.resource-heading strong, .inspector-section-heading h3 { flex: 1; min-width: 0; } +.inspector-section-heading { margin-bottom: 8px; } +.inspector-section-heading h3 { margin: 0; } +.goal-create { display: grid; grid-template-columns: 1fr auto; gap: 5px; } +.background-task .resource-heading { padding: 8px; } +.background-task .resource-heading button, .cron-row .resource-heading button { min-height: 22px; } +.mcp-row .resource-actions { padding: 0 8px 8px; } diff --git a/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.test.tsx b/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.test.tsx new file mode 100644 index 00000000..410296ce --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { type AgentSummary } from '../../../../shared/contracts' +import { AgentTopology } from './agent-topology' + +describe('AgentTopology', () => { + it('shows roots, descendants, missing parents, cycles, and bounded depth', () => { + const agents: AgentSummary[] = [ + agent('root', 'Root'), + agent('child', 'Child', 'root'), + agent('orphan', 'Orphan', 'missing'), + agent('cycle-a', 'Cycle A', 'cycle-b'), + agent('cycle-b', 'Cycle B', 'cycle-a'), + ...Array.from({ length: 10 }, (_, index) => ( + agent(`deep-${index}`, `Deep ${index}`, index === 0 ? 'root' : `deep-${index - 1}`) + )), + ] + render() + + expect(screen.getByLabelText('Agent topology')).toBeInTheDocument() + expect(screen.getByText('Child').closest('[data-agent-id]')).toHaveAttribute( + 'data-parent-id', 'root', + ) + expect(screen.getByText('Orphan').closest('[data-agent-id]')).toHaveAttribute( + 'data-parent-id', 'missing', + ) + expect(screen.getByText('Cycle A')).toBeInTheDocument() + expect(screen.getByText('Cycle B')).toBeInTheDocument() + expect(screen.getByText('Deep 9')).toBeInTheDocument() + }) + + it('renders an explicit empty topology', () => { + render() + expect(screen.getByText('No agents in this session.')).toBeInTheDocument() + }) + + it('marks unavailable nodes without preventing inspection', () => { + render() + const shadow = screen.getByRole('treeitem', { name: /Shadow/ }) + expect(shadow).toHaveTextContent('failed · retained') + expect(shadow).toHaveTextContent('spawn failed') + expect(shadow).toBeEnabled() + expect(screen.getByRole('treeitem', { name: /Remote/ })).toHaveTextContent('unavailable') + }) + + it('selects an agent and exposes the selected state', () => { + const onSelect = vi.fn() + const { rerender } = render( + , + ) + fireEvent.click(screen.getByRole('treeitem', { name: /Root/ })) + expect(onSelect).toHaveBeenCalledWith('root') + rerender( + , + ) + expect(screen.getByRole('treeitem', { name: /Root/ })).toHaveClass('selected') + }) + + it('filters retained Agents and supports tree keyboard navigation', () => { + render() + expect(screen.queryByText('Retained')).not.toBeInTheDocument() + expect(screen.getByText('2 active · 1 retained')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'All' })) + const root = document.querySelector('[data-agent-id="root"]')! + root.focus() + fireEvent.keyDown(root, { key: 'ArrowDown' }) + const live = document.querySelector('[data-agent-id="live"]')! + expect(live).toHaveFocus() + fireEvent.keyDown(live, { key: 'End' }) + expect(document.querySelector('[data-agent-id="retained"]')).toHaveFocus() + }) +}) + +function agent(id: string, name: string, parentId?: string): AgentSummary { + return { id, name, status: 'running', ...(parentId ? { parentId } : {}) } +} diff --git a/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.tsx b/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.tsx new file mode 100644 index 00000000..2a423b84 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/agent-topology.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { type AgentSummary } from '../../../../shared/contracts' +import { useI18n } from '../../../browser/i18n-context' + +interface AgentTopologyProps { + readonly agents: readonly AgentSummary[] + readonly selectedAgentId?: string | undefined + readonly onSelect?: ((agentId: string) => void) | undefined +} + +export function AgentTopology(props: AgentTopologyProps): React.JSX.Element { + const { t } = useI18n() + const [showAll, setShowAll] = useState(false) + const retained = props.agents.filter((agent) => isRetained(agent)) + const activeCount = props.agents.length - retained.length + const visible = showAll ? props.agents : props.agents.filter((agent) => ( + !isRetained(agent) || !agent.parentId || agent.id === props.selectedAgentId + )) + const graph = buildGraph(visible) + return ( +
+ {retained.length > 0 && ( +
+ {t('topology.counts', { active: activeCount, retained: retained.length })} +
+ + +
+
+ )} +
+ {graph.roots.map((agent) => ( + + ))} +
+ {props.agents.length === 0 && ( +

{t('topology.empty')}

+ )} +
+ ) +} + +interface AgentGraph { + readonly byId: ReadonlyMap + readonly children: ReadonlyMap + readonly roots: readonly AgentSummary[] +} + +function TopologyBranch(props: { + readonly agent: AgentSummary + readonly graph: AgentGraph + readonly selectedAgentId?: string | undefined + readonly onSelect?: ((agentId: string) => void) | undefined + readonly path: ReadonlySet + readonly level: number +}): React.JSX.Element { + const { t } = useI18n() + const nextPath = new Set(props.path).add(props.agent.id) + const children = (props.graph.children.get(props.agent.id) ?? []) + .filter((child) => !nextPath.has(child.id)) + const parent = props.agent.parentId ? props.graph.byId.get(props.agent.parentId) : undefined + return ( +
+ + {children.length > 0 && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +function buildGraph(agents: readonly AgentSummary[]): AgentGraph { + const byId = new Map(agents.map((agent) => [agent.id, agent])) + const children = new Map() + const claimed = new Set() + const attach = (parentId: string, child: AgentSummary): void => { + const list = children.get(parentId) ?? [] + if (!list.some((candidate) => candidate.id === child.id)) list.push(child) + children.set(parentId, list) + claimed.add(child.id) + } + for (const agent of agents) { + if (agent.parentId && byId.has(agent.parentId)) attach(agent.parentId, agent) + } + for (const parent of agents) { + for (const childId of parent.children ?? []) { + const child = byId.get(childId) + if (child) attach(parent.id, child) + } + } + const roots = agents.filter((agent) => !claimed.has(agent.id)) + const reached = new Set() + const visit = (agent: AgentSummary): void => { + if (reached.has(agent.id)) return + reached.add(agent.id) + for (const child of children.get(agent.id) ?? []) visit(child) + } + roots.forEach(visit) + for (const agent of agents) { + if (!reached.has(agent.id)) { + roots.push(agent) + visit(agent) + } + } + return { byId, children, roots } +} + +function runtimeLabel(agent: AgentSummary): string { + const model = agent.model ? ` · ${agent.model}` : '' + const tool = agent.lastTool ? ` · ${agent.lastTool}` : '' + return `${model}${tool}` +} + +function isRetained(agent: AgentSummary): boolean { + return agent.status === 'completed' || agent.status === 'failed' +} + +function moveTreeFocus(event: React.KeyboardEvent): void { + if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return + if (!(event.target instanceof HTMLButtonElement)) return + const nodes = Array.from(event.currentTarget.querySelectorAll('[role="treeitem"]')) + const current = nodes.indexOf(event.target) + const next = event.key === 'Home' ? 0 : event.key === 'End' ? nodes.length - 1 + : Math.max(0, Math.min(nodes.length - 1, current + (event.key === 'ArrowDown' ? 1 : -1))) + event.preventDefault() + nodes[next]?.focus() +} diff --git a/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.test.tsx b/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.test.tsx new file mode 100644 index 00000000..53350397 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.test.tsx @@ -0,0 +1,115 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + createTestAPI, + sessionDetail, + sessionOne, + updatedAt, +} from '../../../../../test/support/workbench/api-stub' +import { type DesktopProjection } from '../../../browser/desktop-event-projector' +import { useAgentControl } from './use-agent-control' + +const runtime = { + id: 'runtime-1', sessionId: sessionOne.id, workspaceId: sessionOne.workspaceId, + generation: 4, state: 'ready' as const, rootAgent: 'agent-session-1', startedAt: updatedAt, +} +const detail = sessionDetail(sessionOne) +const projection: DesktopProjection = { + hostStatus: 'ready', sessions: [sessionOne], runtimes: [runtime], detail, +} +const target = { + sessionId: sessionOne.id, runtimeId: runtime.id, + generation: runtime.generation, agentId: runtime.rootAgent, +} + +describe('useAgentControl', () => { + it('builds an exact runtime target for control and interrupt', async () => { + const controlAgent = vi.fn(async () => undefined) + const interruptAgent = vi.fn(async () => undefined) + const { api } = createTestAPI({ controlAgent, interruptAgent }) + const hook = renderHook(() => useAgentControl(api, projection)) + expect(hook.result.current.available(runtime.rootAgent)).toBe(true) + expect(hook.result.current.available('missing')).toBe(false) + let controlled: boolean | undefined + await act(async () => { + controlled = await hook.result.current.control( + runtime.rootAgent, { type: 'mode', mode: 'plan' }, + ) + }) + expect(controlled).toBe(true) + expect(controlAgent).toHaveBeenCalledWith({ + target, command: { type: 'mode', mode: 'plan' }, + }) + await act(async () => hook.result.current.interrupt(runtime.rootAgent)) + expect(interruptAgent).toHaveBeenCalledWith(target) + expect(hook.result.current.error).toBeUndefined() + }) + + it('rejects incomplete projections before crossing preload', async () => { + const controlAgent = vi.fn(async () => undefined) + const { api } = createTestAPI({ controlAgent }) + const { detail: _detail, ...projectionWithoutDetail } = projection + const hook = renderHook( + ({ value }: { value: DesktopProjection }) => useAgentControl(api, value), + { initialProps: { value: projectionWithoutDetail } }, + ) + let controlled: boolean | undefined + await act(async () => { + controlled = await hook.result.current.control(runtime.rootAgent, { type: 'clear' }) + }) + expect(controlled).toBe(false) + expect(hook.result.current.error).toContain('no longer has a live runtime') + expect(controlAgent).not.toHaveBeenCalled() + for (const value of [ + { ...projection, runtimes: [] }, + { ...projection, runtimes: [{ ...runtime, sessionId: 'other' }] }, + { ...projection, runtimes: [{ ...runtime, state: 'stopped' as const }] }, + { ...projection, detail: { ...detail, agents: [] } }, + { ...projection, detail: { + ...detail, agents: detail.agents.map((agent) => ({ + ...agent, controllable: false, + })), + } }, + { ...projection, detail: { + ...detail, agents: detail.agents.map((agent) => ({ + ...agent, status: 'completed' as const, + })), + } }, + { ...projection, detail: { + ...detail, agents: detail.agents.map((agent) => ({ + ...agent, status: 'failed' as const, + })), + } }, + ]) { + hook.rerender({ value }) + expect(hook.result.current.available(runtime.rootAgent)).toBe(false) + } + }) + + it('reports failures and suppresses concurrent commands', async () => { + let release!: () => void + const pending = new Promise((resolve) => { release = resolve }) + const controlAgent = vi.fn(() => pending) + const { api } = createTestAPI({ controlAgent }) + const hook = renderHook(() => useAgentControl(api, projection)) + let first!: Promise + act(() => { first = hook.result.current.control(runtime.rootAgent, { type: 'clear' }) }) + expect(hook.result.current.busy).toBe(true) + let suppressed: boolean | undefined + await act(async () => { + suppressed = await hook.result.current.control(runtime.rootAgent, { type: 'suspend' }) + }) + expect(suppressed).toBe(false) + expect(controlAgent).toHaveBeenCalledOnce() + release() + await act(async () => first) + expect(hook.result.current.busy).toBe(false) + + controlAgent.mockRejectedValueOnce('denied') + await act(async () => hook.result.current.control(runtime.rootAgent, { type: 'clear' })) + expect(hook.result.current.error).toBe('denied') + controlAgent.mockRejectedValueOnce(new Error('closed')) + await act(async () => hook.result.current.control(runtime.rootAgent, { type: 'clear' })) + expect(hook.result.current.error).toBe('closed') + }) +}) diff --git a/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.ts b/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.ts new file mode 100644 index 00000000..baa60df0 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/use-agent-control.ts @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { + type AgentControlCommand, + type AgentControlTarget, + type LoopalDesktopAPI, +} from '../../../../shared/contracts' +import { type DesktopProjection } from '../../../browser/desktop-event-projector' + +export function useAgentControl( + api: LoopalDesktopAPI, + projection: DesktopProjection, +) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + + const target = (agentId: string): AgentControlTarget | undefined => { + const detail = projection.detail + const runtimeId = detail?.session.activeRuntimeId + const runtime = projection.runtimes.find((candidate) => candidate.id === runtimeId) + const agent = detail?.agents.find((candidate) => candidate.id === agentId) + if (!detail || !runtime || runtime.sessionId !== detail.session.id + || !agent || !['starting', 'ready'].includes(runtime.state) + || agent.controllable === false + || agent.status === 'completed' || agent.status === 'failed') return undefined + return { + sessionId: detail.session.id, + runtimeId: runtime.id, + generation: runtime.generation, + agentId, + } + } + + const run = async ( + agentId: string, + command?: AgentControlCommand, + ): Promise => { + if (busy) return false + const selected = target(agentId) + if (!selected) { + setError('The selected agent no longer has a live runtime.') + return false + } + setBusy(true) + setError(undefined) + try { + if (command) await api.controlAgent({ target: selected, command }) + else await api.interruptAgent(selected) + return true + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + return false + } finally { + setBusy(false) + } + } + + return { + busy, + error, + available: (agentId: string) => target(agentId) !== undefined, + interrupt: (agentId: string) => run(agentId), + control: (agentId: string, command: AgentControlCommand) => run(agentId, command), + } +} diff --git a/apps/desktop/src/workbench/contrib/agents/browser/workbench.agent-control.test.tsx b/apps/desktop/src/workbench/contrib/agents/browser/workbench.agent-control.test.tsx new file mode 100644 index 00000000..63edc86f --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/workbench.agent-control.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { richView } from '../../../../../test/fixtures/workbench/rich-session' +import { + createStage2Callbacks, + stage2Model, +} from '../../../../../test/fixtures/workbench/attention' +import { + createTestAPI, sessionDetail, sessionOne, +} from '../../../../../test/support/workbench/api-stub' +import { Workbench } from '../../../browser/workbench' + +describe('Workbench Agent control feedback', () => { + it('keeps settings command failures visible in the central session surface', async () => { + const detail = { + ...sessionDetail(sessionOne), + view: richView(), + } + const controlAgent = vi.fn(async () => { throw new Error('control denied') }) + const { api } = createTestAPI({ openSession: async () => detail, controlAgent }) + render() + expect(await screen.findByTestId('active-session-title')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + expect(await screen.findByTestId('settings-pane')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('settings-navigation') + .querySelector('[data-section="agent"]')!) + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('control denied')) + expect(screen.getByTestId('settings-pane')).toBeInTheDocument() + }) +}) diff --git a/apps/desktop/src/workbench/contrib/agents/browser/workbench.child-agent.test.tsx b/apps/desktop/src/workbench/contrib/agents/browser/workbench.child-agent.test.tsx new file mode 100644 index 00000000..850ca505 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/agents/browser/workbench.child-agent.test.tsx @@ -0,0 +1,91 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { richView } from '../../../../../test/fixtures/workbench/rich-session' +import { + createTestAPI, sessionDetail, sessionOne, updatedAt, +} from '../../../../../test/support/workbench/api-stub' +import { Workbench } from '../../../browser/workbench' + +describe('Workbench child agent selection', () => { + it('routes messages and resources to a live child while retaining completed output', async () => { + const detail = { + ...sessionDetail(sessionOne), + agents: [{ + id: 'agent-session-1', name: 'Loopal', status: 'running' as const, + children: ['child'], + }, { + id: 'child', name: 'Research child', status: 'waiting' as const, + parentId: 'agent-session-1', conversation: [], + view: richView({ + compactBanner: 'Child context compacted.', + goal: { + id: 'child-goal', objective: 'Child-only goal', status: 'active', + createdAt: updatedAt, updatedAt, + }, + }), + }], + } + const sendMessage = vi.fn(async () => undefined) + const { api, events } = createTestAPI({ openSession: async () => detail, sendMessage }) + render() + await screen.findByText(`Conversation for ${sessionOne.title}`) + fireEvent.change(screen.getByLabelText('Message Loopal'), { + target: { value: 'Root-only draft' }, + }) + fireEvent.click(screen.getByRole('tab', { name: 'Agents' })) + fireEvent.click(screen.getByRole('treeitem', { name: /Research child/ })) + expect(screen.getByText('Child context compacted.')).toBeInTheDocument() + expect(screen.getByText('Child-only goal')).toBeInTheDocument() + const composer = screen.getByLabelText('Message Research child') + expect(composer).toHaveValue('') + fireEvent.change(composer, { target: { value: 'Report status' } }) + fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }) + expect(sendMessage).not.toHaveBeenCalled() + fireEvent.keyDown(composer, { key: 'Enter', shiftKey: false }) + await waitFor(() => expect(sendMessage).toHaveBeenCalledWith( + sessionOne.id, 'Report status', 'child', + )) + replaceChild(events, detail, { status: 'starting' }) + expect(screen.getByLabelText('Message Research child')).toBeEnabled() + replaceChild(events, detail, { status: 'waiting', controllable: false }) + expect(screen.getByLabelText('Message Research child')).toBeDisabled() + replaceChild(events, detail, { status: 'completed' }) + const retained = screen.getByLabelText('Message Research child') + expect(retained).toBeDisabled() + expect(retained).toHaveAttribute('placeholder', expect.stringContaining('read-only')) + }) + + it('renders a child with no projected conversation or view', async () => { + const detail = { + ...sessionDetail(sessionOne), + agents: [{ + id: 'agent-session-1', name: 'Loopal', status: 'running' as const, + children: ['starting-child'], + }, { + id: 'starting-child', name: 'Starting child', status: 'starting' as const, + parentId: 'agent-session-1', error: 'waiting for registration', + }], + } + const { api } = createTestAPI({ openSession: async () => detail }) + render() + await screen.findByText(`Conversation for ${sessionOne.title}`) + fireEvent.click(screen.getByRole('tab', { name: 'Agents' })) + fireEvent.click(screen.getByRole('treeitem', { name: /Starting child/ })) + expect(screen.getByTestId('conversation')).toHaveTextContent( + 'Viewing Starting child · starting · waiting for registration', + ) + }) +}) + +function replaceChild( + events: ReturnType['events'], + detail: Awaited>, + patch: { readonly status: 'starting' | 'waiting' | 'completed'; readonly controllable?: boolean }, +): void { + act(() => events.fire({ + type: 'session_detail_replaced', + detail: { + ...detail, + agents: detail.agents.map((agent) => agent.id === 'child' ? { ...agent, ...patch } : agent), + }, + })) +} diff --git a/apps/desktop/src/workbench/contrib/attention/browser/attention-pane.tsx b/apps/desktop/src/workbench/contrib/attention/browser/attention-pane.tsx new file mode 100644 index 00000000..d6828344 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/attention/browser/attention-pane.tsx @@ -0,0 +1,123 @@ +import { + type Stage2WorkbenchCallbacks, + type Stage2WorkbenchModel, +} from '../../../browser/stage2-view-model' +import { useI18n } from '../../../browser/i18n-context' + +interface AttentionPaneProps { + readonly kind: 'permissions' | 'questions' + readonly model: Stage2WorkbenchModel + readonly callbacks: Stage2WorkbenchCallbacks + readonly showEmpty?: boolean +} + +export function AttentionPane(props: AttentionPaneProps): React.JSX.Element { + const { t } = useI18n() + if (props.kind === 'permissions') { + return ( +
+ {props.model.permissions.map((request) => ( +
+ + {request.agentId + ? t('attention.riskAgent', { risk: request.risk, agent: request.agentId }) + : t('attention.risk', { risk: request.risk })} + +

{request.title}

+

{request.description}

+ {request.command && {request.command}} +
+ + + +
+
+ ))} + {(props.showEmpty ?? true) && props.model.permissions.length === 0 && ( + + )} +
+ ) + } + + return ( +
+ {props.model.questions.map((question) => ( +
+ + {question.agentId + ? t('attention.questionAgent', { agent: question.agentId }) + : t('attention.question')} + + {question.classifier && ( + + {question.classifier.label} + + )} +

{question.prompt}

+
+ {question.choices.map((choice) => ( + + ))} +
+ + {question.submit && ( +
+ + +
+ )} +
+ ))} + {(props.showEmpty ?? true) && props.model.questions.length === 0 && ( + + )} +
+ ) +} + +function AttentionEmpty({ label }: { readonly label: string }): React.JSX.Element { + return

{label}

+} diff --git a/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.test.ts b/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.test.ts new file mode 100644 index 00000000..3ab65273 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.test.ts @@ -0,0 +1,55 @@ +import { + permissionItem, + questionAnswer, + questionIndex, + questionItems, + questionRequestId, +} from './attention-projector' + +describe('Attention projector', () => { + it('projects permission and multi-question requests', () => { + expect(permissionItem({ + id: 'p', sessionId: 's', runtimeId: 'r', generation: 1, + agentId: 'a', tool: 'shell', title: 'Run', detail: 'ls', + risk: 'high', createdAt: '2026-07-11T12:00:00.000Z', + })).toMatchObject({ id: 'a:p', agentId: 'a', command: 'shell', description: 'ls' }) + const items = questionItems({ + id: 'request', sessionId: 's', runtimeId: 'r', generation: 1, + agentId: 'a', classifierRunning: false, + classifierStatus: { kind: 'failed', reason: 'provider timeout' }, + createdAt: '2026-07-11T12:00:00.000Z', questions: [ + { question: 'Continue?', header: 'Decision', allowMultiple: false, + options: [{ label: 'Yes', description: 'Proceed' }] }, + { question: 'Mode?', allowMultiple: true, + options: [{ label: 'Fast', description: '' }] }, + ], + }, [ + { selected: ['0:Yes'], other: '' }, + { selected: ['0:Fast'], other: '' }, + ]) + expect(items.map((item) => item.prompt)).toEqual(['Decision: Continue?', 'Mode?']) + expect(items.map((item) => item.agentId)).toEqual(['a', 'a']) + expect(items[0]?.classifier).toEqual({ + kind: 'failed', label: 'Auto-answer unavailable · provider timeout', + }) + expect(items[1]?.classifier).toBeUndefined() + expect(items[1]?.choices[0]?.description).toBeUndefined() + expect(items[1]?.selectedChoiceIds).toEqual(['0:Fast']) + expect(items[1]?.otherText).toBe('') + expect(items[1]?.submit).toEqual({ requestId: 'a:request', enabled: true }) + expect(questionRequestId('a:request:1')).toBe('a:request') + expect(questionIndex('a:request:1')).toBe(1) + expect(questionAnswer('0:Yes')).toBe('Yes') + expect(questionItems({ + id: 'auto', sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'a', + classifierRunning: true, createdAt: '2026-07-11T12:00:00.000Z', + questions: [{ question: 'Auto?', allowMultiple: false, options: [] }], + })[0]?.classifier).toEqual({ kind: 'running', label: 'Auto-answering · 0.0s' }) + expect(questionItems({ + id: 'done', sessionId: 's', runtimeId: 'r', generation: 1, agentId: 'a', + classifierRunning: false, classifierStatus: { kind: 'completed', answers: ['Yes'] }, + createdAt: '2026-07-11T12:00:00.000Z', + questions: [{ question: 'Done?', allowMultiple: false, options: [] }], + })[0]?.classifier).toEqual({ kind: 'completed', label: 'Auto-answer ready' }) + }) +}) diff --git a/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.ts b/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.ts new file mode 100644 index 00000000..972b2694 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/attention/browser/attention-projector.ts @@ -0,0 +1,81 @@ +import { + type PermissionRequest, + type QuestionRequest, +} from '../../../../shared/contracts' +import { + type PermissionItem, + type QuestionItem, +} from '../../../browser/stage2-view-model' +import { + choiceAnswer, questionAnswers, type QuestionAnswerDraft, +} from './question-answer-draft' + +export function permissionItem(request: PermissionRequest): PermissionItem { + return { + id: attentionRequestId(request), + agentId: request.agentId, + title: request.title, + description: request.detail, + risk: request.risk, + command: request.tool, + } +} + +export function questionItems( + request: QuestionRequest, + selected: readonly QuestionAnswerDraft[] = [], +): readonly QuestionItem[] { + const ready = questionAnswers(request, selected) !== undefined + const requestId = attentionRequestId(request) + return request.questions.map((question, index) => ({ + id: `${requestId}:${index}`, + agentId: request.agentId, + prompt: question.header + ? `${question.header}: ${question.question}` + : question.question, + allowMultiple: question.allowMultiple, + selectedChoiceIds: selected[index]?.selected ?? [], + otherText: selected[index]?.other ?? '', + choices: question.options.map((option, optionIndex) => ({ + id: `${optionIndex}:${option.label}`, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + ...(index === 0 && classifierItem(request) ? { classifier: classifierItem(request)! } : {}), + ...(index === request.questions.length - 1 + ? { submit: { requestId, enabled: ready } } + : {}), + })) +} + +function classifierItem(request: QuestionRequest): QuestionItem['classifier'] { + const status = request.classifierStatus + if ((!status || status.kind === 'none') && !request.classifierRunning) return undefined + if (!status || status.kind === 'running') { + const seconds = ((status?.elapsedMs ?? 0) / 1_000).toFixed(1) + return { kind: 'running', label: `Auto-answering · ${seconds}s` } + } + if (status.kind === 'failed') { + return { kind: 'failed', label: `Auto-answer unavailable · ${status.reason || 'unknown error'}` } + } + return { kind: 'completed', label: 'Auto-answer ready' } +} + +export function questionRequestId(id: string): string { + return id.slice(0, id.lastIndexOf(':')) +} + +export function questionIndex(id: string): number { + return Number(id.slice(id.lastIndexOf(':') + 1)) +} + +export function questionAnswer(choiceId: string): string { + return choiceAnswer(choiceId) +} + +export function attentionRequestId(request: { + readonly agentId: string + readonly id: string +}): string { + return `${request.agentId}:${request.id}` +} diff --git a/apps/desktop/src/workbench/contrib/attention/browser/plan-approval-pane.tsx b/apps/desktop/src/workbench/contrib/attention/browser/plan-approval-pane.tsx new file mode 100644 index 00000000..aed6a460 --- /dev/null +++ b/apps/desktop/src/workbench/contrib/attention/browser/plan-approval-pane.tsx @@ -0,0 +1,57 @@ +import { + type Stage2WorkbenchCallbacks, type Stage2WorkbenchModel, +} from '../../../browser/stage2-view-model' +import { useI18n } from '../../../browser/i18n-context' + +export function PlanApprovalPane(props: { + readonly model: Stage2WorkbenchModel + readonly callbacks: Stage2WorkbenchCallbacks +}): React.JSX.Element { + const { t } = useI18n() + return ( +
+ {props.model.planApprovals.map((request) => ( +
+ {t('plan.kind', { agent: request.agentId })} +

{t('plan.review')}

+ {request.path} +
{request.content}
+