diff --git a/.env.example b/.env.example index 054c0a41..1fed3dc3 100644 --- a/.env.example +++ b/.env.example @@ -31,13 +31,14 @@ VITE_DEFAULT_LANGUAGE=en # ── LLM(模型接入) ────────────────────────────────────────────────────────── # 模型配置存数据库(不走 env):启动后在 Web「设置 → 系统管理 → 模型服务」 # 填 OpenAI 兼容端点(base_url / api_key / 模型名),或本地单机模式跑 -# `hugagent onboard` 交互引导。 +# `hugagent-server onboard` 交互引导。 # ── 互联网搜索(可选;也可在「设置 → 系统管理 → 服务配置」里配,DB 优先) ── -# INTERNET_SEARCH_ENGINE=tavily # tavily / baidu / langsearch +# INTERNET_SEARCH_ENGINE=tavily # tavily / baidu / langsearch / deepseek # TAVILY_API_KEY= # BAIDU_API_KEY= # LANGSEARCH_API_KEY= +# DEEPSEEK_SEARCH_API_KEY= # ── 存储(社区版:本地文件) ───────────────────────────────────────────────── STORAGE_TYPE=s3 diff --git a/README.md b/README.md index 636b9550..d95b57cd 100644 --- a/README.md +++ b/README.md @@ -252,11 +252,11 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh The installer clones the source into `~/.hugagent/source`, creates an isolated Python environment, builds the web app and runs the first-time setup wizard, then opens [http://127.0.0.1:3001](http://127.0.0.1:3001). Start it again later with -`~/.hugagent/venv/bin/hugagent`. +`~/.hugagent/venv/bin/hugagent-server`. > [!WARNING] > It listens on `127.0.0.1` only by default. If you really need remote access, use -> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`, and set up a strong +> `hugagent-server serve --host 0.0.0.0 --port 3001 --no-browser`, and set up a strong > password, a firewall and HTTPS first. Don't expose it directly on an untrusted network. The one-command install uses SQLite, in-process state and a local subprocess diff --git a/README_CN.md b/README_CN.md index a96fd4e2..3545dd22 100644 --- a/README_CN.md +++ b/README_CN.md @@ -220,11 +220,11 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh 安装器会把源码拉到 `~/.hugagent/source`,创建隔离 Python 环境、构建 Web 应用并进入 首次配置向导,完成后打开 [http://127.0.0.1:3001](http://127.0.0.1:3001)。之后用 -`~/.hugagent/venv/bin/hugagent` 再次启动。 +`~/.hugagent/venv/bin/hugagent-server` 再次启动。 > [!WARNING] > 默认仅监听 `127.0.0.1`。确需远程访问时用 -> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强密码、防火墙 +> `hugagent-server serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强密码、防火墙 > 与 HTTPS,不要直接暴露在不受信任的网络中。 一键安装使用 SQLite、进程内状态与本地子进程沙箱,适合试用与开发;选项与故障 diff --git a/docker-compose.yml b/docker-compose.yml index c6c2cc8e..37900643 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -151,6 +151,7 @@ services: TAVILY_API_KEY: ${TAVILY_API_KEY:-} BAIDU_API_KEY: ${BAIDU_API_KEY:-} LANGSEARCH_API_KEY: ${LANGSEARCH_API_KEY:-} + DEEPSEEK_SEARCH_API_KEY: ${DEEPSEEK_SEARCH_API_KEY:-} MEM0_ENABLED: ${MEM0_ENABLED:-false} MEM0_GRAPH_ENABLED: ${MEM0_GRAPH_ENABLED:-false} MEMORY_MODEL_URL: ${MEMORY_MODEL_URL:-} @@ -291,6 +292,7 @@ services: TAVILY_API_KEY: ${TAVILY_API_KEY:-} BAIDU_API_KEY: ${BAIDU_API_KEY:-} LANGSEARCH_API_KEY: ${LANGSEARCH_API_KEY:-} + DEEPSEEK_SEARCH_API_KEY: ${DEEPSEEK_SEARCH_API_KEY:-} QUERY_DATABASE_URL: ${QUERY_DATABASE_URL:-} QUERY_DATABASE_TIMEOUT_SECONDS: ${QUERY_DATABASE_TIMEOUT_SECONDS:-} QUERY_DATABASE_RETRY_TIMES: ${QUERY_DATABASE_RETRY_TIMES:-} diff --git a/docker/object-storage/compose.object-storage.yml b/docker/object-storage/compose.object-storage.yml index ba0b8334..21d79236 100644 --- a/docker/object-storage/compose.object-storage.yml +++ b/docker/object-storage/compose.object-storage.yml @@ -11,7 +11,8 @@ services: MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set a random S3_SECRET_KEY} volumes: - object_storage_data:/data - networks: [hugagent-network] + # Personal Agent object mounts run s3fs in a helper on the compute network. + networks: [hugagent-network, personal-agent-compute] healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/minio/health/live', timeout=5).read()"] interval: 10s @@ -42,5 +43,8 @@ services: mcp: depends_on: object-storage-init: {condition: service_completed_successfully} +networks: + personal-agent-compute: + name: ${COMPOSE_PROJECT_NAME:-hugagent}_personal-agent-compute volumes: object_storage_data: diff --git a/document/en/deployment/environment-variables.md b/document/en/deployment/environment-variables.md index 8a10e2b6..e9143d4c 100644 --- a/document/en/deployment/environment-variables.md +++ b/document/en/deployment/environment-variables.md @@ -184,10 +184,11 @@ the database has no value. | `MCP_HOST` | `mcp` (compose default) | MCP container hostname; set `127.0.0.1` for local debugging | CE | | `MCP_MARKET_REVALIDATE_INTERVAL` | `21600` | Remote MCP marketplace snapshot revalidation interval in seconds (minimum `300`) | CE | | `MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK` | `false` | Allow users to add MCP endpoints on loopback, RFC1918, or IPv6 ULA addresses; enable only in trusted private deployments. Cloud-metadata/link-local ranges stay blocked, and users cannot submit private endpoints to the marketplace | CE | -| `INTERNET_SEARCH_ENGINE` | `tavily` (compose default) | Internet search engine: `tavily` / `baidu` / `langsearch` | CE | +| `INTERNET_SEARCH_ENGINE` | `tavily` (compose default) | Internet search engine: `tavily` / `baidu` / `langsearch` / `deepseek` | CE | | `TAVILY_API_KEY` | (empty) | Tavily Search API key; required when Tavily is selected | CE | | `BAIDU_API_KEY` | (empty) | Baidu search API key; required when Baidu is selected | CE | | `LANGSEARCH_API_KEY` | (empty) | LangSearch Web Search API key; required when LangSearch is selected | CE | +| `DEEPSEEK_SEARCH_API_KEY` | (empty) | DeepSeek platform API key used for the Search API; required when DeepSeek is selected | CE | | `INTERNET_SEARCH_COUNTRY` / `INTERNET_SEARCH_AUTO_PARAMETERS` | (empty) | Tavily country ranking preference (general only; omitted when unset) / automatic parameters | CE | | `QUERY_DATABASE_URL` | `http://your-database-api-host:6200` | HTTP backend for the data-warehouse query tool | EE (industry tool) | | `QUERY_DATABASE_TIMEOUT_SECONDS` / `QUERY_DATABASE_RETRY_TIMES` / `QUERY_DATABASE_MAX_OUTPUT_TOKENS` | (empty) | Data-warehouse call parameters | EE | diff --git a/document/en/deployment/quick-install.md b/document/en/deployment/quick-install.md index c41b178b..78bf3e1d 100644 --- a/document/en/deployment/quick-install.md +++ b/document/en/deployment/quick-install.md @@ -42,7 +42,7 @@ The installer will: 3. Detect and install `bubblewrap` on Linux, or verify the system `sandbox-exec` on macOS; installation fails when the sandbox backend is unavailable instead of falling back to raw execution; 4. Detect optional LibreOffice and, when it is missing, explain the unavailable features and ask whether to install it; skipping it or a failed install doesn't block the remaining features; 5. Create a virtual environment at `~/.hugagent/venv` (using [uv](https://github.com/astral-sh/uv) when available, or `python -m venv` otherwise), and rebuild an incomplete environment left by an interrupted run; -6. Install `requirements.txt`, the `hugagent` console command, the built-in Agent Skills Python and Node.js dependencies, and optional local knowledge-base dependencies; +6. Install `requirements.txt`, the `hugagent-server` console command, the built-in Agent Skills Python and Node.js dependencies, and optional local knowledge-base dependencies; 7. Build the frontend at `src/frontend/dist`; 8. Enter the interactive first-run wizard. @@ -75,8 +75,8 @@ restore a plugin that a user deliberately removed. **Step 4 (optional) · File parser** — parsing uploaded PDFs / scanned documents needs an external parser service (MinerU-compatible); enter its API URL to enable it (written to `file_parser.api_url`), or press Enter to skip. Excel / CSV / PPTX / text parse in-process and need none of this. **Step 5 (optional) · Internet search** — agent web search needs a search -engine key. Select `tavily` (default), `baidu` (Qianfan AppBuilder), or -`langsearch`, and enter the API key for the selected engine. Press Enter to +engine key. Select `tavily` (default), `baidu` (Qianfan AppBuilder), +`langsearch`, or `deepseek`, and enter the API key for the selected engine. Press Enter to skip this step. You can also configure it later under **Settings → System → Service Config**. @@ -84,7 +84,7 @@ At the end the wizard prints a **host-capability summary** (whether Node.js, pan > **Warning:** The server listens on `127.0.0.1` by default. If the server must > accept remote connections, run -> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`, and configure a +> `hugagent-server serve --host 0.0.0.0 --port 3001 --no-browser`, and configure a > strong administrator password, a firewall, and HTTPS first. Don't expose the > service directly on an untrusted network. @@ -93,7 +93,7 @@ At the end the wizard prints a **host-capability summary** (whether Node.js, pan `onboard` accepts bypass flags for scripted installs: ```bash -hugagent onboard \ +hugagent-server onboard \ --username admin --password '' \ --model-base-url https://api.deepseek.com/v1 \ --model-api-key '' --model-name deepseek-chat \ @@ -109,10 +109,10 @@ hugagent onboard \ ## Daily use ```bash -hugagent # initialized → start server and open browser; not initialized → enter the wizard -hugagent serve # start explicitly (--host to change bind address, --port to change port) -hugagent onboard # re-run the wizard / change configuration -hugagent doctor # environment self-check (Python version, port availability, data dir, frontend build, deps, …) +hugagent-server # initialized → start server and open browser; not initialized → enter the wizard +hugagent-server serve # start explicitly (--host to change bind address, --port to change port) +hugagent-server onboard # re-run the wizard / change configuration +hugagent-server doctor # environment self-check (Python version, port availability, data dir, frontend build, deps, …) ``` ## Data directory @@ -166,16 +166,16 @@ The no-Docker single-machine mode is built to be lightweight. Here is how it dif | Symptom | Fix | |---|---| -| Startup reports the port is in use | `hugagent serve --port `; or run `hugagent doctor` first to check | +| Startup reports the port is in use | `hugagent-server serve --port `; or run `hugagent-server doctor` first to check | | The page shows JSON instead of the app | Frontend not built: `cd src/frontend && npm run build`, or set `FRONTEND_DIST_DIR` to a built `dist` | -| Login reports the model is unavailable | Re-run `hugagent onboard` to reconfigure the model (the wizard tests connectivity) | +| Login reports the model is unavailable | Re-run `hugagent-server onboard` to reconfigure the model (the wizard tests connectivity) | | Startup logs repeatedly report `AllocTimestamp` / `Method not implemented` | Stop the service and rerun the public one-command installer. The installer reconciles PyMilvus and Milvus Lite to compatible versions without deleting `~/.hugagent/milvus.db`. | -| Want to switch model / change config | Re-run `hugagent onboard`, or log in and adjust under Settings → System → Model Services / Service Config | +| Want to switch model / change config | Re-run `hugagent-server onboard`, or log in and adjust under Settings → System → Model Services / Service Config | | PPT/Word preview reports that LibreOffice isn't installed | Re-run the one-command installer and choose to install it when prompted. On Debian/Ubuntu, you can instead run `sudo apt-get update && sudo apt-get install -y libreoffice-impress libreoffice-writer libreoffice-calc`, then restart HugAgentOS. | | bash reports a missing OS sandbox runner | Install `bubblewrap` on Linux (`sudo apt-get install bubblewrap` on Debian/Ubuntu), then restart. Until it is installed, every preset except *Full access* refuses host commands outright — by design, rather than running them unconfined. | | bash reports that this system cannot enforce the policy | The preset asks for isolation beyond what this platform provides — the Windows low-integrity sandbox cannot narrow reads, and cannot cut the network without administrator rights. Relax the corresponding setting (for example move the *network* category from *block* back to *confirm*), or use a platform that offers the capability. | -| Skill execution repeatedly reports `fork: Resource temporarily unavailable` | Stop the current service, rerun the public installer to upgrade, and start `hugagent` again. If an older version left child processes behind, inspect processes owned by the current user and, when needed, sign out of the login session before retrying. | -| Is the environment ready | `hugagent doctor` runs a one-shot self-check | +| Skill execution repeatedly reports `fork: Resource temporarily unavailable` | Stop the current service, rerun the public installer to upgrade, and start `hugagent-server` again. If an older version left child processes behind, inspect processes owned by the current user and, when needed, sign out of the login session before retrying. | +| Is the environment ready | `hugagent-server doctor` runs a one-shot self-check | ## Related source diff --git a/document/en/getting-started/quick-start.md b/document/en/getting-started/quick-start.md index acdd4e41..a6e25a9a 100644 --- a/document/en/getting-started/quick-start.md +++ b/document/en/getting-started/quick-start.md @@ -125,7 +125,7 @@ After setup, HugAgentOS starts automatically and opens Run the installed command whenever you want to start the application again: ```bash -~/.hugagent/venv/bin/hugagent +~/.hugagent/venv/bin/hugagent-server ``` You can add the command to your shell path: diff --git a/document/en/modules/auth.md b/document/en/modules/auth.md index 13a8ca15..08641a54 100644 --- a/document/en/modules/auth.md +++ b/document/en/modules/auth.md @@ -64,7 +64,7 @@ ontology validation. `users_shadow.metadata`. Refreshing or signing in again doesn't repeat a completed setup flow. -The terminal-based `hugagent onboard` command already configures the account +The terminal-based `hugagent-server onboard` command already configures the account and model. After it finishes successfully, it writes the same completion marker so the browser doesn't repeat the setup. diff --git a/document/en/modules/mcp-tools.md b/document/en/modules/mcp-tools.md index 404705c8..4a36c029 100644 --- a/document/en/modules/mcp-tools.md +++ b/document/en/modules/mcp-tools.md @@ -82,7 +82,7 @@ It is the only **per-request** server: for each chat request the backend injects ### internet_search — web search (CE) `internet_search(query, queries, max_results, topic, search_depth, include_raw_content)` -selects Tavily, Baidu, or LangSearch through `INTERNET_SEARCH_ENGINE`, reading only the selected API key. +selects Tavily, Baidu, LangSearch, or DeepSeek through `INTERNET_SEARCH_ENGINE`, reading only the selected API key. Supply either one `query` or up to four distinct `queries`. Queries run concurrently; results are interleaved in input-query order and deduplicated by URL, with at most eight sources. The default is five; set `max_results=8` for research. Sources are not filtered by language. Tavily receives a country preference @@ -91,6 +91,9 @@ only when explicitly configured and `topic=general`; there is no implicit China Public information and technical research can use search directly. Internal business questions prioritize the relevant internal authority. When snippets cannot support a key claim, use `web_fetch` to verify the original page. Tavily supports topic, depth and raw content; LangSearch maps summary to content. +DeepSeek maps summary to content and `include_raw_content` to page content, limits each query to 128 characters +(`query_too_long`), restricts `topic=news` to the last 30 days with `after:` (dropping undated pages), and drops +rows with `rerank_score` below 0.5. The single source list is `result.results`, with title, URL, content, available publication date, retrieval time and matched query indices. Baidu's original date is preserved as `date_raw`; retrieval time never diff --git a/document/en/modules/personal-agent.md b/document/en/modules/personal-agent.md index 9bf7a32a..dd881621 100644 --- a/document/en/modules/personal-agent.md +++ b/document/en/modules/personal-agent.md @@ -256,6 +256,8 @@ Personal Agent manages the user's HugAgentOS projects, chats and runs with one t The cloud computer includes the lightweight `hugagent` CLI. It manages existing backends. Web clients and desktop cloud mode share `cloud`. Desktop local mode uses `device:`. +Inside the cloud computer this client is called `hugagent`. Installed anywhere else (a `pip install` of the backend source, or the wheel built by `scripts/build-hugagent-cli.py`), the same client is the `hugagent-session` command with the same options. On a workstation, `hugagent` is the terminal client (`packages/cli`). The local server's `onboard`, `serve`, `doctor` and `engine` commands belong to `hugagent-server`; the session client only points you there when it receives one. + ```bash hugagent device list hugagent --target cloud chat list diff --git a/document/zh-CN/deployment/environment-variables.md b/document/zh-CN/deployment/environment-variables.md index 3361ef4b..7cdab904 100644 --- a/document/zh-CN/deployment/environment-variables.md +++ b/document/zh-CN/deployment/environment-variables.md @@ -182,10 +182,11 @@ Config 管理台会分别保存 Dify、FastGPT、WeKnora 的 URL、API Key 与 | `MCP_HOST` | `mcp`(compose 默认) | MCP 容器主机名;本地调试可设 `127.0.0.1` | CE | | `MCP_MARKET_REVALIDATE_INTERVAL` | `21600` | MCP 市场远程工具快照复检周期(秒,最小 `300`) | CE | | `MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK` | `false` | 是否允许用户自助添加回环、RFC1918 或 IPv6 ULA 地址上的 MCP;仅应在用户可信的私有化环境开启,云元数据/链路本地等高风险地址仍会拦截,私有地址也不能由用户申请上架市场 | CE | -| `INTERNET_SEARCH_ENGINE` | `tavily`(compose 默认) | 联网搜索引擎:`tavily` / `baidu` / `langsearch` | CE | +| `INTERNET_SEARCH_ENGINE` | `tavily`(compose 默认) | 联网搜索引擎:`tavily` / `baidu` / `langsearch` / `deepseek` | CE | | `TAVILY_API_KEY` | (空) | Tavily Search API Key;选择 Tavily 时必填 | CE | | `BAIDU_API_KEY` | (空) | 百度搜索 API Key;选择百度时必填 | CE | | `LANGSEARCH_API_KEY` | (空) | LangSearch Web Search API Key;选择 LangSearch 时必填 | CE | +| `DEEPSEEK_SEARCH_API_KEY` | (空) | 调用 Search API 的 DeepSeek 开放平台 API Key;选择 DeepSeek 时必填 | CE | | `INTERNET_SEARCH_COUNTRY` / `INTERNET_SEARCH_AUTO_PARAMETERS` | (空) | Tavily 国家排名偏好(仅 general,未配置则不传)/ 自动参数 | CE | | `QUERY_DATABASE_URL` | `http://your-database-api-host:6200` | 数仓查询工具的 HTTP 后端 | EE(行业工具) | | `QUERY_DATABASE_TIMEOUT_SECONDS` / `QUERY_DATABASE_RETRY_TIMES` / `QUERY_DATABASE_MAX_OUTPUT_TOKENS` | (空) | 数仓查询调用参数 | EE | diff --git a/document/zh-CN/deployment/quick-install.md b/document/zh-CN/deployment/quick-install.md index d24ab26b..fd9dbcb3 100644 --- a/document/zh-CN/deployment/quick-install.md +++ b/document/zh-CN/deployment/quick-install.md @@ -42,7 +42,7 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh 3. Linux 检测并安装 `bubblewrap`,macOS 检查系统 `sandbox-exec`;沙箱后端不可用时安装失败,不会降级为裸执行; 4. 检测可选的 LibreOffice;缺失时说明不可用能力并询问是否立即安装,跳过或安装失败不会阻断其余功能; 5. 在 `~/.hugagent/venv` 创建虚拟环境(检测到 [uv](https://github.com/astral-sh/uv) 时使用 uv,否则使用 `python -m venv`),并自动重建上次中断留下的不完整环境; -6. 安装 `requirements.txt`、`hugagent` 控制台命令、内置 Agent Skills 的 Python/Node.js 依赖,以及可选的本地知识库依赖; +6. 安装 `requirements.txt`、`hugagent-server` 控制台命令、内置 Agent Skills 的 Python/Node.js 依赖,以及可选的本地知识库依赖; 7. 把前端构建到 `src/frontend/dist`; 8. 进入交互式首次配置向导。 @@ -73,14 +73,14 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh **第 4 步(可选)· 配置文件解析服务**——上传 PDF / 扫描件解析需要一个外部解析服务(MinerU 兼容),填入其 API URL 即可(写入 `file_parser.api_url`);直接回车跳过。Excel / CSV / PPTX / 文本为进程内解析,无需此项。 **第 5 步(可选)· 配置互联网搜索**——智能体联网检索需要一个搜索引擎 -密钥。选择 `tavily`(默认)、`baidu`(千帆 AppBuilder)或 `langsearch`, +密钥。选择 `tavily`(默认)、`baidu`(千帆 AppBuilder)、`langsearch` 或 `deepseek`, 然后填写所选引擎对应的 API Key;直接回车可跳过。事后也可在网页 「设置 → 系统管理 → 服务配置」里补配。 引导末尾会打印**本机能力概览**(Node.js / pandoc / LibreOffice 是否就绪,对应 React 建站 / Word 转换 / PPT 和 Word 在线预览),随后自动起服务并打开 `http://127.0.0.1:3001/`。 > **警告:** 服务默认只监听 `127.0.0.1`。若服务器确实需要接受远程连接,请使用 -> `hugagent serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强管理员密码、 +> `hugagent-server serve --host 0.0.0.0 --port 3001 --no-browser`,并先配置强管理员密码、 > 防火墙和 HTTPS。不要在不受信任的网络中直接暴露服务。 ### 非交互安装(自动化 / CI) @@ -88,7 +88,7 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh `onboard` 支持旁路参数,用于脚本化安装: ```bash -hugagent onboard \ +hugagent-server onboard \ --username admin --password '<强密码>' \ --model-base-url https://api.deepseek.com/v1 \ --model-api-key '' --model-name deepseek-chat \ @@ -113,10 +113,10 @@ hugagent onboard \ ## 日常使用 ```bash -hugagent # 已初始化 → 起服务并打开浏览器;未初始化 → 自动进引导 -hugagent serve # 显式起服务(--host 改监听地址,--port 改端口,--no-browser 不开浏览器) -hugagent onboard # 重跑引导 / 改配置 -hugagent doctor # 环境自检(Python 版本、端口占用、数据目录、前端构建、依赖等) +hugagent-server # 已初始化 → 起服务并打开浏览器;未初始化 → 自动进引导 +hugagent-server serve # 显式起服务(--host 改监听地址,--port 改端口,--no-browser 不开浏览器) +hugagent-server onboard # 重跑引导 / 改配置 +hugagent-server doctor # 环境自检(Python 版本、端口占用、数据目录、前端构建、依赖等) ``` ## 数据目录 @@ -168,16 +168,16 @@ hugagent doctor # 环境自检(Python 版本、端口占用、数据目录 | 现象 | 处理 | |---|---| -| 起服务报端口被占用 | `hugagent serve --port <其他端口>`;或先 `hugagent doctor` 查占用 | +| 起服务报端口被占用 | `hugagent-server serve --port <其他端口>`;或先 `hugagent-server doctor` 查占用 | | 网页打开是一段 JSON 而非应用 | 前端未构建:`cd src/frontend && npm run build`,或设 `FRONTEND_DIST_DIR` 指向已构建的 `dist` | -| 登录报模型不可用 | 重跑 `hugagent onboard` 重配模型(引导会实测连通性) | +| 登录报模型不可用 | 重跑 `hugagent-server onboard` 重配模型(引导会实测连通性) | | 启动日志反复出现 `AllocTimestamp` / `Method not implemented` | 停止当前服务并重新运行公开一键安装器。安装器会把 PyMilvus 与 Milvus Lite 校准到兼容版本,不会删除 `~/.hugagent/milvus.db`。 | -| 想换模型 / 改配置 | 重跑 `hugagent onboard`,或登录后到「设置 → 系统管理 → 模型服务 / 服务配置」调整 | +| 想换模型 / 改配置 | 重跑 `hugagent-server onboard`,或登录后到「设置 → 系统管理 → 模型服务 / 服务配置」调整 | | PPT/Word 预览提示 LibreOffice 未安装 | 重新运行一键安装器并在提示时选择安装;Debian/Ubuntu 也可执行 `sudo apt-get update && sudo apt-get install -y libreoffice-impress libreoffice-writer libreoffice-calc`,然后重启 HugAgentOS | | bash 提示缺少 OS 沙箱运行器 | Linux 安装 `bubblewrap`(Debian/Ubuntu:`sudo apt-get install bubblewrap`)后重启。装上之前,除「完全放开」外的权限档会直接拒绝执行本机命令——这是设计如此,不会退化成无隔离运行 | | bash 提示当前系统无法执行这条策略 | 说明这一档要求的隔离超出了本系统能力,例如 Windows 的低完整性沙箱无法限制读取范围、也无法在不提权的情况下断网。按提示放宽对应设置(如把「网络」类别从「阻止」改回「确认」),或换到能提供该能力的系统 | -| 技能执行反复出现 `fork: Resource temporarily unavailable` | 停止当前服务,重新运行公开安装器完成升级,再启动 `hugagent`。旧版本若留下子进程,先检查当前用户的进程,必要时注销当前登录会话后重试。 | -| 环境是否就绪 | `hugagent doctor` 一次性自检 | +| 技能执行反复出现 `fork: Resource temporarily unavailable` | 停止当前服务,重新运行公开安装器完成升级,再启动 `hugagent-server`。旧版本若留下子进程,先检查当前用户的进程,必要时注销当前登录会话后重试。 | +| 环境是否就绪 | `hugagent-server doctor` 一次性自检 | ## 相关源码 diff --git a/document/zh-CN/getting-started/quick-start.md b/document/zh-CN/getting-started/quick-start.md index e9127b62..c4053d7b 100644 --- a/document/zh-CN/getting-started/quick-start.md +++ b/document/zh-CN/getting-started/quick-start.md @@ -116,7 +116,7 @@ curl -fsSL https://raw.githubusercontent.com/ZJU-REAL/HugAgentOS/main/install.sh 以后需要启动应用时,运行已安装的命令: ```bash -~/.hugagent/venv/bin/hugagent +~/.hugagent/venv/bin/hugagent-server ``` 也可以把命令加入 Shell 的 `PATH`: diff --git a/document/zh-CN/modules/auth.md b/document/zh-CN/modules/auth.md index 0f0c65df..b916e5a6 100644 --- a/document/zh-CN/modules/auth.md +++ b/document/zh-CN/modules/auth.md @@ -56,7 +56,7 @@ HugAgentOS 的认证与权限体系覆盖三条互相独立的链路:**终端 - 完成状态存放在当前管理员的 `users_shadow.metadata` 中,并带版本号。刷新或重新登录 后不会重复进入已完成的向导。 -一键安装器的终端 `hugagent onboard` 已经完成账号和模型配置,因此成功结束时会写入 +一键安装器的终端 `hugagent-server onboard` 已经完成账号和模型配置,因此成功结束时会写入 同一个完成标记,不会再重复显示网页向导。 ### Mock SSO(开发联调) diff --git a/document/zh-CN/modules/mcp-tools.md b/document/zh-CN/modules/mcp-tools.md index b8706b50..44441e1f 100644 --- a/document/zh-CN/modules/mcp-tools.md +++ b/document/zh-CN/modules/mcp-tools.md @@ -75,14 +75,16 @@ HugAgentOS 的工具生态构建在 [MCP(Model Context Protocol)](https://mo ### internet_search — 联网搜索(CE) `internet_search(query, queries, max_results, topic, search_depth, include_raw_content)` -通过 `INTERNET_SEARCH_ENGINE` 选择 Tavily、百度或 LangSearch,只读取所选引擎的 API Key。 +通过 `INTERNET_SEARCH_ENGINE` 选择 Tavily、百度、LangSearch 或 DeepSeek,只读取所选引擎的 API Key。 单个 `query` 与 `queries` 二选一;批量最多 4 个不同查询并发,按输入查询顺序轮流取结果, 按 URL 去重后返回最多 8 条来源。默认返回 5 条,复杂调研可显式设置 `max_results=8`。 不限制来源语言;Tavily 国家偏好仅在显式配置且 `topic=general` 时发送,没有默认中国偏好。 公开资料和技术调研可直接使用;内部业务问题优先内部权威来源。摘要不足以支持关键结论时, 可自主调用 `web_fetch` 核验原文。`topic`、`search_depth`、原始正文参数由 Tavily 支持; -LangSearch 的 summary 映射为 content。 +LangSearch 的 summary 映射为 content。DeepSeek 的 summary 映射为 content,`include_raw_content` +映射为页面正文;单个查询最长 128 字符(超出返回 `query_too_long`);`topic=news` 时用 `after:` +限定最近 30 天(无日期页面会被排除);`rerank_score` 低于 0.5 的结果会被丢弃。 来源统一保存在 `result.results`,包含标题、URL、摘要、可用发布时间、获取时间和命中查询序号。 百度日期原值以 `date_raw` 保留,未知日期不会替换为抓取时间。`content_truncated` 表示内容裁剪, diff --git a/document/zh-CN/modules/personal-agent.md b/document/zh-CN/modules/personal-agent.md index ec8bce3a..b056ce30 100644 --- a/document/zh-CN/modules/personal-agent.md +++ b/document/zh-CN/modules/personal-agent.md @@ -260,6 +260,8 @@ Personal Agent 用一个类型化工具 `pa_hugagent` 管理用户的 HugAgentOS 云端电脑内置轻量 `hugagent` CLI。CLI 管理现有后台。网页端和桌面端的云端模式共用 `cloud`。桌面端的本机模式使用 `device:`。 +云端电脑里这个客户端就叫 `hugagent`。在云电脑以外安装同一个客户端(`pip install` 后端源码,或 `scripts/build-hugagent-cli.py` 打出的 wheel)时,命令名是 `hugagent-session`,用法相同。工作站上的 `hugagent` 是终端客户端(`packages/cli`)。本机服务的 `onboard`、`serve`、`doctor`、`engine` 由 `hugagent-server` 提供,会话客户端收到这些命令时只提示改用 `hugagent-server`。 + ```bash hugagent device list hugagent --target cloud chat list diff --git a/install.sh b/install.sh index 94ebc093..494ab979 100755 --- a/install.sh +++ b/install.sh @@ -365,7 +365,7 @@ info "Building the web application" VITE_EDITION=ce VITE_DEFAULT_LANGUAGE=en NODE_OPTIONS="${BUILD_NODE_OPTIONS# }" npm run build ) -HUGAGENT_BIN="${VENV_DIR}/bin/hugagent" +HUGAGENT_BIN="${VENV_DIR}/bin/hugagent-server" [[ -x "${HUGAGENT_BIN}" ]] || die "The HugAgentOS command was not installed correctly." printf '\n' diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/cli/DESIGN.md b/packages/cli/DESIGN.md index 2abda9cf..ad264c24 100644 --- a/packages/cli/DESIGN.md +++ b/packages/cli/DESIGN.md @@ -155,7 +155,61 @@ packages/cli/ - **P2 对标补齐**:`@文件`、`!命令`、图片、`/compact`、`/permissions`、`/fork` `/tree`、`/diff` `/review`、AGENTS.md 层级、`--output rpc`。 - **P3 生态与运维**:`device:`、`/sessions` `/runs` `/devices`、MCP / 技能命令、主题按键、导出、npm 发布与升级。 -## 8. 风险 +## 8. 实现契约(并行开发时各方只认这一节) + +### 8.1 本机引擎(Python 侧提供,`LocalEngine` 消费) + +- 启动:`hugagent-server engine [--port 0] [--idle-timeout 900] [--ready-file PATH]`;开发态等价于 `python src/backend/cli.py engine …`。数据目录沿用 `HUGAGENT_HOME`(默认 `~/.hugagent`)。 +- 只绑定 127.0.0.1;不开浏览器,不要求前端 dist。 +- 就绪文件:默认 `$HUGAGENT_HOME/engine.json`,权限 0600,原子写入,在服务能处理请求之后才写。内容: + ```json + {"version":1,"url":"http://127.0.0.1:","token":"sk-jx-…","pid":123,"started_at":"","home":"<绝对路径>"} + ``` +- `token` 是本机管理员的个人 API Key(名称 `hugagent-cli`),可作为 `Authorization: Bearer` 访问 `{url}/v1/*` 下的全部接口。 +- 未初始化(还没有管理员)时:退出码 3,stderr 输出 `{"error":"not_initialized"}`。 +- 空闲退出:`--idle-timeout` 秒内既没有 HTTP 请求、也没有活动 run,就优雅退出并删除就绪文件。 +- `LocalEngine` 的发现顺序: + 1. 读就绪文件,校验 pid 存活、`GET {url}/health` 返回 200; + 2. 否则按顺序找命令并以分离进程启动,日志写到 `$HUGAGENT_HOME/logs/engine.log`,最多等 120 秒出现就绪文件: + - 环境变量 `HUGAGENT_ENGINE_CMD`(JSON 数组); + - PATH 上的 `hugagent-server`; + - 设置了 `HUGAGENT_REPO` 时,用 `[${HUGAGENT_PYTHON:-python3}, "$HUGAGENT_REPO/src/backend/cli.py"]`。 + +### 8.2 当前目录 → 本地项目(后端提供) + +- `POST /v1/projects/local/resolve`,请求体 `{"path":"<绝对路径>","create":true,"name":"<可选>"}`。 +- 返回 `success_response`,data 为 `{"project_id","name","local_path","created":bool}`。 +- 按 realpath 匹配已有的 `kind=local` 项目;不存在且 `create=true` 时按现有本地项目创建流程新建。非本机模式返回 404。 +- AGENTS.md 层级(只影响本地项目的提示词组装),由外到内合并: + 1. 全局 `$HUGAGENT_HOME/AGENTS.md`; + 2. 从最近的 git 根目录(找不到时以 `$HOME` 为界)一路往下,直到项目目录,每一级目录里的 `AGENTS.md`。 + + 项目根目录那份仍然是唯一可通过 API 编辑的文件。 + +### 8.3 手动压缩与消息长度(后端提供) + +- `POST /v1/chats/{chat_id}/compact`:同步压缩该会话上下文。返回 data `{"compacted":bool,"tokens_before":int|null,"tokens_after":int|null}`;会话有活动 run 时返回 409。 +- `ChatRequest.message` 的上限从 10000 提高到 100000 字符。 +- 插话(`SteerChatRunRequest.message`)保持 10000,不改数据库:它落在 `chat_steer_queue` 表的 CHECK 约束里,要改得同时动 EE 迁移、CE 迁移和本地 SQLite 重建表。插话本来就是简短的中途指令,长内容应该作为普通消息发送。TUI 在插话超长时提示用户改为普通消息。 +- `hugagent_cli/contract.py` 的会话代理上限不变。 + +### 8.4 CLI 命令行(`packages/cli`) + +- `hugagent [prompt]`:交互 TUI。 +- `hugagent -p/--print `:非交互;不给 prompt 时读 stdin。 +- 输出格式 `--output text|json|rpc`: + - `json`:每个 SSE 事件原样输出为一行 JSON,最后追加一行 `{"type":"cli_result","status":"completed|failed|cancelled","chat_id","run_id"}`; + - `rpc`:从 stdin 读 JSON 行命令 `{"id","method","params"}`,回应 `{"id","result"|"error"}`,事件以 `{"event":{…}}` 推送;方法与 Engine 接口一一对应。 +- `--mode local|cloud|device:`;`-c/--continue`;`-r/--resume [chat_id]`;`--model :`;`--server `(cloud)。 +- 非交互模式遇到 `file_confirm` 默认拒绝,加 `--auto-approve` 时允许;遇到 `user_question` 一律 skip。 +- 子命令: + - `hugagent login [--server URL]` / `logout`:凭据存 `~/.hugagent/cli.json`(0600); + - `hugagent serve|onboard|doctor`:转发给 `hugagent-server`。 +- `--version`、`--help`。 +- 退出码:0 完成,1 失败,2 用法错误,3 需要初始化,130 中断。 +- 交互模式入口固定为 `src/modes/interactive.ts` 的 `runInteractive(options: RunOptions)`。UI 只依赖 `src/engine/engine.ts`。 + +## 9. 风险 - **Python 引擎冷启动慢**:完整 lifespan 要数秒。先实测;用"接入已运行的后端 + 空闲保活"缓解;engine 子命令只启动 agent 运行需要的步骤。 - **两套运行时**:只用 cloud 的用户只需要 Node;local 需要 Python 运行时,优先复用桌面端已安装的那份。 diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..34966311 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,126 @@ +# hugagent + +HugAgentOS in your terminal. The agent, tools, sandbox, compaction, skills and memory all run in the +HugAgentOS backend; this package is the client: a pi-style TUI, a one-shot `-p` mode, a JSONL event +stream and a JSON-lines embedding protocol. Design: [DESIGN.md](DESIGN.md) (§8 is the contract). + +## Install + +Local mode needs the Python backend's `hugagent-server` command. From the repository root: + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt && pip install --no-deps -e . +hugagent-server onboard --no-serve # once: admin account and main model + +(cd packages/chat-sdk && npm install && npm run build) +(cd packages/cli && npm install && npm run build && npm install -g .) + +cd ~/code/my-project && hugagent +``` + +`npm install -g .` links the checkout, so `npm run build` is enough after a change. To run from the checkout +without a global install, use `node packages/cli/dist/main.js`. If `hugagent-server` is not on `PATH`, point the +client at the repo with `HUGAGENT_REPO=` and `HUGAGENT_PYTHON=/.venv/bin/python`. + +## Where the agent runs: `--mode` + +| Mode | What it talks to | Local files | +|---|---|---| +| `local` (default) | the shared headless engine on this machine (`hugagent-server engine`) | the current directory, bound as a local project | +| `cloud` | a remote HugAgentOS (`hugagent login`) | none: attach files instead | +| `device:` | another paired computer, through the cloud's session-manager queue | that computer's | + +The default is `--mode`, then `defaultMode` in `$HUGAGENT_HOME/cli.json`, then `local`. + +### local + +1. Reads `$HUGAGENT_HOME/engine.json` (default `~/.hugagent`); if its pid is alive and `GET /health` answers 200, attaches. +2. Otherwise starts the engine detached, logging to `$HUGAGENT_HOME/logs/engine.log`, and waits up to 120 s for the ready file. + The command is the first of: `HUGAGENT_ENGINE_CMD` (JSON array, e.g. `["python3","/repo/src/backend/cli.py"]`), + `hugagent-server` on `PATH`, `[${HUGAGENT_PYTHON:-python3}, "$HUGAGENT_REPO/src/backend/cli.py"]`. +3. Binds the current directory with `POST /v1/projects/local/resolve`; new sessions go into that project and + `-c` / `-r` only see its sessions. + +The engine is shared and idles out by itself; `hugagent` never stops it. A first start takes about 10 s, +later ones about 6 s, an attach about 0.1 s. A machine that was never set up exits 3: run `hugagent onboard --no-serve`. + +### cloud and device + +```bash +hugagent login --server https://agent.example.com/api # prompts for a personal API key, verifies it +hugagent --mode cloud +hugagent --mode device: -p "what is running?" +hugagent logout +``` + +Credentials live in `$HUGAGENT_HOME/cli.json` (mode 0600); `HUGAGENT_SERVER` / `HUGAGENT_API_KEY` override it, +and the saved key is only sent to the server it was saved for. Plain `http://` is refused except for loopback +or with `HUGAGENT_ALLOW_HTTP=1`. The server URL is the API root: include `/api` behind the default nginx gateway. + +Device mode has no live stream: a turn shows `run_started`, then the final answer once the run settles. It cannot +fork, compact, approve, answer questions, pick models or change permission modes. + +## Non-interactive + +```bash +hugagent -p "list the files here" # answer on stdout, tool lines and notes on stderr +git diff | hugagent -p "review this" # no prompt: read stdin (or pass -p -) +hugagent -p "fix the test" --output json # every SSE event as a line, then a cli_result line +hugagent -c -p "and now the docs" # continue the latest session of this directory +hugagent -r chat_59b5… -p "go on" # a given session +``` + +The last JSON line is `{"type":"cli_result","status":"completed|failed|cancelled","chat_id","run_id"}` +(plus `error` when it did not complete). Events are passed through unchanged; see +`.claude/skills/hugagent-frontend-dev/references/sse-events.md`. + +Approvals: a `file_confirm` is denied unless `--auto-approve`; every `user_question` is skipped. Ctrl+C cancels +the run on the server and exits 130 (a second Ctrl+C exits at once). + +Exit codes: 0 completed, 1 failed, 2 usage error, 3 setup required (`onboard` or `login`), 130 interrupted. + +## `--output rpc` + +One JSON command per stdin line, replies and events on stdout. Commands run concurrently, so `cancel`, `steer` +and `confirm` work while a `send` streams. + +```jsonc +→ {"id":1,"method":"newSession","params":{"title":"from my editor"}} +← {"id":1,"result":{"chatId":"chat_…","title":"from my editor","projectId":"prj_…"}} +→ {"id":2,"method":"send","params":{"chatId":"chat_…","message":"hi"}} +← {"event":{"type":"run_started","run_id":"…"},"request":2} +← {"event":{"type":"content","delta":"Hello"},"request":2} +← {"id":2,"result":{"status":"completed","chatId":"chat_…","runId":"…"}} +``` + +Methods mirror `src/engine/engine.ts`: `info`, `connect`, `close`, `newSession {title?}`, `listSessions {limit?}`, +`history {chatId, limit?}`, `fork {chatId, messageId?}`, `send {chatId, message, model?, attachments?}`, +`resume {runId, fromOffset?}`, `activeRun {chatId}`, `cancel {runId}`, `steer {runId, message, mode?}`, +`confirm {chatId, confirmId, decision, optionId?}`, `answer {chatId, requestId, answers}`, `compact {chatId}`, +`listModels`, `getPermissionMode`, `setPermissionMode {mode}`. Errors are +`{"id","error":{"message","status"?,"code"?,"exitCode"?}}`. No approval policy applies: the client answers +`file_confirm` and `user_question` itself. stdin EOF waits for running commands, then exits 0. + +## Server commands + +`hugagent serve|onboard|doctor …` runs `hugagent-server` (found the same way as the engine) with the terminal and +passes its exit code on. + +## Layout + +``` +src/main.ts arguments, login/logout, server forwarding, mode dispatch +src/config.ts $HUGAGENT_HOME/cli.json, server URL policy, credentials +src/engine/engine.ts the Engine interface the UI is built against +src/engine/http.ts authenticated HTTP over @hugagent/chat-sdk, envelope + error handling +src/engine/base.ts shared HTTP engine (reconnects a dropped stream from its last offset) +src/engine/local.ts engine discovery / spawn, cwd → local project +src/engine/remote.ts cloud and device: +src/engine/headless.ts one non-interactive turn: session choice, approval policy, Ctrl+C +src/engine/index.ts EngineFactory +src/modes/ interactive (TUI), print, json, rpc +test/ node --test against a fake HugAgentOS (node:http + SSE) +``` + +`npm test` builds and runs the tests. diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json new file mode 100644 index 00000000..63db7171 --- /dev/null +++ b/packages/cli/package-lock.json @@ -0,0 +1,179 @@ +{ + "name": "@hugagent/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@hugagent/cli", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "@hugagent/chat-sdk": "file:../chat-sdk", + "@mariozechner/pi-tui": "0.73.1", + "chalk": "^5.5.0" + }, + "bin": { + "hugagent": "dist/main.js" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "~5.9.3" + }, + "engines": { + "node": ">=20" + } + }, + "../chat-sdk": { + "name": "@hugagent/chat-sdk", + "version": "1.0.0", + "license": "UNLICENSED", + "devDependencies": { + "@types/react": "^19.2.7", + "react": "^19.2.0", + "typescript": "~5.9.3" + }, + "peerDependencies": { + "react": ">=19 <20" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@hugagent/chat-sdk": { + "resolved": "../chat-sdk", + "link": true + }, + "node_modules/@mariozechner/pi-tui": { + "version": "0.73.1", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-tui/-/pi-tui-0.73.1.tgz", + "integrity": "sha512-ybVsRnUbzQRtbocltJ2OXb2QogrO67N2BlUyKjZz9BHcZYiDJtNkcKQockxDjsVvDc0uBCLDX6iZJoBElBd8fw==", + "deprecated": "please use @earendil-works/pi-tui instead going forward", + "license": "MIT", + "dependencies": { + "@types/mime-types": "^2.1.4", + "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", + "marked": "^15.0.12", + "mime-types": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "koffi": "^2.9.0" + } + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/koffi": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.3.tgz", + "integrity": "sha512-E9y1AsgYGlaxMhcZzHr8y96QF2U5XzA12GGVAfbWqIubTwPNMXQarfBzePNXHe0xtIEtNd6ifAv3GAKYGUeBAQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..47993e03 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hugagent/cli", + "version": "0.1.0", + "description": "HugAgentOS terminal client: local and cloud agent sessions in a pi-style TUI", + "type": "module", + "license": "UNLICENSED", + "bin": { + "hugagent": "dist/main.js" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test test/*.test.mjs" + }, + "dependencies": { + "@hugagent/chat-sdk": "file:../chat-sdk", + "@mariozechner/pi-tui": "0.73.1", + "chalk": "^5.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "~5.9.3" + } +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 00000000..924680b3 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,112 @@ +/** + * CLI settings and cloud credentials in $HUGAGENT_HOME/cli.json (default ~/.hugagent/cli.json, mode 0600). + * `hugagent login` writes `{server, apiKey}`; HUGAGENT_SERVER / HUGAGENT_API_KEY override them. + */ +import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Mode } from './engine/engine.js'; +import { CliError, EXIT } from './engine/errors.js'; + +export interface CliConfig { + server?: string; + apiKey?: string; + /** Mode used when --mode is not given. */ + defaultMode?: string; + [key: string]: unknown; +} + +export function hugagentHome(env: NodeJS.ProcessEnv = process.env): string { + const configured = env.HUGAGENT_HOME?.trim(); + return configured ? resolve(configured) : join(homedir(), '.hugagent'); +} + +export function configPath(env: NodeJS.ProcessEnv = process.env): string { + return join(hugagentHome(env), 'cli.json'); +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): CliConfig { + const path = configPath(env); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw error; + } + try { + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object'); + return parsed as CliConfig; + } catch { + throw new CliError(`${path} is not valid JSON; fix or delete it`, EXIT.usage); + } +} + +/** Atomic write, owner-only: the file holds an API key. */ +export function saveConfig(config: CliConfig, env: NodeJS.ProcessEnv = process.env): void { + const home = hugagentHome(env); + mkdirSync(home, { recursive: true, mode: 0o700 }); + const path = configPath(env); + const temp = `${path}.${process.pid}.tmp`; + writeFileSync(temp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + chmodSync(temp, 0o600); + renameSync(temp, path); +} + +const DEVICE_ID = /^[A-Za-z0-9_-]{1,100}$/; + +export function parseMode(value: string): Mode | null { + if (value === 'local' || value === 'cloud') return value; + if (value.startsWith('device:') && DEVICE_ID.test(value.slice(7))) return value as Mode; + return null; +} + +const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); + +export function isLoopbackHost(hostname: string): boolean { + return LOOPBACK.has(hostname) || hostname.endsWith('.localhost'); +} + +/** Normalized server URL; https is required except for loopback or HUGAGENT_ALLOW_HTTP=1. */ +export function validateServerUrl(value: string, env: NodeJS.ProcessEnv = process.env): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CliError(`Invalid server URL: ${value}`, EXIT.usage); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password || url.search || url.hash) { + throw new CliError(`Invalid server URL: ${value} (expected https://host[/path])`, EXIT.usage); + } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname) && env.HUGAGENT_ALLOW_HTTP !== '1') { + throw new CliError(`Refusing plain HTTP to ${url.host}: use https, or set HUGAGENT_ALLOW_HTTP=1 for a trusted network`, EXIT.usage); + } + return url.toString().replace(/\/+$/, ''); +} + +export interface CloudCredentials { server: string; apiKey: string } + +/** + * Server: --server > HUGAGENT_SERVER > cli.json. API key: HUGAGENT_API_KEY > cli.json, and the saved + * key is only used for the server it was saved for, so --server never leaks it to another host. + */ +export function resolveCloudCredentials(serverFlag: string | undefined, env: NodeJS.ProcessEnv = process.env): CloudCredentials { + const config = loadConfig(env); + const raw = serverFlag || env.HUGAGENT_SERVER?.trim() || config.server; + if (!raw) throw new CliError('No cloud server configured: run `hugagent login --server https://…` or pass --server', EXIT.setup); + const server = validateServerUrl(raw, env); + const saved = config.server && config.apiKey && safeNormalize(config.server) === server ? config.apiKey : undefined; + const apiKey = env.HUGAGENT_API_KEY?.trim() || saved; + if (!apiKey) throw new CliError(`Not logged in to ${server}: run \`hugagent login --server ${server}\``, EXIT.setup); + if (/[\r\n]/.test(apiKey)) throw new CliError('API key must be a single line', EXIT.usage); + return { server, apiKey }; +} + +function safeNormalize(server: string): string | undefined { + try { + return validateServerUrl(server, { ...process.env, HUGAGENT_ALLOW_HTTP: '1' }); + } catch { + return undefined; + } +} diff --git a/packages/cli/src/engine/base.ts b/packages/cli/src/engine/base.ts new file mode 100644 index 00000000..c85b479f --- /dev/null +++ b/packages/cli/src/engine/base.ts @@ -0,0 +1,201 @@ +/** Shared Engine implementation for every mode that talks the full HTTP + SSE API (local, cloud). */ +import type { ChatEvent, ChatHistoryMessage, ChatRequest, ChatSession, FileDecision, QuestionAnswer } from '@hugagent/chat-sdk'; +import type { ActiveRun, CompactSummary, DeliveryMode, Engine, EngineInfo, ModelOption, PermissionMode, SendOptions, + SessionSummary } from './engine.js'; +import { CliError, HugAgentOSHttpError, isAbortError } from './errors.js'; +import type { HugAgentOSHttp } from './http.js'; + +const RECONNECT_ATTEMPTS = 5; +export const STEER_LIMIT = 10_000; + +export function toSummary(session: ChatSession): SessionSummary { + return { + chatId: session.chat_id, + title: session.title || '', + updatedAt: typeof session.updated_at === 'string' ? session.updated_at : undefined, + projectId: typeof session.project_id === 'string' ? session.project_id : undefined, + }; +} + +/** Page size the list endpoints accept for a wanted count. */ +export function pageSizeFor(limit: number): number { + return Math.min(Math.max(limit, 1), 100); +} + +/** Newest-first summaries of a page of chats, at most `limit`. */ +export function latestSummaries(sessions: ChatSession[], limit: number): SessionSummary[] { + return newestFirst(sessions.map(toSummary)).slice(0, limit); +} + +/** `open` runs once and is shared by concurrent callers; a failed attempt is forgotten so the next call retries. */ +export function openOnce(open: () => Promise): () => Promise { + let pending: Promise | undefined; + return () => (pending ??= open().catch((error: unknown) => { + pending = undefined; + throw error; + })); +} + +export function newestFirst(sessions: SessionSummary[]): SessionSummary[] { + return [...sessions].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); +} + +export function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); + const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new DOMException('Aborted', 'AbortError')); }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** A dropped stream (not a refusal) that a resume from the last offset can repair. */ +function isDisconnect(error: unknown): boolean { + if (error instanceof HugAgentOSHttpError) return error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504; + if (!(error instanceof Error)) return false; + return error.name === 'TypeError' || /SSE disconnected|terminated|ECONNRESET|socket/i.test(error.message); +} + +export abstract class HttpEngine implements Engine { + abstract readonly info: EngineInfo; + protected client?: HugAgentOSHttp; + private readonly opening = openOnce(() => this.open()); + + /** Start or attach to the backend and set `this.client`. */ + protected abstract open(): Promise; + abstract newSession(title?: string): Promise; + abstract listSessions(limit?: number): Promise; + + connect(): Promise { + return this.opening(); + } + + async close(): Promise { + // Nothing to release: HTTP is stateless and the backend outlives this client. + } + + protected get http(): HugAgentOSHttp { + if (!this.client) throw new CliError('Engine is not connected; call connect() first'); + return this.client; + } + + async history(chatId: string, limit = 50): Promise { + const pageSize = pageSizeFor(limit); + const collected: ChatHistoryMessage[] = []; + // order=desc picks the most recent page first, but each page is still in + // conversation order, so sort by sequence rather than reversing. + for (let page = 1; collected.length < limit; page++) { + const { items } = await this.http.messages(chatId, page, pageSize, 'desc'); + collected.push(...items); + if (items.length < pageSize) break; + } + const seq = (message: ChatHistoryMessage) => (typeof message.chat_seq === 'number' ? message.chat_seq : 0); + return collected.sort((a, b) => seq(a) - seq(b)).slice(-limit); + } + + async fork(chatId: string, messageId?: string): Promise { + return toSummary(await this.http.fork(chatId, messageId)); + } + + async *send(chatId: string, message: string, options: SendOptions = {}): AsyncIterable { + const request: ChatRequest = { chat_id: chatId, message }; + if (options.attachments?.length) request.attachments = options.attachments; + if (options.model) { + request.model_provider_id = options.model.providerId; + request.model_name = options.model.modelName; + } + yield* this.follow(this.http.send(request, options.signal), options.signal); + } + + resume(runId: string, fromOffset = 0, signal?: AbortSignal): AsyncIterable { + return this.follow(this.http.resume(runId, fromOffset, signal), signal, runId, fromOffset || undefined); + } + + /** Relays a run's events; a dropped connection resumes from the last offset instead of failing. */ + private async *follow(stream: AsyncIterable, signal?: AbortSignal, runId?: string, + offset?: number): AsyncGenerator { + let source = stream; + let attempts = 0; + for (;;) { + try { + for await (const event of source) { + if (event.type === 'run_started' && typeof event.run_id === 'string') runId = event.run_id; + if (typeof event.event_offset === 'number') { + if (offset !== undefined && event.event_offset <= offset) continue; + offset = event.event_offset; + } + attempts = 0; + yield event; + } + return; + } catch (error) { + if (signal?.aborted || isAbortError(error) || !runId || !isDisconnect(error) || attempts >= RECONNECT_ATTEMPTS) throw error; + attempts += 1; + await delay(400 * attempts, signal); + source = this.http.resume(runId, offset ?? 0, signal); + } + } + } + + async activeRun(chatId: string): Promise { + const run = await this.http.activeRun(chatId); + if (!run || typeof run.run_id !== 'string') return null; + return { runId: run.run_id, offset: typeof run.last_event_offset === 'number' ? run.last_event_offset : undefined }; + } + + cancel(runId: string): Promise { + return this.http.cancel(runId); + } + + async steer(runId: string, message: string, mode: DeliveryMode = 'steer'): Promise { + // chat_steer_queue still caps queued input at 10000 characters (ChatRequest allows 100000). + if (message.length > STEER_LIMIT) throw new CliError(`Messages sent while a run is working are limited to ${STEER_LIMIT} characters`); + await this.http.steer(runId, message, mode); + } + + async confirm(chatId: string, confirmId: string, decision: FileDecision, optionId?: string): Promise { + await this.http.confirmFile(chatId, confirmId, decision, optionId); + } + + async answer(chatId: string, requestId: string, answers: QuestionAnswer[]): Promise { + await this.http.answerQuestion(chatId, requestId, answers); + } + + async compact(chatId: string): Promise { + try { + const result = await this.http.compact(chatId); + return { + compacted: result.compacted === true, + tokensBefore: result.tokens_before ?? null, + tokensAfter: result.tokens_after ?? null, + }; + } catch (error) { + // FastAPI's own 404 for an unknown route; a missing chat carries its own message. + if (error instanceof HugAgentOSHttpError && error.status === 404 && error.message.startsWith('Not Found (')) { + throw new CliError('This server has no manual compaction endpoint (POST /v1/chats/{id}/compact); update it'); + } + throw error; + } + } + + async listModels(): Promise { + const caps = await this.http.modelCapabilities(); + const models = caps.user_model_switch?.enabled ? caps.user_model_switch.models ?? [] : []; + return models + .filter((model) => model.provider_id && model.model_name) + .map((model) => ({ + providerId: model.provider_id, + modelName: model.model_name, + label: model.display_name || model.model_name, + current: !!model.is_default, + })); + } + + getPermissionMode(): Promise { + return this.http.getToolApproval(); + } + + async setPermissionMode(mode: PermissionMode): Promise { + await this.http.setToolApproval(mode); + } +} diff --git a/packages/cli/src/engine/engine.ts b/packages/cli/src/engine/engine.ts new file mode 100644 index 00000000..644fa7df --- /dev/null +++ b/packages/cli/src/engine/engine.ts @@ -0,0 +1,123 @@ +/** + * The one contract between the TUI / output modes and where the agent runs. + * + * Every mode (local, cloud, device:) speaks the same HugAgentOS HTTP + SSE API, + * so events reach the UI unchanged: see + * .claude/skills/hugagent-frontend-dev/references/sse-events.md for every event type. + * The UI depends on this file only, never on a concrete engine. + */ +import type { ChatEvent, ChatHistoryMessage, FileDecision, QuestionAnswer } from '@hugagent/chat-sdk'; + +export type { ChatEvent, ChatHistoryMessage, FileDecision, QuestionAnswer }; + +/** Where the agent runs. `local` works in the current directory on this machine. */ +export type Mode = 'local' | 'cloud' | `device:${string}`; + +export interface EngineInfo { + mode: Mode; + /** Short status-bar text, e.g. "local · ~/repo" or "cloud · agent.example.com". */ + label: string; + /** Base URL the engine talks to (local: loopback engine). */ + server: string; + /** Absolute directory the agent works in; set only when it can reach this machine's files. */ + workspace?: string; + /** The project new sessions are created in (local: the project bound to `workspace`). */ + projectId?: string; + /** False for cloud and device modes: the agent cannot read or change local files. */ + canAccessCwd: boolean; +} + +export interface SessionSummary { + chatId: string; + title: string; + updatedAt?: string; + projectId?: string; +} + +export interface ModelOption { + providerId: string; + modelName: string; + label: string; + current?: boolean; +} + +/** steer: apply inside the running turn; followUp: right after it; nextRun: as the next message. */ +export type DeliveryMode = 'steer' | 'followUp' | 'nextRun'; + +/** Tool execution preset, as served by /v1/tool-approval. */ +export type PermissionMode = 'ask' | 'auto' | 'full'; + +export interface SendOptions { + /** Per-message model override (ChatRequest.model_provider_id / model_name). */ + model?: { providerId: string; modelName: string }; + attachments?: { file_id: string; name: string; mime_type: string }[]; + signal?: AbortSignal; +} + +/** What a manual compaction did; `compacted` is false when nothing was old enough to fold. */ +export interface CompactSummary { + compacted: boolean; + tokensBefore: number | null; + tokensAfter: number | null; +} + +export interface ActiveRun { + runId: string; + /** Last delivered event offset, for resume(). */ + offset?: number; +} + +export interface Engine { + readonly info: EngineInfo; + + /** Start or attach to the backend and bind the workspace. Idempotent. */ + connect(): Promise; + /** Release the connection; never stops a local engine other clients may share. */ + close(): Promise; + + newSession(title?: string): Promise; + /** Newest first; local mode lists only the sessions of the bound project. */ + listSessions(limit?: number): Promise; + /** Oldest first, at most `limit` latest messages. */ + history(chatId: string, limit?: number): Promise; + /** Branch a session (POST /v1/chats/{id}/fork); returns the new session. */ + fork(chatId: string, messageId?: string): Promise; + + /** Start a turn and stream its events until the run ends (`end` / [DONE]). */ + send(chatId: string, message: string, options?: SendOptions): AsyncIterable; + /** Re-follow a run from an event offset (reconnect, or after /resume). */ + resume(runId: string, fromOffset?: number, signal?: AbortSignal): AsyncIterable; + activeRun(chatId: string): Promise; + cancel(runId: string): Promise; + steer(runId: string, message: string, mode?: DeliveryMode): Promise; + + /** Answer a `file_confirm` event (file writes, commands, tool permissions all use it). */ + confirm(chatId: string, confirmId: string, decision: FileDecision, optionId?: string): Promise; + /** Answer a `user_question` event. */ + answer(chatId: string, requestId: string, answers: QuestionAnswer[]): Promise; + + /** Compact a session's context now (POST /v1/chats/{id}/compact). */ + compact(chatId: string): Promise; + + listModels(): Promise; + getPermissionMode(): Promise; + setPermissionMode(mode: PermissionMode): Promise; +} + +/** Builds the engine for a mode; implemented in engine/index.ts. */ +export type EngineFactory = (mode: Mode, options: { cwd: string }) => Promise; + +/** Options every output mode receives from main.ts. */ +export interface RunOptions { + engine: Engine; + /** Create engines for /mode switches. */ + createEngine: EngineFactory; + cwd: string; + /** Session to continue (-c / -r / --session). */ + chatId?: string; + model?: { providerId: string; modelName: string }; + /** `hugagent [prompt]`: send this as the first message once the UI is up. */ + prompt?: string; + /** `-r` without an id: let the user pick a session of this engine to resume. */ + pickSession?: boolean; +} diff --git a/packages/cli/src/engine/errors.ts b/packages/cli/src/engine/errors.ts new file mode 100644 index 00000000..3ee6375f --- /dev/null +++ b/packages/cli/src/engine/errors.ts @@ -0,0 +1,47 @@ +/** Errors the engine layer raises; `exitCode` is what main.ts exits with when one reaches the top. */ + +export const EXIT = { ok: 0, failed: 1, usage: 2, setup: 3, interrupted: 130 } as const; + +export class CliError extends Error { + constructor(message: string, public readonly exitCode: number = EXIT.failed) { + super(message); + this.name = 'CliError'; + } +} + +/** An HTTP call to HugAgentOS failed; `detail` is the server's error body, kept for display. */ +export class HugAgentOSHttpError extends CliError { + constructor( + message: string, + public readonly status: number, + public readonly code?: number, + public readonly detail?: unknown, + ) { + super(message, EXIT.failed); + this.name = 'HugAgentOSHttpError'; + } +} + +/** The local engine or cloud login needs a one-time setup step before anything works. */ +export class SetupRequiredError extends CliError { + constructor(message: string) { + super(message, EXIT.setup); + this.name = 'SetupRequiredError'; + } +} + +/** The engine for this mode cannot do what was asked (device mode lacks most of the API). */ +export class UnsupportedError extends CliError { + constructor(message: string) { + super(message, EXIT.failed); + this.name = 'UnsupportedError'; + } +} + +export function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/engine/headless.ts b/packages/cli/src/engine/headless.ts new file mode 100644 index 00000000..5c31ae67 --- /dev/null +++ b/packages/cli/src/engine/headless.ts @@ -0,0 +1,199 @@ +/** + * One non-interactive turn, shared by the text and JSON output modes: picks the session, applies the + * non-interactive approval policy (DESIGN.md §8.4: deny file_confirm unless --auto-approve, skip + * every user_question) and turns Ctrl+C into a run cancel. + */ +import type { ChatEvent, Engine, FileDecision, QuestionAnswer } from './engine.js'; +import { CliError, EXIT, errorMessage } from './errors.js'; + +export type TurnStatus = 'completed' | 'failed' | 'cancelled'; + +/** What main.ts hands the text and JSON modes. The engine may not be connected yet. */ +export interface HeadlessOptions { + engine: Engine; + prompt: string; + chatId?: string; + continueLast?: boolean; + model?: { providerId: string; modelName: string }; + autoApprove: boolean; + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; +} + +export interface TurnResult { + status: TurnStatus; + chatId: string; + runId?: string; + error?: string; +} + +export interface TurnOptions { + autoApprove: boolean; + model?: { providerId: string; modelName: string }; + signal?: AbortSignal; + onEvent(event: ChatEvent): void; + /** One line about what the policy answered on the user's behalf. */ + onNote?(note: string): void; + onRunId?(runId: string): void; +} + +/** How one headless turn ended; exactly one of `result` and `error` is set. */ +export interface HeadlessOutcome { + chatId?: string; + result?: TurnResult; + /** Thrown while connecting, choosing the session or running the turn. */ + error?: unknown; + interrupted: boolean; +} + +/** Connect, choose the session and run one turn with Ctrl+C wired to cancel: the spine of `-p` and `--output json`. */ +export async function runHeadless( + options: HeadlessOptions, + hooks: Pick, +): Promise { + let chatId: string | undefined; + const interrupt = new Interrupt(options.engine, () => chatId); + try { + await options.engine.connect(); + chatId = await chooseChat(options.engine, options.chatId, !!options.continueLast); + const result = await runTurn(options.engine, chatId, options.prompt, { + autoApprove: options.autoApprove, + model: options.model, + signal: interrupt.signal, + onRunId: (runId) => { interrupt.runId = runId; }, + ...hooks, + }); + return { chatId, result, interrupted: interrupt.interrupted }; + } catch (error) { + return { chatId, error, interrupted: interrupt.interrupted }; + } finally { + await interrupt.dispose(); + } +} + +/** Exit code per DESIGN.md §8.4: 0 completed, 130 interrupted, the CliError's own code, else 1. */ +export function headlessExitCode(outcome: HeadlessOutcome): number { + if (outcome.result) { + if (outcome.result.status === 'completed') return EXIT.ok; + return outcome.result.status === 'cancelled' && outcome.interrupted ? EXIT.interrupted : EXIT.failed; + } + if (outcome.interrupted) return EXIT.interrupted; + return outcome.error instanceof CliError ? outcome.error.exitCode : EXIT.failed; +} + +/** -r wins, then -c (latest session of this engine), else a new session. */ +export async function chooseChat(engine: Engine, chatId: string | undefined, continueLast: boolean): Promise { + if (chatId) return chatId; + if (continueLast) { + const [latest] = await engine.listSessions(1); + if (latest) return latest.chatId; + } + return (await engine.newSession()).chatId; +} + +export function eventError(event: ChatEvent): string { + const value = event.error ?? event.message ?? event.content; + if (typeof value === 'string' && value) return value; + if (value && typeof value === 'object') { + const nested = (value as Record).message; + return typeof nested === 'string' ? nested : JSON.stringify(value); + } + return 'Run failed'; +} + +export async function runTurn(engine: Engine, chatId: string, message: string, options: TurnOptions): Promise { + const result: TurnResult = { status: 'completed', chatId }; + let failure: string | undefined; + let endStatus: string | undefined; + try { + for await (const event of engine.send(chatId, message, { model: options.model, signal: options.signal })) { + if (event.type === 'run_started' && typeof event.run_id === 'string' && !result.runId) { + result.runId = event.run_id; + options.onRunId?.(event.run_id); + } + options.onEvent(event); + if (event.type === 'error') failure ??= eventError(event); + else if (event.type === 'end' && typeof event.status === 'string') endStatus = event.status; + else if (event.type === 'file_confirm' && typeof event.confirm_id === 'string' && !event.expired) { + const decision: FileDecision = options.autoApprove ? 'allow' : 'deny'; + await engine.confirm(chatId, event.confirm_id, decision).then( + () => options.onNote?.(`${decision === 'allow' ? 'approved' : 'denied'} ${describeConfirm(event)}` + + (decision === 'deny' ? ' (pass --auto-approve to allow)' : '')), + (error: unknown) => options.onNote?.(`could not answer approval ${event.confirm_id}: ${errorMessage(error)}`), + ); + } else if (event.type === 'user_question' && typeof event.request_id === 'string') { + const questions = Array.isArray(event.questions) ? event.questions : []; + const answers: QuestionAnswer[] = questions + .map((question) => (question && typeof question === 'object' ? (question as Record).id : undefined)) + .filter((id): id is string => typeof id === 'string') + .map((id) => ({ id, selected: [], skipped: true })); + if (answers.length) { + await engine.answer(chatId, event.request_id, answers).then( + () => options.onNote?.(`skipped ${answers.length} question(s) from the agent (non-interactive)`), + (error: unknown) => options.onNote?.(`could not skip question ${event.request_id}: ${errorMessage(error)}`), + ); + } + } + } + } catch (error) { + if (options.signal?.aborted) return { ...result, status: 'cancelled' }; + return { ...result, status: 'failed', error: errorMessage(error) }; + } + if (options.signal?.aborted || endStatus === 'cancelled') return { ...result, status: 'cancelled' }; + if (failure || endStatus === 'failed' || endStatus === 'needs_attention') { + return { ...result, status: 'failed', error: failure ?? `Run ${endStatus}` }; + } + return result; +} + +function describeConfirm(event: ChatEvent): string { + for (const key of ['title', 'summary', 'description', 'path', 'file_path', 'command', 'tool_name', 'action']) { + const value = event[key]; + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 160); + } + return 'a pending action'; +} + +/** + * Ctrl+C during a turn: abort following, cancel the run on the server, then let the mode exit 130. + * A second Ctrl+C exits at once. + */ +export class Interrupt { + readonly controller = new AbortController(); + runId?: string; + private cancelling?: Promise; + + constructor(private readonly engine: Engine, private readonly chatId: () => string | undefined) { + process.on('SIGINT', this.handle); + } + + get signal(): AbortSignal { + return this.controller.signal; + } + + get interrupted(): boolean { + return this.controller.signal.aborted; + } + + private readonly handle = () => { + if (this.cancelling) process.exit(130); + this.controller.abort(); + this.cancelling = this.cancelRun(); + }; + + private async cancelRun(): Promise { + const work = (async () => { + let runId = this.runId; + const chatId = this.chatId(); + if (!runId && chatId) runId = (await this.engine.activeRun(chatId).catch(() => null))?.runId; + if (runId) await this.engine.cancel(runId); + })().catch(() => {}); + await Promise.race([work, new Promise((resolve) => setTimeout(resolve, 5000).unref())]); + } + + /** Wait for the cancel request to go out, and stop listening for Ctrl+C. */ + async dispose(): Promise { + process.off('SIGINT', this.handle); + await this.cancelling; + } +} diff --git a/packages/cli/src/engine/http.ts b/packages/cli/src/engine/http.ts new file mode 100644 index 00000000..188e96a2 --- /dev/null +++ b/packages/cli/src/engine/http.ts @@ -0,0 +1,303 @@ +/** + * One authenticated HTTP layer for every engine: `ChatClient` from @hugagent/chat-sdk for the + * streaming and confirmation calls, plus the HugAgentOS endpoints the SDK does not cover. + * Every JSON call unwraps the `{code, data}` envelope; failures become HugAgentOSHttpError with + * the HTTP status and the server's own message. + */ +import { randomUUID } from 'node:crypto'; +import { ChatApiError, ChatClient, type ChatEvent, type ChatHistoryMessage, type ChatRequest, type ChatSession, + type FileDecision, type QuestionAnswer } from '@hugagent/chat-sdk'; +import type { DeliveryMode, PermissionMode } from './engine.js'; +import { HugAgentOSHttpError } from './errors.js'; + +const JSON_TIMEOUT_MS = 30_000; + +export interface HttpOptions { + /** API root, e.g. http://127.0.0.1:8123 or https://agent.example.com/api. */ + baseUrl: string; + token: string; + fetch?: typeof fetch; +} + +export interface LocalProject { project_id: string; name: string; local_path: string; created: boolean } +export interface CompactResult { compacted: boolean; tokens_before: number | null; tokens_after: number | null } +export interface RunStatus { run_id: string; chat_id: string; message_id?: string; status: string } +export interface ActiveRunInfo { run_id: string; message_id?: string; status?: string; last_event_offset?: number } +export interface SelectableModel { + provider_id: string; + display_name: string; + model_name: string; + provider?: string; + is_default?: boolean; +} +export interface ModelCapabilities { + main_agent?: Record; + user_model_switch?: { enabled?: boolean; models?: SelectableModel[] }; + [key: string]: unknown; +} +export interface Page { items: T[]; [key: string]: unknown } +/** A session-manager operation receipt (personal_agent/sessions/session_queue.py `public`). */ +export interface OperationJob { + id: string; + target?: string; + operation?: string; + status: string; + result?: unknown; + deadline?: number; + [key: string]: unknown; +} + +/** Turns any error body HugAgentOS produces into one readable line. */ +export function describeErrorBody(status: number, body: unknown): { message: string; code?: number; detail?: unknown } { + const fallback = { message: 'HTTP ' + status }; + if (!body || typeof body !== 'object') return fallback; + const record = body as Record; + const code = typeof record.code === 'number' ? record.code : undefined; + // error_response(): {code, message, data} + if (typeof record.message === 'string' && record.message) { + return { message: record.message, code, detail: record.data ?? record.detail }; + } + const detail = record.detail; + if (typeof detail === 'string' && detail) return { message: detail, code, detail }; + if (Array.isArray(detail)) { + // FastAPI validation: [{loc: [...], msg}] + const parts = detail.map((item) => { + const entry = item && typeof item === 'object' ? item as Record : {}; + const loc = Array.isArray(entry.loc) ? entry.loc.filter((part) => part !== 'body').join('.') : ''; + const msg = typeof entry.msg === 'string' ? entry.msg : JSON.stringify(item); + return loc ? loc + ': ' + msg : msg; + }); + return { message: parts.join('; ') || fallback.message, code, detail }; + } + if (detail && typeof detail === 'object') { + const nested = detail as Record; + const message = typeof nested.message === 'string' ? nested.message + : typeof nested.error === 'string' ? nested.error : JSON.stringify(detail); + return { message, code: typeof nested.code === 'number' ? nested.code : code, detail }; + } + if (typeof record.error === 'string') return { message: record.error, code, detail: record }; + return fallback; +} + +function httpError(status: number, body: unknown): HugAgentOSHttpError { + const { message, code, detail } = describeErrorBody(status, body); + return new HugAgentOSHttpError(message + ' (HTTP ' + status + ')', status, code, detail); +} + +/** ChatClient only reads `message`; rebuild the full description from what it kept. */ +function translate(error: unknown): unknown { + if (!(error instanceof ChatApiError)) return error; + const body = /^HTTP \d+$/.test(error.message) ? { detail: error.detail } : { message: error.message, detail: error.detail }; + const { message, code, detail } = describeErrorBody(error.status, body); + // An envelope code the SDK kept wins, with the detail it came with. + const known = error.code !== undefined; + return new HugAgentOSHttpError(message + ' (HTTP ' + error.status + ')', error.status, + known ? error.code : code, known ? error.detail : detail); +} + +/** An SDK call with its error translated like every other call here. */ +function viaSdk(call: Promise): Promise { + return call.catch((error: unknown) => { throw translate(error); }); +} + +async function* translated(source: AsyncIterable): AsyncGenerator { + try { + yield* source; + } catch (error) { + throw translate(error); + } +} + +const seg = (value: string) => { + if (!value || !value.trim()) throw new Error('Identifier must not be empty'); + return encodeURIComponent(value); +}; + +export class HugAgentOSHttp { + readonly baseUrl: string; + readonly chat: ChatClient; + private readonly fetcher: typeof fetch; + + constructor(private readonly options: HttpOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + const base = options.fetch ?? globalThis.fetch.bind(globalThis); + // Credentials never follow a redirect to another host. + this.fetcher = ((input: RequestInfo | URL, init?: RequestInit) => base(input, { ...init, redirect: 'error' })) as typeof fetch; + this.chat = new ChatClient({ + baseUrl: this.baseUrl, + headers: () => this.headers(), + credentials: 'omit', + fetch: this.fetcher, + }); + } + + private headers(): Record { + return { Authorization: 'Bearer ' + this.options.token, Accept: 'application/json' }; + } + + /** One JSON call: envelope unwrapped, errors carry status + server detail. */ + async json(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(JSON_TIMEOUT_MS); + const headers: Record = this.headers(); + if (body !== undefined) headers['Content-Type'] = 'application/json'; + let response: Response; + try { + response = await this.fetcher(this.baseUrl + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + } catch (error) { + if (signal?.aborted) throw error; + if (timeout.aborted) throw new HugAgentOSHttpError(`${method} ${path} timed out`, 0); + throw new HugAgentOSHttpError(`Cannot reach ${this.baseUrl}: ${causeOf(error)}`, 0); + } + const text = await response.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + if (!response.ok) throw new HugAgentOSHttpError(`${text.slice(0, 200)} (HTTP ${response.status})`, response.status); + throw new HugAgentOSHttpError(`Invalid JSON from ${method} ${path} (HTTP ${response.status})`, response.status); + } + } + if (!response.ok) throw httpError(response.status, parsed); + if (parsed === undefined) return undefined as T; + if (parsed && typeof parsed === 'object' && 'code' in parsed && 'data' in parsed) { + const envelope = parsed as { code: unknown; data: T; message?: string }; + if (typeof envelope.code === 'number' && (envelope.code < 10000 || envelope.code >= 20000)) { + throw new HugAgentOSHttpError((envelope.message || 'API request failed') + ' (code ' + envelope.code + ')', + response.status, envelope.code, envelope.data); + } + return envelope.data; + } + return parsed as T; + } + + /** GET /health on the server root, never throws. */ + async health(timeoutMs = 3000): Promise { + try { + const response = await this.fetcher(this.baseUrl + '/health', { signal: AbortSignal.timeout(timeoutMs) }); + await response.body?.cancel().catch(() => {}); + return response.status === 200; + } catch { + return false; + } + } + + // ── Sessions ──────────────────────────────────────────────────────────── + + createChat(data: { title?: string; projectId?: string }, signal?: AbortSignal) { + const body: Record = {}; + if (data.title) body.title = data.title; + if (data.projectId) body.project_id = data.projectId; + return this.json('POST', '/v1/chats', body, signal); + } + + listChats(page = 1, pageSize = 20, signal?: AbortSignal) { + return this.json>('GET', '/v1/chats?' + query({ page, page_size: pageSize, exclude_automation: true }), undefined, signal); + } + + listProjectChats(projectId: string, page = 1, pageSize = 20, signal?: AbortSignal) { + return this.json>('GET', `/v1/projects/${seg(projectId)}/chats?` + query({ page, page_size: pageSize, scope: 'mine' }), undefined, signal); + } + + /** `order=desc` makes page 1 the latest messages. */ + messages(chatId: string, page = 1, pageSize = 50, order: 'asc' | 'desc' = 'desc', signal?: AbortSignal) { + return this.json>('GET', `/v1/chats/${seg(chatId)}/messages?` + query({ page, page_size: pageSize, order }), undefined, signal); + } + + fork(chatId: string, throughMessageId?: string) { + const body: Record = { request_id: randomUUID() }; + if (throughMessageId) body.through_message_id = throughMessageId; + return this.json('POST', `/v1/chats/${seg(chatId)}/fork`, body); + } + + compact(chatId: string) { + return this.json('POST', `/v1/chats/${seg(chatId)}/compact`, {}); + } + + /** Bind a folder to its local project, creating it, and grant it to the OS sandbox. */ + resolveLocalProject(path: string) { + return this.json('POST', '/v1/projects/local/resolve', { path, create: true, grant: true }); + } + + // ── Runs ──────────────────────────────────────────────────────────────── + + send(request: ChatRequest, signal?: AbortSignal): AsyncGenerator { + return translated(this.chat.send(request, signal)); + } + + resume(runId: string, from = 0, signal?: AbortSignal): AsyncGenerator { + return translated(this.chat.resume(runId, from, signal)); + } + + activeRun(chatId: string, signal?: AbortSignal): Promise { + return viaSdk(this.chat.activeRun(chatId, signal)) as Promise; + } + + async cancel(runId: string): Promise { + await viaSdk(this.chat.cancel(runId)); + } + + /** Each steer gets a fresh steer_id; the server de-duplicates retries by it. */ + steer(runId: string, message: string, mode: DeliveryMode = 'steer') { + return this.json>('POST', `/v1/chat-runs/${seg(runId)}/steer`, { + steer_id: randomUUID().replace(/-/g, ''), message, delivery_mode: mode, + }); + } + + confirmFile(chatId: string, confirmId: string, decision: FileDecision, optionId?: string) { + return viaSdk(this.chat.confirmFile(chatId, confirmId, decision, optionId)); + } + + answerQuestion(chatId: string, requestId: string, answers: QuestionAnswer[]) { + return viaSdk(this.chat.answerQuestion(chatId, requestId, answers)); + } + + // ── Settings ──────────────────────────────────────────────────────────── + + /** The web model picker's source (frontend api.ts getMainModelCapabilities). */ + modelCapabilities() { + return this.json('GET', '/v1/models/capabilities'); + } + + async getToolApproval(): Promise { + return (await this.json<{ mode: PermissionMode }>('GET', '/v1/tool-approval')).mode; + } + + async setToolApproval(mode: PermissionMode): Promise { + return (await this.json<{ mode: PermissionMode }>('PUT', '/v1/tool-approval', { mode })).mode; + } + + // ── Session-manager queue (device:) ───────────────────────────────── + + submitOperation(target: string, operation: string, args: Record) { + return this.json('POST', '/v1/personal-agent/session-manager/operations', { + target, operation, args, request_key: randomUUID().replace(/-/g, ''), + }); + } + + getOperation(id: string, signal?: AbortSignal) { + return this.json('GET', `/v1/personal-agent/session-manager/operations/${seg(id)}`, undefined, signal); + } + + listDevices() { + return this.json('GET', '/v1/personal-agent/session-manager/devices'); + } +} + +function query(values: Record): string { + return new URLSearchParams(Object.entries(values).map(([key, value]) => [key, String(value)])).toString(); +} + +function causeOf(error: unknown): string { + if (error instanceof Error) { + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error) return cause.message; + return error.message; + } + return String(error); +} diff --git a/packages/cli/src/engine/index.ts b/packages/cli/src/engine/index.ts new file mode 100644 index 00000000..e19175a8 --- /dev/null +++ b/packages/cli/src/engine/index.ts @@ -0,0 +1,33 @@ +/** The EngineFactory: builds and connects the engine for a mode. */ +import { resolveCloudCredentials } from '../config.js'; +import type { Engine, EngineFactory, Mode } from './engine.js'; +import { LocalEngine } from './local.js'; +import { DeviceEngine, RemoteEngine } from './remote.js'; + +export interface FactoryDefaults { + /** --server: the cloud server for cloud and device modes. */ + server?: string; + env?: NodeJS.ProcessEnv; + /** Progress text while a local engine starts (only shown by callers that want it). */ + onStatus?: (message: string) => void; +} + +export function buildEngine(mode: Mode, options: { cwd: string }, defaults: FactoryDefaults = {}): Engine { + const env = defaults.env ?? process.env; + if (mode === 'local') return new LocalEngine({ cwd: options.cwd, env, onStatus: defaults.onStatus }); + const credentials = resolveCloudCredentials(defaults.server, env); + return mode === 'cloud' ? new RemoteEngine(credentials) : new DeviceEngine(mode, credentials); +} + +/** Engines come back connected (connect() is idempotent, so calling it again is harmless). */ +export function engineFactory(defaults: FactoryDefaults = {}): EngineFactory { + return async (mode, options) => { + const engine = buildEngine(mode, options, defaults); + await engine.connect(); + return engine; + }; +} + +export { LocalEngine } from './local.js'; +export { DeviceEngine, RemoteEngine } from './remote.js'; +export * from './errors.js'; diff --git a/packages/cli/src/engine/local.ts b/packages/cli/src/engine/local.ts new file mode 100644 index 00000000..92b2adc1 --- /dev/null +++ b/packages/cli/src/engine/local.ts @@ -0,0 +1,192 @@ +/** + * LocalEngine: attach to (or start) the shared headless engine on this machine and bind the + * current directory to a backend local project (DESIGN.md §8.1, §8.2). + */ +import { spawn } from 'node:child_process'; +import { closeSync, mkdirSync, openSync, readFileSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { hugagentHome } from '../config.js'; +import { HttpEngine, latestSummaries, pageSizeFor, toSummary } from './base.js'; +import type { EngineInfo, SessionSummary } from './engine.js'; +import { CliError, EXIT, HugAgentOSHttpError, SetupRequiredError } from './errors.js'; +import { HugAgentOSHttp } from './http.js'; +import { resolveServerCommand } from './server-command.js'; + +export interface ReadyFile { + version: number; + url: string; + token: string; + pid: number; + started_at?: string; + home?: string; +} + +export interface LocalEngineOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + /** How long a fresh engine may take to write its ready file (default 120 s). */ + startTimeoutMs?: number; + /** Progress lines while starting the engine ("Starting local engine…"). */ + onStatus?: (message: string) => void; +} + +export function readyFilePath(env: NodeJS.ProcessEnv = process.env): string { + return join(hugagentHome(env), 'engine.json'); +} + +export function engineLogPath(env: NodeJS.ProcessEnv = process.env): string { + return join(hugagentHome(env), 'logs', 'engine.log'); +} + +export function readReadyFile(path: string): ReadyFile | null { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const ready = parsed as Partial; + if (typeof ready.url !== 'string' || typeof ready.token !== 'string' || !ready.token || + typeof ready.pid !== 'number' || !Number.isInteger(ready.pid) || ready.pid <= 0) return null; + try { + const url = new URL(ready.url); + // The engine only ever binds loopback; anything else is not ours to send the token to. + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) return null; + } catch { + return null; + } + return ready as ReadyFile; +} + +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function usable(ready: ReadyFile): Promise { + if (!pidAlive(ready.pid)) return false; + const http = new HugAgentOSHttp({ baseUrl: ready.url, token: ready.token }); + for (let attempt = 0; attempt < 3; attempt++) { + if (await http.health()) return true; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + return false; +} + +function logTail(path: string, lines = 15): string { + try { + return readFileSync(path, 'utf8').trimEnd().split('\n').slice(-lines).join('\n'); + } catch { + return ''; + } +} + +/** + * Reuse the running engine, or start one detached and wait for its ready file. Concurrent starts + * are safe without a lock here: `hugagent-server engine` holds its own per-home lock, and a second + * one waits for the first and exits 0 once the ready file describes it. + */ +export async function discoverEngine(options: Omit = {}): Promise { + const env = options.env ?? process.env; + const readyPath = readyFilePath(env); + const existing = readReadyFile(readyPath); + if (existing && await usable(existing)) return existing; + + const home = hugagentHome(env); + mkdirSync(join(home, 'logs'), { recursive: true, mode: 0o700 }); + const timeoutMs = options.startTimeoutMs ?? 120_000; + const logPath = engineLogPath(env); + const command = resolveServerCommand(env); + options.onStatus?.('Starting local engine…'); + let exit: { code: number | null; signal: NodeJS.Signals | null; error?: Error } | undefined; + const log = openSync(logPath, 'a', 0o600); + try { + const child = spawn(command[0], [...command.slice(1), 'engine', '--ready-file', readyPath], { + cwd: home, + env, + detached: true, + stdio: ['ignore', log, log], + windowsHide: true, + }); + child.on('exit', (code, signal) => { exit ??= { code, signal }; }); + child.on('error', (error) => { exit = { code: null, signal: null, error }; }); + child.unref(); + } finally { + closeSync(log); + } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ready = readReadyFile(readyPath); + if (ready && await usable(ready)) return ready; + const stopped = exit as typeof exit; + if (stopped) { + if (stopped.code === EXIT.setup) { + throw new SetupRequiredError('HugAgentOS is not initialized on this machine: run `hugagent onboard --no-serve` first'); + } + const reason = stopped.error ? stopped.error.message : stopped.signal ? `signal ${stopped.signal}` : `exit code ${stopped.code}`; + const tail = logTail(logPath); + throw new CliError(`Local engine stopped before it was ready (${reason}); see ${logPath}${tail ? '\n' + tail : ''}`); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new CliError(`Local engine did not become ready within ${Math.round(timeoutMs / 1000)} s; see ${logPath}`); +} + +export function tildify(path: string): string { + const home = homedir(); + return path === home ? '~' : path.startsWith(home + '/') ? '~' + path.slice(home.length) : path; +} + +export class LocalEngine extends HttpEngine { + readonly info: EngineInfo; + + constructor(private readonly options: LocalEngineOptions) { + super(); + this.info = { mode: 'local', label: 'local · ' + tildify(options.cwd), server: '', workspace: options.cwd, canAccessCwd: true }; + } + + protected async open(): Promise { + const ready = await discoverEngine(this.options); + const http = new HugAgentOSHttp({ baseUrl: ready.url, token: ready.token }); + let path: string; + try { + path = realpathSync(this.options.cwd); + } catch { + throw new CliError(`Working directory does not exist: ${this.options.cwd}`); + } + let project; + try { + // Running hugagent in a folder is consent to work in it (Codex's workspace-write): + // the server binds it and grants it to the OS sandbox, never $HOME or / wholesale. + project = await http.resolveLocalProject(path); + } catch (error) { + if (error instanceof HugAgentOSHttpError && error.status === 404) { + throw new CliError(`The local engine at ${ready.url} cannot bind folders (POST /v1/projects/local/resolve: ${error.message}); update hugagent-server`); + } + if (error instanceof HugAgentOSHttpError && error.status === 401) { + throw new CliError(`The local engine rejected its own token; delete ${readyFilePath(this.options.env)} and retry`); + } + throw error; + } + this.client = http; + this.info.server = ready.url; + this.info.projectId = project.project_id; + } + + async newSession(title?: string): Promise { + await this.connect(); + return toSummary(await this.http.createChat({ title, projectId: this.info.projectId })); + } + + async listSessions(limit = 20): Promise { + await this.connect(); + const page = await this.http.listProjectChats(this.info.projectId!, 1, pageSizeFor(limit)); + return latestSummaries(page.items, limit); + } +} diff --git a/packages/cli/src/engine/remote.ts b/packages/cli/src/engine/remote.ts new file mode 100644 index 00000000..70b00070 --- /dev/null +++ b/packages/cli/src/engine/remote.ts @@ -0,0 +1,211 @@ +/** + * RemoteEngine (cloud) talks to a remote HugAgentOS over the full HTTP + SSE API. + * DeviceEngine (device:) drives another paired computer through the session-manager + * operation queue (src/backend/hugagent_cli/contract.py); that link has no SSE, so a turn is + * synthesized from run.status polls and the final chat.history message. + */ +import { randomUUID } from 'node:crypto'; +import type { ChatEvent, ChatHistoryMessage, ChatSession } from '@hugagent/chat-sdk'; +import { HttpEngine, delay, latestSummaries, openOnce, pageSizeFor, toSummary } from './base.js'; +import type { ActiveRun, CompactSummary, DeliveryMode, Engine, EngineInfo, Mode, ModelOption, PermissionMode, SendOptions, + SessionSummary } from './engine.js'; +import { CliError, HugAgentOSHttpError, SetupRequiredError, UnsupportedError } from './errors.js'; +import { HugAgentOSHttp, describeErrorBody, type Page, type RunStatus } from './http.js'; + +export interface RemoteOptions { + server: string; + apiKey: string; + fetch?: typeof fetch; +} + +function hostOf(server: string): string { + try { + const url = new URL(server); + return url.host + (url.pathname !== '/' ? url.pathname.replace(/\/+$/, '') : ''); + } catch { + return server; + } +} + +function rejected(error: unknown, server: string): unknown { + if (error instanceof HugAgentOSHttpError && (error.status === 401 || error.status === 403)) { + return new SetupRequiredError(`${server} rejected the API key (${error.message}): run \`hugagent login --server ${server}\``); + } + return error; +} + +export class RemoteEngine extends HttpEngine { + readonly info: EngineInfo; + + constructor(private readonly options: RemoteOptions) { + super(); + this.info = { mode: 'cloud', label: 'cloud · ' + hostOf(options.server), server: options.server, canAccessCwd: false }; + } + + protected async open(): Promise { + const http = new HugAgentOSHttp({ baseUrl: this.options.server, token: this.options.apiKey, fetch: this.options.fetch }); + try { + // Any authenticated read proves the key before the first real request. + await http.getToolApproval(); + } catch (error) { + throw rejected(error, this.options.server); + } + this.client = http; + } + + async newSession(title?: string): Promise { + await this.connect(); + return toSummary(await this.http.createChat({ title })); + } + + async listSessions(limit = 20): Promise { + await this.connect(); + const page = await this.http.listChats(1, pageSizeFor(limit)); + return latestSummaries(page.items, limit); + } +} + +const OPERATION_WAIT_MS = 60_000; +const RUN_POLL_MS = 2_000; +const DEVICE_MESSAGE_LIMIT = 10_000; +const LIVE_RUN = new Set(['pending', 'queued', 'running']); + +export class DeviceEngine implements Engine { + readonly info: EngineInfo; + private readonly http: HugAgentOSHttp; + private readonly opening = openOnce(() => this.open()); + + constructor(mode: Mode, private readonly options: RemoteOptions) { + this.http = new HugAgentOSHttp({ baseUrl: options.server, token: options.apiKey, fetch: options.fetch }); + this.info = { mode, label: `${mode} · via ${hostOf(options.server)}`, server: options.server, canAccessCwd: false }; + } + + connect(): Promise { + return this.opening(); + } + + private async open(): Promise { + let devices: unknown; + try { + devices = await this.http.listDevices(); + } catch (error) { + throw rejected(error, this.options.server); + } + const id = this.info.mode.slice('device:'.length); + const list = Array.isArray(devices) ? devices + : devices && typeof devices === 'object' && Array.isArray((devices as Page).items) ? (devices as Page).items : null; + if (list) { + const ids = list.flatMap((device) => { + const row = device && typeof device === 'object' ? device as Record : {}; + return [row.id, row.device_id].filter((value): value is string => typeof value === 'string'); + }); + if (ids.length && !ids.includes(id)) throw new CliError(`No paired device ${id}; paired: ${ids.join(', ')}`); + } + } + + async close(): Promise {} + + /** Queue one operation and wait for the device's receipt. */ + private async op(operation: string, args: Record, signal?: AbortSignal): Promise { + await this.connect(); + let job = await this.http.submitOperation(this.info.mode, operation, args); + if (!job || typeof job.id !== 'string') return job as T; + const deadline = Date.now() + OPERATION_WAIT_MS; + let wait = 300; + while ((job.status === 'queued' || job.status === 'sent') && Date.now() < deadline) { + await delay(wait, signal); + wait = Math.min(wait * 1.5, 1000); + job = await this.http.getOperation(job.id, signal); + } + if (job.status === 'queued' || job.status === 'sent') { + throw new CliError(`${this.info.mode} did not answer ${operation} within ${OPERATION_WAIT_MS / 1000} s (operation ${job.id}); is the device online with sessions enabled?`); + } + if (job.status !== 'completed') throw new CliError(`${operation} on ${this.info.mode}: ${job.status} (operation ${job.id})`); + const result = job.result; + if (result && typeof result === 'object' && 'error' in result && (result as Record).error) { + const record = result as Record; + const status = typeof record.http_status === 'number' ? record.http_status : 0; + const { message } = describeErrorBody(status, record); + throw new HugAgentOSHttpError(`${operation} failed on ${this.info.mode}: ${message}`, status, undefined, record); + } + return result as T; + } + + async newSession(title?: string): Promise { + return toSummary(await this.op('chat.create', title ? { title } : {})); + } + + async listSessions(limit = 20): Promise { + const page = await this.op>('chat.list', { page: 1, page_size: pageSizeFor(limit) }); + return latestSummaries(page?.items ?? [], limit); + } + + async history(chatId: string, limit = 50): Promise { + const page = await this.op>('chat.history', { + chat_id: chatId, page: 1, page_size: pageSizeFor(limit), order: 'desc', + }); + return (page?.items ?? []).slice(0, limit).reverse(); + } + + async *send(chatId: string, message: string, options: SendOptions = {}): AsyncIterable { + if (options.model) throw new UnsupportedError('Per-message model selection is not available in device mode'); + if (options.attachments?.length) throw new UnsupportedError('Attachments are not available in device mode'); + if (message.length > DEVICE_MESSAGE_LIMIT) throw new UnsupportedError(`Device messages are limited to ${DEVICE_MESSAGE_LIMIT} characters`); + const started = await this.op>('chat.send', { chat_id: chatId, message }, options.signal); + const runId = typeof started?.run_id === 'string' ? started.run_id : ''; + if (!runId) throw new CliError(`${this.info.mode} accepted the message but returned no run id`); + yield { ...started, type: 'run_started', run_id: runId, chat_id: chatId }; + yield* this.followRun(runId, chatId, options.signal); + } + + resume(runId: string, _fromOffset?: number, signal?: AbortSignal): AsyncIterable { + return this.followRun(runId, undefined, signal); + } + + /** Poll run.status until the run settles, then emit its answer from chat.history. */ + private async *followRun(runId: string, chatId: string | undefined, signal?: AbortSignal): AsyncGenerator { + let status: RunStatus; + for (;;) { + status = await this.op('run.status', { run_id: runId }, signal); + if (!LIVE_RUN.has(status?.status)) break; + await delay(RUN_POLL_MS, signal); + } + const chat = chatId ?? status.chat_id; + if (chat) { + const recent = await this.op>('chat.history', { chat_id: chat, page: 1, page_size: 10, order: 'desc' }, signal); + const items = recent?.items ?? []; + const answer = items.find((item) => item.role === 'assistant' && (!status.message_id || item.message_id === status.message_id)) + ?? items.find((item) => item.role === 'assistant'); + if (answer?.content) yield { type: 'content', content: answer.content, message_id: answer.message_id, run_id: runId }; + } + if (status.status === 'failed' || status.status === 'needs_attention') { + yield { type: 'error', error: status.status === 'failed' ? 'Run failed on the device' : 'Run needs attention on the device', run_id: runId }; + } + yield { type: 'end', run_id: runId, status: status.status }; + } + + async activeRun(chatId: string): Promise { + const run = await this.op | null>('run.active', { chat_id: chatId }); + return run && typeof run.run_id === 'string' ? { runId: run.run_id } : null; + } + + async cancel(runId: string): Promise { + await this.op('run.cancel', { run_id: runId }); + } + + async steer(runId: string, message: string, mode: DeliveryMode = 'steer'): Promise { + await this.op('run.steer', { run_id: runId, message, steer_id: randomUUID().replace(/-/g, ''), delivery_mode: mode }); + } + + private unsupported(what: string): never { + throw new UnsupportedError(`${what} is not available in device mode; switch with /mode local or /mode cloud`); + } + + async fork(): Promise { this.unsupported('Forking a session'); } + async confirm(): Promise { this.unsupported('Approving from this terminal'); } + async answer(): Promise { this.unsupported('Answering questions from this terminal'); } + async compact(): Promise { this.unsupported('Manual compaction'); } + async listModels(): Promise { this.unsupported('Model selection'); } + async getPermissionMode(): Promise { this.unsupported('Permission modes'); } + async setPermissionMode(): Promise { this.unsupported('Permission modes'); } +} diff --git a/packages/cli/src/engine/server-command.ts b/packages/cli/src/engine/server-command.ts new file mode 100644 index 00000000..ab3247e6 --- /dev/null +++ b/packages/cli/src/engine/server-command.ts @@ -0,0 +1,56 @@ +/** + * Which command runs the Python server (`hugagent-server`), shared by the local engine spawn + * and the `hugagent serve|onboard|doctor` forwarding (DESIGN.md §8.1): + * 1. HUGAGENT_ENGINE_CMD, a JSON array such as ["python3", "/repo/src/backend/cli.py"]; + * 2. `hugagent-server` on PATH; + * 3. [${HUGAGENT_PYTHON:-python3}, "$HUGAGENT_REPO/src/backend/cli.py"] when HUGAGENT_REPO is set. + */ +import { accessSync, constants, existsSync, statSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; +import { CliError, EXIT } from './errors.js'; + +export function resolveServerCommand(env: NodeJS.ProcessEnv = process.env): string[] { + const configured = env.HUGAGENT_ENGINE_CMD?.trim(); + if (configured) { + let parsed: unknown; + try { + parsed = JSON.parse(configured); + } catch { + parsed = null; + } + if (!Array.isArray(parsed) || !parsed.length || !parsed.every((part) => typeof part === 'string' && part)) { + throw new CliError('HUGAGENT_ENGINE_CMD must be a JSON array of strings, e.g. ["hugagent-server"]', EXIT.usage); + } + return parsed as string[]; + } + const onPath = findExecutable('hugagent-server', env); + if (onPath) return [onPath]; + const repo = env.HUGAGENT_REPO?.trim(); + if (repo) { + const script = join(repo, 'src', 'backend', 'cli.py'); + if (!existsSync(script)) throw new CliError(`HUGAGENT_REPO is set but ${script} does not exist`, EXIT.usage); + return [env.HUGAGENT_PYTHON?.trim() || 'python3', script]; + } + throw new CliError( + 'Cannot find the HugAgentOS server: install it (pip install hugagent, which provides hugagent-server), ' + + 'or set HUGAGENT_REPO to a HugAgentOS checkout, or HUGAGENT_ENGINE_CMD to a JSON command array', + ); +} + +export function findExecutable(name: string, env: NodeJS.ProcessEnv = process.env): string | null { + const extensions = process.platform === 'win32' ? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : ['']; + for (const dir of (env.PATH || '').split(delimiter)) { + if (!dir) continue; + for (const extension of extensions) { + const candidate = join(dir, name + extension); + try { + if (!statSync(candidate).isFile()) continue; + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not here + } + } + } + return null; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 00000000..c2a54a10 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,318 @@ +#!/usr/bin/env node +/** hugagent entry: parse arguments, build the engine for --mode, run one output mode. */ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { configPath, loadConfig, parseMode, saveConfig, validateServerUrl } from './config.js'; +import type { Mode } from './engine/engine.js'; +import { CliError, EXIT, HugAgentOSHttpError, errorMessage } from './engine/errors.js'; +import { buildEngine, engineFactory } from './engine/index.js'; +import { HugAgentOSHttp } from './engine/http.js'; +import { resolveServerCommand } from './engine/server-command.js'; +import { CLI_VERSION } from './version.js'; + +const HELP = `hugagent — HugAgentOS in your terminal + +Usage + hugagent [options] [prompt] interactive session (prompt is sent first) + hugagent -p [options] [prompt] run one turn and exit; reads stdin when no prompt + hugagent --output rpc [options] JSON-lines embedding protocol on stdin/stdout + hugagent login [--server URL] save a cloud server and API key + hugagent logout forget the saved API key + hugagent serve|onboard|doctor … forwarded to hugagent-server + +Options + -p, --print [prompt] non-interactive; "-" or no prompt reads stdin + --output text|json|rpc output format (default text) + --mode local|cloud|device: where the agent runs (default: config defaultMode, else local) + -c, --continue continue the latest session + -r, --resume [chat_id] resume a session (pick one when no id is given) + --session same as --resume + --model : model for this run + --server cloud server (cloud and device modes) + --auto-approve non-interactive: allow file/command approvals (default deny) + -h, --help show this help + -v, --version print the version + +Exit codes: 0 completed, 1 failed, 2 usage error, 3 setup required, 130 interrupted. +Environment: HUGAGENT_HOME, HUGAGENT_SERVER, HUGAGENT_API_KEY, HUGAGENT_ALLOW_HTTP=1, + HUGAGENT_ENGINE_CMD (JSON array), HUGAGENT_REPO, HUGAGENT_PYTHON. +`; + +type Output = 'text' | 'json' | 'rpc'; + +interface ParsedArgs { + command: 'run' | 'help' | 'version' | 'login' | 'logout' | 'server'; + /** serve | onboard | doctor, with the arguments forwarded untouched. */ + serverCommand?: string; + serverArgs?: string[]; + print: boolean; + /** -p value or positional words; '-' means stdin. */ + prompt?: string; + output: Output; + mode?: Mode; + continueLast: boolean; + resume: boolean; + chatId?: string; + model?: { providerId: string; modelName: string }; + server?: string; + autoApprove: boolean; +} + +const SERVER_COMMANDS = new Set(['serve', 'onboard', 'doctor']); +/** An optional -r value is taken as a chat id only when it looks like one, not like a prompt word. */ +const CHAT_ID = /^(?=.*\d)[A-Za-z0-9_-]{6,100}$/; + +function usage(message: string): never { + throw new CliError(message + '\nRun `hugagent --help` for usage.', EXIT.usage); +} + +function parseModel(value: string): { providerId: string; modelName: string } { + const split = value.indexOf(':'); + if (split <= 0 || split === value.length - 1) usage(`--model expects :, got "${value}"`); + return { providerId: value.slice(0, split), modelName: value.slice(split + 1) }; +} + +function parseArgs(argv: string[]): ParsedArgs { + const parsed: ParsedArgs = { + command: 'run', print: false, output: 'text', continueLast: false, resume: false, autoApprove: false, + }; + if (argv[0] && SERVER_COMMANDS.has(argv[0])) { + return { ...parsed, command: 'server', serverCommand: argv[0], serverArgs: argv.slice(1) }; + } + let args = argv; + if (argv[0] === 'login' || argv[0] === 'logout') { + parsed.command = argv[0]; + args = argv.slice(1); + } + const words: string[] = []; + let printValue: string | undefined; + for (let index = 0; index < args.length; index++) { + let arg = args[index]; + let inline: string | undefined; + if (arg === '--') { + words.push(...args.slice(index + 1)); + break; + } + if (arg.startsWith('--') && arg.includes('=')) { + inline = arg.slice(arg.indexOf('=') + 1); + arg = arg.slice(0, arg.indexOf('=')); + } + const next = args[index + 1]; + const takesValue = (name: string): string => { + if (inline !== undefined) return inline; + if (next === undefined || (next.startsWith('-') && next !== '-')) usage(`${name} needs a value`); + index += 1; + return next; + }; + const optional = (accept: (value: string) => boolean): string | undefined => { + if (inline !== undefined) return inline; + if (next !== undefined && (!next.startsWith('-') || next === '-') && accept(next)) { + index += 1; + return next; + } + return undefined; + }; + switch (arg) { + case '-h': case '--help': parsed.command = 'help'; break; + case '-v': case '--version': parsed.command = 'version'; break; + case '-p': case '--print': + parsed.print = true; + printValue = optional(() => true) ?? printValue; + break; + case '--output': { + const value = takesValue('--output'); + if (value !== 'text' && value !== 'json' && value !== 'rpc') usage(`--output must be text, json or rpc, got "${value}"`); + parsed.output = value; + break; + } + case '--mode': { + const value = takesValue('--mode'); + parsed.mode = parseMode(value) ?? usage(`--mode must be local, cloud or device:, got "${value}"`); + break; + } + case '-c': case '--continue': parsed.continueLast = true; break; + case '-r': case '--resume': + parsed.resume = true; + parsed.chatId = optional((value) => CHAT_ID.test(value)) ?? parsed.chatId; + break; + case '--session': parsed.resume = true; parsed.chatId = takesValue('--session'); break; + case '--model': parsed.model = parseModel(takesValue('--model')); break; + case '--server': parsed.server = takesValue('--server'); break; + case '--auto-approve': parsed.autoApprove = true; break; + default: + if (arg.startsWith('-') && arg !== '-') usage(`Unknown option ${arg}`); + words.push(arg); + } + } + if (parsed.command === 'login' || parsed.command === 'logout') { + if (words.length) usage(`Unexpected argument for ${parsed.command}: ${words[0]}`); + return parsed; + } + const parts = printValue !== undefined ? [printValue, ...words] : words; + if (parts.length) parsed.prompt = parts.join(' '); + if (parsed.continueLast && parsed.resume) usage('Use either --continue or --resume, not both'); + return parsed; +} + + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +function ask(question: string, hidden = false): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + if (hidden) { + const internal = rl as unknown as { _writeToOutput: (text: string) => void }; + internal._writeToOutput = (text: string) => { + if (text.includes(question)) process.stderr.write(question); + }; + } + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + if (hidden) process.stderr.write('\n'); + resolve(answer.trim()); + }); + }); +} + +async function login(args: ParsedArgs): Promise { + const config = loadConfig(); + const interactive = process.stdin.isTTY === true; + let raw = args.server || process.env.HUGAGENT_SERVER?.trim() || ''; + if (!raw && interactive) raw = await ask(`Server URL${config.server ? ` [${config.server}]` : ''}: `) || config.server || ''; + if (!raw) raw = config.server ?? ''; + if (!raw) usage('hugagent login needs --server '); + const server = validateServerUrl(raw); + let apiKey = process.env.HUGAGENT_API_KEY?.trim() || ''; + if (!apiKey) { + apiKey = interactive ? await ask('API key (Settings → API keys): ', true) : (await readStdin()).trim(); + } + if (!apiKey) usage('No API key given'); + if (/[\r\n]/.test(apiKey)) usage('API key must be a single line'); + try { + await new HugAgentOSHttp({ baseUrl: server, token: apiKey }).getToolApproval(); + } catch (error) { + if (error instanceof HugAgentOSHttpError && (error.status === 401 || error.status === 403)) { + throw new CliError(`${server} rejected this API key (${error.message})`); + } + throw new CliError(`Could not verify the key with ${server}: ${errorMessage(error)}`); + } + saveConfig({ ...config, server, apiKey }); + process.stderr.write(`Logged in to ${server} (saved to ${configPath()})\n`); + return EXIT.ok; +} + +function logout(): number { + const config = loadConfig(); + if (!config.apiKey) { + process.stderr.write('Not logged in\n'); + return EXIT.ok; + } + const { apiKey: _removed, ...rest } = config; + saveConfig(rest); + process.stderr.write(`Logged out${config.server ? ' of ' + config.server : ''}\n`); + return EXIT.ok; +} + +/** `hugagent serve|onboard|doctor`: hand the terminal to hugagent-server and pass its exit code on. */ +function forwardToServer(command: string, args: string[]): Promise { + const base = resolveServerCommand(); + return new Promise((resolve, reject) => { + const child = spawn(base[0], [...base.slice(1), command, ...args], { stdio: 'inherit' }); + // The terminal delivers Ctrl+C to the child too; let it decide how to stop. + const ignore = () => {}; + process.on('SIGINT', ignore); + child.on('error', (error) => { + process.off('SIGINT', ignore); + reject(new CliError(`Cannot run ${base[0]}: ${error.message}`)); + }); + child.on('exit', (code, signal) => { + process.off('SIGINT', ignore); + resolve(code ?? (signal === 'SIGINT' ? EXIT.interrupted : EXIT.failed)); + }); + }); +} + +async function main(argv: string[]): Promise { + const args = parseArgs(argv); + switch (args.command) { + case 'help': process.stdout.write(HELP); return EXIT.ok; + case 'version': process.stdout.write(CLI_VERSION + '\n'); return EXIT.ok; + case 'login': return login(args); + case 'logout': return logout(); + case 'server': return forwardToServer(args.serverCommand!, args.serverArgs ?? []); + default: break; + } + + const config = loadConfig(); + let mode: Mode = 'local'; + if (args.mode) mode = args.mode; + else if (typeof config.defaultMode === 'string') { + mode = parseMode(config.defaultMode) ?? usage(`defaultMode in ${configPath()} must be local, cloud or device:`); + } + const cwd = process.cwd(); + const status = (message: string) => process.stderr.write(message + '\n'); + + if (args.output === 'rpc') { + if (args.print || args.prompt !== undefined) usage('--output rpc takes commands on stdin, not a prompt'); + return (await import('./modes/rpc.js')).runRpc({ engine: buildEngine(mode, { cwd }, { server: args.server }) }); + } + + const interactive = !args.print && args.output !== 'json' && + process.stdin.isTTY === true && process.stdout.isTTY === true; + if (!interactive) { + if (args.resume && !args.chatId) usage('--resume needs a chat id in non-interactive mode (or use --continue)'); + let prompt = args.prompt; + if (prompt === undefined || prompt === '-') { + if (process.stdin.isTTY) usage('No prompt: pass one after -p or pipe it on stdin'); + prompt = await readStdin(); + } + if (!prompt.trim()) usage('The prompt is empty'); + let engine; + try { + engine = buildEngine(mode, { cwd }, { server: args.server, onStatus: status }); + } catch (error) { + // JSON consumers always get a closing cli_result, even when no engine could be built. + if (args.output === 'json') { + process.stdout.write(JSON.stringify({ type: 'cli_result', status: 'failed', chat_id: null, run_id: null, error: errorMessage(error) }) + '\n'); + } + throw error; + } + const options = { + engine, prompt, chatId: args.chatId, continueLast: args.continueLast, model: args.model, autoApprove: args.autoApprove, + }; + return args.output === 'json' + ? (await import('./modes/json.js')).runJson(options) + : (await import('./modes/print.js')).runPrint(options); + } + + // Load the TUI while the engine starts; a cold local engine takes seconds. + const tui = import('./modes/interactive.js'); + const createEngine = engineFactory({ server: args.server }); + const engine = await engineFactory({ server: args.server, onStatus: status })(mode, { cwd }); + let chatId = args.chatId; + if (args.continueLast) chatId = (await engine.listSessions(1))[0]?.chatId; + const { runInteractive } = await tui; + return runInteractive({ + engine, + createEngine, + cwd, + chatId, + model: args.model, + prompt: args.prompt, + pickSession: args.resume && !args.chatId, + }); +} + +/** Exit only after piped stdout has drained (pipes are asynchronous on macOS). */ +function exit(code: number): void { + process.stdout.write('', () => process.exit(code)); +} + +main(process.argv.slice(2)).then(exit, (error: unknown) => { + process.stderr.write('hugagent: ' + errorMessage(error) + '\n'); + exit(error instanceof CliError ? error.exitCode : EXIT.failed); +}); diff --git a/packages/cli/src/modes/interactive.ts b/packages/cli/src/modes/interactive.ts new file mode 100644 index 00000000..cc0f5c41 --- /dev/null +++ b/packages/cli/src/modes/interactive.ts @@ -0,0 +1,37 @@ +import type { RunOptions } from '../engine/engine.js'; +import { errorMessage } from '../engine/errors.js'; +import { InteractiveApp } from '../tui/app.js'; + +/** pi-style TUI. Returns the process exit code. */ +export async function runInteractive(options: RunOptions): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + process.stderr.write('hugagent: interactive mode needs a terminal; use -p for pipes and scripts\n'); + return 2; + } + const app = new InteractiveApp(options); + + // Engine failures surface as transcript lines; anything that escapes still must not leave + // the terminal in raw mode. + const onRejection = (reason: unknown) => { + app.transcript.error(`内部错误:${errorMessage(reason)}`); + app.tui.requestRender(); + }; + const onException = (error: Error) => { + app.emergencyStop(); + process.stderr.write(`hugagent: ${error.stack ?? error.message}\n`); + process.exit(1); + }; + const onSignal = () => { void app.shutdown(130); }; + process.on('unhandledRejection', onRejection); + process.on('uncaughtException', onException); + process.on('SIGTERM', onSignal); + process.on('SIGHUP', onSignal); + try { + return await app.start(); + } finally { + process.off('unhandledRejection', onRejection); + process.off('uncaughtException', onException); + process.off('SIGTERM', onSignal); + process.off('SIGHUP', onSignal); + } +} diff --git a/packages/cli/src/modes/json.ts b/packages/cli/src/modes/json.ts new file mode 100644 index 00000000..58c33fd8 --- /dev/null +++ b/packages/cli/src/modes/json.ts @@ -0,0 +1,29 @@ +/** + * `--output json`: every SSE event as one JSON line on stdout, unchanged, then + * {"type":"cli_result","status":"completed|failed|cancelled","chat_id","run_id"} (DESIGN.md §8.4). + * Policy notes go to stderr so stdout stays machine-readable. + */ +import { errorMessage } from '../engine/errors.js'; +import { type HeadlessOptions, headlessExitCode, runHeadless } from '../engine/headless.js'; + +export async function runJson(options: HeadlessOptions): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const line = (value: unknown) => stdout.write(JSON.stringify(value) + '\n'); + const outcome = await runHeadless(options, { + onEvent: line, + onNote: (note) => stderr.write('hugagent: ' + note + '\n'), + }); + const thrown = outcome.error === undefined ? undefined : errorMessage(outcome.error); + if (thrown !== undefined) stderr.write('hugagent: ' + thrown + '\n'); + const status = outcome.result?.status ?? (outcome.interrupted ? 'cancelled' : 'failed'); + const error = outcome.result?.error ?? thrown; + line({ + type: 'cli_result', + status, + chat_id: outcome.chatId ?? null, + run_id: outcome.result?.runId ?? null, + ...(status !== 'completed' && error ? { error } : {}), + }); + return headlessExitCode(outcome); +} diff --git a/packages/cli/src/modes/print.ts b/packages/cli/src/modes/print.ts new file mode 100644 index 00000000..522efcdb --- /dev/null +++ b/packages/cli/src/modes/print.ts @@ -0,0 +1,75 @@ +/** `hugagent -p`: the answer streams to stdout; tool lines, approvals and errors go to stderr. */ +import type { ChatEvent } from '../engine/engine.js'; +import { errorMessage } from '../engine/errors.js'; +import { type HeadlessOptions, eventError, headlessExitCode, runHeadless } from '../engine/headless.js'; +import { oneLine, summarizeArgs } from '../tui/format.js'; + +const CONTENT = new Set(['content', 'ai_message', 'text', 'delta']); +const TOOL_START = new Set(['tool_call_start', 'tool_use', 'tool_start']); +const TOOL_END = new Set(['tool_result', 'tool_end']); + +export function contentDelta(event: ChatEvent): string { + for (const key of ['delta', 'content', 'text']) { + const value = event[key]; + if (typeof value === 'string') return value; + } + return ''; +} + +export async function runPrint(options: HeadlessOptions): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const sameTerminal = (stdout as NodeJS.WriteStream).isTTY === true && (stderr as NodeJS.WriteStream).isTTY === true; + let midLine = false; + const out = (text: string) => { + if (!text) return; + stdout.write(text); + midLine = !text.endsWith('\n'); + }; + const err = (line: string) => { + // Keep a side note from landing in the middle of an answer line on a shared terminal. + if (sameTerminal && midLine) out('\n'); + stderr.write(line + '\n'); + }; + const tools = new Map(); + const toolName = (event: ChatEvent) => String(event.name ?? event.tool_name ?? tools.get(String(event.id))?.name ?? 'tool'); + + let sawError = false; + const outcome = await runHeadless(options, { + onNote: (note) => err('· ' + note), + onEvent: (event) => { + const id = String(event.id ?? ''); + if (CONTENT.has(event.type)) out(contentDelta(event)); + else if (event.type === 'content_replace') { + err('· answer revised:'); + out((midLine ? '\n' : '') + contentDelta(event)); + } else if (TOOL_START.has(event.type)) { + tools.set(id, { name: toolName(event), printed: false }); + } else if (event.type === 'tool_call') { + const tool = tools.get(id) ?? { name: toolName(event), printed: false }; + tool.name = toolName(event); + if (!tool.printed) { + const args = summarizeArgs(event.input ?? event.args ?? event.tool_args ?? event.arguments, 100); + err(`→ ${tool.name}${args ? ' ' + args : ''}`); + tool.printed = true; + } + tools.set(id, tool); + } else if (TOOL_END.has(event.type)) { + const tool = tools.get(id) ?? { name: toolName(event), printed: false }; + if (!tool.printed) err(`→ ${tool.name}`); + if (event.error) err(` ✗ ${tool.name}: ${oneLine(typeof event.error === 'string' ? event.error : JSON.stringify(event.error), 200)}`); + tools.delete(id); + } else if (event.type === 'error') { + sawError = true; + err('error: ' + eventError(event)); + } + }, + }); + if (midLine) out('\n'); + const { result } = outcome; + if (result?.status === 'cancelled') err('· cancelled'); + // An `error` event was already printed as it arrived; a thrown error was not. + else if (result?.status === 'failed' && !sawError && result.error) err('error: ' + result.error); + else if (!result && !outcome.interrupted) err('error: ' + errorMessage(outcome.error)); + return headlessExitCode(outcome); +} diff --git a/packages/cli/src/modes/rpc.ts b/packages/cli/src/modes/rpc.ts new file mode 100644 index 00000000..13cbfb78 --- /dev/null +++ b/packages/cli/src/modes/rpc.ts @@ -0,0 +1,191 @@ +/** + * `--output rpc`: embed hugagent in another program (DESIGN.md §8.4). + * stdin {"id","method","params"} one command per line; methods mirror Engine + * stdout {"id","result"} | {"id","error"} one reply per command + * {"event":{…},"request":""} events of a running send/resume, before its reply + * Commands run concurrently, so `cancel`, `steer` and `confirm` work while a `send` is streaming. + * A send replies when its run ends: {"status","chatId","runId"}. No approval policy is applied: + * the client answers file_confirm / user_question itself. + */ +import { createInterface } from 'node:readline'; +import type { ChatEvent, Engine, FileDecision, QuestionAnswer } from '../engine/engine.js'; +import { CliError, EXIT, HugAgentOSHttpError, errorMessage } from '../engine/errors.js'; + +export interface RpcOptions { + engine: Engine; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +} + +type Params = Record; +type Id = string | number | null; + +class ParamError extends Error {} + +function str(params: Params, key: string, required = true): string | undefined { + const value = params[key]; + if (value === undefined || value === null) { + if (required) throw new ParamError(`params.${key} is required`); + return undefined; + } + if (typeof value !== 'string' || (required && !value)) throw new ParamError(`params.${key} must be a string`); + return value; +} + +function num(params: Params, key: string): number | undefined { + const value = params[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value)) throw new ParamError(`params.${key} must be a number`); + return value; +} + +function errorBody(error: unknown): Record { + const body: Record = { message: errorMessage(error) }; + if (error instanceof ParamError) body.code = 'invalid_params'; + if (error instanceof HugAgentOSHttpError) { + body.status = error.status; + if (error.code !== undefined) body.code = error.code; + } + if (error instanceof CliError) body.exitCode = error.exitCode; + return body; +} + +const RPC_METHODS = [ + 'info', 'connect', 'close', 'newSession', 'listSessions', 'history', 'fork', 'send', 'resume', 'activeRun', + 'cancel', 'steer', 'confirm', 'answer', 'compact', 'listModels', 'getPermissionMode', 'setPermissionMode', +] as const; + +export async function runRpc(options: RpcOptions): Promise { + const { engine } = options; + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + const write = (value: unknown) => stdout.write(JSON.stringify(value) + '\n'); + const streams = new Map(); + const pending = new Set>(); + + const stream = async (id: Id, source: AsyncIterable, chatId?: string) => { + let runId: string | undefined; + let status = 'completed'; + for await (const event of source) { + if (event.type === 'run_started' && typeof event.run_id === 'string' && !runId) { + runId = event.run_id; + const entry = streams.get(id); + if (entry) entry.runId = runId; + } + if (event.type === 'error') status = 'failed'; + if (event.type === 'end' && typeof event.status === 'string' && event.status !== 'completed') { + status = event.status === 'cancelled' ? 'cancelled' : 'failed'; + } + write({ event, request: id }); + } + return { status, chatId: chatId ?? null, runId: runId ?? null }; + }; + + const call = async (id: Id, method: string, params: Params): Promise => { + if (method === 'info') return engine.info; + if (method === 'close') return engine.close(); + await engine.connect(); + switch (method) { + case 'connect': return engine.info; + case 'newSession': return engine.newSession(str(params, 'title', false)); + case 'listSessions': return engine.listSessions(num(params, 'limit')); + case 'history': return engine.history(str(params, 'chatId')!, num(params, 'limit')); + case 'fork': return engine.fork(str(params, 'chatId')!, str(params, 'messageId', false)); + case 'send': { + const chatId = str(params, 'chatId')!; + const controller = new AbortController(); + streams.set(id, { controller }); + try { + return await stream(id, engine.send(chatId, str(params, 'message')!, { + model: params.model as { providerId: string; modelName: string } | undefined, + attachments: params.attachments as { file_id: string; name: string; mime_type: string }[] | undefined, + signal: controller.signal, + }), chatId); + } finally { + streams.delete(id); + } + } + case 'resume': { + const controller = new AbortController(); + streams.set(id, { controller, runId: str(params, 'runId')! }); + try { + return await stream(id, engine.resume(str(params, 'runId')!, num(params, 'fromOffset'), controller.signal)); + } finally { + streams.delete(id); + } + } + case 'activeRun': return engine.activeRun(str(params, 'chatId')!); + case 'cancel': return engine.cancel(str(params, 'runId')!); + case 'steer': return engine.steer(str(params, 'runId')!, str(params, 'message')!, + str(params, 'mode', false) as 'steer' | 'followUp' | 'nextRun' | undefined); + case 'confirm': return engine.confirm(str(params, 'chatId')!, str(params, 'confirmId')!, + str(params, 'decision')! as FileDecision, str(params, 'optionId', false)); + case 'answer': { + if (!Array.isArray(params.answers)) throw new ParamError('params.answers must be an array'); + return engine.answer(str(params, 'chatId')!, str(params, 'requestId')!, params.answers as QuestionAnswer[]); + } + case 'compact': return engine.compact(str(params, 'chatId')!); + case 'listModels': return engine.listModels(); + case 'getPermissionMode': return engine.getPermissionMode(); + case 'setPermissionMode': { + const mode = str(params, 'mode')!; + if (mode !== 'ask' && mode !== 'auto' && mode !== 'full') throw new ParamError('params.mode must be ask, auto or full'); + return engine.setPermissionMode(mode); + } + default: + throw new ParamError(`Unknown method ${method}; expected one of ${RPC_METHODS.join(', ')}`); + } + }; + + const handle = (line: string) => { + if (!line.trim()) return; + let request: unknown; + try { + request = JSON.parse(line); + } catch { + write({ id: null, error: { message: 'Invalid JSON', code: 'parse_error' } }); + return; + } + const record = request && typeof request === 'object' && !Array.isArray(request) ? request as Record : {}; + const id = (typeof record.id === 'string' || typeof record.id === 'number' ? record.id : null) as Id; + const method = record.method; + const params = record.params && typeof record.params === 'object' && !Array.isArray(record.params) ? record.params as Params : {}; + if (typeof method !== 'string') { + write({ id, error: { message: 'method must be a string', code: 'invalid_request' } }); + return; + } + const work = call(id, method, params).then( + (result) => { write({ id, result: result ?? null }); }, + (error: unknown) => { write({ id, error: errorBody(error) }); }, + ); + pending.add(work); + void work.finally(() => pending.delete(work)); + }; + + // Ctrl+C: stop reading, cancel every run being followed, exit 130. + let interrupted = false; + let cancelling: Promise | undefined; + const lines = createInterface({ input: stdin, crlfDelay: Infinity }); + const onSigint = () => { + if (interrupted) process.exit(EXIT.interrupted); + interrupted = true; + const runs = [...streams.values()]; + for (const entry of runs) entry.controller.abort(); + cancelling = Promise.race([ + Promise.allSettled(runs.flatMap((entry) => (entry.runId ? [engine.cancel(entry.runId)] : []))), + new Promise((resolve) => setTimeout(resolve, 5000).unref()), + ]); + lines.close(); + }; + process.on('SIGINT', onSigint); + try { + for await (const line of lines) handle(line); + // stdin closed: finish what was asked, then leave. + while (pending.size) await Promise.allSettled([...pending]); + await cancelling; + } finally { + process.off('SIGINT', onSigint); + await engine.close().catch(() => {}); + } + return interrupted ? EXIT.interrupted : EXIT.ok; +} diff --git a/packages/cli/src/tui/app-editor.ts b/packages/cli/src/tui/app-editor.ts new file mode 100644 index 00000000..fab55190 --- /dev/null +++ b/packages/cli/src/tui/app-editor.ts @@ -0,0 +1,43 @@ +/** pi-tui Editor plus the app-level keys, the way pi's CustomEditor does it. */ +import { Editor, matchesKey, type EditorOptions, type EditorTheme, type TUI } from '@mariozechner/pi-tui'; + +export interface EditorActions { + escape(): void; + ctrlC(): void; + /** Ctrl+D on an empty editor. */ + exit(): void; + toggleThinking(): void; + toggleTools(): void; + /** Alt+Enter: returns true when it queued the text (only while a run streams). */ + followUp(text: string): boolean; +} + +export class AppEditor extends Editor { + actions?: EditorActions; + + constructor(tui: TUI, theme: EditorTheme, options?: EditorOptions) { + super(tui, theme, options); + } + + override handleInput(data: string): void { + const a = this.actions; + if (!a) return super.handleInput(data); + if (matchesKey(data, 'escape')) { + if (this.isShowingAutocomplete()) return super.handleInput(data); + return a.escape(); + } + if (matchesKey(data, 'ctrl+c')) return a.ctrlC(); + if (matchesKey(data, 'ctrl+d') && this.getText().length === 0) return a.exit(); + if (matchesKey(data, 'ctrl+t')) return a.toggleThinking(); + if (matchesKey(data, 'ctrl+o')) return a.toggleTools(); + if (matchesKey(data, 'alt+enter') && !this.isShowingAutocomplete()) { + const text = this.getExpandedText().trim(); + if (text && a.followUp(text)) { + this.addToHistory(text); + this.setText(''); + return; + } + } + super.handleInput(data); + } +} diff --git a/packages/cli/src/tui/app.ts b/packages/cli/src/tui/app.ts new file mode 100644 index 00000000..45138ce7 --- /dev/null +++ b/packages/cli/src/tui/app.ts @@ -0,0 +1,855 @@ +/** + * The interactive session: pi's layout (transcript, queued messages, status, editor, footer), + * driven by engine events. Owns run bookkeeping, steering, inline prompts and slash commands. + */ +import { + Container, Loader, ProcessTerminal, Spacer, Text, TruncatedText, TUI, + type Component, type Terminal, +} from '@mariozechner/pi-tui'; +import type { + ActiveRun, ChatEvent, DeliveryMode, Engine, Mode, PermissionMode, QuestionAnswer, RunOptions, +} from '../engine/engine.js'; +import { STEER_LIMIT } from '../engine/base.js'; +import { errorMessage } from '../engine/errors.js'; +import { CLI_VERSION } from '../version.js'; +import { AppEditor } from './app-editor.js'; +import { BashBlock, runBash } from './bash.js'; +import { copyToClipboard } from './clipboard.js'; +import { AppAutocomplete, HELP_TEXT, HOTKEYS_TEXT, parseCommand } from './commands.js'; +import type { ConfirmRequest, Plan, QuestionRequest } from './events.js'; +import { Footer, PERMISSION_LABELS } from './footer.js'; +import { formatTokens, oneLine } from './format.js'; +import { PlanView } from './plan.js'; +import { confirmTitle, createConfirmPrompt, type ConfirmDecision } from './prompts/confirm.js'; +import { QuestionPrompt } from './prompts/question.js'; +import { TitledSelect } from './prompts/select.js'; +import { editorTheme, theme } from './theme.js'; +import { Transcript, type Signal, type ViewState } from './transcript.js'; + +interface QueuedInput { text: string; mode: DeliveryMode; sent: boolean } + +interface RunState { + runId?: string; + abort: AbortController; + startedAt: number; + cancelRequested: boolean; + queued: QueuedInput[]; + handoff?: { runId: string; message: string }; + plan?: Plan; +} + +type PendingPrompt = + | { kind: 'confirm'; id: string; request: ConfirmRequest } + | { kind: 'question'; id: string; request: QuestionRequest }; + +const DOUBLE_PRESS_MS = 1000; +const CANCEL_GRACE_MS = 3000; +/** Backend cap on steer messages (DESIGN.md §8.3); normal sends allow 100000. */ + + +export interface AppOptions extends RunOptions { + /** Injected in tests; defaults to the real terminal. */ + terminal?: Terminal; +} + +export class InteractiveApp { + readonly tui: TUI; + readonly view: ViewState = { expandThinking: false, expandTools: false }; + readonly transcript = new Transcript(this.view); + readonly editor: AppEditor; + readonly footer: Footer; + private header = new Container(); + private pending = new Container(); + private planBox = new Container(); + private status = new Container(); + private editorContainer = new Container(); + + engine: Engine; + chatId?: string; + private model?: { providerId: string; modelName: string }; + private modelLabel?: string; + run?: RunState; + private prompts: PendingPrompt[] = []; + private activePrompt?: { entry: PendingPrompt; component: Component }; + private loader?: Loader; + private bash?: { block: BashBlock; kill: () => void }; + private bashContext: string[] = []; + private nextMessages: string[] = []; + private lastCtrlC = 0; + private hintTimer?: NodeJS.Timeout; + private switching = false; + private shuttingDown = false; + private exited!: (code: number) => void; + readonly done: Promise; + + constructor(private options: AppOptions) { + this.engine = options.engine; + this.chatId = options.chatId; + this.model = options.model; + this.done = new Promise((resolve) => { this.exited = resolve; }); + this.tui = new TUI(options.terminal ?? new ProcessTerminal()); + this.editor = new AppEditor(this.tui, editorTheme, { paddingX: 1 }); + this.editor.setAutocompleteProvider(new AppAutocomplete(options.cwd, () => this.engine.info.canAccessCwd)); + this.footer = new Footer({ info: this.engine.info, model: this.model?.modelName }); + this.editorContainer.addChild(this.editor); + this.wireEditor(); + } + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + start(): Promise { + this.buildHeader(); + this.renderStatus(); + for (const c of [this.header, this.transcript.container, this.pending, this.planBox, this.status, this.editorContainer, this.footer]) { + this.tui.addChild(c); + } + this.tui.setFocus(this.editor); + this.tui.start(); + void this.init(); + return this.done; + } + + private async init(): Promise { + void this.refreshEngineState(); + if (this.chatId) await this.loadSession(this.chatId); + else if (this.options.pickSession) await this.runCommand('resume', ''); // `-r` with no id + // `hugagent "…"`: the prompt on the command line is the first message. + if (this.options.prompt?.trim()) await this.submit(this.options.prompt); + } + + private buildHeader(): void { + const v = CLI_VERSION; + const logo = theme.bold(theme.accent('hugagent')) + (v ? theme.dim(` v${v}`) : ''); + const hints = ['esc 中断', 'ctrl+c 清空/退出', '/ 命令', '! 本机命令', 'alt+enter 排队', 'ctrl+t 思考', 'ctrl+o 工具'] + .map((h) => theme.dim(h)).join(theme.muted(' · ')); + this.header.addChild(new Spacer(1)); + this.header.addChild(new Text(`${logo}\n${hints}`, 1, 0)); + } + + /** Permission mode and model label for the footer; failures only leave the footer blank. */ + private async refreshEngineState(): Promise { + const engine = this.engine; + const [permission, models] = await Promise.all([ + engine.getPermissionMode().catch(() => undefined), + this.model ? Promise.resolve(undefined) : engine.listModels().catch(() => undefined), + ]); + if (engine !== this.engine) return; + const current = models?.find((m) => m.current); + this.footer.update({ + info: engine.info, + permission, + ...(this.model ? { model: this.modelLabel ?? this.model.modelName } : current ? { model: current.label || current.modelName } : {}), + }); + this.tui.requestRender(); + } + + async shutdown(code = 0): Promise { + if (this.shuttingDown) return; + this.shuttingDown = true; + const run = this.run; + if (run?.runId) await Promise.race([this.engine.cancel(run.runId).catch(() => {}), sleep(1500)]); + run?.abort.abort(); + this.bash?.kill(); + this.loader?.stop(); + if (this.hintTimer) clearTimeout(this.hintTimer); + try { + await this.tui.terminal.drainInput?.(300); + } catch { /* not every terminal drains */ } + this.tui.stop(); + await Promise.race([this.engine.close().catch(() => {}), sleep(2000)]); + this.exited(code); + } + + /** Last-resort restore for uncaught errors: put the terminal back before anything is printed. */ + emergencyStop(): void { + try { + this.loader?.stop(); + this.tui.stop(); + } catch { /* already stopped */ } + } + + // ── Editor wiring ──────────────────────────────────────────────────────── + + private wireEditor(): void { + this.editor.onSubmit = (text) => { void this.submit(text); }; + this.editor.onChange = (text) => { + this.editor.borderColor = text.trimStart().startsWith('!') ? theme.bashMode : theme.borderMuted; + }; + this.editor.actions = { + escape: () => this.escape(), + ctrlC: () => this.ctrlC(), + exit: () => { void this.shutdown(); }, + toggleThinking: () => this.toggleView('expandThinking'), + toggleTools: () => this.toggleView('expandTools'), + followUp: (text) => { + if (!this.run) return false; + void this.queueInput(text, 'followUp'); + return true; + }, + }; + } + + private toggleView(key: keyof ViewState): void { + this.view[key] = !this.view[key]; + this.transcript.container.invalidate(); + this.flashHint(key === 'expandThinking' + ? (this.view.expandThinking ? '思考已展开' : '思考已折叠') + : (this.view.expandTools ? '工具详情已展开' : '工具详情已折叠')); + this.tui.requestRender(); + } + + private escape(): void { + if (this.bash) return this.bash.kill(); + if (this.run) return void this.cancelRun(); + if (this.editor.getText().startsWith('!')) { + this.editor.setText(''); + this.editor.borderColor = theme.borderMuted; + this.tui.requestRender(); + } + } + + private ctrlC(): void { + const now = Date.now(); + if (now - this.lastCtrlC < DOUBLE_PRESS_MS) { + void this.shutdown(); + return; + } + this.lastCtrlC = now; + this.editor.setText(''); + this.flashHint('再按一次 ctrl+c 退出'); + this.tui.requestRender(); + } + + /** A short dim line above the editor that clears itself. */ + private flashHint(text: string): void { + if (this.hintTimer) clearTimeout(this.hintTimer); + this.renderStatus(text); + this.hintTimer = setTimeout(() => { + this.hintTimer = undefined; + this.renderStatus(); + this.tui.requestRender(); + }, DOUBLE_PRESS_MS + 200); + } + + private renderStatus(hint?: string): void { + this.status.clear(); + // The loader brings its own blank line; idle, keep one between transcript and editor like pi. + this.status.addChild(this.loader ?? new Spacer(1)); + if (hint) this.status.addChild(new TruncatedText(theme.dim(hint), 1, 0)); + } + + // ── Input ──────────────────────────────────────────────────────────────── + + private async submit(raw: string): Promise { + const text = raw.trim(); + if (!text) return; + this.editor.addToHistory(text); + const command = parseCommand(text); + if (command) return this.runCommand(command.name, command.args); + const unknown = /^\/([a-z][\w-]*)(\s|$)/i.exec(text); + if (unknown) { + this.editor.setText(text); + this.flashHint(`未知命令 /${unknown[1]},/help 查看全部命令`); + this.tui.requestRender(); + return; + } + if (text.startsWith('!')) return this.bashCommand(text); + if (this.run) return this.queueInput(text, 'steer'); + await this.sendMessage(text); + } + + private async queueInput(text: string, mode: DeliveryMode): Promise { + const run = this.run; + if (!run) return this.sendMessage(text); + if (text.length > STEER_LIMIT) { + // Too long to steer: it goes out as a normal message once this run ends. + this.nextMessages.push(text); + this.renderPending(); + this.flashHint(`超过 ${STEER_LIMIT} 字不能插话,会在本轮结束后作为普通消息发送`); + this.tui.requestRender(); + return; + } + const item: QueuedInput = { text, mode, sent: false }; + run.queued.push(item); + this.renderPending(); + if (run.runId) await this.deliver(run, item); + } + + private async deliver(run: RunState, item: QueuedInput): Promise { + if (!run.runId || item.sent) return; + item.sent = true; + try { + await this.engine.steer(run.runId, item.text, item.mode); + } catch (error) { + // The run may have just ended: fall back to sending it as the next message. + run.queued = run.queued.filter((q) => q !== item); + this.nextMessages.push(item.text); + this.transcript.notice(`· 插话没送达(${errorMessage(error)}),会作为下一条消息发送`); + } + this.renderPending(); + this.tui.requestRender(); + } + + private renderPending(): void { + this.pending.clear(); + const queued = this.run?.queued ?? []; + if (!queued.length && !this.nextMessages.length) return; + this.pending.addChild(new Spacer(1)); + for (const q of queued) { + const label = q.mode === 'steer' ? '插话' : q.mode === 'followUp' ? '排队' : '下一条'; + this.pending.addChild(new TruncatedText(theme.dim(`${label}: ${oneLine(q.text, 200)}`), 1, 0)); + } + for (const text of this.nextMessages) this.pending.addChild(new TruncatedText(theme.dim(`下一条: ${oneLine(text, 200)}`), 1, 0)); + this.pending.addChild(new TruncatedText(theme.dim('↳ esc 中断并把排队内容放回输入框'), 1, 0)); + } + + private async bashCommand(text: string): Promise { + const excluded = text.startsWith('!!'); + const command = text.slice(excluded ? 2 : 1).trim(); + if (!command) return; + if (!this.engine.info.canAccessCwd) { + this.transcript.notice(`! 命令只在 local 模式可用:当前是 ${this.engine.info.mode},agent 访问不到本机文件。用 /mode local 切回本机。`, theme.warning); + this.tui.requestRender(); + return; + } + if (this.bash) { + this.flashHint('已有命令在运行,按 esc 先停止它'); + this.editor.setText(text); + return; + } + const block = new BashBlock(command, excluded); + this.transcript.add(block); + const { child, done } = runBash(block, this.engine.info.workspace ?? this.options.cwd, () => this.tui.requestRender()); + this.bash = { + block, + kill: () => { + block.killed = true; + child.kill('SIGTERM'); + }, + }; + this.tui.requestRender(); + await done; + this.bash = undefined; + if (!excluded) this.bashContext.push(block.contextText()); + this.tui.requestRender(); + } + + // ── Runs ───────────────────────────────────────────────────────────────── + + private async ensureSession(text: string): Promise { + if (this.chatId) return this.chatId; + try { + const session = await this.engine.newSession(oneLine(text, 40)); + this.chatId = session.chatId; + this.footer.update({ sessionTitle: undefined }); + return session.chatId; + } catch (error) { + this.transcript.error(`无法创建会话:${errorMessage(error)}`); + this.tui.requestRender(); + return undefined; + } + } + + async sendMessage(text: string): Promise { + if (this.switching) { + this.flashHint('正在切换执行位置,请稍候'); + this.editor.setText(text); + return; + } + this.transcript.addUser(text); + if (!this.engine.info.canAccessCwd && /(^|\s)@\S/.test(text)) { + this.transcript.notice(`· 当前是 ${this.engine.info.mode}:agent 访问不到本机文件,@路径 只会作为文字发送`); + } + this.tui.requestRender(); + const chatId = await this.ensureSession(text); + if (!chatId) return; + const message = this.bashContext.length ? [...this.bashContext, text].join('\n\n') : text; + this.bashContext = []; + const abort = new AbortController(); + const run = this.beginRun(abort); + let stream: AsyncIterable; + try { + stream = this.engine.send(chatId, message, { model: this.model, signal: abort.signal }); + } catch (error) { + this.transcript.error(errorMessage(error)); + this.endRun(run); + return; + } + await this.follow(run, stream); + } + + private beginRun(abort: AbortController, runId?: string): RunState { + const run: RunState = { abort, startedAt: Date.now(), cancelRequested: false, queued: [], runId }; + this.run = run; + this.transcript.beginRun(); + this.loader = new Loader(this.tui, theme.accent, theme.muted, '工作中… (esc 中断)'); + this.renderStatus(); + this.footer.update({ runStartedAt: run.startedAt, runState: undefined }); + this.tui.requestRender(); + return run; + } + + private async follow(run: RunState, stream: AsyncIterable): Promise { + let failed = false; + try { + for await (const event of stream) { + if (this.run !== run) break; + for (const signal of this.transcript.apply(event)) await this.onSignal(run, signal); + this.tui.requestRender(); + } + } catch (error) { + if (!run.abort.signal.aborted) { + failed = true; + this.transcript.current?.addNotice('✗ ' + errorMessage(error), theme.error); + } + } + await this.finishRun(run, failed); + } + + private async onSignal(run: RunState, signal: Signal): Promise { + switch (signal.type) { + case 'run_started': + run.runId = signal.runId; + if (run.cancelRequested) void this.engine.cancel(signal.runId).catch(() => {}); + for (const item of run.queued) void this.deliver(run, item); + break; + case 'confirm': + this.enqueuePrompt({ kind: 'confirm', id: signal.request.confirmId, request: signal.request }); + break; + case 'confirm_expired': + if (this.removePrompt(signal.confirmId)) this.transcript.current?.addNotice('· 一项确认已超时取消'); + break; + case 'question': + this.enqueuePrompt({ kind: 'question', id: signal.request.requestId, request: signal.request }); + break; + case 'question_resolved': + this.removePrompt(signal.requestId); + break; + case 'context': + this.footer.update({ contextPercent: signal.percent }); + break; + case 'plan': + run.plan = signal.plan; + this.planBox.clear(); + this.planBox.addChild(new PlanView(signal.plan)); + break; + case 'queued_run': + run.handoff = { runId: signal.handoff.runId, message: signal.handoff.message }; + this.dropQueued(run, signal.handoff.message); + break; + case 'steer_applied': + this.dropQueued(run, signal.message); + this.transcript.beginTurn(); + break; + case 'error': case 'end': + break; + } + } + + /** The backend took a queued input: stop showing it as pending. */ + private dropQueued(run: RunState, message: string): void { + const index = run.queued.findIndex((q) => q.text.trim() === message.trim()); + run.queued.splice(index >= 0 ? index : 0, run.queued.length ? 1 : 0); + this.renderPending(); + } + + private endRun(run: RunState): void { + if (this.run !== run) return; + this.run = undefined; + this.loader?.stop(); + this.loader = undefined; + this.renderStatus(); + this.footer.update({ runStartedAt: undefined, runState: undefined }); + this.clearPrompts(); + this.tui.requestRender(); + } + + private async finishRun(run: RunState, failed: boolean): Promise { + if (this.run !== run) return; + const interrupted = run.cancelRequested; + this.transcript.endRun(interrupted); + if (interrupted) this.transcript.notice('◼ 已中断', theme.warning); + if (run.plan) { + this.transcript.add(new PlanView(run.plan)); + this.planBox.clear(); + } + const leftover = run.queued; + this.endRun(run); + if (failed || interrupted || this.shuttingDown) { + this.renderPending(); + return; + } + // A queued message the backend turned into its own run: follow it. + if (run.handoff) { + this.transcript.addUser(run.handoff.message, '排队'); + return this.attach({ runId: run.handoff.runId }); + } + if (leftover.length && this.chatId) { + const active = await this.engine.activeRun(this.chatId).catch(() => null); + if (active) return this.attach(active); + this.nextMessages.push(...leftover.map((q) => q.text)); + } + this.renderPending(); + if (this.nextMessages.length) { + const text = this.nextMessages.join('\n\n'); + this.nextMessages = []; + this.renderPending(); + await this.sendMessage(text); + } + } + + /** Follow a run that is already going (resume, handoff). */ + private async attach(active: ActiveRun): Promise { + const abort = new AbortController(); + const run = this.beginRun(abort, active.runId); + let stream: AsyncIterable; + try { + stream = this.engine.resume(active.runId, active.offset, abort.signal); + } catch (error) { + this.transcript.error(errorMessage(error)); + this.endRun(run); + return; + } + await this.follow(run, stream); + } + + async cancelRun(): Promise { + const run = this.run; + if (!run || run.cancelRequested) return; + run.cancelRequested = true; + this.transcript.cancelling = true; + // Like pi: queued inputs the run never applied go back into the editor. + const unsent = [...run.queued.map((q) => q.text), ...this.nextMessages]; + run.queued = []; + this.nextMessages = []; + if (unsent.length) { + this.editor.setText([...unsent, this.editor.getText()].filter((t) => t.trim()).join('\n\n')); + } + this.renderPending(); + this.clearPrompts(); + this.footer.update({ runState: '正在中断…' }); + this.loader?.setMessage('正在中断…'); + this.tui.requestRender(); + if (run.runId) { + try { + await this.engine.cancel(run.runId); + } catch (error) { + this.transcript.current?.addNotice('✗ 中断失败:' + errorMessage(error), theme.error); + } + } + setTimeout(() => { if (this.run === run) run.abort.abort(); }, run.runId ? CANCEL_GRACE_MS : 0).unref?.(); + } + + // ── Inline prompts ─────────────────────────────────────────────────────── + + private enqueuePrompt(entry: PendingPrompt): void { + if (this.prompts.some((p) => p.id === entry.id)) return; + this.prompts.push(entry); + if (!this.activePrompt) this.showNextPrompt(); + } + + private removePrompt(id: string): boolean { + const before = this.prompts.length; + this.prompts = this.prompts.filter((p) => p.id !== id); + if (this.activePrompt?.entry.id === id) { + this.activePrompt = undefined; + this.showNextPrompt(); + } + return this.prompts.length !== before; + } + + private clearPrompts(): void { + this.prompts = []; + if (this.activePrompt) { + this.activePrompt = undefined; + this.showNextPrompt(); + } + } + + private showNextPrompt(): void { + const entry = this.prompts[0]; + if (!entry) { + this.restoreEditor(); + return; + } + const component = entry.kind === 'confirm' + ? createConfirmPrompt(entry.request, (d) => void this.answerConfirm(entry, d)) + : new QuestionPrompt(entry.request, (answers) => void this.answerQuestion(entry, answers)); + this.activePrompt = { entry, component }; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.tui.setFocus(component); + this.loader?.setMessage(entry.kind === 'confirm' ? '等待你确认…' : '等待你回答…'); + this.footer.update({ runState: entry.kind === 'confirm' ? '等待确认' : '等待回答' }); + this.tui.requestRender(); + } + + private restoreEditor(): void { + if (this.editorContainer.children[0] !== this.editor) { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + } + this.tui.setFocus(this.editor); + if (this.run && !this.run.cancelRequested) { + this.loader?.setMessage('工作中… (esc 中断)'); + this.footer.update({ runState: undefined }); + } + this.tui.requestRender(); + } + + private async answerConfirm(entry: Extract, d: ConfirmDecision): Promise { + this.removePrompt(entry.id); + const chosen = d.optionId ? entry.request.options.find((o) => o.id === d.optionId)?.title : undefined; + const label = { allow: '已允许', allow_session: '本会话内已允许', deny: '已拒绝', choice: `已选择「${chosen ?? d.optionId}」`, skip: '交给助手决定' }[d.decision]; + const style = d.decision === 'deny' ? theme.warning : theme.dim; + this.transcript.current?.addNotice(`${d.decision === 'deny' ? '✗' : '✓'} ${label} · ${confirmTitle(entry.request).replace(/^需要确认 · /, '')}`, style); + this.tui.requestRender(); + if (!this.chatId) return; + try { + await this.engine.confirm(this.chatId, entry.id, d.decision, d.optionId); + } catch (error) { + this.transcript.current?.addNotice('✗ 确认没送达:' + errorMessage(error), theme.error); + this.tui.requestRender(); + } + } + + private async answerQuestion(entry: Extract, answers: QuestionAnswer[]): Promise { + this.removePrompt(entry.id); + const summary = answers.map((a) => { + const q = entry.request.questions.find((x) => x.id === a.id); + if (a.skipped) return '跳过'; + const picked = a.selected.map((id) => q?.options.find((o) => o.id === id)?.label ?? id); + return [...picked, ...(a.custom ? [a.custom] : [])].join('、'); + }).join(' | '); + this.transcript.current?.addNotice(`? 已回答:${oneLine(summary, 120)}`); + this.tui.requestRender(); + if (!this.chatId) return; + try { + await this.engine.answer(this.chatId, entry.id, answers); + } catch (error) { + this.transcript.current?.addNotice('✗ 回答没送达:' + errorMessage(error), theme.error); + this.tui.requestRender(); + } + } + + /** Pick-one selector in the editor's place (slash commands). */ + private select(title: string, items: { value: string; label: string; description?: string }[], initialValue?: string, body?: string): Promise { + return new Promise((resolve) => { + const finish = (value?: string) => { + this.activePrompt = undefined; + if (this.prompts.length) this.showNextPrompt(); + else this.restoreEditor(); + resolve(value); + }; + const component = new TitledSelect({ title, body, items, initialValue, onSelect: (item) => finish(item.value), onCancel: () => finish() }); + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.tui.setFocus(component); + this.tui.requestRender(); + }); + } + + // ── Slash commands ─────────────────────────────────────────────────────── + + private async runCommand(name: string, args: string): Promise { + try { + switch (name) { + case 'mode': return await this.cmdMode(args); + case 'model': return await this.cmdModel(); + case 'new': return this.cmdNew(); + case 'resume': return await this.cmdResume(); + case 'fork': return await this.cmdFork(); + case 'compact': return await this.cmdCompact(); + case 'permissions': return await this.cmdPermissions(args); + case 'copy': return this.cmdCopy(); + case 'hotkeys': return this.info('快捷键', HOTKEYS_TEXT); + case 'help': return this.info('命令', HELP_TEXT); + case 'quit': return await this.shutdown(); + } + } catch (error) { + this.transcript.error(`/${name} 失败:${errorMessage(error)}`); + this.restoreEditor(); + } finally { + // Every case returns, so redraw here: a slow command (/compact) ends long after any other repaint. + if (name !== 'quit') this.tui.requestRender(); + } + } + + private info(title: string, body: string): void { + this.transcript.notice(theme.bold(title) + '\n' + body, theme.muted); + this.tui.requestRender(); + } + + private busy(): boolean { + if (!this.run) return false; + this.flashHint('运行中:先按 esc 中断,或等它结束'); + return true; + } + + private async cmdMode(arg: string): Promise { + let target = arg; + if (!target) { + const current = this.engine.info.mode; + const items = [ + { value: 'local', label: 'local', description: '在本机当前目录工作' }, + { value: 'cloud', label: 'cloud', description: '远端服务,访问不到本机文件' }, + ]; + if (current.startsWith('device:')) items.push({ value: current, label: current, description: '远程设备' }); + const picked = await this.select(`执行位置(当前 ${current})`, items, current, '远程设备用 /mode device:'); + if (!picked) return; + target = picked; + } + if (target !== 'local' && target !== 'cloud' && !/^device:.+/.test(target)) { + this.transcript.error(`未知的执行位置:${target}(可选 local / cloud / device:)`); + return; + } + if (target === this.engine.info.mode) return this.flashHint(`已经在 ${target}`); + if (this.busy()) return; + this.switching = true; + this.flashHint(`正在连接 ${target}…`); + this.tui.requestRender(); + try { + const next = await this.options.createEngine(target as Mode, { cwd: this.options.cwd }); + await next.connect(); + const previous = this.engine; + this.engine = next; + void previous.close().catch(() => {}); + this.chatId = undefined; + this.footer.update({ info: next.info, contextPercent: undefined, sessionTitle: undefined, model: this.modelLabel ?? this.model?.modelName, permission: undefined }); + this.transcript.divider(`${next.info.label}${next.info.canAccessCwd ? '' : ' · 无本地文件访问'}`); + try { + const session = await next.newSession(); + this.chatId = session.chatId; + } catch (error) { + this.transcript.notice(`· 新会话会在第一条消息时创建(${errorMessage(error)})`); + } + void this.refreshEngineState(); + } catch (error) { + this.transcript.error(`切换到 ${target} 失败:${errorMessage(error)}`); + } finally { + this.switching = false; + this.tui.requestRender(); + } + } + + private async cmdModel(): Promise { + const models = await this.engine.listModels(); + if (!models.length) return this.transcript.notice('· 没有可用的模型'); + const key = (m: { providerId: string; modelName: string }) => `${m.providerId}\u0000${m.modelName}`; + const current = this.model ? key(this.model) : models.find((m) => m.current) ? key(models.find((m) => m.current)!) : undefined; + const picked = await this.select('选择模型(对之后的消息生效)', models.map((m) => ({ + value: key(m), label: m.label || m.modelName, description: m.providerId + (m.current ? ' · 默认' : ''), + })), current); + if (!picked) return; + const model = models.find((m) => key(m) === picked)!; + this.model = { providerId: model.providerId, modelName: model.modelName }; + this.modelLabel = model.label || model.modelName; + this.footer.update({ model: model.label || model.modelName }); + this.flashHint(`模型:${model.label || model.modelName}`); + } + + private cmdNew(): void { + if (this.busy()) return; + this.chatId = undefined; + this.footer.update({ contextPercent: undefined, sessionTitle: undefined }); + this.transcript.divider('新会话'); + } + + private async cmdResume(): Promise { + if (this.busy()) return; + const sessions = await this.engine.listSessions(30); + if (!sessions.length) return this.transcript.notice('· 这里还没有会话'); + const picked = await this.select('恢复会话', sessions.map((s) => ({ + value: s.chatId, label: s.title || '(无标题)', description: s.updatedAt ? relativeTime(s.updatedAt) : undefined, + })), this.chatId); + if (picked) await this.loadSession(picked, sessions.find((s) => s.chatId === picked)?.title); + } + + async loadSession(chatId: string, title?: string): Promise { + this.chatId = chatId; + this.transcript.divider(`会话 · ${title || chatId}`); + this.footer.update({ sessionTitle: title, contextPercent: undefined }); + this.tui.requestRender(); + try { + const [history, active] = await Promise.all([ + this.engine.history(chatId, 50), + this.engine.activeRun(chatId).catch(() => null), + ]); + // The in-flight reply comes back through the run's snapshot instead. + this.transcript.renderHistory(active ? history.filter((m) => !m.in_flight) : history); + this.tui.requestRender(); + if (active) await this.attach(active); + } catch (error) { + this.transcript.error(`读取会话失败:${errorMessage(error)}`); + this.tui.requestRender(); + } + } + + private async cmdFork(): Promise { + if (!this.chatId) return this.transcript.notice('· 还没有会话可以分叉'); + if (this.busy()) return; + const session = await this.engine.fork(this.chatId); + this.chatId = session.chatId; + this.footer.update({ sessionTitle: session.title }); + this.transcript.divider(`已分叉 · ${session.title || session.chatId}`); + } + + private async cmdCompact(): Promise { + if (!this.chatId) return this.transcript.notice('· 还没有会话需要压缩'); + if (this.busy()) return; + this.flashHint('正在压缩上下文…'); + this.tui.requestRender(); + const result = await this.engine.compact(this.chatId); + const tokens = (count: number | null) => (count === null ? '?' : formatTokens(count)); + if (result.compacted) { + this.transcript.notice(`✓ 已压缩上下文 · ${tokens(result.tokensBefore)} → 约 ${tokens(result.tokensAfter)} tokens`); + } else { + // The server keeps the recent turns verbatim; a short session has nothing older to fold. + this.transcript.notice( + `· 无需压缩${result.tokensBefore === null ? '' : ` · 当前约 ${tokens(result.tokensBefore)} tokens`},最近的对话会原样保留`, + ); + } + } + + private async cmdPermissions(arg: string): Promise { + const modes: PermissionMode[] = ['ask', 'auto', 'full']; + let mode = arg as PermissionMode; + if (!arg) { + const current = await this.engine.getPermissionMode().catch(() => undefined); + const picked = await this.select('工具审批档位', [ + { value: 'ask', label: 'ask', description: '写文件、执行命令前先问我' }, + { value: 'auto', label: 'auto', description: '常规操作自动放行,危险操作才问' }, + { value: 'full', label: 'full', description: '全部放行' }, + ], current); + if (!picked) return; + mode = picked as PermissionMode; + } + if (!modes.includes(mode)) return this.transcript.error(`未知的审批档位:${arg}(可选 ask / auto / full)`); + await this.engine.setPermissionMode(mode); + this.footer.update({ permission: mode }); + this.flashHint(PERMISSION_LABELS[mode]); + } + + private cmdCopy(): void { + const text = this.transcript.lastAnswer; + if (!text) return this.flashHint('还没有回答可以复制'); + copyToClipboard(text, (data) => this.tui.terminal.write(data)); + this.flashHint(`已复制 ${text.length} 字`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms).unref?.()); +} + + +function relativeTime(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const s = Math.round((Date.now() - t) / 1000); + if (s < 60) return '刚刚'; + if (s < 3600) return `${Math.round(s / 60)} 分钟前`; + if (s < 86400) return `${Math.round(s / 3600)} 小时前`; + return `${Math.round(s / 86400)} 天前`; +} + diff --git a/packages/cli/src/tui/bash.ts b/packages/cli/src/tui/bash.ts new file mode 100644 index 00000000..0688040e --- /dev/null +++ b/packages/cli/src/tui/bash.ts @@ -0,0 +1,89 @@ +/** `!command`: run in the user's shell in cwd, show the output, keep it as context for the next message. */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { truncateToWidth, type Component } from '@mariozechner/pi-tui'; +import { formatDuration } from './format.js'; +import { theme } from './theme.js'; + +const MAX_CONTEXT_CHARS = 20_000; +const SHOWN_LINES = 20; + +export class BashBlock implements Component { + /** The latest output only: enough for the screen and for the next message's context. */ + private output = ''; + /** Lines dropped from the front so `output` stays bounded. */ + private droppedLines = 0; + /** Rendered lines once the command has ended, by width. */ + private finished?: { width: number; lines: string[] }; + exitCode?: number | null; + killed = false; + readonly startedAt = Date.now(); + endedAt?: number; + constructor(readonly command: string, readonly excluded: boolean) {} + + append(text: string): void { + this.output += text; + if (this.output.length > 2 * MAX_CONTEXT_CHARS) { + const cut = this.output.length - MAX_CONTEXT_CHARS; + this.droppedLines += this.output.slice(0, cut).split('\n').length - 1; + this.output = this.output.slice(cut); + } + } + + render(width: number): string[] { + if (this.finished?.width === width) return this.finished.lines; + const running = this.endedAt === undefined; + const status = running ? theme.accent('运行中') + : this.killed ? theme.warning('已停止') + : this.exitCode === 0 ? theme.success('✓') : theme.error(`退出码 ${this.exitCode}`); + const time = formatDuration((this.endedAt ?? Date.now()) - this.startedAt); + const note = this.excluded ? theme.dim(' · 不带入上下文') : ''; + const lines = ['', truncateToWidth(` ${theme.bashMode('$ ' + this.command)} ${status} ${theme.dim(time)}${note}`, width)]; + const body = this.output.replace(/\s+$/, '').split('\n').filter((_, i, all) => all.length > 1 || all[0] !== ''); + const shownFrom = Math.max(0, body.length - SHOWN_LINES); + const hidden = shownFrom + this.droppedLines; + if (hidden) lines.push(truncateToWidth(' ' + theme.dim(`… 前面 ${hidden} 行省略`), width)); + for (const l of body.slice(shownFrom)) lines.push(truncateToWidth(' ' + theme.muted(l.replace(/\t/g, ' ')), width)); + if (!running) this.finished = { width, lines }; + return lines; + } + + invalidate(): void {} + + /** What the agent sees with the next message. */ + contextText(): string { + let out = this.output.replace(/\s+$/, ''); + if (out.length > MAX_CONTEXT_CHARS || this.droppedLines) out = '…(已截断)\n' + out.slice(-MAX_CONTEXT_CHARS); + const code = this.killed ? '已被用户停止' : `退出码 ${this.exitCode}`; + return `我在终端里执行了 \`${this.command}\`(${code}):\n\`\`\`\n${out}\n\`\`\``; + } +} + +/** Strip colour and cursor escapes so command output stays plain text in the transcript and context. */ +function plain(text: string): string { + return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '').replace(/\r(?!\n)/g, '\n'); +} + +export function runBash(block: BashBlock, cwd: string, onUpdate: () => void): { child: ChildProcess; done: Promise } { + const shell = process.env.SHELL || '/bin/sh'; + const child = spawn(shell, ['-c', block.command], { cwd, env: { ...process.env, TERM: 'dumb' }, stdio: ['ignore', 'pipe', 'pipe'] }); + const append = (chunk: Buffer) => { + block.append(plain(chunk.toString('utf8'))); + onUpdate(); + }; + child.stdout?.on('data', append); + child.stderr?.on('data', append); + const done = new Promise((resolve) => { + child.on('error', (err) => { + block.append(String(err.message)); + block.exitCode = 127; + block.endedAt = Date.now(); + resolve(); + }); + child.on('close', (code) => { + block.exitCode = code; + block.endedAt = Date.now(); + resolve(); + }); + }); + return { child, done }; +} diff --git a/packages/cli/src/tui/clipboard.ts b/packages/cli/src/tui/clipboard.ts new file mode 100644 index 00000000..da2a0a23 --- /dev/null +++ b/packages/cli/src/tui/clipboard.ts @@ -0,0 +1,19 @@ +/** Copy via OSC 52 (works over SSH in most terminals), plus pbcopy on macOS where Terminal.app ignores OSC 52. */ +import { spawn } from 'node:child_process'; + +export function osc52(text: string): string { + return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`; +} + +export function copyToClipboard(text: string, write: (data: string) => void): void { + write(osc52(text)); + if (process.platform === 'darwin' && !process.env.SSH_TTY) { + try { + const child = spawn('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'] }); + child.on('error', () => {}); + child.stdin.end(text); + } catch { + // OSC 52 already sent. + } + } +} diff --git a/packages/cli/src/tui/commands.ts b/packages/cli/src/tui/commands.ts new file mode 100644 index 00000000..ddcdd64d --- /dev/null +++ b/packages/cli/src/tui/commands.ts @@ -0,0 +1,140 @@ +/** Slash commands, their autocomplete, and `@file` completion (local mode only). */ +import { execFile } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import { basename, join, relative } from 'node:path'; +import { + CombinedAutocompleteProvider, fuzzyFilter, + type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, +} from '@mariozechner/pi-tui'; + +export interface CommandSpec { name: string; description: string; argumentHint?: string; args?: string[] } + +export const COMMANDS: CommandSpec[] = [ + { name: 'mode', description: '切换执行位置', argumentHint: '[local|cloud|device:]', args: ['local', 'cloud', 'device:'] }, + { name: 'model', description: '选择模型(对之后的消息生效)' }, + { name: 'new', description: '开始新会话' }, + { name: 'resume', description: '恢复之前的会话' }, + { name: 'fork', description: '从当前会话分叉出新会话' }, + { name: 'compact', description: '立即压缩当前会话上下文' }, + { name: 'permissions', description: '工具审批档位', argumentHint: '[ask|auto|full]', args: ['ask', 'auto', 'full'] }, + { name: 'copy', description: '复制最后一条回答到剪贴板' }, + { name: 'hotkeys', description: '快捷键一览' }, + { name: 'help', description: '命令一览' }, + { name: 'quit', description: '退出' }, +]; + +export interface ParsedCommand { name: string; args: string } + +/** `/name args` → parsed; anything else (including `/path/like/this`) → null. */ +export function parseCommand(text: string): ParsedCommand | null { + const match = /^\/([a-z][\w-]*)(?:\s+([\s\S]*))?$/i.exec(text.trim()); + if (!match) return null; + const name = match[1].toLowerCase(); + return COMMANDS.some((c) => c.name === name) ? { name, args: (match[2] ?? '').trim() } : null; +} + +function slashCommands(): SlashCommand[] { + return COMMANDS.map((c) => ({ + name: c.name, + description: c.description, + argumentHint: c.argumentHint, + getArgumentCompletions: c.args + ? (prefix: string) => { + // A fully typed argument needs no list, so Enter submits instead of re-applying it. + const items = c.args!.filter((a) => a.startsWith(prefix) && a !== prefix).map((a) => ({ value: a, label: a })); + return items.length ? items : null; + } + : undefined, + })); +} + +/** Candidate files for `@`: git's view of the tree when available, else a bounded walk. */ +async function listFiles(cwd: string, limit = 5000): Promise { + const fromGit = await new Promise((resolve) => { + execFile('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => { + resolve(err ? null : stdout.split('\n').filter(Boolean).slice(0, limit)); + }); + }); + if (fromGit) return fromGit; + const out: string[] = []; + const skip = new Set(['node_modules', '.git', 'dist', 'build', '.venv', '__pycache__']); + const queue = [cwd]; + while (queue.length && out.length < limit) { + const dir = queue.shift()!; + let entries; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; } + for (const e of entries) { + if (e.name.startsWith('.') && e.name !== '.github') continue; + const full = join(dir, e.name); + if (e.isDirectory()) { if (!skip.has(e.name)) queue.push(full); } + else out.push(relative(cwd, full)); + } + } + return out; +} + +/** Wraps pi's provider: slash commands as-is; `@` fuzzy files from our own list (no `fd` needed). */ +export class AppAutocomplete implements AutocompleteProvider { + private inner: CombinedAutocompleteProvider; + private files?: Promise; + private filesAt = 0; + + constructor(private cwd: string, private canAccessCwd: () => boolean) { + this.inner = new CombinedAutocompleteProvider(slashCommands(), cwd); + } + + private atPrefix(line: string, col: number): string | null { + const before = line.slice(0, col); + const match = /(?:^|[\s(])(@[^\s]*)$/.exec(before); + return match ? match[1] : null; + } + + private fileList(): Promise { + if (!this.files || Date.now() - this.filesAt > 30_000) { + this.filesAt = Date.now(); + this.files = listFiles(this.cwd); + } + return this.files; + } + + async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: { signal: AbortSignal; force?: boolean }): Promise { + const prefix = this.atPrefix(lines[cursorLine] ?? '', cursorCol); + if (prefix !== null) { + if (!this.canAccessCwd()) return null; + const query = prefix.slice(1); + const files = await this.fileList(); + if (options.signal.aborted) return null; + const matches = query ? fuzzyFilter(files, query, (f) => f) : files; + const items: AutocompleteItem[] = matches.slice(0, 20).map((f) => ({ value: '@' + f, label: basename(f), description: f })); + return items.length ? { items, prefix } : null; + } + if (!this.canAccessCwd() && !(lines[cursorLine] ?? '').startsWith('/')) return null; + return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } + + applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string) { + return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + if (!this.canAccessCwd()) return false; + return this.inner.shouldTriggerFileCompletion(lines, cursorLine, cursorCol); + } +} + +export const HELP_TEXT = COMMANDS.map((c) => `/${c.name}${c.argumentHint ? ' ' + c.argumentHint : ''} — ${c.description}`).join('\n') + + '\n!命令 — 在本机当前目录执行(仅 local),输出会带进下一条消息;!!命令 不带入' + + '\n@路径 — 引用当前目录下的文件(仅 local)'; + +export const HOTKEYS_TEXT = [ + 'enter 发送;运行中 = 插话(steer)', + 'alt+enter 运行中 = 排到本轮之后(follow-up)', + 'shift+enter 换行', + 'esc 中断运行 / 关闭补全 / 停止 !命令', + 'ctrl+c 清空输入;1 秒内按两次退出', + 'ctrl+d 输入为空时退出', + 'ctrl+t 展开 / 折叠思考', + 'ctrl+o 展开 / 折叠工具详情', + '↑ / ↓ 历史输入', + 'tab 补全路径与命令', +].join('\n'); diff --git a/packages/cli/src/tui/events.ts b/packages/cli/src/tui/events.ts new file mode 100644 index 00000000..bff7f02e --- /dev/null +++ b/packages/cli/src/tui/events.ts @@ -0,0 +1,108 @@ +/** + * Payload parsers for the backend SSE events the TUI acts on + * (.claude/skills/hugagent-frontend-dev/references/sse-events.md). Lenient: missing fields + * fall back to empty values, unknown shapes return null. + */ +import { str } from './format.js'; + +export interface ConfirmOption { id: string; title: string; brief?: string } + +/** A `file_confirm` (or `design_pick`) waiting for the user. */ +export interface ConfirmRequest { + confirmId: string; + op: string; + path: string; + message?: string; + kind?: string; + options: ConfirmOption[]; + /** design_pick: pick one option or let the assistant decide; no allow/deny. */ + pickOnly: boolean; +} + +export interface QuestionOption { id: string; label: string; description?: string; recommended: boolean } + +export interface Question { + id: string; + header?: string; + question: string; + description?: string; + multiSelect: boolean; + options: QuestionOption[]; +} + +export interface QuestionRequest { requestId: string; questions: Question[] } + +export type PlanStatus = 'pending' | 'in_progress' | 'completed'; +export interface Plan { title: string; steps: { title: string; status: PlanStatus }[] } + +export interface QueuedRunHandoff { runId: string; message: string; deliveryMode: string } + +const RECOMMENDED_SUFFIX = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i; + +export function parseConfirm(ev: Record): ConfirmRequest | null { + const confirmId = str(ev.confirm_id); + if (!confirmId) return null; + const rawOptions = Array.isArray(ev.options) ? (ev.options as Record[]) : []; + return { + confirmId, + op: str(ev.op), + path: str(ev.logical_path ?? ev.path), + message: str(ev.message ?? ev.question) || undefined, + kind: str(ev.kind) || undefined, + options: rawOptions + .map((o) => ({ id: str(o.id ?? o.option_id), title: str(o.title ?? o.label), brief: str(o.brief ?? o.description) || undefined })) + .filter((o) => o.id && o.title), + pickOnly: ev.type === 'design_pick' || ev.kind === 'design_pick', + }; +} + +export function parseQuestion(ev: Record): QuestionRequest | null { + const requestId = str(ev.request_id); + const rawQuestions = Array.isArray(ev.questions) ? (ev.questions as Record[]) : []; + const questions = rawQuestions + .map((q) => ({ + id: str(q.id), + header: str(q.header) || undefined, + question: str(q.question), + description: str(q.description) || undefined, + multiSelect: q.multi_select === true, + options: (Array.isArray(q.options) ? (q.options as Record[]) : []) + .map((o) => { + const label = str(o.label); + return { + id: str(o.id), + label: label.replace(RECOMMENDED_SUFFIX, ''), + description: str(o.description) || undefined, + recommended: o.recommended === true || RECOMMENDED_SUFFIX.test(label), + }; + }) + .filter((o) => o.id && o.label), + })) + .filter((q) => q.id && q.question); + return requestId && questions.length ? { requestId, questions } : null; +} + +export function parsePlan(ev: Record): Plan | null { + const steps = (Array.isArray(ev.steps) ? (ev.steps as Record[]) : []) + .map((s) => ({ + title: str(s?.title).trim(), + status: (s?.status === 'in_progress' || s?.status === 'completed' ? s.status : 'pending') as PlanStatus, + })) + .filter((s) => s.title); + return steps.length ? { title: str(ev.title), steps } : null; +} + +export function parseQueuedRun(ev: Record): QueuedRunHandoff | null { + const runId = str(ev.run_id); + return runId ? { runId, message: str(ev.message), deliveryMode: str(ev.delivery_mode) } : null; +} + +export function errorText(value: unknown): string { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object') { + const v = value as Record; + return str(v.message ?? v.detail ?? v.error) || JSON.stringify(value); + } + return String(value); +} diff --git a/packages/cli/src/tui/footer.ts b/packages/cli/src/tui/footer.ts new file mode 100644 index 00000000..fbe782d4 --- /dev/null +++ b/packages/cli/src/tui/footer.ts @@ -0,0 +1,51 @@ +/** Status bar under the editor: where the agent runs, model, permissions, context fill, run state. */ +import { truncateToWidth, visibleWidth, type Component } from '@mariozechner/pi-tui'; +import type { EngineInfo, PermissionMode } from '../engine/engine.js'; +import { formatDuration } from './format.js'; +import { theme } from './theme.js'; + +export const PERMISSION_LABELS: Record = { ask: '审批:按需', auto: '审批:自动', full: '审批:完全放开' }; + +export interface FooterState { + info: EngineInfo; + model?: string; + permission?: PermissionMode; + contextPercent?: number; + /** Set while a run streams. */ + runStartedAt?: number; + /** Short state word shown instead of the timer, e.g. "等待确认". */ + runState?: string; + sessionTitle?: string; +} + +export class Footer implements Component { + constructor(public state: FooterState) {} + + update(patch: Partial): void { this.state = { ...this.state, ...patch }; } + + render(width: number): string[] { + const s = this.state; + const title = s.sessionTitle ? ` • ${s.sessionTitle}` : ''; + let where = theme.dim(s.info.label + title); + if (!s.info.canAccessCwd) { + // "cloud · 无本地文件访问" stays in view the whole time: the agent cannot touch this machine. + const rest = s.info.label.startsWith(s.info.mode + ' · ') ? s.info.label.slice(s.info.mode.length + 3) : s.info.label; + where = theme.warning(`${s.info.mode} · 无本地文件访问`) + theme.dim(` · ${rest}${title}`); + } + const left: string[] = [theme.dim(s.model || '默认模型')]; + if (s.permission) left.push(theme.dim(PERMISSION_LABELS[s.permission])); + if (s.contextPercent !== undefined) { + const pct = `ctx ${Math.round(s.contextPercent)}%`; + left.push(s.contextPercent > 90 ? theme.error(pct) : s.contextPercent > 70 ? theme.warning(pct) : theme.dim(pct)); + } + const leftText = left.join(theme.dim(' · ')); + const right = s.runStartedAt !== undefined + ? theme.accent(s.runState ?? `运行中 ${formatDuration(Date.now() - s.runStartedAt)}`) + : theme.dim(s.runState ?? '空闲'); + const gap = width - visibleWidth(leftText) - visibleWidth(right); + const stats = gap >= 2 ? leftText + ' '.repeat(gap) + right : truncateToWidth(leftText + ' ' + right, width); + return [truncateToWidth(where, width), stats]; + } + + invalidate(): void {} +} diff --git a/packages/cli/src/tui/format.ts b/packages/cli/src/tui/format.ts new file mode 100644 index 00000000..7ac9c9ee --- /dev/null +++ b/packages/cli/src/tui/format.ts @@ -0,0 +1,109 @@ +/** Pure formatting helpers shared by the transcript, footer and prompts. */ + +/** Keys whose value best says what a tool call is about, in order of preference. */ +const SUMMARY_KEYS = [ + 'command', 'cmd', 'path', 'file_path', 'filepath', 'file', 'filename', 'query', 'q', 'url', 'pattern', + 'skill', 'name', 'prompt', 'task', 'description', 'title', 'content', +]; + +export function oneLine(text: string, max = 80): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > max ? flat.slice(0, max - 1) + '…' : flat; +} + +/** Parse tool arguments that may still be a partial JSON string while they stream. */ +export function parseArgs(input: unknown): unknown { + if (typeof input !== 'string') return input; + const text = input.trim(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + // Partial JSON: pull the first complete "key": "value" pair so the line shows something useful. + const pairs = [...text.matchAll(/"([\w-]+)"\s*:\s*"((?:[^"\\]|\\.)*)"?/g)]; + if (pairs.length) { + const obj: Record = {}; + for (const [, key, value] of pairs) obj[key] = value.replace(/\\n/g, ' ').replace(/\\(.)/g, '$1'); + return obj; + } + return text; + } +} + +/** Short human summary of a tool call's arguments: the most telling value, flattened to one line. */ +export function summarizeArgs(input: unknown, max = 60): string { + const value = parseArgs(input); + if (value == null) return ''; + if (typeof value === 'string') return oneLine(value, max); + if (typeof value !== 'object') return oneLine(String(value), max); + if (Array.isArray(value)) return value.length ? `${value.length} 项` : ''; + const obj = value as Record; + for (const key of SUMMARY_KEYS) { + const v = obj[key]; + if (typeof v === 'string' && v.trim()) return oneLine(v, max); + if (Array.isArray(v) && v.length && typeof v[0] === 'string') return oneLine(v.join(' '), max); + } + for (const v of Object.values(obj)) { + if (typeof v === 'string' && v.trim()) return oneLine(v, max); + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + } + return ''; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +export function outputText(output: unknown): string { + if (output == null) return ''; + if (typeof output === 'string') return output; + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } +} + +/** "12 行" for multi-line text output, otherwise its size. Empty output gives ''. */ +export function resultSize(output: unknown): string { + if (output == null || output === '') return ''; + if (typeof output === 'string') { + const lines = output.split('\n').length; + return lines > 1 ? `${lines} 行` : formatBytes(Buffer.byteLength(output)); + } + let compact: string; + try { compact = JSON.stringify(output) ?? ''; } catch { compact = String(output); } + return formatBytes(Buffer.byteLength(compact)); +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; + const s = ms / 1000; + if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`; + const m = Math.floor(s / 60); + return `${m}m${Math.round(s - m * 60)}s`; +} + +export function formatTokens(count: number): string { + if (count < 1000) return String(count); + if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1_000_000) return `${Math.round(count / 1000)}k`; + return `${(count / 1_000_000).toFixed(1)}M`; +} + +/** Context fill in percent from a `context_usage` payload (event or `meta.context_usage`). */ +export function contextPercent(raw: unknown): number | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const r = raw as Record; + const used = Number(r.used_tokens); + const window = Number(r.context_window); + if (!Number.isFinite(used) || !Number.isFinite(window) || window <= 0) return undefined; + return Math.min(100, Math.max(0, (used / window) * 100)); +} + + +export function str(value: unknown): string { + return typeof value === 'string' ? value : value == null ? '' : String(value); +} diff --git a/packages/cli/src/tui/plan.ts b/packages/cli/src/tui/plan.ts new file mode 100644 index 00000000..4c448390 --- /dev/null +++ b/packages/cli/src/tui/plan.ts @@ -0,0 +1,23 @@ +/** `plan_update` checklist: live above the editor while the run streams, frozen into the transcript at the end. */ +import { truncateToWidth, type Component } from '@mariozechner/pi-tui'; +import type { Plan } from './events.js'; +import { theme } from './theme.js'; + +export class PlanView implements Component { + constructor(public plan: Plan) {} + + render(width: number): string[] { + const done = this.plan.steps.filter((s) => s.status === 'completed').length; + const title = this.plan.title ? `计划 · ${this.plan.title}` : '计划'; + const lines = ['', truncateToWidth(` ${theme.bold(title)} ${theme.dim(`${done}/${this.plan.steps.length}`)}`, width)]; + for (const step of this.plan.steps) { + const line = step.status === 'completed' ? theme.dim(` ✓ ${step.title}`) + : step.status === 'in_progress' ? theme.accent(` ▸ ${step.title}`) + : ` ${theme.muted('○')} ${step.title}`; + lines.push(truncateToWidth(line, width)); + } + return lines; + } + + invalidate(): void {} +} diff --git a/packages/cli/src/tui/prompts/confirm.ts b/packages/cli/src/tui/prompts/confirm.ts new file mode 100644 index 00000000..41503273 --- /dev/null +++ b/packages/cli/src/tui/prompts/confirm.ts @@ -0,0 +1,45 @@ +/** `file_confirm` / `design_pick` answered inline: allow / allow for this session / deny, or one of its options. */ +import type { FileDecision } from '../../engine/engine.js'; +import type { ConfirmRequest } from '../events.js'; +import { TitledSelect } from './select.js'; + +export interface ConfirmDecision { decision: FileDecision; optionId?: string } + +const OP_LABELS: Record = { + write: '写入文件', create: '创建文件', edit: '修改文件', delete: '删除文件', move: '移动文件', rename: '重命名', + mkdir: '创建目录', exec: '执行命令', run: '执行命令', command: '执行命令', bash: '执行命令', tool: '使用工具', +}; + +export function confirmTitle(request: ConfirmRequest): string { + if (request.pickOnly) return '需要你选择'; + const op = OP_LABELS[request.op] ?? (request.op || '操作'); + return `需要确认 · ${op}${request.path ? ' ' + request.path : ''}`; +} + +export function confirmItems(request: ConfirmRequest) { + const choices = request.options.map((o) => ({ value: 'choice:' + o.id, label: o.title, description: o.brief })); + if (request.pickOnly) return [...choices, { value: 'skip', label: '让助手决定' }]; + return [ + { value: 'allow', label: '允许', description: '只这一次' }, + { value: 'allow_session', label: '本会话内都允许', description: '同类操作不再询问' }, + ...choices, + { value: 'deny', label: '拒绝', description: '助手会收到拒绝并继续' }, + ]; +} + +export function toDecision(value: string): ConfirmDecision { + if (value.startsWith('choice:')) return { decision: 'choice', optionId: value.slice('choice:'.length) }; + return { decision: value as FileDecision }; +} + +export function createConfirmPrompt(request: ConfirmRequest, done: (d: ConfirmDecision) => void): TitledSelect { + return new TitledSelect({ + title: '⚠ ' + confirmTitle(request), + body: request.message, + items: confirmItems(request), + attention: true, + hint: request.pickOnly ? '↑↓ 选择 · enter 确认 · esc 让助手决定' : '↑↓ 选择 · enter 确认 · esc 拒绝', + onSelect: (item) => done(toDecision(item.value)), + onCancel: () => done({ decision: request.pickOnly ? 'skip' : 'deny' }), + }); +} diff --git a/packages/cli/src/tui/prompts/question.ts b/packages/cli/src/tui/prompts/question.ts new file mode 100644 index 00000000..30e75735 --- /dev/null +++ b/packages/cli/src/tui/prompts/question.ts @@ -0,0 +1,149 @@ +/** + * `user_question` answered inline, one question at a time: pick an option (space toggles on + * multi-select), type your own answer, or skip. Collected answers go to `engine.answer`. + */ +import { Input, matchesKey, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable } from '@mariozechner/pi-tui'; +import type { QuestionAnswer } from '../../engine/engine.js'; +import type { Question, QuestionRequest } from '../events.js'; +import { theme } from '../theme.js'; + +const CUSTOM = '__custom__'; +const SKIP = '__skip__'; + +export class QuestionPrompt implements Component, Focusable { + private index = 0; + private cursor = 0; + private checked = new Set(); + private answers: QuestionAnswer[] = []; + private input = new Input(); + private typing = false; + private _focused = false; + + constructor(private request: QuestionRequest, private done: (answers: QuestionAnswer[]) => void) { + this.input.onSubmit = (value) => this.submitCustom(value); + this.input.onEscape = () => { this.typing = false; }; + this.preselect(); + } + + get focused(): boolean { return this._focused; } + set focused(value: boolean) { + this._focused = value; + this.input.focused = value && this.typing; + } + + get requestId(): string { return this.request.requestId; } + private get question(): Question { return this.request.questions[this.index]; } + + private rows(): { value: string; label: string; description?: string }[] { + return [ + ...this.question.options.map((o) => ({ value: o.id, label: o.label + (o.recommended ? theme.success(' 推荐') : ''), description: o.description })), + { value: CUSTOM, label: '自己输入…' }, + { value: SKIP, label: '跳过' }, + ]; + } + + private preselect(): void { + const rec = this.question.options.findIndex((o) => o.recommended); + this.cursor = rec >= 0 ? rec : 0; + this.checked.clear(); + } + + private next(answer: QuestionAnswer): void { + this.answers.push(answer); + this.typing = false; + this.input.setValue(''); + this.input.focused = false; + if (this.index + 1 >= this.request.questions.length) return this.done(this.answers); + this.index += 1; + this.preselect(); + } + + private submitCustom(value: string): void { + const custom = value.trim(); + if (!custom) return; + this.next({ id: this.question.id, selected: this.question.multiSelect ? [...this.checked] : [], custom }); + } + + /** Esc on the list: skip this and every remaining question. */ + skipAll(): void { + for (let i = this.index; i < this.request.questions.length; i++) { + this.answers.push({ id: this.request.questions[i].id, selected: [], skipped: true }); + } + this.done(this.answers); + } + + handleInput(data: string): void { + if (this.typing) { + if (matchesKey(data, 'escape')) { + this.typing = false; + this.input.focused = false; + return; + } + this.input.handleInput(data); + return; + } + const rows = this.rows(); + if (matchesKey(data, 'up')) this.cursor = (this.cursor - 1 + rows.length) % rows.length; + else if (matchesKey(data, 'down') || matchesKey(data, 'tab')) this.cursor = (this.cursor + 1) % rows.length; + else if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) this.skipAll(); + else if (/^[1-9]$/.test(data) && Number(data) <= rows.length) { + this.cursor = Number(data) - 1; + this.choose(rows[this.cursor].value, false); + } else if (matchesKey(data, 'space') && this.question.multiSelect) this.toggle(rows[this.cursor].value); + else if (matchesKey(data, 'enter')) this.choose(rows[this.cursor].value, true); + } + + private toggle(value: string): void { + if (value === CUSTOM || value === SKIP) return; + if (this.checked.has(value)) this.checked.delete(value); + else this.checked.add(value); + } + + private choose(value: string, enter: boolean): void { + if (value === SKIP) return this.next({ id: this.question.id, selected: [], skipped: true }); + if (value === CUSTOM) { + this.typing = true; + this.input.focused = this._focused; + return; + } + if (this.question.multiSelect) { + if (!enter) return this.toggle(value); + if (!this.checked.size) this.checked.add(value); + return this.next({ id: this.question.id, selected: [...this.checked] }); + } + this.next({ id: this.question.id, selected: [value] }); + } + + render(width: number): string[] { + const q = this.question; + const border = theme.warning('─'.repeat(Math.max(1, width))); + const total = this.request.questions.length; + const head = `? ${q.header ? q.header + ' · ' : ''}${total > 1 ? `(${this.index + 1}/${total}) ` : ''}`; + const lines = [border]; + for (const l of wrapTextWithAnsi(theme.bold(head + q.question), Math.max(10, width - 2))) lines.push(truncateToWidth(' ' + l, width)); + if (q.description) { + for (const l of wrapTextWithAnsi(theme.muted(q.description), Math.max(10, width - 2))) lines.push(truncateToWidth(' ' + l, width)); + } + lines.push(''); + this.rows().forEach((row, i) => { + const selected = i === this.cursor; + const box = q.multiSelect && row.value !== CUSTOM && row.value !== SKIP ? (this.checked.has(row.value) ? '[x] ' : '[ ] ') : ''; + const prefix = selected ? theme.accent('→ ') : ' '; + const label = `${i + 1}. ${box}${row.label}`; + const desc = row.description ? theme.muted(' ' + row.description) : ''; + lines.push(truncateToWidth(prefix + (selected ? theme.accent(label) : label) + desc, width)); + }); + if (this.typing) { + lines.push(''); + lines.push(...this.input.render(Math.max(1, width - 2)).map((l) => truncateToWidth(' ' + l, width))); + } + lines.push(''); + const hint = this.typing ? 'enter 提交 · esc 返回选项' + : q.multiSelect ? '↑↓ 移动 · space 勾选 · enter 确认 · esc 全部跳过' : '↑↓ 选择 · enter 确认 · 数字直选 · esc 全部跳过'; + lines.push(truncateToWidth(' ' + theme.dim(hint), width)); + lines.push(border); + return lines; + } + + invalidate(): void { this.input.invalidate(); } +} diff --git a/packages/cli/src/tui/prompts/select.ts b/packages/cli/src/tui/prompts/select.ts new file mode 100644 index 00000000..42c7ccf4 --- /dev/null +++ b/packages/cli/src/tui/prompts/select.ts @@ -0,0 +1,48 @@ +/** A bordered pick-one list that takes the editor's place, like pi's selectors. */ +import { SelectList, truncateToWidth, wrapTextWithAnsi, type Component, type SelectItem } from '@mariozechner/pi-tui'; +import { selectListTheme, theme } from '../theme.js'; + +export interface TitledSelectOptions { + title: string; + /** Extra lines under the title (wrapped). */ + body?: string; + items: SelectItem[]; + initialValue?: string; + hint?: string; + maxVisible?: number; + onSelect: (item: SelectItem) => void; + onCancel: () => void; + /** Accent the border (things waiting on the user). */ + attention?: boolean; +} + +export class TitledSelect implements Component { + readonly list: SelectList; + constructor(private options: TitledSelectOptions) { + this.list = new SelectList(options.items, options.maxVisible ?? 8, selectListTheme, { maxPrimaryColumnWidth: 48 }); + const index = options.initialValue ? options.items.findIndex((i) => i.value === options.initialValue) : -1; + if (index >= 0) this.list.setSelectedIndex(index); + this.list.onSelect = options.onSelect; + this.list.onCancel = options.onCancel; + } + + handleInput(data: string): void { this.list.handleInput(data); } + + render(width: number): string[] { + const border = (this.options.attention ? theme.warning : theme.border)('─'.repeat(Math.max(1, width))); + const lines = [border, truncateToWidth(' ' + theme.bold(this.options.title), width)]; + if (this.options.body) { + for (const raw of this.options.body.split('\n')) { + for (const l of wrapTextWithAnsi(theme.muted(raw), Math.max(10, width - 2))) lines.push(truncateToWidth(' ' + l, width)); + } + } + lines.push(''); + lines.push(...this.list.render(width)); + lines.push(''); + lines.push(truncateToWidth(' ' + theme.dim(this.options.hint ?? '↑↓ 选择 · enter 确认 · esc 取消'), width)); + lines.push(border); + return lines; + } + + invalidate(): void { this.list.invalidate(); } +} diff --git a/packages/cli/src/tui/theme.ts b/packages/cli/src/tui/theme.ts new file mode 100644 index 00000000..6c59632f --- /dev/null +++ b/packages/cli/src/tui/theme.ts @@ -0,0 +1,51 @@ +/** Colours and component themes, taken from pi's dark theme so the TUI reads like pi. */ +import chalk from 'chalk'; +import type { EditorTheme, MarkdownTheme, SelectListTheme } from '@mariozechner/pi-tui'; + +const hex = (color: string) => chalk.hex(color); + +export const theme = { + accent: hex('#8abeb7'), + border: hex('#5f87ff'), + borderMuted: hex('#505050'), + success: hex('#b5bd68'), + error: hex('#cc6666'), + warning: hex('#ffff00'), + muted: hex('#808080'), + dim: hex('#666666'), + thinking: hex('#808080'), + bashMode: hex('#b5bd68'), + userBg: chalk.bgHex('#343541'), + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), +}; + +export const markdownTheme: MarkdownTheme = { + heading: hex('#f0c674'), + link: hex('#81a2be'), + linkUrl: theme.dim, + code: theme.accent, + codeBlock: theme.success, + codeBlockBorder: theme.muted, + quote: theme.muted, + quoteBorder: theme.muted, + hr: theme.muted, + listBullet: theme.accent, + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), +}; + +export const selectListTheme: SelectListTheme = { + selectedPrefix: theme.accent, + selectedText: theme.accent, + description: theme.muted, + scrollInfo: theme.muted, + noMatch: theme.muted, +}; + +export const editorTheme: EditorTheme = { + borderColor: theme.borderMuted, + selectList: selectListTheme, +}; diff --git a/packages/cli/src/tui/transcript.ts b/packages/cli/src/tui/transcript.ts new file mode 100644 index 00000000..8179996c --- /dev/null +++ b/packages/cli/src/tui/transcript.ts @@ -0,0 +1,702 @@ +/** + * Engine events → transcript components (text / thinking / tool lines / notices). + * + * `Transcript.apply()` folds one SSE event into the current assistant turn and returns the + * signals the app must act on (prompts, footer, run bookkeeping). Rendering is pure + * `render(width)`, so it can be tested without a terminal. + */ +import { Box, Container, Markdown, Spacer, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from '@mariozechner/pi-tui'; +import type { ChatEvent, ChatHistoryMessage } from '../engine/engine.js'; +import { + errorText, parseConfirm, parsePlan, parseQuestion, parseQueuedRun, + type ConfirmRequest, type Plan, type QuestionRequest, type QueuedRunHandoff, +} from './events.js'; +import { contextPercent, formatDuration, oneLine, outputText, parseArgs, resultSize, str, summarizeArgs } from './format.js'; +import { markdownTheme, theme } from './theme.js'; + +/** Display toggles shared by every component (Ctrl+T thinking, Ctrl+O tools). */ +export interface ViewState { expandThinking: boolean; expandTools: boolean } + +export type Signal = + | { type: 'run_started'; runId: string } + | { type: 'confirm'; request: ConfirmRequest } + | { type: 'confirm_expired'; confirmId: string } + | { type: 'question'; request: QuestionRequest } + | { type: 'question_resolved'; requestId: string; outcome: string } + | { type: 'context'; percent: number } + | { type: 'plan'; plan: Plan } + | { type: 'queued_run'; handoff: QueuedRunHandoff } + | { type: 'steer_applied'; message: string } + | { type: 'error'; message: string } + | { type: 'end' }; + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +const spinner = () => SPINNER[Math.floor(Date.now() / 80) % SPINNER.length]; + +/** Fixed-width lines: every line a component returns must fit the terminal. */ +function fit(line: string, width: number): string { + return visibleWidth(line) > width ? truncateToWidth(line, width) : line; +} + +function indentWrap(text: string, width: number, indent: string, style: (s: string) => string, maxLines: number): string[] { + const inner = Math.max(10, width - visibleWidth(indent)); + const raws = text.split('\n'); + const out: string[] = []; + // Wrap only what is shown; the rest is counted in source lines, not wrapped ones. + for (let i = 0; i < raws.length; i++) { + const wrapped = wrapTextWithAnsi(raws[i], inner); + for (const line of wrapped) out.push(fit(indent + style(line), width)); + if (out.length > maxLines) { + const hidden = out.length - maxLines + raws.length - i - 1; + return [...out.slice(0, maxLines), fit(indent + theme.dim(`… 还有 ${hidden} 行`), width)]; + } + } + return out; +} + +// ── Leaf components ────────────────────────────────────────────────────────── + +export class UserMessage implements Component { + private box: Box; + constructor(public readonly text: string, label?: string) { + this.box = new Box(1, 1, (s) => theme.userBg(s)); + const body = label ? `${theme.dim(label)} ${text}` : text; + this.box.addChild(new Markdown(body, 0, 0, markdownTheme)); + } + render(width: number): string[] { return this.box.render(width); } + invalidate(): void { this.box.invalidate(); } +} + +/** A dim / coloured one-off line (errors, notices, dividers). */ +export class Notice implements Component { + constructor(private text: string, private style: (s: string) => string = theme.dim) {} + setText(text: string, style?: (s: string) => string): void { + this.text = text; + if (style) this.style = style; + } + render(width: number): string[] { + return this.text.split('\n').flatMap((l) => wrapTextWithAnsi(this.style(l), Math.max(1, width - 1)).map((w) => fit(' ' + w, width))); + } + invalidate(): void {} +} + +export class Divider implements Component { + constructor(private label = '') {} + render(width: number): string[] { + if (!this.label) return [theme.borderMuted('─'.repeat(Math.max(1, width)))]; + const head = `── ${this.label} `; + const rest = Math.max(0, width - visibleWidth(head)); + return [fit(theme.borderMuted('── ') + theme.muted(this.label + ' ') + theme.borderMuted('─'.repeat(rest)), width)]; + } + invalidate(): void {} +} + +export class TextBlock implements Component { + private md: Markdown; + text = ''; + constructor(text = '') { + this.md = new Markdown('', 1, 0, markdownTheme); + this.setText(text); + } + setText(text: string): void { + this.text = text; + this.md.setText(text.trim()); + } + append(delta: string): void { this.setText(this.text + delta); } + render(width: number): string[] { return this.text.trim() ? this.md.render(width) : []; } + invalidate(): void { this.md.invalidate(); } +} + +export class ThinkingBlock implements Component { + text = ''; + readonly startedAt: number; + endedAt?: number; + private md = new Markdown('', 1, 0, markdownTheme, { color: theme.thinking, italic: true }); + constructor(private view: ViewState, startedAt = Date.now(), private fixedSeconds?: number) { + this.startedAt = startedAt; + } + append(delta: string): void { + this.text += delta; + this.md.setText(this.text.trim()); + } + end(at = Date.now()): void { if (this.endedAt === undefined) this.endedAt = at; } + get seconds(): number { + if (this.fixedSeconds !== undefined) return this.fixedSeconds; + return Math.max(1, Math.round(((this.endedAt ?? Date.now()) - this.startedAt) / 1000)); + } + render(width: number): string[] { + const active = this.endedAt === undefined; + const label = active ? `${spinner()} 思考中 ${this.seconds} 秒…` : this.fixedSeconds === 0 ? '思考' : `思考 ${this.seconds} 秒`; + const hint = this.text.trim() && !this.view.expandThinking ? theme.dim(' ctrl+t 展开') : ''; + const head = fit(' ' + theme.thinking(theme.italic(label)) + hint, width); + if (!this.view.expandThinking || !this.text.trim()) return [head]; + return [head, ...this.md.render(width)]; + } + invalidate(): void { this.md.invalidate(); } +} + +export type ToolStatus = 'running' | 'success' | 'error' | 'interrupted'; + +interface SubStep { toolId?: string; name: string; input?: unknown; inputText?: string; output?: unknown; status: ToolStatus; text?: string } + +export class ToolLine implements Component { + name: string; + displayName?: string; + input?: unknown; + argsText = ''; + output?: unknown; + error?: string; + status: ToolStatus = 'running'; + progress?: string; + durationMs?: number; + readonly startedAt = Date.now(); + subSteps: SubStep[] = []; + /** Summary and result size, recomputed only when the input or output they came from changes. */ + private derived: { args?: unknown; argsText?: string; summary?: string; output?: unknown; size?: string } = {}; + constructor(private view: ViewState, public id: string, name: string) { + this.name = name || 'tool'; + } + + private summary(): string { + const d = this.derived; + if (d.summary === undefined || d.args !== this.input || d.argsText !== this.argsText) { + Object.assign(d, { args: this.input, argsText: this.argsText, summary: summarizeArgs(this.args()) }); + } + return d.summary!; + } + + private size(): string { + const d = this.derived; + if (d.size === undefined || d.output !== this.output) Object.assign(d, { output: this.output, size: resultSize(this.output) }); + return d.size!; + } + + finish(status: ToolStatus): void { + this.status = status; + if (this.durationMs === undefined) this.durationMs = Date.now() - this.startedAt; + } + + private args(): unknown { return this.input ?? (this.argsText ? parseArgs(this.argsText) : undefined); } + + private headline(width: number): string { + const icon = this.status === 'running' ? theme.accent(spinner()) + : this.status === 'success' ? theme.success('✓') + : this.status === 'interrupted' ? theme.warning('◼') : theme.error('✗'); + const parts = [this.displayName || this.name]; + const summary = this.summary(); + if (summary) parts.push(summary); + if (this.status === 'running') { + if (this.progress) parts.push(this.progress); + parts.push(formatDuration(Date.now() - this.startedAt)); + } else { + if (this.status === 'error') parts.push(oneLine(this.error || errorText(this.output) || '失败', 80)); + else if (this.status === 'interrupted') parts.push('已中断'); + else { + const size = this.size(); + if (size) parts.push(size); + } + if (this.durationMs !== undefined) parts.push(formatDuration(this.durationMs)); + } + const style = this.status === 'error' ? theme.error : theme.muted; + const [name, ...rest] = parts; + const text = (this.status === 'error' ? theme.error(name) : name) + (rest.length ? style(' · ' + rest.join(' · ')) : ''); + return fit(` ${icon} ${text}`, width); + } + + private subLine(step: SubStep, width: number): string { + if (step.text !== undefined) return fit(' ' + theme.dim('└ ' + oneLine(step.text, 100)), width); + const icon = step.status === 'running' ? theme.accent(spinner()) : step.status === 'success' ? theme.success('✓') : theme.error('✗'); + const summary = summarizeArgs(step.input ?? (step.inputText ? parseArgs(step.inputText) : undefined)); + const size = step.status === 'success' ? resultSize(step.output) : ''; + const rest = [summary, size].filter(Boolean).join(' · '); + return fit(` ${theme.dim('└')} ${icon} ${theme.muted(step.name + (rest ? ' · ' + rest : ''))}`, width); + } + + render(width: number): string[] { + const lines = [this.headline(width)]; + const tools = this.subSteps.filter((s) => s.text === undefined || this.view.expandTools); + const shown = this.view.expandTools ? tools : tools.slice(-5); + if (shown.length < tools.length) lines.push(fit(' ' + theme.dim(`└ … 前面还有 ${tools.length - shown.length} 步`), width)); + for (const step of shown) lines.push(this.subLine(step, width)); + if (this.view.expandTools) { + const args = this.args(); + if (args !== undefined) lines.push(...indentWrap(outputText(args), width, ' ', theme.dim, 8)); + const out = this.status === 'error' && this.error ? this.error : outputText(this.output); + if (out) lines.push(...indentWrap(out, width, ' │ ', this.status === 'error' ? theme.error : theme.muted, 14)); + } + return lines; + } + + invalidate(): void {} + + applySubagent(ev: Record): void { + const subType = str(ev.sub_type ?? ev.subType); + if (subType === 'job_progress') { + const total = Number(ev.total) || 0; + this.progress = total ? `${Number(ev.settled) || 0}/${total}` : '准备中'; + return; + } + const agent = str(ev.agent_name); + if (agent) this.displayName = `${this.name} · ${agent}`; + const tid = str(ev.tool_id); + const find = () => (tid ? this.subSteps.find((s) => s.toolId === tid) : undefined); + const upsert = (patch: Partial) => { + const step = find(); + if (step) Object.assign(step, patch); + else this.subSteps.push({ toolId: tid || undefined, name: str(ev.tool_name) || 'tool', status: 'running', ...patch }); + }; + if (subType === 'tool_call') upsert(ev.input != null ? { input: ev.input } : {}); + else if (subType === 'tool_call_delta') upsert({ inputText: (find()?.inputText ?? '') + str(ev.arguments_delta) }); + else if (subType === 'tool_result') upsert({ status: str(ev.status) === 'error' ? 'error' : 'success', output: ev.output }); + else if (subType === 'content' || subType === 'thinking') { + const last = this.subSteps[this.subSteps.length - 1]; + if (last && last.text !== undefined) last.text += str(ev.delta); + else this.subSteps.push({ name: '', status: 'success', text: str(ev.delta) }); + } else if (subType === 'error') this.subSteps.push({ name: '', status: 'error', text: '⚠ ' + (errorText(ev.error) || 'error') }); + else if (subType === 'end') this.finish(ev.ok === true ? 'success' : str(ev.status) === 'cancelled' ? 'interrupted' : 'error'); + } +} + +// ── Inline splitting ──────────────────────────────────────────────── + +/** Splits `…` out of streamed content; holds back a possibly split tag. */ +export class ThinkSplitter { + private inThink = false; + private carry = ''; + feed(delta: string): { kind: 'text' | 'thinking'; text: string }[] { + let buf = this.carry + delta; + this.carry = ''; + const out: { kind: 'text' | 'thinking'; text: string }[] = []; + while (buf) { + const tag = this.inThink ? '' : ''; + const at = buf.indexOf(tag); + if (at >= 0) { + if (at > 0) out.push({ kind: this.inThink ? 'thinking' : 'text', text: buf.slice(0, at) }); + buf = buf.slice(at + tag.length); + this.inThink = !this.inThink; + continue; + } + // Hold back a trailing prefix of the tag (" 0; n--) { + if (tag.startsWith(buf.slice(-n))) { hold = n; break; } + } + const emit = buf.slice(0, buf.length - hold); + if (emit) out.push({ kind: this.inThink ? 'thinking' : 'text', text: emit }); + this.carry = buf.slice(buf.length - hold); + buf = ''; + } + return out; + } + flush(): { kind: 'text' | 'thinking'; text: string }[] { + const rest = this.carry; + this.carry = ''; + return rest ? [{ kind: this.inThink ? 'thinking' : 'text', text: rest }] : []; + } +} + +// ── Assistant turn ────────────────────────────────────────────────────────── + +type Segment = TextBlock | ThinkingBlock | ToolLine | Notice; + +/** One assistant reply: segments in arrival order, blank lines only around prose. */ +export class AssistantTurn implements Component { + segments: Segment[] = []; + private compactions = new Map(); + private splitter = new ThinkSplitter(); + /** `tools` is shared by every turn of one run: a call may finish after a steer split the reply. */ + constructor(private view: ViewState, private tools = new Map()) {} + + get text(): string { + return this.segments.filter((s): s is TextBlock => s instanceof TextBlock).map((s) => s.text).join('\n\n').trim(); + } + get isEmpty(): boolean { return this.segments.length === 0; } + + private last(): Segment | undefined { return this.segments[this.segments.length - 1]; } + + private endThinking(): void { + const last = this.last(); + if (last instanceof ThinkingBlock) last.end(); + } + + appendText(delta: string): void { + for (const part of this.splitter.feed(delta)) { + if (part.kind === 'thinking') this.appendThinking(part.text); + else this.appendPlain(part.text); + } + } + + private appendPlain(delta: string): void { + if (!delta) return; + const last = this.last(); + if (last instanceof TextBlock) return last.append(delta); + if (!delta.trim()) return; + this.endThinking(); + this.segments.push(new TextBlock(delta)); + } + + replaceText(text: string): void { + this.segments = this.segments.filter((s) => !(s instanceof TextBlock)); + this.endThinking(); + this.segments.push(new TextBlock(text)); + } + + appendThinking(delta: string): void { + if (!delta) return; + const last = this.last(); + if (last instanceof ThinkingBlock && last.endedAt === undefined) return last.append(delta); + const block = new ThinkingBlock(this.view); + block.append(delta); + this.segments.push(block); + } + + addNotice(text: string, style?: (s: string) => string): Notice { + this.endThinking(); + const notice = new Notice(text, style); + this.segments.push(notice); + return notice; + } + + tool(id: string, name: string): ToolLine { + let line = id ? this.tools.get(id) : undefined; + if (!line) { + this.endThinking(); + line = new ToolLine(this.view, id, name); + if (id) this.tools.set(id, line); + this.segments.push(line); + } else if (name && line.name === 'tool') line.name = name; + return line; + } + + /** tool_result without an id matches the newest running call with that name. */ + findTool(id: string, name: string): ToolLine | undefined { + if (id && this.tools.has(id)) return this.tools.get(id); + const running = [...this.tools.values()].reverse(); + return running.find((t) => t.status === 'running' && (!name || t.name === name)) ?? undefined; + } + + allTools(): ToolLine[] { return [...this.tools.values()]; } + + compaction(ev: Record): void { + const id = str(ev.id) || 'compaction'; + const status = str(ev.status); + const label = status === 'running' ? '⟳ 正在压缩上下文…' + : status === 'completed' ? '✓ 已压缩上下文' + : status === 'skipped' ? '· 跳过上下文压缩' + : `✗ 上下文压缩${status === 'interrupted' ? '中断' : '失败'}${ev.reason ? ':' + str(ev.reason) : ''}`; + const style = status === 'failed' || status === 'interrupted' ? theme.error : theme.dim; + const existing = this.compactions.get(id); + if (existing) existing.setText(label, style); + else this.compactions.set(id, this.addNotice(label, style)); + } + + /** + * Close the turn. `boundary` (a steer split the reply) leaves tool calls running; `end` and + * `interrupted` settle them too. + */ + finish(mode: 'boundary' | 'end' | 'interrupted' = 'end'): void { + for (const part of this.splitter.flush()) { + if (part.kind === 'thinking') this.appendThinking(part.text); + else this.appendPlain(part.text); + } + for (const seg of this.segments) { + if (seg instanceof ThinkingBlock) seg.end(); + if (mode !== 'boundary' && seg instanceof ToolLine && seg.status === 'running') seg.finish(mode === 'interrupted' ? 'interrupted' : 'success'); + } + } + + render(width: number): string[] { + const lines: string[] = []; + let prev: Segment | undefined; + for (const seg of this.segments) { + const out = seg.render(width); + if (!out.length) continue; + const prose = seg instanceof TextBlock; + const open = this.view.expandThinking && (seg instanceof ThinkingBlock || prev instanceof ThinkingBlock); + if (prev && (prose || prev instanceof TextBlock || open)) lines.push(''); + lines.push(...out); + prev = seg; + } + return lines.length ? ['', ...lines] : []; + } + + invalidate(): void { for (const s of this.segments) s.invalidate(); } +} + +// ── Transcript ────────────────────────────────────────────────────────────── + +export class Transcript { + readonly container = new Container(); + current?: AssistantTurn; + private lastAnswerText = ''; + private runTools = new Map(); + /** Set once the user cancelled: the backend's terminal error is expected, not news. */ + cancelling = false; + constructor(readonly view: ViewState) {} + + add(component: Component): void { this.container.addChild(component); } + + addUser(text: string, label?: string): void { + this.add(new Spacer(1)); + this.add(new UserMessage(text, label)); + } + + notice(text: string, style: (s: string) => string = theme.dim): void { + this.add(new Spacer(1)); + this.add(new Notice(text, style)); + } + + error(text: string): void { this.notice('✗ ' + text, theme.error); } + + divider(label: string): void { + this.add(new Spacer(1)); + this.add(new Divider(label)); + } + + /** A new run: fresh tool index, first turn. */ + beginRun(): AssistantTurn { + this.endTurn(); + this.runTools = new Map(); + this.cancelling = false; + return this.beginTurn(); + } + + /** Settle the run: close the open turn and any call left running in an earlier turn. */ + endRun(interrupted: boolean): void { + this.endTurn(interrupted ? 'interrupted' : 'end'); + for (const tool of this.runTools.values()) { + if (tool.status === 'running') tool.finish(interrupted ? 'interrupted' : 'success'); + } + } + + beginTurn(): AssistantTurn { + this.endTurn('boundary'); + this.current = new AssistantTurn(this.view, this.runTools); + this.add(this.current); + return this.current; + } + + endTurn(mode: 'boundary' | 'end' | 'interrupted' = 'end'): void { + if (!this.current) return; + this.current.finish(mode); + if (this.current.text) this.lastAnswerText = this.current.text; + if (this.current.isEmpty) this.container.removeChild(this.current); + this.current = undefined; + } + + get lastAnswer(): string { return this.current?.text || this.lastAnswerText; } + + private turn(): AssistantTurn { return this.current ?? this.beginTurn(); } + + /** Fold one event into the transcript; returns what the app has to do about it. */ + apply(event: ChatEvent): Signal[] { + const ev = event as Record; + const type = str(ev.type); + const signals: Signal[] = []; + const usage = (raw: unknown) => { + const percent = contextPercent(raw); + if (percent !== undefined) signals.push({ type: 'context', percent }); + }; + switch (type) { + case 'run_started': + if (ev.run_id) signals.push({ type: 'run_started', runId: str(ev.run_id) }); + break; + case 'content': case 'ai_message': case 'text': case 'delta': { + const delta = str(ev.delta ?? ev.content ?? ev.text); + if (delta) this.turn().appendText(delta); + break; + } + case 'content_replace': + this.turn().replaceText(str(ev.content ?? ev.text)); + break; + case 'thinking': case 'thought': { + const delta = str(ev.delta ?? ev.content ?? ev.text); + if (delta) this.turn().appendThinking(delta); + break; + } + case 'tool_call_start': case 'tool_use': case 'tool_start': case 'tool_call': { + const line = this.turn().tool(str(ev.id ?? ev.tool_id ?? ev.tool_call_id), str(ev.name ?? ev.tool_name)); + const input = ev.input ?? ev.args ?? ev.tool_args ?? ev.arguments; + if (input !== undefined && input !== null) line.input = typeof input === 'string' ? parseArgs(input) : input; + if (typeof ev.display_name === 'string' && ev.display_name) line.displayName = ev.display_name; + break; + } + case 'tool_call_delta': { + const line = this.turn().tool(str(ev.id ?? ev.tool_id), str(ev.name)); + line.argsText += str(ev.arguments_delta ?? ev.delta); + break; + } + case 'tool_progress': { + const line = this.turn().findTool(str(ev.id ?? ev.tool_id), ''); + if (line) { + const total = Number(ev.total); + const count = Number.isFinite(total) && total > 0 ? `${Number(ev.progress) || 0}/${total}` : ''; + line.progress = [count, str(ev.message)].filter(Boolean).join(' ') || undefined; + } + break; + } + case 'tool_result': case 'tool_end': { + const id = str(ev.id ?? ev.tool_id ?? ev.tool_call_id); + const name = str(ev.name ?? ev.tool_name); + const line = this.turn().findTool(id, name) ?? this.turn().tool(id, name); + line.output = ev.output ?? ev.result ?? line.output; + if (typeof ev.duration_ms === 'number') line.durationMs = ev.duration_ms; + if (typeof ev.subagent_name === 'string' && ev.subagent_name.trim()) line.displayName = `智能体 · ${ev.subagent_name.trim()}`; + const failed = !!ev.error || ev.status === 'error'; + if (failed) line.error = errorText(ev.error) || errorText(line.output); + line.finish(failed ? 'error' : ev.status === 'interrupted' ? 'interrupted' : 'success'); + break; + } + case 'subagent_event': { + const turn = this.turn(); + const parentId = str(ev.parent_tool_id); + const parentName = str(ev.parent_tool_name) || 'call_subagent'; + const parent = (parentId && turn.findTool(parentId, '')) + || [...turn.allTools()].reverse().find((t) => t.name === parentName) + || [...turn.allTools()].reverse().find((t) => t.status === 'running'); + parent?.applySubagent(ev); + break; + } + case 'plan_update': { + const plan = parsePlan(ev); + if (plan) signals.push({ type: 'plan', plan }); + break; + } + case 'compaction_progress': + this.turn().compaction(ev); + break; + case 'compaction_notice': { + const c = ev.context_compaction as Record | undefined; + const covered = Number(c?.covered_message_count); + this.turn().addNotice(Number.isFinite(covered) && covered > 0 ? `· 较早的 ${covered} 条消息已在后台压缩` : '· 较早的上下文已在后台压缩'); + usage(c?.context_usage); + break; + } + case 'context_usage': + usage(ev); + break; + case 'meta': + usage(ev.context_usage); + break; + case 'file_confirm': case 'design_pick': { + const request = parseConfirm(ev); + if (!request) break; + if (ev.expired) signals.push({ type: 'confirm_expired', confirmId: request.confirmId }); + else signals.push({ type: 'confirm', request }); + break; + } + case 'user_question': { + const request = parseQuestion(ev); + if (request) signals.push({ type: 'question', request }); + break; + } + case 'user_question_resolved': + if (ev.request_id) signals.push({ type: 'question_resolved', requestId: str(ev.request_id), outcome: str(ev.outcome) }); + if (ev.outcome === 'timeout') this.turn().addNotice('· 等待回答超时,助手将采用默认方案继续'); + break; + case 'steer_applied': { + const message = str(ev.message).trim(); + // The steer splits the reply: freeze this turn, show the user's words, continue in a new one. + this.endTurn('boundary'); + if (message) this.addUser(message, '插话'); + signals.push({ type: 'steer_applied', message }); + break; + } + case 'queued_run_started': { + const handoff = parseQueuedRun(ev); + if (handoff) signals.push({ type: 'queued_run', handoff }); + break; + } + case 'run_snapshot': { + // Resume: install what the run produced so far, then keep following. + const state = (ev.state ?? {}) as Record; + const turn = this.turn(); + const thinking = str(state.thinking); + if (thinking) turn.appendThinking(thinking); + const tools = Array.isArray(state.tool_calls) ? (state.tool_calls as Record[]) : []; + for (const t of tools) { + const line = turn.tool(str(t.id), str(t.name)); + line.input = t.input ?? t.args; + if (t.status && t.status !== 'running') { + line.output = t.output; + line.finish(t.status === 'error' ? 'error' : 'success'); + } + } + const content = str(state.content ?? state.text ?? state.full); + if (content) turn.replaceText(content); + break; + } + case 'error': { + const message = errorText(ev.error) || errorText(ev.message) || '运行出错'; + if (!this.cancelling) this.turn().addNotice('✗ ' + message, theme.error); + signals.push({ type: 'error', message }); + break; + } + case 'end': case 'run_terminal': + signals.push({ type: 'end' }); + break; + default: + break; + } + return signals; + } + + /** Render stored messages (oldest first) as finished turns. */ + renderHistory(messages: ChatHistoryMessage[]): void { + for (const m of messages) { + if (m.role === 'user') { + this.addUser(str(m.content)); + continue; + } + if (m.role !== 'assistant') continue; + this.endTurn(); + const turn = new AssistantTurn(this.view); + this.current = turn; + this.add(turn); + const thinking = Array.isArray(m.thinking) + ? (m.thinking as unknown[]).map((b) => (typeof b === 'string' ? b : str((b as Record)?.content ?? (b as Record)?.text))).join('\n') + : str(m.thinking); + if (thinking.trim()) { + const block = new ThinkingBlock(this.view, 0, 0); + block.append(thinking); + block.end(0); + turn.segments.push(block); + } + const tools = Array.isArray(m.tool_calls) ? (m.tool_calls as Record[]) : []; + // Stored calls use the event field names (tool_name, tool_args, tool_id), with older aliases. + const addTool = (raw: Record) => { + const line = turn.tool(str(raw.tool_id ?? raw.id), str(raw.tool_name ?? raw.name)); + line.input = raw.tool_args ?? raw.input ?? raw.args ?? raw.arguments; + line.output = raw.output ?? raw.result ?? raw.summary; + if (typeof raw.duration_ms === 'number') line.durationMs = raw.duration_ms; + const status = str(raw.status); + if (status === 'error') line.error = errorText(raw.error) || errorText(line.output); + line.finish(status === 'error' ? 'error' : status === 'interrupted' ? 'interrupted' : 'success'); + if (line.durationMs === undefined || line.durationMs < 5) line.durationMs = undefined; + }; + const appendText = (text: string) => { + const clean = text.replace(/[\s\S]*?<\/think>/g, ''); + if (clean.trim()) turn.appendText(clean); + }; + // metadata.segments keeps text and tool calls in the order they streamed; + // without it, fall back to all tools first, then the whole answer. + const segments = (m.metadata as Record | undefined)?.segments; + if (Array.isArray(segments) && segments.length) { + const shown = new Set(); + for (const segment of segments as Record[]) { + if (segment.type === 'text') appendText(str(segment.text)); + else if (segment.type === 'tool' && typeof segment.index === 'number' && tools[segment.index]) { + shown.add(segment.index); + addTool(tools[segment.index]); + } + } + tools.forEach((raw, index) => { if (!shown.has(index)) addTool(raw); }); + } else { + tools.forEach(addTool); + appendText(str(m.content)); + } + if (m.error) turn.addNotice('✗ ' + errorText(m.error), theme.error); + this.endTurn(); + } + } +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 00000000..cb6fdd2d --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,5 @@ +import { createRequire } from 'node:module'; + +/** This package's version, read once from package.json. */ +export const CLI_VERSION: string = + (createRequire(import.meta.url)('../package.json') as { version?: string }).version ?? '0.0.0'; diff --git a/packages/cli/test/cli-modes.test.mjs b/packages/cli/test/cli-modes.test.mjs new file mode 100644 index 00000000..e6db6f61 --- /dev/null +++ b/packages/cli/test/cli-modes.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { LocalEngine } from '../dist/engine/local.js'; +import { runRpc } from '../dist/modes/rpc.js'; +import { startFakeServer, TOKEN } from './engine-fake-server.mjs'; + +const MAIN = fileURLToPath(new URL('../dist/main.js', import.meta.url)); +const FAKE_ENGINE = fileURLToPath(new URL('./engine-fake-engine.mjs', import.meta.url)); + +let server; +let home; +before(async () => { + server = await startFakeServer(); + home = mkdtempSync(join(tmpdir(), 'hugagent-cli-home-')); + writeFileSync(join(home, 'engine.json'), JSON.stringify({ version: 1, url: server.url, token: TOKEN, pid: process.pid })); +}); +after(() => server.close()); + +function cli(args, { input, env = {}, onStdout } = {}) { + const child = spawn(process.execPath, [MAIN, ...args], { + cwd: home, + env: { ...process.env, HUGAGENT_HOME: home, HUGAGENT_ENGINE_CMD: '["false"]', HUGAGENT_SERVER: '', HUGAGENT_API_KEY: '', ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; onStdout?.(stdout, child); }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + if (input !== undefined) child.stdin.end(input); + const done = new Promise((resolve) => child.on('close', (code) => resolve({ code, stdout, stderr }))); + return Object.assign(done, { child }); +} + +const lines = (text) => text.trim().split('\n').map((line) => JSON.parse(line)); + +test('--help and --version', async () => { + const help = await cli(['--help']); + assert.equal(help.code, 0); + assert.match(help.stdout, /--output text\|json\|rpc/); + const version = await cli(['--version']); + assert.equal(version.stdout.trim(), JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version); +}); + +test('text mode: answer on stdout, tool lines on stderr', async () => { + const result = await cli(['-p', 'hello there']); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'Hello, world\n'); + assert.match(result.stderr, /→ list_dir \./); + const send = server.state.requests.findLast((r) => r.path === '/v1/agents/responses'); + assert.equal(send.body.message, 'hello there'); + assert.equal(send.body.stream, true); +}); + +test('prompt from stdin, --model and -r', async () => { + const result = await cli(['-p', '--model', 'prov:deep:seek', '-r', 'chat42abc'], { input: 'from stdin\n' }); + assert.equal(result.code, 0, result.stderr); + const send = server.state.requests.findLast((r) => r.path === '/v1/agents/responses'); + assert.deepEqual([send.body.chat_id, send.body.message, send.body.model_provider_id, send.body.model_name], + ['chat42abc', 'from stdin\n', 'prov', 'deep:seek']); +}); + +test('json mode: every event as a line, then cli_result', async () => { + const result = await cli(['-p', 'hi', '--output', 'json', '-c']); + assert.equal(result.code, 0, result.stderr); + const events = lines(result.stdout); + assert.deepEqual(events.map((e) => e.type), ['run_started', 'thinking', 'content', 'tool_call', 'tool_result', 'content', 'end', 'cli_result']); + assert.equal(typeof events[0].event_offset, 'number', 'events are passed through unchanged'); + assert.deepEqual(events.at(-1), { type: 'cli_result', status: 'completed', chat_id: 'new1', run_id: 'run-new1' }); +}); + +test('non-interactive approvals: deny by default, allow with --auto-approve, skip questions', async () => { + const denied = await cli(['-p', 'approve', '--output', 'json']); + assert.equal(denied.code, 0, denied.stderr); + assert.equal(server.state.confirms.at(-1).decision, 'deny'); + assert.match(denied.stderr, /denied a\.txt \(pass --auto-approve to allow\)/); + const allowed = await cli(['-p', 'approve', '--auto-approve']); + assert.equal(server.state.confirms.at(-1).decision, 'allow'); + assert.match(allowed.stdout, /decision=allow/); + const asked = await cli(['-p', 'question']); + assert.equal(asked.code, 0); + assert.deepEqual(server.state.answers.at(-1).answers, [ + { id: 'a', selected: [], skipped: true }, { id: 'b', selected: [], skipped: true }, + ]); +}); + +test('exit codes: failed run 1, usage 2, setup 3', async () => { + const failed = await cli(['-p', 'fail', '--output', 'json']); + assert.equal(failed.code, 1); + assert.deepEqual(lines(failed.stdout).at(-1), { type: 'cli_result', status: 'failed', chat_id: lines(failed.stdout).at(-1).chat_id, run_id: lines(failed.stdout).at(-1).run_id, error: 'model exploded' }); + const failedText = await cli(['-p', 'fail']); + assert.equal(failedText.code, 1); + assert.equal(failedText.stderr.match(/model exploded/g).length, 1, 'error printed once'); + + for (const args of [['--mode', 'mars', '-p', 'x'], ['--output', 'rpc', '-p', 'x'], ['--model', 'nocolon', '-p', 'x'], ['-c', '-r', 'abc123', '-p', 'x'], ['--frobnicate']]) { + const usage = await cli(args); + assert.equal(usage.code, 2, args.join(' ') + ': ' + usage.stderr); + } + + const noLogin = await cli(['--mode', 'cloud', '-p', 'x']); + assert.equal(noLogin.code, 3); + assert.match(noLogin.stderr, /hugagent login/); + + const freshHome = mkdtempSync(join(tmpdir(), 'hugagent-cli-fresh-')); + const uninitialized = await cli(['-p', 'x'], { + env: { HUGAGENT_HOME: freshHome, HUGAGENT_ENGINE_CMD: JSON.stringify([process.execPath, FAKE_ENGINE]), FAKE_ENGINE_EXIT: '3' }, + }); + assert.equal(uninitialized.code, 3); + assert.match(uninitialized.stderr, /onboard/); +}); + +test('Ctrl+C cancels the run and exits 130', async () => { + let signalled = false; + const run = cli(['-p', 'hang', '--output', 'json'], { + onStdout: (stdout, child) => { + if (!signalled && stdout.includes('run_started')) { + signalled = true; + child.kill('SIGINT'); + } + }, + }); + const result = await run; + assert.equal(result.code, 130, result.stderr); + assert.equal(server.state.cancels.at(-1), 'run-' + lines(result.stdout).at(-1).chat_id); + assert.equal(lines(result.stdout).at(-1).status, 'cancelled'); +}); + +test('login saves {server, apiKey} with mode 0600 and logout forgets the key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hugagent-cli-login-')); + const bad = await cli(['login', '--server', server.url], { input: 'wrong-key\n', env: { HUGAGENT_HOME: dir } }); + assert.equal(bad.code, 1); + assert.match(bad.stderr, /rejected this API key/); + const good = await cli(['login', '--server', server.url], { input: TOKEN + '\n', env: { HUGAGENT_HOME: dir } }); + assert.equal(good.code, 0, good.stderr); + const path = join(dir, 'cli.json'); + assert.deepEqual(JSON.parse(readFileSync(path, 'utf8')), { server: server.url, apiKey: TOKEN }); + assert.equal(statSync(path).mode & 0o777, 0o600); + const cloud = await cli(['--mode', 'cloud', '-p', 'hi'], { env: { HUGAGENT_HOME: dir } }); + assert.equal(cloud.code, 0, cloud.stderr); + assert.equal(cloud.stdout, 'Hello, world\n'); + // A saved key never goes to a different --server. + const other = await cli(['--mode', 'cloud', '--server', 'http://127.0.0.1:9', '-p', 'hi'], { env: { HUGAGENT_HOME: dir } }); + assert.equal(other.code, 3); + assert.equal((await cli(['logout'], { env: { HUGAGENT_HOME: dir } })).code, 0); + assert.deepEqual(JSON.parse(readFileSync(path, 'utf8')), { server: server.url }); +}); + +test('serve|onboard|doctor are forwarded to hugagent-server with its exit code', async () => { + const env = { HUGAGENT_ENGINE_CMD: JSON.stringify([process.execPath, FAKE_ENGINE]) }; + const doctor = await cli(['doctor', '--verbose'], { env }); + assert.equal(doctor.code, 4); + assert.equal(doctor.stdout, 'forwarded:doctor --verbose\n'); + const missing = await cli(['onboard'], { env: { HUGAGENT_ENGINE_CMD: '', PATH: '/nonexistent' } }); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /Cannot find the HugAgentOS server/); +}); + +test('rpc: JSON-lines round trip mirroring Engine', async () => { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + let output = ''; + stdout.on('data', (chunk) => { output += chunk; }); + const engine = new LocalEngine({ cwd: home, env: { ...process.env, HUGAGENT_HOME: home } }); + const done = runRpc({ engine, stdin, stdout }); + for (const command of [ + { id: 1, method: 'connect' }, + { id: 2, method: 'newSession', params: { title: 'rpc' } }, + { id: 3, method: 'send', params: { chatId: 'rpcchat', message: 'hi' } }, + { id: 4, method: 'getPermissionMode' }, + { id: 5, method: 'nope' }, + { id: 6, method: 'history' }, + ]) stdin.write(JSON.stringify(command) + '\n'); + stdin.write('not json\n'); + stdin.end(); + assert.equal(await done, 0); + const messages = lines(output); + const reply = (id) => messages.find((m) => m.id === id && !m.event); + assert.equal(reply(1).result.projectId, 'proj1'); + assert.equal(reply(2).result.projectId, 'proj1'); + assert.deepEqual(reply(3).result, { status: 'completed', chatId: 'rpcchat', runId: 'run-rpcchat' }); + assert.equal(reply(4).result, 'ask'); + assert.match(reply(5).error.message, /Unknown method nope/); + assert.equal(reply(6).error.code, 'invalid_params'); + assert.deepEqual(messages.find((m) => m.id === null).error.code, 'parse_error'); + const events = messages.filter((m) => m.event); + assert.ok(events.every((m) => m.request === 3)); + assert.deepEqual(events.map((m) => m.event.type), ['run_started', 'thinking', 'content', 'tool_call', 'tool_result', 'content', 'end']); + assert.ok(messages.indexOf(reply(3)) > messages.indexOf(events.at(-1)), 'reply follows its events'); +}); diff --git a/packages/cli/test/engine-fake-engine.mjs b/packages/cli/test/engine-fake-engine.mjs new file mode 100644 index 00000000..117093bd --- /dev/null +++ b/packages/cli/test/engine-fake-engine.mjs @@ -0,0 +1,25 @@ +// Stands in for `hugagent-server` in the spawn tests: `engine --ready-file PATH` serves the fake +// API and writes the §8.1 ready file; FAKE_ENGINE_EXIT=3 mimics an uninitialized install. +import { writeFileSync, renameSync } from 'node:fs'; +import { startFakeServer, TOKEN } from './engine-fake-server.mjs'; + +const args = process.argv.slice(2); +if (process.env.FAKE_ENGINE_ARGS_FILE) writeFileSync(process.env.FAKE_ENGINE_ARGS_FILE, JSON.stringify(args)); +if (process.env.FAKE_ENGINE_EXIT) { + process.stderr.write('{"error":"not_initialized"}\n'); + process.exit(Number(process.env.FAKE_ENGINE_EXIT)); +} +if (args[0] !== 'engine') { + process.stdout.write('forwarded:' + args.join(' ') + '\n'); + process.exit(args[0] === 'doctor' ? 4 : 0); +} +const readyFile = args[args.indexOf('--ready-file') + 1]; +const server = await startFakeServer(); +const temp = readyFile + '.tmp'; +writeFileSync(temp, JSON.stringify({ + version: 1, url: server.url, token: TOKEN, pid: process.pid, started_at: new Date().toISOString(), home: process.env.HUGAGENT_HOME, +}), { mode: 0o600 }); +renameSync(temp, readyFile); +process.stdout.write('engine ready on ' + server.url + '\n'); +setTimeout(() => process.exit(0), 60_000).unref(); +process.on('SIGTERM', () => process.exit(0)); diff --git a/packages/cli/test/engine-fake-server.mjs b/packages/cli/test/engine-fake-server.mjs new file mode 100644 index 00000000..bcf4c2b7 --- /dev/null +++ b/packages/cli/test/engine-fake-server.mjs @@ -0,0 +1,204 @@ +// A fake HugAgentOS for the CLI tests: node:http, the {code,data} envelope, and SSE framed as in +// .claude/skills/hugagent-frontend-dev/references/sse-events.md. The user message picks the script: +// "fail" → error event; "hang" → run_started then silence; "approve" → file_confirm and wait for the +// answer; "question" → user_question and wait; "drop" → cut the stream mid-run (resume finishes it). +import { createServer } from 'node:http'; + +export const TOKEN = 'sk-jx-test-token'; + +const ok = (data, code = 10000) => ({ code, message: 'Success', data, trace_id: 't', timestamp: 0 }); + +export async function startFakeServer({ token = TOKEN, port = 0 } = {}) { + const state = { + requests: [], + confirms: [], + answers: [], + cancels: [], + steers: [], + resumes: [], + operations: new Map(), + runStatusCalls: 0, + waiters: new Map(), + }; + let offset = 0; + const sse = (res, event) => { + offset += 1; + res.write(`data: ${JSON.stringify({ server_ts: Date.now(), event_offset: offset, ...event })}\n\n`); + }; + const waitFor = (key) => new Promise((resolve) => state.waiters.set(key, resolve)); + const wake = (key, value) => { state.waiters.get(key)?.(value); state.waiters.delete(key); }; + + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://x'); + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString(); + const body = raw ? JSON.parse(raw) : undefined; + state.requests.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body }); + const json = (status, value) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(value)); + }; + if (url.pathname === '/health') return json(200, { status: 'ok' }); + if (req.headers.authorization !== 'Bearer ' + token) return json(401, { detail: 'Not authenticated' }); + + const route = `${req.method} ${url.pathname}`; + let match; + if (route === 'GET /v1/tool-approval') return json(200, ok({ mode: 'ask' })); + if (route === 'PUT /v1/tool-approval') return json(200, ok({ mode: body.mode })); + if (route === 'POST /v1/projects/local/resolve') { + return json(200, ok({ project_id: 'proj1', name: 'repo', local_path: body.path, created: false })); + } + if (route === 'POST /v1/chats') { + return json(201, ok({ chat_id: 'chat' + (state.requests.length + 100), title: body.title ?? '新对话', project_id: body.project_id ?? null }, 10001)); + } + if (route === 'GET /v1/projects/proj1/chats') { + return json(200, ok({ items: [ + { chat_id: 'old1', title: 'older', project_id: 'proj1', updated_at: '2026-01-01T00:00:00' }, + { chat_id: 'new1', title: 'newer', project_id: 'proj1', updated_at: '2026-02-01T00:00:00' }, + ] })); + } + if (route === 'GET /v1/chats') return json(200, ok({ items: [{ chat_id: 'cloud1', title: 'cloud chat' }] })); + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/messages$/))) { + if (match[1] === 'busy') return json(409, { detail: { code: 'busy', message: 'chat is busy' } }); + // Like the real route: order=desc picks the latest page, but a page stays in conversation order. + return json(200, ok({ items: [ + { message_id: 'm1', role: 'user', content: 'first', chat_seq: 1 }, + { message_id: 'm2', role: 'assistant', content: 'second', chat_seq: 2 }, + ] })); + } + if (url.pathname === '/v1/chats/envelope/messages-bad') return json(200, { code: 20001, message: 'business refused', data: null }); + if (url.pathname === '/v1/chats/validation/fork') { + return json(422, { detail: [{ loc: ['body', 'request_id'], msg: 'field required' }] }); + } + if (url.pathname === '/v1/chats/proxy/compact') { + res.writeHead(502, { 'Content-Type': 'text/html' }); + return res.end('Bad Gateway'); + } + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/compact$/))) { + if (match[1] === 'missing') return json(404, { detail: 'Not Found' }); + return json(200, ok({ compacted: true, tokens_before: 100, tokens_after: 10 })); + } + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/fork$/))) { + return json(201, ok({ chat_id: match[1] + '-fork', title: 'fork', project_id: 'proj1' }, 10001)); + } + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/file-confirm$/))) { + state.confirms.push(body); + wake('confirm', body); + return json(200, ok({ ok: true })); + } + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/user-questions\/([^/]+)\/answer$/))) { + state.answers.push(body); + wake('answer', body); + return json(200, ok({ ok: true })); + } + if ((match = url.pathname.match(/^\/v1\/chats\/([^/]+)\/active-run$/))) return json(200, ok(null)); + if ((match = url.pathname.match(/^\/v1\/chat-runs\/([^/]+)\/cancel$/))) { + state.cancels.push(match[1]); + wake('cancel', match[1]); + return json(200, ok({ cancelled: true })); + } + if ((match = url.pathname.match(/^\/v1\/chat-runs\/([^/]+)\/steer$/))) { + state.steers.push(body); + return json(200, ok({ queued: true })); + } + if (route === 'GET /v1/models/capabilities') { + return json(200, ok({ main_agent: {}, user_model_switch: { enabled: true, models: [ + { provider_id: 'p1', display_name: 'Model One', model_name: 'one', is_default: true }, + { provider_id: 'p2', display_name: '', model_name: 'two' }, + ] } })); + } + if ((match = url.pathname.match(/^\/v1\/chats\/stream\/([^/]+)$/))) { + state.resumes.push({ runId: match[1], from: Number(url.searchParams.get('from')) }); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + sse(res, { type: 'content', delta: ' resumed' }); + sse(res, { type: 'end' }); + return res.end('data: [DONE]\n\n'); + } + if (route === 'POST /v1/agents/responses') return respond(body, res); + + // session-manager queue (device:) + if (route === 'GET /v1/personal-agent/session-manager/devices') return json(200, ok([{ id: 'dev1', name: 'laptop' }])); + if (route === 'POST /v1/personal-agent/session-manager/operations') { + const id = 'op' + (state.operations.size + 1); + state.operations.set(id, body); + return json(202, ok({ id, target: body.target, operation: body.operation, status: 'sent', result: null })); + } + if ((match = url.pathname.match(/^\/v1\/personal-agent\/session-manager\/operations\/([^/]+)$/))) { + const op = state.operations.get(match[1]); + if (!op) return json(404, { detail: 'Operation not found' }); + return json(200, ok({ id: match[1], status: 'completed', result: deviceResult(op) })); + } + return json(404, { detail: 'Not Found' }); + }); + + function deviceResult(op) { + switch (op.operation) { + case 'chat.create': return { chat_id: 'dchat', title: op.args.title ?? '新对话' }; + case 'chat.send': return { type: 'run_started', run_id: 'drun', message_id: 'dm2' }; + case 'run.status': + state.runStatusCalls += 1; + return { run_id: 'drun', chat_id: 'dchat', message_id: 'dm2', status: state.runStatusCalls > 1 ? 'completed' : 'running' }; + case 'chat.history': return { items: [{ message_id: 'dm2', role: 'assistant', content: 'device answer' }] }; + case 'chat.list': return { items: [{ chat_id: 'dchat', title: 'on device' }] }; + case 'run.cancel': return { error: 'request_refused', http_status: 404 }; + default: return {}; + } + } + + async function respond(body, res) { + const runId = 'run-' + body.chat_id; + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write(': heartbeat\n\n'); + sse(res, { type: 'run_started', run_id: runId, message_id: 'msg1' }); + const message = body.message; + if (message === 'hang') { + await waitFor('cancel'); + sse(res, { type: 'error', error: 'cancelled by user' }); + sse(res, { type: 'end' }); + return res.end('data: [DONE]\n\n'); + } + if (message === 'drop') { + sse(res, { type: 'content', delta: 'partial' }); + // Let the frames reach the client before the connection drops. + return setTimeout(() => res.socket.destroy(), 100); + } + if (message === 'fail') { + sse(res, { type: 'error', error: 'model exploded' }); + sse(res, { type: 'end' }); + return res.end('data: [DONE]\n\n'); + } + if (message === 'approve') { + sse(res, { type: 'tool_call_start', id: 't1', name: 'write_file' }); + sse(res, { type: 'tool_call', id: 't1', name: 'write_file', input: { path: 'a.txt' } }); + sse(res, { type: 'file_confirm', confirm_id: 'cf1', path: 'a.txt' }); + const decision = await waitFor('confirm'); + sse(res, { type: 'tool_result', id: 't1', output: decision.decision }); + sse(res, { type: 'content', delta: 'decision=' + decision.decision }); + sse(res, { type: 'end' }); + return res.end('data: [DONE]\n\n'); + } + if (message === 'question') { + sse(res, { type: 'user_question', request_id: 'q1', questions: [{ id: 'a', question: 'Which?' }, { id: 'b', question: 'Why?' }] }); + await waitFor('answer'); + sse(res, { type: 'content', delta: 'answered' }); + sse(res, { type: 'end' }); + return res.end('data: [DONE]\n\n'); + } + sse(res, { type: 'thinking', delta: 'hmm' }); + sse(res, { type: 'content', delta: 'Hello' }); + sse(res, { type: 'tool_call', id: 't1', name: 'list_dir', input: { path: '.' } }); + sse(res, { type: 'tool_result', id: 't1', output: 'a b' }); + sse(res, { type: 'content', delta: ', world' }); + sse(res, { type: 'end' }); + res.end('data: [DONE]\n\n'); + } + + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}`; + return { + url, + state, + close: () => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }), + }; +} diff --git a/packages/cli/test/engine-http.test.mjs b/packages/cli/test/engine-http.test.mjs new file mode 100644 index 00000000..575fd1f5 --- /dev/null +++ b/packages/cli/test/engine-http.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { HugAgentOSHttp } from '../dist/engine/http.js'; +import { HugAgentOSHttpError, UnsupportedError } from '../dist/engine/errors.js'; +import { DeviceEngine, RemoteEngine } from '../dist/engine/remote.js'; +import { validateServerUrl } from '../dist/config.js'; +import { startFakeServer, TOKEN } from './engine-fake-server.mjs'; + +let server; +before(async () => { server = await startFakeServer(); }); +after(() => server.close()); + +const http = () => new HugAgentOSHttp({ baseUrl: server.url, token: TOKEN }); + +async function rejectsWith(promise, check) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof HugAgentOSHttpError, `expected HugAgentOSHttpError, got ${error}`); + check(error); + return true; + }); +} + +test('unwraps the {code,data} envelope', async () => { + assert.equal(await http().getToolApproval(), 'ask'); + const project = await http().resolveLocalProject('/tmp/repo'); + assert.equal(project.project_id, 'proj1'); + assert.equal(server.state.requests.at(-1).body.create, true); +}); + +test('errors carry status and the server detail', async () => { + await rejectsWith(http().messages('busy'), (error) => { + assert.equal(error.status, 409); + assert.equal(error.message, 'chat is busy (HTTP 409)'); + assert.deepEqual(error.detail, { code: 'busy', message: 'chat is busy' }); + }); + await rejectsWith(http().json('GET', '/v1/chats/envelope/messages-bad'), (error) => { + assert.equal(error.code, 20001); + assert.match(error.message, /business refused/); + }); + await rejectsWith(http().fork('validation'), (error) => { + assert.equal(error.status, 422); + assert.equal(error.message, 'request_id: field required (HTTP 422)'); + }); + await rejectsWith(http().compact('proxy'), (error) => { + assert.equal(error.status, 502); + assert.match(error.message, /Bad Gateway/); + }); + await rejectsWith(new HugAgentOSHttp({ baseUrl: server.url, token: 'wrong' }).getToolApproval(), (error) => { + assert.equal(error.status, 401); + assert.equal(error.message, 'Not authenticated (HTTP 401)'); + }); + await rejectsWith(new HugAgentOSHttp({ baseUrl: 'http://127.0.0.1:9', token: 'x' }).getToolApproval(), (error) => { + assert.equal(error.status, 0); + assert.match(error.message, /Cannot reach/); + }); +}); + +test('steer sends a fresh steer_id each time', async () => { + await http().steer('run1', 'also check tests', 'followUp'); + await http().steer('run1', 'again'); + const [first, second] = server.state.steers.slice(-2); + assert.equal(first.delivery_mode, 'followUp'); + assert.equal(second.delivery_mode, 'steer'); + assert.ok(first.steer_id && first.steer_id.length <= 64); + assert.notEqual(first.steer_id, second.steer_id); +}); + +test('cloud engine: sessions, models, reconnect after a dropped stream', async () => { + const engine = new RemoteEngine({ server: server.url, apiKey: TOKEN }); + await engine.connect(); + assert.equal(engine.info.canAccessCwd, false); + assert.match(engine.info.label, /^cloud · 127\.0\.0\.1:/); + assert.deepEqual((await engine.listSessions()).map((s) => s.chatId), ['cloud1']); + assert.deepEqual(await engine.listModels(), [ + { providerId: 'p1', modelName: 'one', label: 'Model One', current: true }, + { providerId: 'p2', modelName: 'two', label: 'two', current: false }, + ]); + const history = await engine.history('c1'); + assert.deepEqual(history.map((m) => m.message_id), ['m1', 'm2'], 'oldest first'); + assert.equal(server.state.requests.at(-1).query.order, 'desc'); + + const text = []; + for await (const event of engine.send('c1', 'drop')) if (event.type === 'content') text.push(event.delta); + assert.equal(text.join(''), 'partial resumed'); + const resume = server.state.resumes.at(-1); + assert.equal(resume.runId, 'run-c1'); + assert.ok(resume.from > 0, 'resumes after the last delivered offset'); + + await assert.rejects(engine.compact('missing'), /no manual compaction endpoint/); + assert.deepEqual(await engine.compact('c1'), { compacted: true, tokensBefore: 100, tokensAfter: 10 }); +}); + +test('cloud engine maps a rejected key to setup-required (exit 3)', async () => { + const engine = new RemoteEngine({ server: server.url, apiKey: 'nope' }); + await assert.rejects(engine.connect(), (error) => error.exitCode === 3 && /hugagent login/.test(error.message)); +}); + +test('device engine synthesizes a turn from the operation queue', async () => { + const engine = new DeviceEngine('device:dev1', { server: server.url, apiKey: TOKEN }); + await engine.connect(); + assert.equal((await engine.newSession('t')).chatId, 'dchat'); + const events = []; + for await (const event of engine.send('dchat', 'hi')) events.push(event); + assert.deepEqual(events.map((e) => e.type), ['run_started', 'content', 'end']); + assert.equal(events[1].content, 'device answer'); + assert.equal(events[2].status, 'completed'); + const sent = [...server.state.operations.values()].find((op) => op.operation === 'chat.send'); + assert.equal(sent.target, 'device:dev1'); + assert.deepEqual(sent.args, { chat_id: 'dchat', message: 'hi' }); + await assert.rejects(engine.cancel('drun'), /run.cancel failed on device:dev1/); + await assert.rejects(engine.compact('dchat'), (error) => error instanceof UnsupportedError); + await assert.rejects(new DeviceEngine('device:ghost', { server: server.url, apiKey: TOKEN }).connect(), /No paired device ghost/); +}); + +test('cloud URLs need https except loopback or HUGAGENT_ALLOW_HTTP=1', () => { + assert.equal(validateServerUrl('https://agent.example.com/api/', {}), 'https://agent.example.com/api'); + assert.equal(validateServerUrl('http://localhost:8000', {}), 'http://localhost:8000'); + assert.throws(() => validateServerUrl('http://agent.example.com', {}), /Refusing plain HTTP/); + assert.equal(validateServerUrl('http://10.0.0.2', { HUGAGENT_ALLOW_HTTP: '1' }), 'http://10.0.0.2'); + assert.throws(() => validateServerUrl('https://user:pw@agent.example.com', {}), /Invalid server URL/); +}); diff --git a/packages/cli/test/engine-local.test.mjs b/packages/cli/test/engine-local.test.mjs new file mode 100644 index 00000000..b5c57961 --- /dev/null +++ b/packages/cli/test/engine-local.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { LocalEngine, discoverEngine, readReadyFile } from '../dist/engine/local.js'; +import { resolveServerCommand } from '../dist/engine/server-command.js'; +import { startFakeServer, TOKEN } from './engine-fake-server.mjs'; + +const FAKE_ENGINE = fileURLToPath(new URL('./engine-fake-engine.mjs', import.meta.url)); +const fakeCmd = JSON.stringify([process.execPath, FAKE_ENGINE]); +const spawned = []; + +let server; +before(async () => { server = await startFakeServer(); }); +after(async () => { + for (const pid of spawned) try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } + await server.close(); +}); + +const home = () => mkdtempSync(join(tmpdir(), 'hugagent-cli-test-')); + +test('attaches to the engine named in a live ready file and binds cwd', async () => { + const dir = home(); + writeFileSync(join(dir, 'engine.json'), JSON.stringify({ version: 1, url: server.url, token: TOKEN, pid: process.pid })); + const cwd = mkdtempSync(join(tmpdir(), 'hugagent-cwd-')); + const engine = new LocalEngine({ cwd, env: { ...process.env, HUGAGENT_HOME: dir, HUGAGENT_ENGINE_CMD: '["false"]' } }); + await engine.connect(); + await engine.connect(); // idempotent + assert.equal(engine.info.projectId, 'proj1'); + assert.equal(engine.info.server, server.url); + assert.equal(engine.info.workspace, cwd); + assert.equal(engine.info.canAccessCwd, true); + const resolves = server.state.requests.filter((r) => r.path === '/v1/projects/local/resolve'); + assert.equal(resolves.length, 1); + assert.equal(resolves[0].body.path, realpathSync(cwd)); + assert.equal(resolves[0].body.grant, true, 'the server grants the folder to the OS sandbox in the same call'); + + const created = await engine.newSession('x'); + assert.equal(created.projectId, 'proj1'); + assert.equal(server.state.requests.at(-1).body.project_id, 'proj1'); + assert.deepEqual((await engine.listSessions()).map((s) => s.chatId), ['new1', 'old1'], 'project chats, newest first'); + await engine.close(); +}); + +test('ignores a ready file whose pid is dead or whose url is not loopback', () => { + const dir = home(); + const path = join(dir, 'engine.json'); + writeFileSync(path, JSON.stringify({ version: 1, url: 'http://evil.example.com', token: 't', pid: 1 })); + assert.equal(readReadyFile(path), null); +}); + +test('spawns HUGAGENT_ENGINE_CMD detached and waits for its ready file', async () => { + const dir = home(); + const argsFile = join(dir, 'args.json'); + writeFileSync(join(dir, 'engine.json'), JSON.stringify({ version: 1, url: server.url, token: TOKEN, pid: 999999 })); + const env = { ...process.env, HUGAGENT_HOME: dir, HUGAGENT_ENGINE_CMD: fakeCmd, FAKE_ENGINE_ARGS_FILE: argsFile }; + const statuses = []; + const ready = await discoverEngine({ env, onStatus: (s) => statuses.push(s) }); + spawned.push(ready.pid); + assert.notEqual(ready.pid, 999999); + assert.notEqual(ready.url, server.url); + assert.deepEqual(JSON.parse(readFileSync(argsFile, 'utf8')), ['engine', '--ready-file', join(dir, 'engine.json')]); + assert.match(readFileSync(join(dir, 'logs', 'engine.log'), 'utf8'), /engine ready on/); + assert.deepEqual(statuses, ['Starting local engine…']); + // The next client reuses it instead of starting another. + const again = await discoverEngine({ env: { ...env, HUGAGENT_ENGINE_CMD: '["false"]' } }); + assert.equal(again.pid, ready.pid); +}); + +test('an engine that exits 3 asks for onboarding (exit code 3)', async () => { + const dir = home(); + const env = { ...process.env, HUGAGENT_HOME: dir, HUGAGENT_ENGINE_CMD: fakeCmd, FAKE_ENGINE_EXIT: '3' }; + await assert.rejects(discoverEngine({ env }), (error) => error.exitCode === 3 && /onboard --no-serve/.test(error.message)); +}); + +test('an engine that dies early reports its log', async () => { + const dir = home(); + const env = { ...process.env, HUGAGENT_HOME: dir, HUGAGENT_ENGINE_CMD: fakeCmd, FAKE_ENGINE_EXIT: '1' }; + await assert.rejects(discoverEngine({ env }), (error) => error.exitCode === 1 && /exit code 1/.test(error.message) && /not_initialized/.test(error.message)); +}); + +test('server command resolution order', () => { + assert.deepEqual(resolveServerCommand({ HUGAGENT_ENGINE_CMD: '["a","b"]', PATH: '' }), ['a', 'b']); + assert.throws(() => resolveServerCommand({ HUGAGENT_ENGINE_CMD: 'hugagent-server' }), /JSON array/); + const repo = mkdtempSync(join(tmpdir(), 'hugagent-repo-')); + assert.throws(() => resolveServerCommand({ HUGAGENT_REPO: repo, PATH: '' }), /does not exist/); + const bin = mkdtempSync(join(tmpdir(), 'hugagent-bin-')); + writeFileSync(join(bin, 'hugagent-server'), '#!/bin/sh\n', { mode: 0o755 }); + assert.deepEqual(resolveServerCommand({ PATH: bin, HUGAGENT_REPO: repo }), [join(bin, 'hugagent-server')]); + assert.throws(() => resolveServerCommand({ PATH: '' }), /Cannot find the HugAgentOS server/); +}); diff --git a/packages/cli/test/tui-app.test.mjs b/packages/cli/test/tui-app.test.mjs new file mode 100644 index 00000000..994e706d --- /dev/null +++ b/packages/cli/test/tui-app.test.mjs @@ -0,0 +1,303 @@ +// The interactive app end to end: real InteractiveApp + pi-tui, a recording terminal, the scripted FakeEngine. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { InteractiveApp } from '../dist/tui/app.js'; +import { FakeEngine } from '../dist/tui/dev/fake-engine.js'; + +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b_pi:c\x07/g, ''); +const tick = (ms = 15) => new Promise((resolve) => setTimeout(resolve, ms)); + +const KEY = { enter: '\r', esc: '\x1b', altEnter: '\x1b\r', down: '\x1b[B', ctrlC: '\x03', ctrlD: '\x04', ctrlT: '\x14' }; + +class RecordingTerminal { + output = ''; + started = false; + start(onInput, onResize) { this.onInput = onInput; this.onResize = onResize; this.started = true; } + stop() { this.started = false; } + async drainInput() {} + write(data) { this.output += data; } + get columns() { return 90; } + get rows() { return 40; } + get kittyProtocolActive() { return false; } + moveBy() {} + hideCursor() {} + showCursor() {} + clearLine() {} + clearFromCursor() {} + clearScreen() {} + setTitle() {} + setProgress() {} +} + +function setup({ mode = 'local', chatId, speed = 0, prompt, pickSession } = {}) { + const terminal = new RecordingTerminal(); + const engine = new FakeEngine(mode, { speed, cwd: process.cwd() }); + const created = []; + const app = new InteractiveApp({ + engine, + createEngine: async (m) => { const e = new FakeEngine(m, { speed: 0 }); created.push(e); return e; }, + cwd: process.cwd(), + chatId, + prompt, + pickSession, + terminal, + }); + const done = app.start(); + const screen = () => app.tui.render(90).map(strip).join('\n'); + const type = async (text, settle = 15) => { + for (const ch of text) terminal.onInput(ch); + await tick(settle); + }; + const press = async (key, settle = 15) => { terminal.onInput(key); await tick(settle); }; + const until = async (predicate, what, limitMs = 2000) => { + for (let i = 0; i < limitMs / 10; i++) { + if (predicate()) return; + await tick(10); + } + assert.fail(`timed out waiting for ${what}\n${screen()}`); + }; + const calls = (method, e = engine) => e.calls.filter((c) => c.method === method); + return { app, engine, terminal, done, screen, type, press, until, calls, created }; +} + +async function quit(ctx) { + await ctx.press(KEY.ctrlC, 5); + await ctx.press(KEY.ctrlC, 5); + assert.equal(await ctx.done, 0); +} + +test('send → tools → inline approval → answer; the session is created lazily', async () => { + const ctx = setup(); + assert.match(ctx.screen(), /local · /); + await ctx.type('看看项目'); + await ctx.press(KEY.enter); + await ctx.until(() => /需要确认 · 写入文件 NOTES\.md/.test(ctx.screen()), 'the approval prompt'); + assert.equal(ctx.calls('newSession').length, 1); + assert.match(ctx.screen(), /✓ list_dir · \. · 4 行/); + assert.match(ctx.screen(), /✗ bash · npm test · exit code 1/); + assert.match(ctx.screen(), /等待确认/); + await ctx.press(KEY.down); // 本会话内都允许 + await ctx.press(KEY.enter); + await ctx.until(() => /空闲/.test(ctx.screen()), 'the run to end'); + const [confirm] = ctx.calls('confirm'); + assert.equal(confirm.args[2], 'allow_session'); + const out = ctx.screen(); + assert.match(out, /✓ 本会话内已允许 · 写入文件 NOTES\.md/); + assert.match(out, /✓ write_file · NOTES\.md/); + assert.match(out, /项目是一个 TypeScript 包/); + assert.match(out, /ctx 19%/); + await quit(ctx); + assert.ok(ctx.engine.closed, 'engine closed on exit'); + assert.equal(ctx.terminal.started, false, 'terminal restored'); +}); + +test('question prompt answers go to engine.answer', async () => { + const ctx = setup(); + await ctx.type('有歧义就问我 question'); + await ctx.press(KEY.enter); + await ctx.until(() => /新服务用哪个数据库/.test(ctx.screen()), 'the question'); + await ctx.press(KEY.enter); // PostgreSQL (recommended, preselected) + await ctx.until(() => /需要哪些附加功能/.test(ctx.screen()), 'question 2'); + await ctx.press(' '); + await ctx.press(KEY.enter); + await ctx.until(() => /空闲/.test(ctx.screen()), 'the run to end'); + const [answer] = ctx.calls('answer'); + assert.deepEqual(answer.args[2], [{ id: 'db', selected: ['pg'] }, { id: 'features', selected: ['auth'] }]); + assert.match(ctx.screen(), /已回答:PostgreSQL \| 登录鉴权/); + await quit(ctx); +}); + +test('while streaming: Enter steers, Alt+Enter queues a follow-up that runs next', async () => { + const ctx = setup({ speed: 1 }); + await ctx.type('写长一点 long'); + await ctx.press(KEY.enter); + await ctx.until(() => /长回答/.test(ctx.screen()), 'the answer to stream'); + await ctx.type('改成英文', 0); + await ctx.press(KEY.enter, 0); + await ctx.type('再总结 long', 0); + await ctx.press(KEY.altEnter, 30); + assert.match(ctx.screen(), /排队: 再总结 long/); + const steers = ctx.calls('steer').map((c) => [c.args[1], c.args[2]]); + assert.deepEqual(steers, [['改成英文', 'steer'], ['再总结 long', 'followUp']]); + await ctx.until(() => ctx.calls('resume').length === 1, 'the follow-up run'); + await ctx.until(() => !ctx.app.run, 'the follow-up run to finish'); + const out = ctx.screen(); + assert.match(out, /插话 改成英文/); + assert.match(out, /排队 再总结 long/); + assert.doesNotMatch(out, /排队: /, 'nothing left pending'); + await quit(ctx); +}); + +test('approval: Esc on the prompt denies', async () => { + const ctx = setup(); + await ctx.type('再来一次'); + await ctx.press(KEY.enter); + await ctx.until(() => /需要确认/.test(ctx.screen()), 'the approval prompt'); + await ctx.press(KEY.esc); + assert.equal(ctx.calls('confirm').at(-1).args[2], 'deny'); + await ctx.until(() => ctx.app.run === undefined, 'the denied run to finish'); + assert.match(ctx.screen(), /✗ write_file · NOTES\.md · 用户拒绝了写入/); + await quit(ctx); +}); + +test('Esc cancels a running turn and puts queued input back into the editor', async () => { + const ctx = setup(); + await ctx.type('有歧义就问我 question'); // waits on a question, so the run stays open + await ctx.press(KEY.enter); + await ctx.until(() => /新服务用哪个数据库/.test(ctx.screen()), 'the question'); + await ctx.press(KEY.esc); // on the question: skip all, the run continues + await ctx.until(() => !/新服务用哪个数据库/.test(ctx.screen()), 'the prompt to close'); + await ctx.until(() => ctx.app.run === undefined, 'run end'); + // A run waiting on a question, cancelled from the editor with Esc. + await ctx.type('问我 question'); + await ctx.press(KEY.enter); + await ctx.until(() => /新服务用哪个数据库/.test(ctx.screen()), 'the question again'); + ctx.app.tui.setFocus(ctx.app.editor); // the user leaves the prompt (as after ctrl+c in pi) + await ctx.type('等下再说'); + await ctx.press(KEY.altEnter); + await ctx.press(KEY.esc); + await ctx.until(() => ctx.app.run === undefined, 'cancel'); + assert.equal(ctx.calls('cancel').length, 1); + assert.match(ctx.screen(), /◼ 已中断/); + assert.doesNotMatch(ctx.screen(), /已被用户中断/); + assert.equal(ctx.app.editor.getText(), '等下再说', 'queued text restored'); + await quit(ctx); +}); + +test('/mode cloud swaps the engine, starts a session there and disables ! and @', async () => { + const ctx = setup(); + await ctx.type('/mode cloud'); + await ctx.press(KEY.enter, 40); + await ctx.until(() => /cloud · 无本地文件访问/.test(ctx.screen()), 'the cloud footer'); + const [cloud] = ctx.created; + assert.ok(cloud.connected); + assert.ok(ctx.engine.closed, 'old engine closed'); + assert.equal(ctx.calls('newSession', cloud).length, 1); + assert.match(ctx.screen(), /── cloud · agent\.example\.com · 无本地文件访问/); + await ctx.type('!ls'); + await ctx.press(KEY.enter); + assert.match(ctx.screen(), /! 命令只在 local 模式可用/); + await quit(ctx); +}); + +test('/model picks a model that the next send carries', async () => { + const ctx = setup(); + await ctx.type('/model'); + await ctx.press(KEY.enter, 30); + await ctx.until(() => /选择模型/.test(ctx.screen()), 'the model list'); + await ctx.press(KEY.down); + await ctx.press(KEY.enter); + assert.match(ctx.screen(), /DeepSeek Reasoner · 审批:按需/); + await ctx.type('长 long'); + await ctx.press(KEY.enter); + await ctx.until(() => !ctx.app.run && ctx.calls('send').length === 1, 'the send'); + assert.deepEqual(ctx.calls('send')[0].args[2], { providerId: 'deepseek', modelName: 'deepseek-reasoner' }); + await quit(ctx); +}); + +test('!command output is shown and prepended to the next message', async () => { + const ctx = setup(); + await ctx.type('!echo 你好-from-shell'); + await ctx.press(KEY.enter); + await ctx.until(() => /✓/.test(ctx.screen()) && /你好-from-shell\s*$/m.test(ctx.screen()), 'the command output'); + await ctx.type('解释一下 long'); + await ctx.press(KEY.enter); + await ctx.until(() => ctx.calls('send').length === 1 && !ctx.app.run, 'the send'); + const message = ctx.calls('send')[0].args[1]; + assert.match(message, /^我在终端里执行了 `echo 你好-from-shell`(退出码 0):\n```\n你好-from-shell\n```\n\n解释一下 long$/); + await quit(ctx); +}); + +test('starting with a chat id renders its history; /resume and /new work; Ctrl+D quits', async () => { + const ctx = setup({ chatId: 'chat-demo' }); + await ctx.until(() => /Safari 15 之前不支持/.test(ctx.screen()), 'history'); + assert.match(ctx.screen(), /✓ read_file · src\/pages\/Login\.tsx · 41 行/); + await ctx.type('/new'); + await ctx.press(KEY.enter); + assert.match(ctx.screen(), /── 新会话/); + assert.equal(ctx.app.chatId, undefined); + await ctx.type('/resume'); + await ctx.press(KEY.enter, 30); + await ctx.until(() => /恢复会话/.test(ctx.screen()), 'the session list'); + await ctx.press(KEY.enter); + await ctx.until(() => ctx.app.chatId === 'chat-demo', 'resume'); + await ctx.press(KEY.ctrlT); + assert.match(ctx.screen(), /先看看登录组件和构建配置/, 'ctrl+t expands thinking'); + await ctx.press(KEY.ctrlD); + assert.equal(await ctx.done, 0); +}); + +test('unknown slash commands are not sent as chat', async () => { + const ctx = setup(); + await ctx.type('/nope'); + await ctx.press(KEY.esc); // close autocomplete if any + await ctx.press(KEY.enter); + assert.equal(ctx.calls('send').length, 0); + assert.match(ctx.screen(), /未知命令 \/nope/); + await quit(ctx); +}); + +test('steer text over 10000 chars is not steered; it is sent as the next message', async () => { + const ctx = setup({ speed: 1 }); + await ctx.type('写长一点 long'); + await ctx.press(KEY.enter); + await ctx.until(() => /长回答/.test(ctx.screen()), 'the answer to stream'); + const long = 'long ' + 'x'.repeat(10_000); // "long" picks a script without an approval + ctx.terminal.onInput(`\x1b[200~${long}\x1b[201~`); // bracketed paste + await tick(15); + await ctx.press(KEY.enter, 30); + assert.equal(ctx.calls('steer').length, 0); + assert.match(ctx.screen(), /下一条: long x{10}/); + assert.match(ctx.screen(), /超过 10000 字不能插话/); + await ctx.until(() => ctx.calls('send').length === 2 && !ctx.app.run, 'the long text to go out as a normal send', 10_000); + assert.equal(ctx.calls('send')[1].args[1], long); + await quit(ctx); +}); + +test('/compact says what the server did: folded tokens, or nothing old enough to fold', async () => { + const ctx = setup({ chatId: 'chat-demo' }); + await ctx.until(() => /local · /.test(ctx.screen()), 'the app'); + ctx.engine.compactResult = { compacted: false, tokensBefore: 18018, tokensAfter: null }; + await ctx.type('/compact'); + await ctx.press(KEY.enter); + await ctx.until(() => /无需压缩/.test(ctx.screen()), 'the nothing-to-compact notice'); + assert.match(ctx.screen(), /无需压缩 · 当前约 18k tokens/); + assert.doesNotMatch(ctx.screen(), /已压缩上下文/); + ctx.engine.compactResult = { compacted: true, tokensBefore: 48210, tokensAfter: 6120 }; + await ctx.type('/compact'); + await ctx.press(KEY.enter); + await ctx.until(() => /已压缩上下文/.test(ctx.screen()), 'the compacted notice'); + assert.match(ctx.screen(), /✓ 已压缩上下文 · 48k → 约 6\.1k tokens/); + assert.equal(ctx.calls('compact').length, 2); + await quit(ctx); +}); + +test('a slow /compact still repaints the terminal when it finishes', async () => { + // Slower than the "正在压缩…" hint (which repaints once when it expires), as a real compaction is. + const ctx = setup({ chatId: 'chat-demo', speed: 6 }); + await ctx.until(() => /local · /.test(ctx.screen()), 'the app'); + await ctx.type('/compact'); + await ctx.press(KEY.enter); + const written = () => strip(ctx.terminal.output); + await ctx.until(() => /已压缩上下文 · 48k → 约 6\.1k tokens/.test(written()), 'the notice on the terminal itself', 6000); + await quit(ctx); +}); + +test('a prompt on the command line is sent as the first message', async () => { + const ctx = setup({ prompt: '看看项目' }); + await ctx.until(() => ctx.calls('send').length === 1, 'the first send'); + assert.equal(ctx.calls('send')[0].args[1], '看看项目'); + // The scripted run stops at an approval, which holds the keyboard; deny it and let the run end. + await ctx.until(() => /需要确认/.test(ctx.screen()), 'the approval prompt'); + await ctx.press(KEY.esc); + await ctx.until(() => /空闲/.test(ctx.screen()), 'the run to end', 5000); + await quit(ctx); +}); + +test('-r without an id opens the session picker', async () => { + const ctx = setup({ pickSession: true }); + await ctx.until(() => /恢复会话/.test(ctx.screen()), 'the session picker'); + assert.equal(ctx.calls('listSessions').length, 1); + await ctx.press(KEY.esc); + await quit(ctx); +}); diff --git a/packages/cli/test/tui-transcript.test.mjs b/packages/cli/test/tui-transcript.test.mjs new file mode 100644 index 00000000..fea3ad26 --- /dev/null +++ b/packages/cli/test/tui-transcript.test.mjs @@ -0,0 +1,254 @@ +// Transcript / footer / prompt rendering, driven by backend SSE events (no terminal needed). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import chalk from 'chalk'; +import { visibleWidth } from '@mariozechner/pi-tui'; + +// Colours on even when stdout is not a TTY; theme colours are built at import, so import after. +chalk.level = 3; +const { Transcript, ThinkSplitter } = await import('../dist/tui/transcript.js'); +const { Footer } = await import('../dist/tui/footer.js'); +const { contextPercent, summarizeArgs, resultSize, parseArgs } = await import('../dist/tui/format.js'); +const { QuestionPrompt } = await import('../dist/tui/prompts/question.js'); +const { createConfirmPrompt } = await import('../dist/tui/prompts/confirm.js'); +const { parseQuestion, parseConfirm } = await import('../dist/tui/events.js'); + +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x07]*\x07/g, ''); +const screen = (component, width = 80) => component.render(width).map(strip).join('\n'); +const fresh = () => new Transcript({ expandThinking: false, expandTools: false }); +const feed = (t, events) => events.flatMap((e) => t.apply(e)); + +test('content deltas stream into one Markdown block', () => { + const t = fresh(); + feed(t, [{ type: 'content', delta: '## 标题\n\n第一' }, { type: 'content', delta: '段 **加粗**' }, { type: 'text', text: '。' }]); + const out = screen(t.container); + assert.match(out, /标题/); + assert.match(out, /第一段 加粗。/); + assert.equal(t.current.text, '## 标题\n\n第一段 **加粗**。'); +}); + +test('content_replace swaps the answer text', () => { + const t = fresh(); + feed(t, [{ type: 'content', delta: '旧答案' }, { type: 'content_replace', content: '新答案' }]); + const out = screen(t.container); + assert.doesNotMatch(out, /旧答案/); + assert.match(out, /新答案/); +}); + +test('thinking collapses to one line and expands with the view toggle', () => { + const t = fresh(); + feed(t, [{ type: 'thinking', delta: '先想一想' }, { type: 'thinking', delta: '再想一想' }, { type: 'content', delta: '答' }]); + t.endTurn(); + let out = screen(t.container); + assert.match(out, /思考 \d+ 秒/); + assert.doesNotMatch(out, /先想一想/); + t.view.expandThinking = true; + out = screen(t.container); + assert.match(out, /先想一想再想一想/); +}); + +test('inline tags are split out even when cut across deltas', () => { + const s = new ThinkSplitter(); + const parts = [...s.feed('推理正文'), ...s.flush()]; + assert.deepEqual(parts.filter((p) => p.text), [{ kind: 'thinking', text: '推理' }, { kind: 'text', text: '正文' }]); +}); + +test('tool call is one quiet line: name · args · size · duration', () => { + const t = fresh(); + feed(t, [ + { type: 'tool_call_start', id: 't1', name: 'read_file' }, + { type: 'tool_call_delta', id: 't1', arguments_delta: '{"path": "src/ma' }, + ]); + assert.match(screen(t.container), /read_file · src\/ma/); + feed(t, [ + { type: 'tool_call', id: 't1', name: 'read_file', input: { path: 'src/main.ts' } }, + { type: 'tool_progress', id: 't1', progress: 3, total: 10, message: '读取中' }, + ]); + assert.match(screen(t.container), /read_file · src\/main\.ts · 3\/10 读取中/); + feed(t, [{ type: 'tool_result', id: 't1', output: 'a\nb\nc', duration_ms: 1500 }]); + const line = screen(t.container).split('\n').find((l) => l.includes('read_file')); + assert.equal(line.trim(), '✓ read_file · src/main.ts · 3 行 · 1.5s'); +}); + +test('tool errors render red with the error text', () => { + const t = fresh(); + feed(t, [{ type: 'tool_call', id: 'b', name: 'bash', input: { command: 'npm test' } }, { type: 'tool_result', id: 'b', status: 'error', error: 'exit code 1' }]); + const raw = t.container.render(80).find((l) => l.includes('bash')); + assert.match(strip(raw), /✗ bash · npm test · exit code 1/); + assert.ok(raw.includes('\x1b[38;2;204;102;102m'), 'error line is red'); +}); + +test('subagent events nest under their parent tool', () => { + const t = fresh(); + feed(t, [ + { type: 'tool_call', id: 'p', name: 'call_subagent', input: { task: '审查' } }, + { type: 'subagent_event', parent_tool_id: 'p', sub_type: 'tool_call', tool_id: 's1', tool_name: 'grep', input: { pattern: 'password' } }, + { type: 'subagent_event', parent_tool_id: 'p', sub_type: 'tool_result', tool_id: 's1', tool_name: 'grep', output: 'x' }, + { type: 'tool_result', id: 'p', subagent_name: '审查员', output: 'ok', duration_ms: 10 }, + ]); + const out = screen(t.container); + assert.match(out, /智能体 · 审查员 · 审查/); + assert.match(out, /└ ✓ grep · password/); +}); + +test('approval, question and lifecycle events become signals', () => { + const t = fresh(); + const signals = feed(t, [ + { type: 'run_started', run_id: 'r1' }, + { type: 'file_confirm', confirm_id: 'c1', op: 'write', logical_path: 'a.md', options: [{ id: 'o1', title: '方案一' }] }, + { type: 'file_confirm', confirm_id: 'c1', expired: true }, + { type: 'user_question', request_id: 'q1', questions: [{ id: 'x', question: '选哪个?', options: [{ id: 'a', label: 'A(推荐)' }] }] }, + { type: 'user_question_resolved', request_id: 'q1', outcome: 'answered' }, + { type: 'context_usage', used_tokens: 32000, context_window: 128000 }, + { type: 'plan_update', title: 'P', steps: [{ title: 's1', status: 'in_progress' }] }, + { type: 'queued_run_started', run_id: 'r2', message: '下一条', delivery_mode: 'follow_up' }, + { type: 'some_future_event', foo: 1 }, + { type: 'end' }, + ]); + assert.deepEqual(signals.map((s) => s.type), ['run_started', 'confirm', 'confirm_expired', 'question', 'question_resolved', 'context', 'plan', 'queued_run', 'end']); + assert.equal(signals[1].request.options[0].id, 'o1'); + assert.equal(signals[3].request.questions[0].options[0].label, 'A'); + assert.equal(signals[3].request.questions[0].options[0].recommended, true); + assert.equal(signals[5].percent, 25); +}); + +test('steer_applied splits the reply; a call started before it still finishes in place', () => { + const t = fresh(); + t.beginRun(); + feed(t, [{ type: 'tool_call', id: 'k', name: 'bash', input: { command: 'sleep 1' } }, { type: 'steer_applied', message: '换个方向' }, { type: 'content', delta: '好的' }]); + let out = screen(t.container); + assert.match(out, /插话 换个方向/); + assert.ok(out.indexOf('bash') < out.indexOf('换个方向') && out.indexOf('换个方向') < out.indexOf('好的')); + feed(t, [{ type: 'tool_result', id: 'k', output: 'done', duration_ms: 1000 }]); + out = screen(t.container); + assert.equal(out.match(/bash/g).length, 1, 'no duplicate tool line'); + assert.match(out, /✓ bash · sleep 1 · 4 B · 1.0s/); +}); + +test('errors are red lines, but not after the user cancelled', () => { + const t = fresh(); + feed(t, [{ type: 'error', error: { message: '上游超时' } }]); + assert.match(screen(t.container), /✗ 上游超时/); + const c = fresh(); + c.beginRun(); + c.cancelling = true; + feed(c, [{ type: 'error', error: '已被用户中断' }]); + assert.doesNotMatch(screen(c.container), /已被用户中断/); +}); + +test('history renders as finished turns', () => { + const t = fresh(); + t.renderHistory([ + { message_id: '1', role: 'user', content: '你好' }, + { message_id: '2', role: 'assistant', content: '想你好!', tool_calls: [{ id: 'x', name: 'ls', input: { path: '.' }, status: 'success', output: 'a' }] }, + ]); + const out = screen(t.container); + assert.match(out, /^ 你好\s*$/m); + assert.match(out, /✓ ls · \./); + assert.match(out, /你好!/); + assert.equal(t.lastAnswer, '你好!'); +}); + +test('stored history keeps text and tool calls interleaved as they streamed', () => { + const t = fresh(); + t.renderHistory([ + { message_id: '1', role: 'user', content: '修 bug' }, + { + message_id: '2', role: 'assistant', content: 'Look first.Found it.Fixed.', + tool_calls: [ + { tool_id: 'a', tool_name: 'Read', tool_args: { file_path: 'calc.py' }, status: 'success', result: 'x', duration_ms: 300 }, + { tool_id: 'b', tool_name: 'Write', tool_args: { file_path: '../out.txt' }, status: 'error', result: { error: '用户拒绝了该本机操作' } }, + ], + metadata: { segments: [ + { type: 'text', text: 'Look first.' }, { type: 'tool', index: 0 }, + { type: 'text', text: 'Found it.' }, { type: 'tool', index: 1 }, { type: 'text', text: 'Fixed.' }, + ] }, + }, + ]); + const out = screen(t.container); + assert.match(out, /✓ Read · calc\.py/); + assert.match(out, /✗ Write · \.\.\/out\.txt/); + assert.doesNotMatch(out, /✓ tool ·/); + assert.doesNotMatch(out, /first\.Found/); + const order = ['修 bug', 'Look first.', 'Read', 'Found it.', 'Write', 'Fixed.'].map((text) => out.indexOf(text)); + assert.deepEqual([...order].sort((a, b) => a - b), order, 'user, then text/tool/text/tool/text in order'); + assert.ok(order.every((index) => index >= 0)); +}); + +test('Chinese text never overflows the width (east-asian widths)', () => { + const t = fresh(); + t.addUser('请帮我把这个函数改成异步的,并且保留原来的错误处理逻辑,谢谢!'.repeat(3)); + feed(t, [ + { type: 'thinking', delta: '分析需求' }, + { type: 'tool_call', id: 'z', name: '读取文件', input: { path: '源代码/模块/中文路径名称很长很长很长很长.ts' } }, + { type: 'tool_result', id: 'z', output: '内容' }, + { type: 'content', delta: '这是一个很长的中文段落,包含 English words、`代码` 和 **加粗**,用来检查折行。'.repeat(4) + '\n\n| 列 | 说明 |\n|---|---|\n| 宽度 | 汉字占两列 |' }, + ]); + for (const width of [20, 33, 47, 80, 120]) { + for (const line of t.container.render(width)) assert.ok(visibleWidth(line) <= width, `width ${width}: ${strip(line)}`); + } + assert.equal(visibleWidth('汉字ab'), 6); +}); + +test('footer: cloud warning, permission, context percent', () => { + const footer = new Footer({ info: { mode: 'cloud', label: 'cloud · agent.example.com', server: 'x', canAccessCwd: false }, model: 'deepseek-chat', permission: 'ask', contextPercent: 42.4 }); + const [where, stats] = footer.render(100).map(strip); + assert.equal(where, 'cloud · 无本地文件访问 · agent.example.com'); + assert.match(stats, /^deepseek-chat · 审批:按需 · ctx 42%\s+空闲$/); + assert.equal(visibleWidth(footer.render(100)[1]), 100); + footer.update({ info: { mode: 'local', label: 'local · ~/repo', server: 'x', canAccessCwd: true }, runStartedAt: Date.now() }); + const lines = footer.render(30).map(strip); + assert.equal(lines[0], 'local · ~/repo'); + for (const l of footer.render(30)) assert.ok(visibleWidth(l) <= 30); +}); + +test('format helpers', () => { + assert.equal(summarizeArgs({ command: 'npm test\n --watch' }), 'npm test --watch'); + assert.equal(summarizeArgs('{"path": "a/b.ts", "x"'), 'a/b.ts'); + assert.equal(resultSize('x\ny'), '2 行'); + assert.equal(resultSize({ ok: true }), '11 B'); + assert.equal(contextPercent({ used_tokens: 50, context_window: 200 }), 25); + assert.equal(contextPercent({}), undefined); + assert.deepEqual(parseArgs('{"a":1}'), { a: 1 }); +}); + +test('question prompt: single pick, multi-select with custom text, esc skips the rest', () => { + const request = parseQuestion({ + request_id: 'q', questions: [ + { id: 'db', question: '数据库?', options: [{ id: 'pg', label: 'PG' }, { id: 'my', label: 'MySQL(推荐)' }] }, + { id: 'f', question: '功能?', multi_select: true, options: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }, + { id: 'z', question: '还有吗?', options: [{ id: 'n', label: '没有' }] }, + ], + }); + let result; + const p = new QuestionPrompt(request, (answers) => { result = answers; }); + assert.match(screen(p), /→ 2\. MySQL 推荐/, 'recommended option is preselected'); + p.handleInput('\r'); + assert.match(screen(p), /\(2\/3\) 功能?/); + p.handleInput(' '); + p.handleInput('\x1b[B'); + p.handleInput('\x1b[B'); + p.handleInput('\r'); // 自己输入… + for (const ch of '中文补充') p.handleInput(ch); + assert.match(screen(p), /中文补充/); + p.handleInput('\r'); + p.handleInput('\x1b'); + assert.deepEqual(result, [ + { id: 'db', selected: ['my'] }, + { id: 'f', selected: ['a'], custom: '中文补充' }, + { id: 'z', selected: [], skipped: true }, + ]); +}); + +test('confirm prompt offers allow / allow_session / options / deny; esc denies', () => { + const request = parseConfirm({ type: 'file_confirm', confirm_id: 'c', op: 'write', logical_path: 'a.md', options: [{ id: 'o', title: '覆盖' }] }); + const decisions = []; + const p = createConfirmPrompt(request, (d) => decisions.push(d)); + const out = screen(p); + for (const label of ['需要确认 · 写入文件 a.md', '允许', '本会话内都允许', '覆盖', '拒绝']) assert.ok(out.includes(label), label); + p.handleInput('\x1b[B'); + p.handleInput('\x1b[B'); + p.handleInput('\r'); + p.handleInput('\x1b'); + assert.deepEqual(decisions, [{ decision: 'choice', optionId: 'o' }, { decision: 'deny' }]); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..0e6bf565 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src", + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/pyproject.toml b/pyproject.toml index fad4f86f..1a3d11a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,15 +28,19 @@ dev = [ "import-linter>=2.15,<3", ] -# Console entry for the no-Docker local profile: `hugagent` (onboard / serve / -# doctor). CE and EE share this technical identifier. +# Console entries. `hugagent-server` runs the no-Docker local profile (onboard / +# serve / doctor / engine); `hugagent-session` is the lightweight cloud/device +# session client. The `hugagent` name belongs to the npm terminal client +# (packages/cli), which forwards server commands to `hugagent-server`. CE and EE +# share these technical identifiers. [project.scripts] -hugagent = "hugagent_cli.main:main" +hugagent-server = "cli:main" +hugagent-session = "hugagent_cli.main:main" # ── Packaging: src/backend is the import root ──────────────────────────────── # The project has always run with PYTHONPATH=src/backend (top-level `api`, # `core`, `orchestration`, … packages). Declare that so an editable/source -# install exposes them and the `hugagent` console script resolves `cli:main`. +# install exposes them and the `hugagent-server` console script resolves `cli:main`. [tool.setuptools] package-dir = {"" = "src/backend"} py-modules = ["cli"] diff --git a/scripts/build-hugagent-cli.py b/scripts/build-hugagent-cli.py index 03f3f68e..66fce19d 100644 --- a/scripts/build-hugagent-cli.py +++ b/scripts/build-hugagent-cli.py @@ -20,7 +20,7 @@ ignore=shutil.ignore_patterns("__pycache__"), ) (base / "pyproject.toml").write_text( - '[build-system]\nrequires = ["setuptools>=68", "wheel"]\nbuild-backend = "setuptools.build_meta"\n[project]\nname = "hugagent-cli"\nversion = "0.1.0"\nrequires-python = ">=3.11"\ndescription = "HugAgentOS cloud and device session client"\n[project.scripts]\nhugagent = "hugagent_cli.main:main"\n' + '[build-system]\nrequires = ["setuptools>=68", "wheel"]\nbuild-backend = "setuptools.build_meta"\n[project]\nname = "hugagent-cli"\nversion = "0.1.0"\nrequires-python = ">=3.11"\ndescription = "HugAgentOS cloud and device session client"\n[project.scripts]\nhugagent-session = "hugagent_cli.main:main"\n' ) subprocess.run( [ diff --git a/src/backend/api/routes/v1/chats/README.md b/src/backend/api/routes/v1/chats/README.md index 7d891cca..0a5e096a 100644 --- a/src/backend/api/routes/v1/chats/README.md +++ b/src/backend/api/routes/v1/chats/README.md @@ -1,8 +1,8 @@ # Chat routes `__init__.py` composes the existing public router in its original order: static -collection paths precede session-id paths. `sessions`, `history`, `reruns`, -`run_views`, `sidebar`, `questions`, `confirms` and `feedback` handle HTTP. +collection paths precede session-id paths. `sessions`, `history`, `compaction`, +`reruns`, `run_views`, `sidebar`, `questions`, `confirms` and `feedback` handle HTTP. `pending_access`, `agent_targets`, `invocation`, `request_context`, `session_context`, `rerun_metadata` and `models` supply shared route operations. Long-running execution and SSE lifecycle remain owned by `core/chat/`. diff --git a/src/backend/api/routes/v1/chats/__init__.py b/src/backend/api/routes/v1/chats/__init__.py index 6520b45b..0e5ba10b 100644 --- a/src/backend/api/routes/v1/chats/__init__.py +++ b/src/backend/api/routes/v1/chats/__init__.py @@ -1,5 +1,6 @@ """Chat API composition; static collection routes precede session-id routes.""" +from api.routes.v1.chats import compaction as chat_compaction from api.routes.v1.chats import confirms as chat_confirms from api.routes.v1.chats import feedback as chat_feedback from api.routes.v1.chats import history as chat_history @@ -18,5 +19,6 @@ router.include_router(chat_run_views.router, prefix="/v1/chats") router.include_router(chat_feedback.router, prefix="/v1/chats") router.include_router(chat_history.router, prefix="/v1/chats") +router.include_router(chat_compaction.router, prefix="/v1/chats") router.include_router(chat_reruns.router, prefix="/v1/chats") router.include_router(chat_sessions.router, prefix="/v1/chats") diff --git a/src/backend/api/routes/v1/chats/compaction.py b/src/backend/api/routes/v1/chats/compaction.py new file mode 100644 index 00000000..f2f8e3bd --- /dev/null +++ b/src/backend/api/routes/v1/chats/compaction.py @@ -0,0 +1,69 @@ +"""Manual context compaction for one chat (the CLI's ``/compact``).""" + +from typing import Any, Dict + +import core.auth.backend as auth_backend +import core.chat.context as chat_context +import core.chat.operations.session_context as chat_session_context +import core.runs.runtime as runtime +from core.auth.backend import UserContext +from core.chat.compaction.manual import compact_chat_now, manual_budget_inputs +from core.chat.operations.admission_errors import chat_busy_http_exception +from core.chat.sequencer import ActiveRunRef, ChatBusyError +from core.chat.service import ChatService +from core.db.threads import run_in_session +from core.infra.exceptions import BadRequestError, ResourceNotFoundError, ServiceUnavailableError +from core.infra.responses import success_response +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +router = APIRouter() + + +def _prepare(db: Session, chat_id: str, auth_user_id: str) -> Dict[str, Any]: + """Owner check + live-run check; returns the coordinator's budget inputs.""" + user_id = chat_context.resolve_db_user_id(db, auth_user_id) + chat_service = ChatService(db) + if chat_service.get_session(chat_id, user_id) is None: + raise ResourceNotFoundError(resource_type="chat_session", resource_id=chat_id) + # Same query as GET /{chat_id}/active-run: while a run writes the history, + # its own mid-/post-turn compaction owns the checkpoint. + run = runtime.get_active_run_for_chat(chat_id, user_id) + if run is not None: + raise chat_busy_http_exception( + ChatBusyError(ActiveRunRef(run.run_id, run.message_id or "", run.status)) + ) + return manual_budget_inputs(chat_service, chat_id) + + +@router.post("/{chat_id}/compact", summary="立即压缩会话上下文") +async def compact_chat( + chat_id: str, + user: UserContext = Depends(auth_backend.get_current_user), +): + """把这段会话较早的历史压缩成摘要,下一轮从压缩后的上下文继续。 + + 与自动压缩走同一条流水线(快照水位 + 租约 + CAS 发布检查点)。历史全部落在 + 保留原文的尾部时没有可压缩的内容,返回 ``compacted=false``;会话有进行中的 + run 时返回 409。摘要要调用模型,可能耗时数十秒。 + """ + budget_inputs = await run_in_session( + _prepare, chat_id, chat_session_context.authenticated_user_id(user) + ) + result = await compact_chat_now(chat_id, budget_inputs) + if result.outcome == "disabled": + raise BadRequestError("上下文压缩已被管理员关闭,无法手动压缩") + if result.outcome == "busy": + raise HTTPException( + status_code=409, + detail={"code": "compaction_busy", "message": "这段会话正在压缩,请稍后再试"}, + ) + if result.outcome == "failed": + raise ServiceUnavailableError("上下文压缩失败:摘要模型未返回结果或检查点写入失败,请稍后重试") + return success_response( + data={ + "compacted": result.outcome == "compacted", + "tokens_before": result.tokens_before, + "tokens_after": result.tokens_after, + } + ) diff --git a/src/backend/api/routes/v1/projects.py b/src/backend/api/routes/v1/projects.py index 5a483f90..1ec865de 100644 --- a/src/backend/api/routes/v1/projects.py +++ b/src/backend/api/routes/v1/projects.py @@ -6,6 +6,7 @@ GET /v1/projects 列表(个人) POST /v1/projects 创建(仅 kind=personal) + POST /v1/projects/local/resolve 按本机目录解析 / 新建本地项目(仅本机模式) GET /v1/projects/{id} 详情(带 folder_name / 容量) PATCH /v1/projects/{id} 改名 / 描述 / pin / icon / instructions DELETE /v1/projects/{id} 软删(不删挂钩文件夹) @@ -144,6 +145,38 @@ def create_project( return created_response(data=svc.get(project.project_id, user_id)) +class ResolveLocalProjectBody(BaseModel): + path: str = Field(..., min_length=1, max_length=4096) + create: bool = True + name: Optional[str] = Field(None, min_length=1, max_length=120) + # Also grant the folder read-write to the OS sandbox (the terminal client works in it). + grant: bool = False + + +@router.post("/local/resolve", summary="按本机目录解析本地项目") +def resolve_local_project( + body: ResolveLocalProjectBody, + user: UserContext = Depends(get_current_user), + db: Session = Depends(get_db), +): + """终端客户端用:按 realpath 匹配当前用户的本地项目,不存在且 create=true 时新建。 + + 非本机模式返回 404,云端 / Web 部署不暴露该端点。 + """ + if not local_mode_enabled(): + raise HTTPException(status_code=404, detail="Not Found") + from core.projects.local_resolve import resolve_local_project as operation + + data = operation( + db, ProjectService(db), str(user.user_id), body.path, create=body.create, name=body.name + ) + if body.grant: + from features.desktop.local.grants import ensure_workspace_grant + + ensure_workspace_grant(data["local_path"]) + return success_response(data=data) + + # ── Detail / Update / Delete ───────────────────────────────────────────── diff --git a/src/backend/cli.py b/src/backend/cli.py index 585df498..0812a73e 100644 --- a/src/backend/cli.py +++ b/src/backend/cli.py @@ -1,12 +1,16 @@ -"""HugAgentOS no-Docker local CLI (``hugagent`` console entry). +"""HugAgentOS no-Docker local server CLI (``hugagent-server`` console entry). hermes-agent-style quick install: one process, SQLite, in-process event log and ephemeral state, subprocess MCP + sandbox — zero Docker / Postgres / Redis. - hugagent # not initialized → onboard; else serve + open browser - hugagent onboard # first-run wizard (admin account → model → serve); re-runnable - hugagent serve # start the server - hugagent doctor # environment self-check + hugagent-server # not initialized → onboard; else serve + open browser + hugagent-server onboard # first-run wizard (admin account → model → serve); re-runnable + hugagent-server serve # start the server + hugagent-server doctor # environment self-check + hugagent-server engine # headless loopback engine for the `hugagent` terminal client + +The ``hugagent`` command itself belongs to the terminal client (npm package +``packages/cli``), which forwards ``serve`` / ``onboard`` / ``doctor`` here. Data lives under ``~/.hugagent/`` (override with ``HUGAGENT_HOME``): ``data.db`` (SQLite), ``storage/``, ``workspace/`` (sandbox), ``logs/``, ``config.env``. @@ -19,6 +23,8 @@ from __future__ import annotations import argparse +import contextlib +import json import os import socket import sys @@ -157,7 +163,7 @@ def apply_local_env(port: int) -> dict: # A local/desktop install is expected to be useful immediately after a # zero-state boot. Keep the three credential-free first-party plugins # as the local-profile default even when the caller is plain - # ``hugagent serve`` rather than one of the installer wrappers. + # ``hugagent-server serve`` rather than one of the installer wrappers. "HUGAGENT_BOOTSTRAP_DEFAULT_PLUGINS": "1", "STORAGE_TYPE": "local", "STORAGE_PATH": str(dd / "storage"), @@ -482,14 +488,15 @@ def configure_file_parser( updated_by="onboard", ) -# ── Internet search (Tavily / Baidu / LangSearch key) ──────────────────────── +# ── Internet search (Tavily / Baidu / LangSearch / DeepSeek key) ───────────── def configure_search_engine(engine: str, api_key: str) -> None: """Write the internet-search engine + key into system_configs. Same path as file parser: the DB is the single source of truth (env - TAVILY_API_KEY/BAIDU_API_KEY/LANGSEARCH_API_KEY only as fallback); after - writing, the MCP subprocess picks up the new key via runtime_env. It can + TAVILY_API_KEY/BAIDU_API_KEY/LANGSEARCH_API_KEY/DEEPSEEK_SEARCH_API_KEY only + as fallback); after writing, the MCP subprocess picks up the new key via + runtime_env. It can also be changed later in the Web UI under Settings -> System Management. """ from core.config.store.system_config import SystemConfigService @@ -500,6 +507,7 @@ def configure_search_engine(engine: str, api_key: str) -> None: "tavily": "tavily_api_key", "baidu": "baidu_api_key", "langsearch": "langsearch_api_key", + "deepseek": "deepseek_api_key", } if engine not in key_fields: raise ValueError(f"不支持的互联网搜索引擎: {engine}") @@ -669,7 +677,7 @@ def cmd_onboard(args) -> int: except Exception as exc: print(f"✗ 模型配置失败:{exc}", file=sys.stderr) if not args.model_base_url: # interactive: let them retry later - print(" 可稍后运行 `hugagent onboard` 重配。") + print(" 可稍后运行 `hugagent-server onboard` 重配。") return 1 # Step 2b (optional) — index/embedding model. Enables the self-built vector @@ -735,14 +743,14 @@ def cmd_onboard(args) -> int: except Exception as exc: # noqa: BLE001 print(f"! 文件解析配置失败:{exc}", file=sys.stderr) - # Step 5 — internet search (Tavily / Baidu / LangSearch key). Optional. + # Step 5 — internet search (Tavily / Baidu / LangSearch / DeepSeek key). Optional. search_key = args.search_api_key search_engine = (args.search_engine or "tavily").strip().lower() if search_key is None and interactive: print("\n[可选] 配置互联网搜索(智能体联网检索用;直接回车跳过)") - print(" 支持 tavily(默认)/ baidu(千帆 AppBuilder)/ langsearch。") - eng = _prompt(" 搜索引擎 (tavily/baidu/langsearch)", "tavily").strip().lower() - search_engine = eng if eng in ("tavily", "baidu", "langsearch") else "tavily" + print(" 支持 tavily(默认)/ baidu(千帆 AppBuilder)/ langsearch / deepseek。") + eng = _prompt(" 搜索引擎 (tavily/baidu/langsearch/deepseek)", "tavily").strip().lower() + search_engine = eng if eng in ("tavily", "baidu", "langsearch", "deepseek") else "tavily" search_key = _prompt(" API Key(留空跳过)", "") if search_key: try: @@ -756,7 +764,7 @@ def cmd_onboard(args) -> int: print("\n✓ 初始化完成。", end=" ") if args.no_serve: - print("运行 `hugagent` 启动服务。") + print("运行 `hugagent-server` 启动服务。") return 0 print("正在启动服务…\n") return cmd_serve(args) @@ -820,6 +828,369 @@ def cmd_serve(args) -> int: uvicorn.run(app, host=host, port=port, log_level="info", access_log=False) return 0 +# ── Engine (headless loopback backend for the `hugagent` terminal client) ──── +# +# Contract: packages/cli/DESIGN.md §8.1. The terminal client finds a running +# engine through the ready file and talks to it over plain HTTP + SSE with a +# personal API key, exactly like a remote server. + +ENGINE_KEY_NAME = "hugagent-cli" +ENGINE_READY_VERSION = 1 +EXIT_NOT_INITIALIZED = 3 +# How long a second `engine` waits for the one already starting (the client +# itself gives up after 120 s), and how long startup may take to become ready. +_ENGINE_WAIT_SECONDS = 120.0 +_ENGINE_READY_TIMEOUT_SECONDS = 300.0 + +def _engine_error(code: str, **extra) -> None: + print(json.dumps({"error": code, **extra}, ensure_ascii=False), file=sys.stderr) + +def _process_alive(pid: int) -> bool: + if pid <= 0: + return False + if os.name == "nt": + # os.kill(pid, 0) is not a probe on Windows: signal 0 is CTRL_C_EVENT. + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION + if not handle: + return False + try: + code = ctypes.c_ulong() + return bool(kernel32.GetExitCodeProcess(handle, ctypes.byref(code))) and ( + code.value == 259 # STILL_ACTIVE + ) + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + +def _loopback_get(url: str, token: str = "", timeout: float = 2.0) -> int: + """Status code of a GET to a loopback URL (0 when unreachable), never via a proxy.""" + from urllib.error import HTTPError + from urllib.request import ProxyHandler, Request, build_opener + + headers = {"Authorization": f"Bearer {token}"} if token else {} + try: + with build_opener(ProxyHandler({})).open(Request(url, headers=headers), timeout=timeout) as r: + return r.status + except HTTPError as exc: + return exc.code + except (OSError, ValueError): + return 0 + +def _read_live_engine(ready_file: Path, home: Path) -> Optional[dict]: + """The ready file's contents when it describes a live engine for ``home``.""" + try: + info = json.loads(ready_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(info, dict) or info.get("version") != ENGINE_READY_VERSION: + return None + url, pid = info.get("url"), info.get("pid") + if ( + not isinstance(url, str) + or not url.startswith("http://127.0.0.1:") + or not isinstance(pid, int) + or info.get("home") != str(home) + or not _process_alive(pid) + ): + return None + return info if _loopback_get(url + "/health") == 200 else None + +def _write_ready_file(path: Path, payload: dict) -> None: + """Atomically publish ``payload`` as a 0600 JSON file (it holds a credential).""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + fd = os.open(temporary, flags, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(temporary) + raise + +def _remove_ready_file(path: Path) -> None: + """Delete the ready file, but only while it still describes this process.""" + try: + if json.loads(path.read_text(encoding="utf-8")).get("pid") != os.getpid(): + return + path.unlink() + except (OSError, ValueError, AttributeError): + pass + +def _try_engine_lock(path: Path): + """Hold ``path`` exclusively until the returned stack closes; ``None`` when taken.""" + from core.infra.file_lock import LockTimeout, locked + + stack = contextlib.ExitStack() + try: + stack.enter_context(locked(path, timeout=0)) + except LockTimeout: + return None + return stack + +def _engine_admin_user_id() -> Optional[str]: + """The active local super_admin the engine acts as (oldest first).""" + from core.db.engine import SessionLocal + from core.db.models import LocalUser, UserShadow + + db = SessionLocal() + try: + rows = ( + db.query(UserShadow) + .join(LocalUser, LocalUser.user_id == UserShadow.user_id) + .filter(LocalUser.status == "active") + .order_by(LocalUser.created_at) + .all() + ) + for row in rows: + if (row.extra_data or {}).get("role") == "super_admin": + return str(row.user_id) + return None + finally: + db.close() + +def issue_engine_token(user_id: str) -> str: + """Mint this start's ``hugagent-cli`` personal key and revoke the earlier ones. + + Personal keys only authenticate when the owner may use API keys, and the + local admin has no such flag until something grants it. Grant it when it + was never set; an explicit ``False`` is the owner's choice and is refused. + Call it after importing ``api.app``: revoking a key cancels the runs it + started, which needs the run services the app composes. + """ + from core.auth.api_keys import ApiKeyService + from core.auth.users import UserService + from core.db.engine import SessionLocal + from core.db.models import UserShadow + + db = SessionLocal() + try: + shadow = db.query(UserShadow).filter(UserShadow.user_id == user_id).one() + allowed = (shadow.extra_data or {}).get("can_use_api_key") + if allowed is False: + raise PermissionError("api_key_disabled") + if allowed is not True: + UserService(db).update_user_metadata(user_id, {"can_use_api_key": True}) + service = ApiKeyService(db) + for key in service.list_keys(user_id): + if key.name == ENGINE_KEY_NAME: + service.revoke_key(user_id, key.id) + _row, raw = service.create_key(user_id, ENGINE_KEY_NAME) + return raw + finally: + db.close() + +class _ActivityTracker: + """ASGI wrapper recording when the last HTTP/WebSocket request ended. + + A request in flight (an open SSE stream included) counts as activity for + as long as it lasts. ``on_shutdown`` runs when lifespan shutdown begins: + uvicorn re-raises SIGTERM once it has shut down, so code after + ``server.run`` never sees that path. + """ + + def __init__(self, app, on_shutdown=None) -> None: + self.app = app + self.on_shutdown = on_shutdown + self.in_flight = 0 + self.last_activity = time.monotonic() + + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan" and self.on_shutdown is not None: + async def watched_receive(): + message = await receive() + if message.get("type") == "lifespan.shutdown": + self.on_shutdown() + return message + + return await self.app(scope, watched_receive, send) + if scope["type"] not in ("http", "websocket"): + return await self.app(scope, receive, send) + self.in_flight += 1 + self.last_activity = time.monotonic() + try: + return await self.app(scope, receive, send) + finally: + self.in_flight -= 1 + self.last_activity = time.monotonic() + + def idle_for(self) -> float: + return 0.0 if self.in_flight else time.monotonic() - self.last_activity + +def _wait_for_other_engine(ready_file: Path, home: Path, lock_path: Path): + """Another engine holds the lock: wait for its ready file or for the lock.""" + deadline = time.monotonic() + _ENGINE_WAIT_SECONDS + while time.monotonic() < deadline: + live = _read_live_engine(ready_file, home) + if live: + return live, None + lock = _try_engine_lock(lock_path) + if lock is not None: + return None, lock + time.sleep(0.5) + return None, None + +def _engine_monitor(server, tracker, *, ready_file: Path, payload: dict, + idle_timeout: float, stopped: threading.Event, outcome: dict) -> None: + """Publish the ready file once the engine serves, then exit it when idle.""" + deadline = time.monotonic() + _ENGINE_READY_TIMEOUT_SECONDS + while not server.started: + if stopped.is_set(): + return + if time.monotonic() > deadline: + outcome["error"] = "startup_timeout" + server.should_exit = True + return + time.sleep(0.1) + # An authenticated route proves both that the engine serves and that the + # ready file never advertises a credential the engine would refuse. + url = payload["url"] + status = _loopback_get(url + "/v1/chats?page=1&page_size=1", payload["token"], 30) + if status != 200: + outcome["error"] = f"token_check_failed:{status}" + server.should_exit = True + return + _write_ready_file(ready_file, payload) + tracker.last_activity = time.monotonic() + _status(f"HugAgentOS engine ready at {url} (pid {os.getpid()}, ready file {ready_file})") + if idle_timeout <= 0: + return + from core.runs.runtime import has_local_runs + + while not stopped.wait(min(5.0, max(0.5, idle_timeout / 4))): + if tracker.idle_for() >= idle_timeout and not has_local_runs(): + _status(f"HugAgentOS engine idle for {int(idle_timeout)}s, shutting down") + # Withdraw the file first so no client picks a stopping engine. + _remove_ready_file(ready_file) + server.should_exit = True + return + +def cmd_engine(args) -> int: + from datetime import datetime, timezone + + started_at = datetime.now(timezone.utc).isoformat(timespec="seconds") + home = data_dir().resolve() + ready_file = ( + Path(args.ready_file).expanduser().resolve() if args.ready_file else home / "engine.json" + ) + live = _read_live_engine(ready_file, home) + if live: + print(json.dumps(live, ensure_ascii=False)) + return 0 + if not _is_initialized(): + _engine_error("not_initialized") + return EXIT_NOT_INITIALIZED + + lock_path = home / "engine.lock" + lock = _try_engine_lock(lock_path) + if lock is None: + live, lock = _wait_for_other_engine(ready_file, home, lock_path) + if live: + print(json.dumps(live, ensure_ascii=False)) + return 0 + if lock is None: + _engine_error("engine_busy", lock=str(lock_path)) + return 1 + try: + return _run_engine(args, home=home, ready_file=ready_file, started_at=started_at) + finally: + lock.close() + +def _run_engine(args, *, home: Path, ready_file: Path, started_at: str) -> int: + # Own the listening socket before anything reads PORT: with --port 0 the + # kernel picks the port, and BACKEND_INTERNAL_URL must name the real one. + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", int(args.port))) + except OSError as exc: + sock.close() + _engine_error("port_unavailable", detail=str(exc)) + return 1 + port = sock.getsockname()[1] + url = f"http://127.0.0.1:{port}" + # An inherited PORT (or a desktop BACKEND_INTERNAL_URL) must not win here. + os.environ["PORT"] = os.environ["BACKEND_PORT"] = str(port) + os.environ["BACKEND_INTERNAL_URL"] = url + # Headless: point the static hosting at nothing so only the API is served. + os.environ["FRONTEND_DIST_DIR"] = os.devnull + try: + apply_local_env(port) + from hugagent_cli.schema import ensure_schema_and_seed + + ensure_schema_and_seed() + bootstrap_default_plugins_if_needed() + user_id = _engine_admin_user_id() + if user_id is None: + _engine_error("not_initialized") + return EXIT_NOT_INITIALIZED + import uvicorn + # Importing the app composes its services; revoking the previous token + # cancels the runs it started, which needs the run services in place. + from api.app import app + + try: + token = issue_engine_token(user_id) + except PermissionError as exc: + _engine_error(str(exc)) + return 1 + + tracker = _ActivityTracker(app, on_shutdown=lambda: _remove_ready_file(ready_file)) + server = uvicorn.Server( + uvicorn.Config(tracker, log_level="info", access_log=False, lifespan="on") + ) + payload = { + "version": ENGINE_READY_VERSION, + "url": url, + "token": token, + "pid": os.getpid(), + "started_at": started_at, + "home": str(home), + } + stopped = threading.Event() + outcome: dict = {} + monitor = threading.Thread( + target=_engine_monitor, + args=(server, tracker), + kwargs=dict( + ready_file=ready_file, + payload=payload, + idle_timeout=float(args.idle_timeout), + stopped=stopped, + outcome=outcome, + ), + name="engine-monitor", + daemon=True, + ) + monitor.start() + try: + # uvicorn's own SIGINT/SIGTERM handling gives the graceful + # shutdown path: lifespan shutdown stops the sidecars. + server.run(sockets=[sock]) + finally: + stopped.set() + _remove_ready_file(ready_file) + if outcome.get("error"): + _engine_error(outcome["error"]) + return 1 + return 0 if server.started else 1 + finally: + sock.close() + # ── Doctor ──────────────────────────────────────────────────────────────────── def cmd_doctor(args) -> int: @@ -885,7 +1256,7 @@ def check(label: str, passed: bool, detail: str = "", *, required: bool = True) check( "已初始化(存在管理员)", _is_initialized(), - "" if _is_initialized() else "运行 `hugagent onboard`", + "" if _is_initialized() else "运行 `hugagent-server onboard`", ) print("─" * 40) @@ -896,7 +1267,7 @@ def check(label: str, passed: bool, detail: str = "", *, required: bool = True) def build_parser() -> argparse.ArgumentParser: # Shared options usable both before and after the subcommand - # (`hugagent --host X --port Y serve` and options after `serve` both work). + # (`hugagent-server --host X --port Y serve` and options after `serve` both work). common = argparse.ArgumentParser(add_help=False) common.add_argument( "--host", @@ -906,7 +1277,7 @@ def build_parser() -> argparse.ArgumentParser: common.add_argument("--port", type=int, default=int(os.getenv("PORT", "3001"))) p = argparse.ArgumentParser( - prog="hugagent", parents=[common], description="HugAgentOS 无 Docker 本地版" + prog="hugagent-server", parents=[common], description="HugAgentOS 无 Docker 本地版" ) sub = p.add_subparsers(dest="command") @@ -934,7 +1305,7 @@ def build_parser() -> argparse.ArgumentParser: po.add_argument("--file-parser-url", help="PDF/文档解析服务 API URL(可选)") po.add_argument( "--search-engine", - choices=["tavily", "baidu", "langsearch"], + choices=["tavily", "baidu", "langsearch", "deepseek"], help="互联网搜索引擎(可选,默认 tavily)", ) po.add_argument("--search-api-key", help="互联网搜索 API Key(可选)") @@ -948,6 +1319,18 @@ def build_parser() -> argparse.ArgumentParser: ps.set_defaults(func=cmd_serve) sub.add_parser("doctor", parents=[common], help="环境自检").set_defaults(func=cmd_doctor) + + # No `common` parent: the engine always binds 127.0.0.1 and picks its port. + pe = sub.add_parser("engine", help="无头本机引擎(供 hugagent 终端客户端使用)") + pe.add_argument("--port", type=int, default=0, help="监听端口(默认 0:自动挑选空闲端口)") + pe.add_argument( + "--idle-timeout", + type=float, + default=900, + help="无请求且无运行中会话多少秒后自动退出(默认 900;0 表示不自动退出)", + ) + pe.add_argument("--ready-file", help="就绪文件路径(默认 $HUGAGENT_HOME/engine.json)") + pe.set_defaults(func=cmd_engine) return p def main(argv: Optional[list] = None) -> int: @@ -955,7 +1338,7 @@ def main(argv: Optional[list] = None) -> int: parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "command", None): - # bare `hugagent`: onboard if fresh, else serve. + # bare `hugagent-server`: onboard if fresh, else serve. args.func = cmd_serve if _is_initialized() else cmd_onboard for attr, val in ( ("username", None), diff --git a/src/backend/core/agent_runtime/stream_direct.py b/src/backend/core/agent_runtime/stream_direct.py index 229a639b..65c5528a 100644 --- a/src/backend/core/agent_runtime/stream_direct.py +++ b/src/backend/core/agent_runtime/stream_direct.py @@ -180,7 +180,12 @@ async def stream_direct(turn: DirectTurn): except json.JSONDecodeError: tool_result_json = {"result": tool_content} if isinstance(payload.get("mcp"), dict): - tool_result_json = payload["mcp"] + tool_result_json = tool_payloads._build_mcp_result_payload( + payload["mcp"], + payload.get("mcp_output_schema"), + tool_content, + tool_result_json, + ) # Skill load: replace the full SKILL.md text with the same # curated detail used by the capability center; affects diff --git a/src/backend/core/agent_runtime/stream_main.py b/src/backend/core/agent_runtime/stream_main.py index 84916e9e..340492cc 100644 --- a/src/backend/core/agent_runtime/stream_main.py +++ b/src/backend/core/agent_runtime/stream_main.py @@ -226,7 +226,12 @@ async def stream_main(turn: MainTurn): except json.JSONDecodeError: tool_result_json = {"result": tool_content} if isinstance(payload.get("mcp"), dict): - tool_result_json = payload["mcp"] + tool_result_json = tool_payloads._build_mcp_result_payload( + payload["mcp"], + payload.get("mcp_output_schema"), + tool_content, + tool_result_json, + ) # Skill load: replace the full SKILL.md text with the same curated detail used by the capability center if is_skill_result: diff --git a/src/backend/core/chat/compaction/coordinator.py b/src/backend/core/chat/compaction/coordinator.py index b3a2a02b..14ef97ab 100644 --- a/src/backend/core/chat/compaction/coordinator.py +++ b/src/backend/core/chat/compaction/coordinator.py @@ -39,6 +39,12 @@ def __init__(self, chat_id: str, *, run_id: str = "", hook_bus: Any = None): self.chat_id = chat_id self.run_id = run_id self.owner = f"compactor:{uuid.uuid4().hex}" + # Outcome of the last ``compact`` call. ``None`` from ``compact`` means + # both "nothing to do" and "failed"; the manual endpoint must tell them + # apart. One of: compacted / nothing / busy / failed. + self.outcome = "" + self.tokens_before: Optional[int] = None + self.tokens_after: Optional[int] = None if hook_bus is not None: self.hook_bus = hook_bus elif run_id: @@ -111,6 +117,8 @@ async def compact( the agent may contain messages not yet visible in the DB snapshot. """ + self.outcome = "failed" + self.tokens_before = self.tokens_after = None acquire_task = asyncio.create_task(asyncio.to_thread(self._acquire)) try: snapshot = await asyncio.shield(acquire_task) @@ -150,6 +158,7 @@ async def compact( return await summary_model.summarize_without_checkpoint( list(in_memory_history), chat_id=self.chat_id, run_id=self.run_id ) + self.outcome = "busy" await report_outcome(self.chat_id, self.run_id, phase.value, "skipped", "busy") return None @@ -168,6 +177,7 @@ async def compact( else persisted ) if not history: + self.outcome = "nothing" return None inputs = dict(budget_inputs or {}) if self.hook_bus is not None: @@ -198,7 +208,9 @@ async def compact( measured_tokens = budget.resolve_snapshot_active_tokens(snapshot, inputs, -1) if measured_tokens > 0: active_tokens = measured_tokens + self.tokens_before = active_tokens if token_limit is not None and not budget.should_compact(active_tokens, token_limit): + self.outcome = "nothing" return None cfg = config_settings.settings.compaction @@ -213,6 +225,7 @@ async def compact( self.chat_id, len(recent), ) + self.outcome = "nothing" return None progress = await start_progress(self.chat_id, self.run_id, phase.value) from core.models.usage import model_usage_scope @@ -256,6 +269,7 @@ async def compact( from core.llm.context.usage import build_compaction_context_usage manifest["replacement_budget_estimate"] = replacement_estimate + self.tokens_after = replacement_estimate["total_estimated_tokens"] manifest["replacement_context_usage"] = build_compaction_context_usage( replacement_estimate, context_window=int(inputs.get("context_window") or 0), @@ -286,6 +300,7 @@ async def compact( ) raise cancellation published = True + self.outcome = "compacted" except Exception as exc: # noqa: BLE001 from core.harness.hooks import HookPaused diff --git a/src/backend/core/chat/compaction/manual.py b/src/backend/core/chat/compaction/manual.py new file mode 100644 index 00000000..50546971 --- /dev/null +++ b/src/backend/core/chat/compaction/manual.py @@ -0,0 +1,68 @@ +"""On-demand compaction between turns (``POST /v1/chats/{chat_id}/compact``).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from core.config import settings as config_settings +from core.db.threads import run_in_session +from core.llm.context import compaction as C +from core.chat.compaction.coordinator import CompactionCoordinator +from core.chat.compaction.usage_projection import get_chat_context_state + +_ATTRIBUTION_KEYS = ("context_window", "model_name", "model_provider_id") + + +@dataclass(frozen=True) +class ManualCompaction: + """``outcome``: the coordinator's (compacted / nothing / busy / failed) or disabled.""" + + outcome: str + tokens_before: Optional[int] = None + tokens_after: Optional[int] = None + + +def manual_budget_inputs(chat_service: Any, chat_id: str) -> Dict[str, Any]: + """Attribute the checkpoint to the model the chat last measured. + + With the same window / model / provider as the last provider measurement, + the coordinator reuses that exact token count as ``tokens_before`` instead + of an estimate, and the context meter keeps its window after compaction. + """ + usage = get_chat_context_state(chat_service, chat_id).get("context_usage") or {} + return {key: usage[key] for key in _ATTRIBUTION_KEYS if usage.get(key)} + + +def _lease_is_live(db: Any, chat_id: str) -> bool: + from core.chat.compaction_snapshots import compaction_lease_held + from core.db.models import ChatCompactionState + + return compaction_lease_held(db.get(ChatCompactionState, chat_id)) + + +async def compact_chat_now(chat_id: str, budget_inputs: Dict[str, Any]) -> ManualCompaction: + """Compact through the shared coordinator, regardless of the trigger ratio. + + Runs as the post-turn phase: the caller has already checked that no run is + live, so the persisted snapshot is the authoritative history — exactly the + post-turn situation. No token limit is passed, so anything older than the + verbatim tail is summarized; a history that fits in the tail is "nothing". + The checkpoint sets ``notice_pending`` like any other, so the next turn + emits the usual ``compaction_notice``. + """ + if not config_settings.settings.compaction.enabled: + return ManualCompaction(outcome="disabled") + coordinator = CompactionCoordinator(chat_id) + await coordinator.compact(phase=C.CompactionPhase.POST_TURN, budget_inputs=dict(budget_inputs)) + outcome = coordinator.outcome + # The snapshot is also refused when no row is newer than the checkpoint + # (an empty chat, or ``/compact`` twice in a row). That is nothing to do, + # not a competing compactor. + if outcome == "busy" and not await run_in_session(_lease_is_live, chat_id): + outcome = "nothing" + return ManualCompaction( + outcome=outcome, + tokens_before=coordinator.tokens_before, + tokens_after=coordinator.tokens_after, + ) diff --git a/src/backend/core/chat/compaction_snapshots.py b/src/backend/core/chat/compaction_snapshots.py index 2b5eaa46..828525b8 100644 --- a/src/backend/core/chat/compaction_snapshots.py +++ b/src/backend/core/chat/compaction_snapshots.py @@ -10,6 +10,14 @@ from core.infra.time import as_utc, utc_now logger = logging.getLogger(__name__) + +def compaction_lease_held(state) -> bool: + """Whether another compactor holds the chat's compaction lease right now.""" + if state is None or not state.lease_owner: + return False + now = utc_now() + return (as_utc(state.lease_expires_at) or now) > now + def ensure_message_sequences(self, chat_id: str) -> None: """Repair legacy/direct ORM rows before they enter a source snapshot.""" from sqlalchemy import func @@ -123,10 +131,10 @@ def acquire_compaction_snapshot_impl( self._ensure_message_sequences(chat_id) state = self._ensure_compaction_state(chat_id) - now = utc_now() - if state.lease_owner and (as_utc(state.lease_expires_at) or now) > now: + if compaction_lease_held(state): self.db.rollback() return None + now = utc_now() checkpoint = ( self.db.get(ChatMessage, state.active_checkpoint_id) diff --git a/src/backend/core/chat/operations/schemas.py b/src/backend/core/chat/operations/schemas.py index e19d93d2..f354f9b8 100644 --- a/src/backend/core/chat/operations/schemas.py +++ b/src/backend/core/chat/operations/schemas.py @@ -2,7 +2,7 @@ from typing import List, Optional -from core.chat.schemas import ChatMode +from core.chat.schemas import USER_MESSAGE_MAX, ChatMode from core.llm.tools.user_questions import MAX_QUESTIONS as USER_QUESTION_MAX from pydantic import BaseModel, Field @@ -73,7 +73,10 @@ class EditAndResendRequest(RerunOptions): message_index: int = Field(..., description="0-based index of the user message in the chat") new_content: str = Field( - ..., min_length=1, max_length=10000, description="New content for the user message" + ..., + min_length=1, + max_length=USER_MESSAGE_MAX, + description="New content for the user message", ) @@ -97,6 +100,8 @@ class FileConfirmBody(BaseModel): class SteerChatRunRequest(BaseModel): steer_id: str = Field(..., min_length=1, max_length=64, description="前端待发送卡片 ID") + # Bounded by chat_steer_queue.message (CHECK length <= 10000, see + # core/chat/steer_queue.py); raising it needs a schema migration first. message: str = Field(..., min_length=1, max_length=10000, description="追加给当前 run 的指令") delivery_mode: Literal["steer", "followUp", "nextRun"] = "steer" replace_latest: bool = True diff --git a/src/backend/core/chat/schemas.py b/src/backend/core/chat/schemas.py index 47bc1896..5cc8ac6a 100644 --- a/src/backend/core/chat/schemas.py +++ b/src/backend/core/chat/schemas.py @@ -2,11 +2,16 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union +from core.chat.limits import CONTENT_MAX from core.config.settings import DEFAULT_CHAT_MODEL_ALIAS from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator ChatMode = Literal["turbo", "fast", "low", "medium", "high", "xhigh", "max"] +# A user message is stored verbatim in chat_messages.content, whose CHECK +# constraint is CONTENT_MAX characters; accepting more would be clamped on write. +USER_MESSAGE_MAX = CONTENT_MAX + class AttachmentItem(BaseModel): """单个文件附件""" @@ -73,7 +78,9 @@ class ChatRequest(BaseModel): _resolved_plugin_mcp_ids: List[str] = PrivateAttr(default_factory=list) chat_id: str = Field(..., description="会话ID,用于维持对话上下文", max_length=100) - message: str = Field(..., description="用户消息内容", min_length=1, max_length=10000) + message: str = Field( + ..., description="用户消息内容", min_length=1, max_length=USER_MESSAGE_MAX + ) stream: bool = Field(False, strict=True, description="true 返回 SSE 事件流;false 一次返回完整 JSON 回复") model_name: Optional[str] = Field( DEFAULT_CHAT_MODEL_ALIAS, description="使用的模型名称(qwen/deepseek)", max_length=50 diff --git a/src/backend/core/chat/tool_payloads.py b/src/backend/core/chat/tool_payloads.py index 462efd6d..f2863091 100644 --- a/src/backend/core/chat/tool_payloads.py +++ b/src/backend/core/chat/tool_payloads.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json from typing import Any, Dict from core.capabilities.catalog.details import get_skill_curated_detail @@ -29,6 +30,53 @@ def _truncate_preview(text: str) -> str: return snippet +def _sdk_wrapped_output(output_schema: Any) -> bool: + """Whether the MCP SDK wrapped a non-object return as ``{"result": ...}``. + + The official SDK names that model ``Output`` with a single required + ``result``; FastMCP marks it with ``x-fastmcp-wrap-result``. + """ + if not isinstance(output_schema, dict): + return False + if output_schema.get("x-fastmcp-wrap-result") is True: + return True + properties = output_schema.get("properties") + return ( + isinstance(properties, dict) + and set(properties) == {"result"} + and output_schema.get("required") == ["result"] + and str(output_schema.get("title") or "").endswith("Output") + ) + + +def _build_mcp_result_payload( + mcp_result: Dict[str, Any], output_schema: Any, tool_content: Any, parsed_content: Any +) -> Any: + """The MCP tool's own value for the card, never the CallToolResult envelope. + + Cards have always rendered the model-facing text: it carries the ``cite_id`` + anchors and the tool's own shape. ``structuredContent`` only stands in when + that text is not JSON (e.g. truncated), minus the SDK's ``{"result": ...}`` + wrapper so the shape still matches. A wrapped string stays wrapped, as the + text path would return it. + """ + structured = mcp_result.get("structuredContent") + if structured is None: + return parsed_content + try: + json.loads(tool_content) + return parsed_content + except (TypeError, ValueError): + pass + if ( + _sdk_wrapped_output(output_schema) + and isinstance(structured, dict) + and isinstance(structured.get("result"), (dict, list)) + ): + return structured["result"] + return structured + + def _build_view_text_file_payload(tool_args: Any, tool_content: Any) -> Dict[str, Any]: """Build the view_text_file SSE payload: send only file metadata + a short preview, avoiding pushing the whole file to the frontend.""" import os diff --git a/src/backend/core/config/store/system_config_constants.py b/src/backend/core/config/store/system_config_constants.py index 698d9458..1588799d 100644 --- a/src/backend/core/config/store/system_config_constants.py +++ b/src/backend/core/config/store/system_config_constants.py @@ -65,7 +65,7 @@ "internet_search.engine", "tavily", "搜索引擎", - "互联网搜索引擎 (tavily / baidu / langsearch)", + "互联网搜索引擎 (tavily / baidu / langsearch / deepseek)", "internet_search", False, ), @@ -93,6 +93,14 @@ "internet_search", True, ), + ( + "internet_search.deepseek_api_key", + None, + "DeepSeek Search API Key", + "DeepSeek Search API 服务密钥(DeepSeek 开放平台 API Key)", + "internet_search", + True, + ), # sandbox / code execution. The admin toggle is the **sole authority** (no env fallback): # explicitly defaults to "true"; admins can turn it off in "System config", which writes "false" and persists. ( @@ -288,6 +296,7 @@ "internet_search.tavily_api_key": "TAVILY_API_KEY", "internet_search.baidu_api_key": "BAIDU_API_KEY", "internet_search.langsearch_api_key": "LANGSEARCH_API_KEY", + "internet_search.deepseek_api_key": "DEEPSEEK_SEARCH_API_KEY", # Note: sandbox.code_capability_enable deliberately does **not** map to an env var —— the admin toggle is the sole # authority (plan B), explicitly seeded to "true", and env CODE_CAPABILITY_ENABLED is retired. **EDITION_CONFIG_KEY_TO_ENV, diff --git a/src/backend/core/infra/probes/service_probes.py b/src/backend/core/infra/probes/service_probes.py index 371428c5..f32cf7db 100644 --- a/src/backend/core/infra/probes/service_probes.py +++ b/src/backend/core/infra/probes/service_probes.py @@ -163,6 +163,41 @@ async def test_langsearch(api_key: str) -> dict: return {"success": False, "latency_ms": latency, "error": str(exc)} +DEEPSEEK_SEARCH_API_URL = "https://api.deepseek.com/v1/tools/search" + + +async def test_deepseek_search(api_key: str) -> dict: + """Test a DeepSeek API key with a minimal Search request.""" + start = time.monotonic() + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + DEEPSEEK_SEARCH_API_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={"query": "test", "count": 1}, + ) + latency = int((time.monotonic() - start) * 1000) + if resp.status_code != 200: + return { + "success": False, + "latency_ms": latency, + "error": f"HTTP {resp.status_code}: {resp.text[:200]}", + } + if isinstance(resp.json().get("results"), list): + return {"success": True, "latency_ms": latency, "error": None} + return { + "success": False, + "latency_ms": latency, + "error": "DeepSeek Search 响应缺少 results 数组", + } + except Exception as exc: + latency = int((time.monotonic() - start) * 1000) + return {"success": False, "latency_ms": latency, "error": str(exc)} + + async def test_service_group(group_key: str) -> dict: """Run one connectivity test for a group (reading the current SystemConfigService config). @@ -213,6 +248,11 @@ async def test_service_group(group_key: str) -> dict: if not api_key: return {"success": False, "error": "LangSearch API Key 未配置", "latency_ms": 0} return await test_langsearch(api_key) + if engine == "deepseek": + api_key = svc.get("internet_search.deepseek_api_key") + if not api_key: + return {"success": False, "error": "DeepSeek Search API Key 未配置", "latency_ms": 0} + return await test_deepseek_search(api_key) if engine == "tavily": api_key = svc.get("internet_search.tavily_api_key") if not api_key: diff --git a/src/backend/core/llm/runtime/stream_events.py b/src/backend/core/llm/runtime/stream_events.py index 123e0865..63dafba8 100644 --- a/src/backend/core/llm/runtime/stream_events.py +++ b/src/backend/core/llm/runtime/stream_events.py @@ -166,6 +166,9 @@ async def map_event( "content": content, "status": state, "mcp": (result_step["blocks"][0].get("metadata") or {}).get("mcp"), + "mcp_output_schema": (result_step["blocks"][0].get("metadata") or {}).get( + "output_schema" + ), }, ) return diff --git a/src/backend/core/llm/tool_permissions/service.py b/src/backend/core/llm/tool_permissions/service.py index 1a244cab..7c6fec80 100644 --- a/src/backend/core/llm/tool_permissions/service.py +++ b/src/backend/core/llm/tool_permissions/service.py @@ -323,6 +323,12 @@ async def _authorize_local_command(self, intent: PermissionIntent) -> dict[str, if blocked is not None: return {"payload": blocked, "reasons": verdict.reasons} + import asyncio + + from core.sandbox.permissions.os_gate import capability_skill_roots + + # Globbing the capability store is file I/O; keep it off the event loop. + skill_roots = await asyncio.to_thread(capability_skill_roots) return { "reasons": verdict.reasons, "local_command": self._local_command_authorization( @@ -331,6 +337,7 @@ async def _authorize_local_command(self, intent: PermissionIntent) -> dict[str, grants=grants, policy=policy, one_shot_write_targets=tuple(verdict.write_paths), + skill_roots=skill_roots, ), } @@ -360,6 +367,7 @@ def _local_command_authorization( grants: Sequence[Any], policy: Any, one_shot_write_targets: Sequence[str] = (), + skill_roots: Sequence[str] = (), ) -> LocalCommandAuthorization: """Turn this run's permission configuration into a sandbox scope. @@ -405,6 +413,10 @@ def _admit(candidate: str) -> None: from core.sandbox.permissions.os_gate import protected_read_paths + # Skill scripts live in the protected capability store; only their own + # directories are reopened, and only for reading. + readable.extend(skill_roots) + return LocalCommandAuthorization( command=command, approval_mode=approval_mode, diff --git a/src/backend/core/projects/instructions.py b/src/backend/core/projects/instructions.py index e415d61c..2160ffeb 100644 --- a/src/backend/core/projects/instructions.py +++ b/src/backend/core/projects/instructions.py @@ -98,12 +98,9 @@ def _read(self, project: Project, *, remember_file: bool) -> dict: if project.kind == "local": target = self._local_file(project) if target.exists(): - # O_NOFOLLOW prevents a symlink swapped in after the path check. - fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) - with os.fdopen(fd, "rb") as stream: - if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): - raise HTTPException(409, "AGENTS.md 必须是普通文件") - data = stream.read(MAX_INSTRUCTIONS_BYTES + 1) + data = _read_regular_file(target) + if data is None: + raise HTTPException(409, "AGENTS.md 必须是普通文件") else: from core.files.spaces.projects import source_key storage = get_storage() @@ -241,3 +238,78 @@ def read_authorized_project_instructions(project_id: str, user_id: str) -> dict: if project is None or resolve_project_permission(db, user_id, project) not in ("admin", "edit", "view"): raise HTTPException(403, "项目不存在或无权读取项目指令") return ProjectInstructionsService(db).read(project) + + +def _read_regular_file(target: Path) -> bytes | None: + """Up to the size limit plus one byte of a regular file; ``None`` for anything else. + + O_NOFOLLOW refuses a symlink swapped in after the path check. + """ + fd = os.open(target, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + return None + return stream.read(MAX_INSTRUCTIONS_BYTES + 1) + + +def _read_inherited_file(target: Path) -> str | None: + """Best-effort read of one outer AGENTS.md; anything invalid is skipped.""" + try: + data = _read_regular_file(target) + text = _decode(data) if data is not None else "" + except (OSError, HTTPException): + return None + return text if text.strip() else None + + +def _ancestor_directories(root: Path) -> list[Path]: + """Directories strictly above ``root`` down from the nearest git root, else ``$HOME``.""" + home = Path.home().resolve() + for directory in (root, *root.parents): + if (directory / ".git").exists(): + bound = directory + break + if directory == home: + bound = home # never search above $HOME + break + else: + # Outside $HOME without a git root: only the project folder itself. + bound = root + chain = [directory for directory in root.parents if directory == bound or bound in directory.parents] + return list(reversed(chain)) + + +def inherited_local_instructions(local_path: str) -> list[tuple[str, str]]: + """Outer AGENTS.md files for a local project, outermost first. + + Global ``$HUGAGENT_HOME/AGENTS.md``, then each directory from the nearest git + root (bounded by ``$HOME``) down to, but excluding, the project folder, whose + own file stays owned by ``ProjectInstructionsService``. Read-only prompt input. + """ + from core.config.runtime_env import local_data_dir + + raw = (local_path or "").strip() + if not raw or not os.path.isabs(raw): + return [] + try: + root = Path(raw).expanduser().resolve() + if not root.is_dir(): + return [] + candidates = [local_data_dir().expanduser() / AGENTS_FILENAME] + candidates += [directory / AGENTS_FILENAME for directory in _ancestor_directories(root)] + except OSError: + return [] + out: list[tuple[str, str]] = [] + seen = {str(root / AGENTS_FILENAME)} + for target in candidates: + try: + key = str(target.parent.resolve() / target.name) + except OSError: + continue + if key in seen: + continue + seen.add(key) + text = _read_inherited_file(target) + if text is not None: + out.append((str(target), text)) + return out diff --git a/src/backend/core/projects/local_resolve.py b/src/backend/core/projects/local_resolve.py new file mode 100644 index 00000000..2d907f37 --- /dev/null +++ b/src/backend/core/projects/local_resolve.py @@ -0,0 +1,90 @@ +"""Resolve a host directory to the caller's local project (terminal client). + +Shared by the EE and CE project routes: match an existing ``kind=local`` project +by the realpath of its bound folder, otherwise create one through the regular +``create_local`` path. Callers gate on ``local_mode_enabled()``. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from fastapi import HTTPException +from sqlalchemy import desc +from sqlalchemy.orm import Session + +from core.db.models import Project +from core.projects.project_names import next_available_folder_name + +_MAX_NAME = 120 + + +def _canonical(path: str) -> str: + raw = (path or "").strip() + if not raw or not os.path.isabs(raw): + raise HTTPException(422, "path 必须是绝对路径") + real = os.path.realpath(raw) + if not os.path.isdir(real): + raise HTTPException(422, "path 必须是已存在的目录") + return real + + +def _local_projects(db: Session, user_id: str): + """The caller's live local projects.""" + return db.query(Project).filter( + Project.kind == "local", + Project.owner_user_id == user_id, + Project.deleted_at.is_(None), + ) + + +def _find(db: Session, user_id: str, real: str) -> Optional[Project]: + rows = _local_projects(db, user_id).order_by(desc(Project.last_activity_at)).all() + for project in rows: + stored = str(((project.extra_data or {}).get("local") or {}).get("path") or "").strip() + if stored and os.path.isabs(stored) and os.path.realpath(stored) == real: + return project + return None + + +def _unique_name(db: Session, user_id: str, wanted: str) -> str: + # Leave room for a " (N)" suffix within the 120-character name limit. + base = wanted.strip()[: _MAX_NAME - 8].strip() or "project" + existing = _local_projects(db, user_id).with_entities(Project.name).all() + return next_available_folder_name(base, existing) + + +def resolve_local_project( + db: Session, + service, + user_id: str, + path: str, + *, + create: bool = True, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Return ``{project_id, name, local_path, created}`` for ``path``.""" + real = _canonical(path) + project = _find(db, user_id, real) + created = False + if project is None: + if not create: + raise HTTPException(404, "该目录尚未绑定本地项目") + from core.projects.environment.service import ProjectEnvironmentService + + environment_service = ProjectEnvironmentService(db) + environment = environment_service.validate(user_id, {}) + with environment_service.creation_metadata(user_id, environment): + project = service.create_local( + user_id, + _unique_name(db, user_id, name or os.path.basename(real)), + real, + ) + created = True + local = (project.extra_data or {}).get("local") or {} + return { + "project_id": project.project_id, + "name": project.name, + "local_path": local.get("path") or real, + "created": created, + } diff --git a/src/backend/core/sandbox/oslayer/bwrap.py b/src/backend/core/sandbox/oslayer/bwrap.py index 959ddb36..f76559b2 100644 --- a/src/backend/core/sandbox/oslayer/bwrap.py +++ b/src/backend/core/sandbox/oslayer/bwrap.py @@ -225,16 +225,25 @@ def _denied_args( (entry.root for entry in resolved.roots_for(AccessMode.DENY)), key=lambda item: (path_depth(item), item), ) + readable = [entry.root for entry in resolved.roots_for(AccessMode.READ)] for path in denied: if os.path.isdir(path) and not os.path.islink(path): descendants = [root for root in writable if root != path and is_within(root, path)] - perms = _DENIED_DIR_PERMS_TRAVERSABLE if descendants else _DENIED_DIR_PERMS_OPAQUE + read_only = [ + root for root in readable + if root != path and is_within(root, path) and root not in descendants + and os.path.isdir(root) + ] + perms = ( + _DENIED_DIR_PERMS_TRAVERSABLE if descendants or read_only else _DENIED_DIR_PERMS_OPAQUE + ) args += ["--perms", perms, "--tmpfs", path] - # Re-create the re-opened writable descendants inside the fresh - # tmpfs before freezing it: bubblewrap cannot create a mount - # point under a directory it has already remounted read-only. - for descendant in sorted(descendants, key=path_depth): - args += ["--bind", descendant, descendant] + # Re-create the re-opened descendants inside the fresh tmpfs + # before freezing it: bubblewrap cannot create a mount point + # under a directory it has already remounted read-only. + reopened = [(root, "--bind") for root in descendants] + [(root, "--ro-bind") for root in read_only] + for descendant, flag in sorted(reopened, key=lambda item: path_depth(item[0])): + args += [flag, descendant, descendant] args += ["--remount-ro", path] continue args += ["--ro-bind", _NULL_DEVICE, path] diff --git a/src/backend/core/sandbox/oslayer/seatbelt.py b/src/backend/core/sandbox/oslayer/seatbelt.py index 37be4998..8b966940 100644 --- a/src/backend/core/sandbox/oslayer/seatbelt.py +++ b/src/backend/core/sandbox/oslayer/seatbelt.py @@ -42,7 +42,6 @@ ) from core.sandbox.oslayer.paths import ( filesystem_root, - is_within, normalize_trusted_top_level_alias, resolve_symlinks, ) @@ -205,30 +204,15 @@ def _build_launch( return SandboxLaunch(backend=self.name, argv_prefix=argv) def _read_section(self, resolved: ResolvedFileSystem, params: _ParamTable) -> str: - denied = resolved.roots_for(AccessMode.DENY) - if resolved.has_full_disk_read_access: - if not denied: - return "; allow read-only file operations\n(allow file-read*)" - root = AccessRoot( - root=filesystem_root(), - denied_subpaths=tuple(entry.root for entry in denied), - ) - section = _access_section("file-read*", "READABLE_ROOT", [root], params, writable=False) - return f"; allow read-only file operations\n{section}" - - readable = resolved.roots_for(AccessMode.READ) - scoped = [ - AccessRoot( - root=entry.root, - denied_subpaths=tuple( - denied_entry.root - for denied_entry in denied - if is_within(denied_entry.root, entry.root) - ), - ) - for entry in readable - ] - section = _access_section("file-read*", "READABLE_ROOT", scoped, params, writable=False) + if resolved.has_full_disk_read_access and not resolved.roots_for(AccessMode.DENY): + return "; allow read-only file operations\n(allow file-read*)" + # Every readable root with its own carve-outs, already resolved + # narrowest-wins: a root granted inside a denied one (the workspace in + # the install's data directory) reopens reads there, since Seatbelt's + # file-write* alone would not let a command read its own script. + section = _access_section( + "file-read*", "READABLE_ROOT", resolved.roots_for(AccessMode.READ), params, writable=False + ) return f"; allow read-only file operations\n{section}" if section else "" def _write_section(self, resolved: ResolvedFileSystem, params: _ParamTable) -> str: diff --git a/src/backend/core/sandbox/permissions/os_gate.py b/src/backend/core/sandbox/permissions/os_gate.py index 34065398..ffcebb58 100644 --- a/src/backend/core/sandbox/permissions/os_gate.py +++ b/src/backend/core/sandbox/permissions/os_gate.py @@ -119,6 +119,39 @@ def protected_read_paths() -> tuple[str, ...]: return tuple(dict.fromkeys(candidates)) +def capability_skill_roots() -> tuple[str, ...]: + """Skill content inside the capability store, which commands may read but not write. + + The store as a whole stays in :func:`protected_read_paths`: beside the skills + it holds ``mcp.json`` and plugin manifests, which can carry credentials. A + skill's own files are instructions and scripts the agent is told to run, so + exactly those directories are reopened read-only — installed skills, every + plugin package's ``skills`` folder, and the per-run views that link to them. + A directory that resolves outside the store is never opened. + """ + from core.config.runtime_env import CAPS_ROOT_ENV + + raw = (os.getenv(CAPS_ROOT_ENV) or "").strip() + if not raw: + return () + root = Path(raw).expanduser() + try: + resolved_root = root.resolve() + except OSError: + return () + candidates = [root / "skills"] + candidates.extend(sorted(root.glob("plugins/*/*/*/package/skills"))) + candidates.extend(sorted(root.glob(".capabilities/views/*/skills"))) + roots = [] + for candidate in candidates: + try: + if candidate.is_dir() and candidate.resolve().is_relative_to(resolved_root): + roots.append(str(candidate)) + except OSError: + continue + return tuple(roots) + + def build_context(*, workspace_root: str, cwd: Optional[str] = None) -> PolicyContext: """Executor facts the policy is resolved against.""" root = normalize_logical(workspace_root) diff --git a/src/backend/core/sandbox/runner/desktop_workspace.py b/src/backend/core/sandbox/runner/desktop_workspace.py index 8c00ede5..2f438116 100644 --- a/src/backend/core/sandbox/runner/desktop_workspace.py +++ b/src/backend/core/sandbox/runner/desktop_workspace.py @@ -37,7 +37,21 @@ def extra_roots(root: str, user_id, *, read_only=False): def subprocess_environment(cwd): import sys - return {"PY_BIN": sys.executable} + from . import server, user_env + + clean = {"PY_BIN": sys.executable} + if not user_env.is_user_folder(cwd, server.WORKSPACE_ROOT): + return clean + # A folder the user chose works like their own terminal; the runner's + # scratch sessions keep the clean environment. Built-in skills are linked + # under the workspace; plugin skills stay in the capability store. + # The runner ships without backend modules, so the env name is spelled out here. + skill_roots = [os.getenv("SANDBOX_SKILLS_DIR", "").strip(), os.getenv("HUGAGENT_CAPS_ROOT", "").strip()] + try: + return user_env.project_environment({**server.SAFE_ENV, **clean}, server.WORKSPACE_ROOT, skill_roots) + except OSError: + # Never let the environment stop a command: run it as before instead. + return clean def execution_directory(raw: str) -> Path: diff --git a/src/backend/core/sandbox/runner/process_runtime.py b/src/backend/core/sandbox/runner/process_runtime.py index 24d4f3cc..8eb1b96e 100644 --- a/src/backend/core/sandbox/runner/process_runtime.py +++ b/src/backend/core/sandbox/runner/process_runtime.py @@ -13,7 +13,9 @@ async def _spawn_subprocess(cmd, cwd, sandbox_launch, files, *, native_group=Fal """One confinement/resource boundary for one-shot and managed commands.""" nproc_limit = server._subprocess_nproc_limit(cmd) env = dict(server.SAFE_ENV) - env.update(server._workspace_rules().subprocess_environment(cwd)) + # Off the event loop: the first command in a user's folder reads their + # login-shell environment, which can take seconds. + env.update(await asyncio.to_thread(server._workspace_rules().subprocess_environment, cwd)) spawn_plan = None if sandbox_launch is not None: cmd = [*sandbox_launch.argv_prefix, *cmd] diff --git a/src/backend/core/sandbox/runner/user_env.py b/src/backend/core/sandbox/runner/user_env.py new file mode 100644 index 00000000..505bf90d --- /dev/null +++ b/src/backend/core/sandbox/runner/user_env.py @@ -0,0 +1,198 @@ +"""The user's own environment, for commands run in a folder the user chose (local mode). + +A local project is the user's real folder, so its commands should find what the +user's terminal finds: Homebrew, pyenv, nvm, cargo, their git and ssh setup. A +desktop app started from the Dock or Start menu does not have that environment, +so on POSIX it is read once from a login shell, the way editors resolve it. + +Two things stay as they are: + +* Credentials never follow. Like Codex's default ``shell_environment_policy``, + any variable whose name looks like a key, secret, token or password is left + out, and so are this backend's own process variables. +* Skill scripts keep their interpreter. Bundled skills run ``python3 /…`` + and need the dependencies installed beside the backend, so a small shim in + front of the user's PATH sends exactly those calls to ``$PY_BIN`` and every + other ``python3`` to the user's own. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from functools import lru_cache +from pathlib import Path +from typing import Iterable, Mapping + +_MARKER = "__HUGAGENT_LOGIN_ENV__" +_CAPTURE_TIMEOUT = 8 +_SECRET_NAME = re.compile(r"KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL", re.IGNORECASE) +# Variables the backend sets for itself (see cli.apply_local_env) or a shell sets per process. +_INTERNAL_PREFIXES = ("HUGAGENT_", "JX_", "SANDBOX_", "MCP_", "BACKEND_", "SCRIPT_RUNNER_", "SITE_", "MILVUS_", "MEM0_") +_INTERNAL_NAMES = frozenset({ + "DATABASE_URL", "REDIS_URL", "DEPLOY_PROFILE", "AUTH_MODE", "SESSION_STORE", "SSO_LOGIN_MODE", + "LOCAL_AUTH_ENABLED", "STORAGE_TYPE", "STORAGE_PATH", "LOG_FILE_PATH", "FRONTEND_DIST_DIR", + "BRAND_PRODUCT_NAME", "PORT", "NO_PROXY", "no_proxy", "PYTHONPATH", "VIRTUAL_ENV", + "_", "PWD", "OLDPWD", "SHLVL", "PS1", "PS2", "PROMPT", "TERM_SESSION_ID", +}) +# Managed values the runner relies on; the user's copy never replaces them. +_MANAGED = ("PY_BIN", "NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH", "JX_FONT_DIR", "PDF_SKILL_DIR", + "OFFICECLI_SKIP_UPDATE", "OFFICECLI_RESIDENT_FLUSH", "PYTHONIOENCODING", "PYTHONDONTWRITEBYTECODE") +_MANAGED_PREFIXES = ("SITE_", "SCRIPT_RUNNER_") + +SHIM_DIR_ENV = "HUGAGENT_SHIM_DIR" +SHIM_ROOTS_ENV = "HUGAGENT_SHIM_SKILL_ROOTS" +_SHIM = r"""#!/bin/sh +# HugAgentOS: a skill's own script runs on the bundled interpreter, where its +# dependencies are installed; every other call is the user's own interpreter. +name=${0##*/} +for arg in "$@"; do + case "$arg" in -*) continue ;; esac + saved=$IFS; IFS=: + for root in $HUGAGENT_SHIM_SKILL_ROOTS; do + case "$arg" in "$root"/*) IFS=$saved; exec "$PY_BIN" "$@" ;; esac + done + IFS=$saved + break +done +rest= +saved=$IFS; IFS=: +for dir in $PATH; do [ "$dir" = "$HUGAGENT_SHIM_DIR" ] || rest=${rest:+$rest:}$dir; done +IFS=$saved +PATH=$rest; export PATH +found=$(command -v "$name" 2>/dev/null) +# macOS ships a /usr/bin/python3 stub that only offers to install the developer tools. +if [ "$found" = "/usr/bin/$name" ] && [ "$(uname)" = Darwin ] && ! /usr/bin/xcode-select -p >/dev/null 2>&1; then + found= +fi +[ -n "$found" ] && exec "$found" "$@" +exec "$PY_BIN" "$@" +""" + + +def is_user_folder(cwd: str | os.PathLike, workspace_root: str) -> bool: + """True when ``cwd`` is a folder of the user's, not the runner's own workspace.""" + try: + return not Path(cwd).resolve().is_relative_to(Path(workspace_root).resolve()) + except OSError: + return False + + +def _keep(name: str) -> bool: + return not ( + name in _INTERNAL_NAMES + or name.startswith(_INTERNAL_PREFIXES) + or _SECRET_NAME.search(name) + ) + + +def _filtered(environ: Mapping[str, str]) -> dict[str, str]: + return {name: value for name, value in environ.items() if _keep(name)} + + +def _parse_login_output(output: bytes) -> dict[str, str]: + """Entries printed by ``env -0`` after the marker; anything a profile echoed before it is ignored.""" + text = output.decode("utf-8", errors="replace") + if _MARKER not in text: + return {} + entries: dict[str, str] = {} + for item in text.split(_MARKER, 1)[1].split("\0"): + name, sep, value = item.partition("=") + if sep and name and "\n" not in name: + entries[name] = value + return entries + + +@lru_cache(maxsize=1) +def login_environment() -> dict[str, str]: + """The user's login-shell environment, credentials and backend internals removed. + + Read once per runner. Falls back to this process's own environment when there + is no login shell to ask (Windows) or it does not answer in time. + """ + captured: dict[str, str] = {} + if os.name != "nt": + shell = os.environ.get("SHELL") or "/bin/sh" + try: + import pwd + + shell = pwd.getpwuid(os.getuid()).pw_shell or shell + except (ImportError, KeyError): + pass + seed = {key: os.environ[key] for key in ("HOME", "USER", "LOGNAME") if key in os.environ} + seed.update(SHELL=shell, TERM="dumb", PATH="/usr/bin:/bin:/usr/sbin:/sbin") + try: + done = subprocess.run( + [shell, "-l", "-i", "-c", f"printf %s {_MARKER}; command env -0"], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env=seed, timeout=_CAPTURE_TIMEOUT, start_new_session=True, + ) + captured = _parse_login_output(done.stdout) + except (OSError, subprocess.SubprocessError): + captured = {} + return _filtered(captured or os.environ) + + +@lru_cache(maxsize=4) +def _shim_directory(workspace_root: str) -> str: + """Write the python shims once per runner, where confined commands can read them. + + Only the runner's own workspace: a shared temp directory could be created + first by another local user. Without it the caller keeps the clean + environment. + """ + return _write_shims(Path(workspace_root) / ".runtime" / "bin") + + +def _write_shims(directory: Path) -> str: + directory.mkdir(parents=True, exist_ok=True) + for name in ("python3", "python"): + target = directory / name + if not target.is_file() or target.read_text(encoding="utf-8") != _SHIM: + temporary = target.with_name(f".{name}.{os.getpid()}.tmp") + temporary.write_text(_SHIM, encoding="utf-8") + temporary.chmod(0o755) + os.replace(temporary, target) + return str(directory) + + +def _join_path(*groups: Iterable[str]) -> str: + seen: dict[str, None] = {} + for group in groups: + for entry in group: + if entry: + seen.setdefault(entry, None) + return os.pathsep.join(seen) + + +def project_environment(managed: Mapping[str, str], workspace_root: str, skill_roots: Iterable[str]) -> dict[str, str]: + """Environment for a command in a user's folder: theirs, plus what the runner needs. + + ``managed`` is the runner's own clean environment. Its tool directories go + after the user's PATH, so the user's ``python3``, ``node`` or ``git`` win. + The result is laid over ``managed``, so a managed variable the user does not + set (a scratch cache directory, thread limits) stays as the runner had it. + """ + user = login_environment() + env = dict(user) + for name, value in managed.items(): + if name in _MANAGED or name.startswith(_MANAGED_PREFIXES) or name not in env: + env[name] = value + if managed.get("NODE_PATH") and user.get("NODE_PATH"): + env["NODE_PATH"] = _join_path(managed["NODE_PATH"].split(os.pathsep), user["NODE_PATH"].split(os.pathsep)) + user_path = user.get("PATH", "").split(os.pathsep) + managed_path = managed.get("PATH", "").split(os.pathsep) + env["PY_BIN"] = managed.get("PY_BIN") or sys.executable + if os.name == "nt": + env["PATH"] = _join_path(user_path, managed_path) + return env + shim = _shim_directory(workspace_root) + env[SHIM_DIR_ENV] = shim + # A skill may be named through its workspace link or the store it points to. + env[SHIM_ROOTS_ENV] = os.pathsep.join( + dict.fromkeys(form for root in skill_roots if root for form in (root.rstrip("/"), os.path.realpath(root))) + ) + env["PATH"] = _join_path([shim], user_path, managed_path) + return env diff --git a/src/backend/extensions/mcp_servers/internet_search_mcp/README.md b/src/backend/extensions/mcp_servers/internet_search_mcp/README.md index b33e36b4..2013dc45 100755 --- a/src/backend/extensions/mcp_servers/internet_search_mcp/README.md +++ b/src/backend/extensions/mcp_servers/internet_search_mcp/README.md @@ -12,12 +12,14 @@ Sources are interleaved by query order and conservatively deduplicated by URL. Configure `INTERNET_SEARCH_ENGINE` and its corresponding API key. Tavily receives `INTERNET_SEARCH_COUNTRY` only when explicitly set and topic is general. +DeepSeek (`DEEPSEEK_SEARCH_API_KEY`) caps queries at 128 characters, adds a 30-day `after:` filter +for news, over-fetches twice `max_results` and drops rows with `rerank_score` below 0.5. There is no language-filter parameter or implicit country preference. Invalid/obsolete arguments are rejected, without legacy payload conversion. The lifespan shares an async HTTP client and an eight-request semaphore. At most 32 queries may be active or queued; the batch deadline is 45 seconds. -429 and retryable 5xx responses get at most two retries within that deadline. +408, 429 and retryable 5xx responses get at most two retries within that deadline. Retry-After is respected, including HTTP-date values; backoff never holds a request slot. Cancellation drains all batch tasks. Errors expose codes, never upstream response bodies. diff --git a/src/backend/extensions/mcp_servers/internet_search_mcp/impl.py b/src/backend/extensions/mcp_servers/internet_search_mcp/impl.py index 3229df15..a612d03e 100755 --- a/src/backend/extensions/mcp_servers/internet_search_mcp/impl.py +++ b/src/backend/extensions/mcp_servers/internet_search_mcp/impl.py @@ -17,7 +17,7 @@ from .merge import merge_sources from .models import SearchRequest -from .providers import KEY_NAMES, ProviderConfig, build_request, normalize_response +from .providers import KEY_NAMES, ProviderConfig, QueryTooLong, build_request, normalize_response logger = logging.getLogger(__name__) BATCH_TIMEOUT = 45.0 @@ -32,7 +32,7 @@ class ResponseTooLarge(ValueError): pass -RETRY_STATUSES = {429, 500, 502, 503, 504} +RETRY_STATUSES = {408, 429, 500, 502, 503, 504} def retry_delay(response: httpx.Response, attempt: int) -> float: @@ -155,6 +155,8 @@ async def _query( code = "network_error" except ResponseTooLarge: code = "response_too_large" + except QueryTooLong: + code = "query_too_long" except (ValueError, TypeError, AttributeError): code = "invalid_response" # Do not expose upstream bodies, request headers, or credential-bearing exception messages. diff --git a/src/backend/extensions/mcp_servers/internet_search_mcp/providers.py b/src/backend/extensions/mcp_servers/internet_search_mcp/providers.py index 085a5cc7..3022a61e 100644 --- a/src/backend/extensions/mcp_servers/internet_search_mcp/providers.py +++ b/src/backend/extensions/mcp_servers/internet_search_mcp/providers.py @@ -1,6 +1,7 @@ """Provider wire formats. Language filtering is deliberately absent.""" from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Any from .models import SearchRequest @@ -8,11 +9,22 @@ BAIDU_SEARCH_URL = "https://qianfan.baidubce.com/v2/ai_search/web_search" LANGSEARCH_SEARCH_URL = "https://api.langsearch.com/v1/web-search" TAVILY_SEARCH_URL = "https://api.tavily.com/search" +DEEPSEEK_SEARCH_URL = "https://api.deepseek.com/v1/tools/search" KEY_NAMES = { "baidu": "BAIDU_API_KEY", + "deepseek": "DEEPSEEK_SEARCH_API_KEY", "langsearch": "LANGSEARCH_API_KEY", "tavily": "TAVILY_API_KEY", } +# DeepSeek rejects longer queries with HTTP 400; operators count toward the limit. +DEEPSEEK_QUERY_CHARS = 128 +DEEPSEEK_NEWS_DAYS = 30 +# rerank_score is 0–3 (>= 1.5 is "high quality"); only clearly irrelevant rows are dropped. +DEEPSEEK_MIN_RERANK_SCORE = 0.5 + + +class QueryTooLong(ValueError): + pass @dataclass(frozen=True) @@ -39,6 +51,22 @@ def build_request( }, ) headers["Authorization"] = f"Bearer {config.api_key}" + if config.engine == "deepseek": + if options.topic == "news": + since = datetime.now(timezone.utc).date() - timedelta(days=DEEPSEEK_NEWS_DAYS) + query = f"{query} after:{since.isoformat()}" + if len(query) > DEEPSEEK_QUERY_CHARS: + raise QueryTooLong(f"DeepSeek queries are limited to {DEEPSEEK_QUERY_CHARS} characters") + return ( + DEEPSEEK_SEARCH_URL, + headers, + { + "query": query, + # Over-fetch so low-relevance rows can be dropped; merge applies the final limit. + "count": options.max_results * 2, + "need_content": options.include_raw_content, + }, + ) if config.engine == "langsearch": return ( LANGSEARCH_SEARCH_URL, @@ -72,6 +100,23 @@ def normalize_response(engine: str, data: Any) -> dict: if data.get("code") != 200: raise ValueError("Provider returned an API error") rows = data.get("data", {}).get("webPages", {}).get("value") + elif engine == "deepseek": + rows = data.get("results") + if isinstance(rows, list) and all(isinstance(row, dict) for row in rows): + rows = [ + { + "title": row.get("title"), + "url": row.get("url"), + "content": row.get("summary") or row.get("snippet") or "", + "published_date": row.get("date"), + **({"raw_content": row["content"]} if row.get("content") else {}), + } + for row in rows + if not ( + isinstance(row.get("rerank_score"), (int, float)) + and row["rerank_score"] < DEEPSEEK_MIN_RERANK_SCORE + ) + ] else: rows = data.get("results") if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): diff --git a/src/backend/features/desktop/local/grants.py b/src/backend/features/desktop/local/grants.py index 27854a43..e4c68d06 100644 --- a/src/backend/features/desktop/local/grants.py +++ b/src/backend/features/desktop/local/grants.py @@ -93,6 +93,27 @@ def add_grant(path: str, mode: str = "readwrite") -> Dict[str, Any]: return entry +def ensure_workspace_grant(path: str) -> bool: + """Grant a folder the user chose to work in read-write, like Codex's workspace-write. + + A home directory or filesystem root is never granted wholesale; there every + out-of-scope access keeps its own approval. A folder already inside a + read-write grant is left alone. Returns whether a grant was added. + """ + real = _canonical_grant_path(path) + home = _canonical_grant_path(os.path.expanduser("~")) + if not real or real == home or os.path.dirname(real) == real: + return False + for grant in list_grants(): + root = _canonical_grant_path(str(grant.get("path") or "")) + if grant.get("mode", "readwrite") == "readwrite" and root and ( + real == root or real.startswith(root.rstrip(os.sep) + os.sep) + ): + return False + add_grant(real, "readwrite") + return True + + def remove_grant(path: str) -> None: real = _canonical_grant_path(path) with _LOCK: diff --git a/src/backend/hugagent_cli/main.py b/src/backend/hugagent_cli/main.py index 68c11810..d9248c32 100644 --- a/src/backend/hugagent_cli/main.py +++ b/src/backend/hugagent_cli/main.py @@ -9,13 +9,27 @@ from .transport import Client -SERVER_COMMANDS = {"onboard", "serve", "doctor"} +# Commands of the local server, which `hugagent-server` owns. This client only +# manages sessions and never runs them, so it points the caller there instead. +SERVER_COMMANDS = {"onboard", "serve", "doctor", "engine"} +# The server's options that may precede its command (`--port 3002 serve`). +SERVER_OPTIONS = {"--host", "--port"} -def server_installed(): - from importlib.util import find_spec - - return find_spec("cli") is not None +def server_command(argv): + """The server command among argv's leading server options, if any.""" + index = 0 + while index < len(argv): + token = argv[index] + if token in SERVER_COMMANDS: + return token + if token in SERVER_OPTIONS: + index += 2 + elif token.split("=", 1)[0] in SERVER_OPTIONS: + index += 1 + else: + return None + return None def paging(sub): @@ -24,10 +38,9 @@ def paging(sub): def parser(): - description = "Manage HugAgentOS cloud and device sessions and project chats." - if server_installed(): - description += " Existing server commands: onboard, serve, doctor." - p = argparse.ArgumentParser(description=description) + p = argparse.ArgumentParser( + description="Manage HugAgentOS cloud and device sessions and project chats." + ) p.add_argument("--server", default=os.environ.get("HUGAGENT_SERVER")) p.add_argument("--target", default="cloud", help="cloud or device:") p.add_argument( @@ -108,14 +121,12 @@ def operation(client, target, name, args, key, wait): def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) - if argv and argv[0] in SERVER_COMMANDS and not server_installed(): - print(json.dumps({"error": argv[0] + " needs a full HugAgentOS server installation. " - "This lightweight client only manages sessions."})) + command = server_command(argv) + if command: + print(json.dumps({"error": command + " is a server command: run `hugagent-server " + + command + "` on a full HugAgentOS installation. " + "This lightweight client only manages sessions."}), file=sys.stderr) return 2 - if (not argv and server_installed()) or (argv and argv[0] in SERVER_COMMANDS): - from cli import main as server_main - - return server_main(argv) args = parser().parse_args(argv) try: client = Client(args.server or "", os.environ.get("HUGAGENT_API_KEY", "")) diff --git a/src/backend/hugagent_cli/transport.py b/src/backend/hugagent_cli/transport.py index 915cc286..1565ba47 100644 --- a/src/backend/hugagent_cli/transport.py +++ b/src/backend/hugagent_cli/transport.py @@ -21,6 +21,31 @@ def enabled(value): return str(value).strip().lower() not in {"0", "false", "no", "off"} +DETAIL_LIMIT = 300 + + +def error_detail(exc): + """The server's own reason for an HTTP error, as one bounded line.""" + try: + body = json.loads(exc.read(64 * 1024) or b"null") + except (OSError, ValueError): + return "" + reason = body + # FastAPI puts it in "detail" (text, object or validation list); the + # project envelope in "message". + for _ in range(3): + if isinstance(reason, dict): + reason = reason.get("detail") or reason.get("message") or reason.get("msg") + elif isinstance(reason, list) and reason: + reason = reason[0] + else: + break + if not isinstance(reason, (str, int, float)) or isinstance(reason, bool): + return "" + text = " ".join(str(reason).split()) + return text if len(text) <= DETAIL_LIMIT else text[: DETAIL_LIMIT - 1] + "…" + + class Client: def __init__(self, server, token, *, timeout=35): parsed = urlsplit(server) @@ -78,7 +103,7 @@ def request(self, method, path, body=None, *, started=False): if len(raw) > 2 * 1024 * 1024: raise RuntimeError("Response exceeds limit") if not raw: - return {"deleted": True} + return {"deleted": True} if method == "DELETE" else {} result = json.loads(raw) return ( result["data"] @@ -86,7 +111,10 @@ def request(self, method, path, body=None, *, started=False): else result ) except HTTPError as exc: - raise RuntimeError("HugAgentOS HTTP " + str(exc.code) + "; request refused") from None + reason = error_detail(exc) + raise RuntimeError( + "HugAgentOS HTTP " + str(exc.code) + ": " + (reason or "request refused") + ) from None except (URLError, TimeoutError, OSError): raise RuntimeError( "Connection unavailable; check operation status before retry" diff --git a/src/backend/prompts/project_section.py b/src/backend/prompts/project_section.py index d0b92829..4feb8b9d 100644 --- a/src/backend/prompts/project_section.py +++ b/src/backend/prompts/project_section.py @@ -7,6 +7,8 @@ """ from __future__ import annotations + +import os from core.llm import prompt_versions as prompt_versions_module from prompts.prompt_runtime import _load_db_prompt_parts # noqa: E402 (cycle-safe) @@ -144,6 +146,7 @@ def _build_local_project_section( project_instructions: str, local_path: str, local_slug: str, + inherited_instructions=(), ) -> str: """Build the desktop **local-mode** project section (ticket #05). @@ -152,10 +155,20 @@ def _build_local_project_section( it directly — no upload — but must respect the authorization + confirmation boundary enforced by the execution policy gate (ticket #07). Only injected when ``project_is_local`` is set, so the cloud/web deployment never sees it. + + ``inherited_instructions`` are outer ``(path, text)`` AGENTS.md files, outermost + first; when present every file, the project root last, is labelled with its path. """ from prompts.desktop_templates import render_desktop_part variables = dict(project_name=(project_name or "").strip(), local_path=(local_path or "").strip()) section = render_desktop_part("project" if variables["local_path"] else "project_missing", **variables) - if project_instructions.strip(): - section += "\n\n" + render_desktop_part("project_instructions", instructions=project_instructions.strip()) + instructions = project_instructions.strip() + blocks = [f"#### `{path}`\n{text.strip()}" for path, text in inherited_instructions or () if text.strip()] + if blocks and instructions: + root = os.path.join(variables["local_path"], "AGENTS.md") + blocks.append(f"#### `{root}`\n{instructions}") + if blocks: + instructions = "\n\n".join(blocks) + if instructions: + section += "\n\n" + render_desktop_part("project_instructions", instructions=instructions) return _collapse_blanks(section) diff --git a/src/backend/prompts/system_builder.py b/src/backend/prompts/system_builder.py index 152e7389..6e8d1ce8 100644 --- a/src/backend/prompts/system_builder.py +++ b/src/backend/prompts/system_builder.py @@ -44,6 +44,11 @@ def build_system_prompt( ctx["desktop_environment"] = (ctx.get("preview_environment") if desktop_preview else None) or runtime.build_environment_context( ctx, current_date=runtime._PROMPT_NOW_SENTINEL ) + if local_mode_enabled() and ctx.get("project_is_local") and ctx.get("project_local_path"): + # Outer AGENTS.md files are prompt inputs too: snapshot them before hashing. + from core.projects.instructions import inherited_local_instructions + + ctx["project_inherited_instructions"] = inherited_local_instructions(ctx["project_local_path"]) # Day granularity only: the date is constant within a day → the system prompt is # byte-stable all day → LLM prefix cache hits all day (a second-level timestamp # would change every request and bust the cache). @@ -402,6 +407,7 @@ def _sort_key(pid: str) -> int: project_instructions=ctx.get("project_instructions") or "", local_path=ctx.get("project_local_path") or "", local_slug=ctx.get("project_local_slug") or "", + inherited_instructions=ctx.get("project_inherited_instructions") or (), ) else: from core.files.edition import context_directory diff --git a/src/backend/tests/api/test_project_local_resolve.py b/src/backend/tests/api/test_project_local_resolve.py new file mode 100644 index 00000000..e578f44c --- /dev/null +++ b/src/backend/tests/api/test_project_local_resolve.py @@ -0,0 +1,157 @@ +"""POST /v1/projects/local/resolve: shell directory -> local project (CLI contract §8.2).""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import api.routes.v1.projects as ee_routes +from core.auth.backend import UserContext, get_current_user +from core.db.engine import get_db +from core.db.models import Project +from tests.services.project_instructions_support import env # noqa: F401 + +_CE_ROUTES = Path(__file__).resolve().parents[4] / "ce/overlay/src/backend/api/routes/v1/projects.py" + + +def _load_ce_routes(): + spec = importlib.util.spec_from_file_location("ce_overlay_projects_routes", _CE_ROUTES) + module = importlib.util.module_from_spec(spec) + # Postponed annotations resolve through sys.modules, as for a real import. + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(params=["ee", "ce"]) +def client(request, env, monkeypatch): # noqa: F811 + db, _ = env + if request.param == "ce" and not _CE_ROUTES.is_file(): + pytest.skip("CE overlay is only present in the main repository; a CE tree tests it as api.routes") + routes = ee_routes if request.param == "ee" else _load_ce_routes() + monkeypatch.setattr(routes, "local_mode_enabled", lambda: True) + app = FastAPI() + app.include_router(routes.router) + state = {"user": "alice"} + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[get_current_user] = lambda: UserContext( + user_id=state["user"], user_center_id=state["user"], username=state["user"] + ) + test_client = TestClient(app) + test_client.routes = routes + test_client.state = state + return test_client + + +def _resolve(client, path, **body): + return client.post("/v1/projects/local/resolve", json={"path": str(path), **body}) + + +def test_resolve_creates_then_reuses_including_symlinked_path(client, tmp_path): + folder = tmp_path / "work" / "my-repo" + folder.mkdir(parents=True) + first = _resolve(client, folder, create=True) + assert first.status_code == 200, first.text + data = first.json()["data"] + assert data["created"] is True + assert data["name"] == "my-repo" + assert data["local_path"] == str(folder.resolve()) + assert data["project_id"].startswith("prj_") + + again = _resolve(client, folder).json()["data"] + assert again == {**data, "created": False} + + link = tmp_path / "link-to-repo" + link.symlink_to(folder, target_is_directory=True) + via_link = _resolve(client, str(link) + "/").json()["data"] + assert via_link["project_id"] == data["project_id"] + assert via_link["created"] is False + + +def test_resolve_matches_project_created_through_regular_route(client, env, tmp_path): # noqa: F811 + db, _ = env + folder = tmp_path / "existing" + folder.mkdir() + created = client.post( + "/v1/projects", json={"name": "Existing", "kind": "local", "local_path": str(folder)} + ) + assert created.status_code == 201, created.text + data = _resolve(client, folder, name="ignored").json()["data"] + assert data["project_id"] == created.json()["data"]["project_id"] + assert data["name"] == "Existing" and data["created"] is False + assert db.query(Project).filter(Project.kind == "local").count() == 1 + + +def test_resolve_deduplicates_default_name_and_scopes_by_caller(client, env, tmp_path): # noqa: F811 + one, two = tmp_path / "a" / "app", tmp_path / "b" / "app" + one.mkdir(parents=True) + two.mkdir(parents=True) + first = _resolve(client, one).json()["data"] + second = _resolve(client, two).json()["data"] + assert (first["name"], second["name"]) == ("app", "app (2)") + assert first["project_id"] != second["project_id"] + + named = tmp_path / "c" + named.mkdir() + assert _resolve(client, named, name="Custom").json()["data"]["name"] == "Custom" + + # Another user never sees alice's project for the same folder. + client.state["user"] = "bob" + other = _resolve(client, one).json()["data"] + assert other["created"] is True and other["project_id"] != first["project_id"] + + +def test_resolve_without_create_returns_404_for_unknown_folder(client, env, tmp_path): # noqa: F811 + db, _ = env + folder = tmp_path / "fresh" + folder.mkdir() + assert _resolve(client, folder, create=False).status_code == 404 + assert db.query(Project).count() == 0 + + +def test_resolve_is_404_outside_local_mode(client, env, monkeypatch, tmp_path): # noqa: F811 + db, _ = env + monkeypatch.setattr(client.routes, "local_mode_enabled", lambda: False) + folder = tmp_path / "x" + folder.mkdir() + assert _resolve(client, folder).status_code == 404 + assert db.query(Project).count() == 0 + + +@pytest.mark.parametrize("bad", ["relative/dir", "", "file", "missing"]) +def test_resolve_rejects_bad_paths_with_422(client, env, tmp_path, bad): # noqa: F811 + db, _ = env + (tmp_path / "file").write_text("not a directory") + path = {"file": tmp_path / "file", "missing": tmp_path / "missing"}.get(bad, bad) + assert _resolve(client, path).status_code == 422 + assert db.query(Project).count() == 0 + + +def test_resolve_with_grant_opens_the_folder_to_the_sandbox_once(client, tmp_path, monkeypatch): + from features.desktop.local import grants + + monkeypatch.setenv("HUGAGENT_HOME", str(tmp_path / "home")) + folder = tmp_path / "work" / "repo" + (folder / "sub").mkdir(parents=True) + response = client.post("/v1/projects/local/resolve", json={"path": str(folder), "grant": True}) + assert response.status_code == 200, response.text + assert grants.list_grants() == [{"path": str(folder.resolve()), "mode": "readwrite"}] + # A folder already inside a read-write grant adds nothing; without grant=true nothing is granted. + client.post("/v1/projects/local/resolve", json={"path": str(folder / "sub"), "grant": True}) + client.post("/v1/projects/local/resolve", json={"path": str(tmp_path / "work")}) + assert len(grants.list_grants()) == 1 + + +def test_workspace_grant_never_covers_home_or_root(tmp_path, monkeypatch): + from features.desktop.local import grants + + monkeypatch.setenv("HUGAGENT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("HOME", str(tmp_path)) + assert grants.ensure_workspace_grant(str(tmp_path)) is False + assert grants.ensure_workspace_grant("/") is False + assert grants.list_grants() == [] diff --git a/src/backend/tests/chat/test_manual_compaction_api.py b/src/backend/tests/chat/test_manual_compaction_api.py new file mode 100644 index 00000000..aabc4cbc --- /dev/null +++ b/src/backend/tests/chat/test_manual_compaction_api.py @@ -0,0 +1,234 @@ +"""HTTP seam tests for ``POST /v1/chats/{chat_id}/compact``.""" + +from __future__ import annotations + +import dataclasses + +import core.db.engine as engine_mod +import httpx +import pytest +from api.middleware.error_handler import setup_error_handlers +from api.routes.v1 import chats +from core.auth.backend import UserContext, get_current_user +from core.chat.compaction import service as S +from core.chat.service import ChatService +from core.config import settings as config_settings +from core.db.models import ChatRun, ChatSession +from core.llm.context import compaction as C +from fastapi import FastAPI +from sqlalchemy.orm import sessionmaker + +CHAT_ID = "chat_manual_compact" + + +@pytest.fixture +def svc(db_session, monkeypatch): + monkeypatch.setattr(engine_mod, "SessionLocal", sessionmaker(bind=db_session.get_bind())) + db_session.add(ChatSession(chat_id=CHAT_ID, user_id="owner", title="manual")) + db_session.commit() + return ChatService(db_session) + + +def _keep_recent(monkeypatch, tokens: int, **overrides) -> None: + real = config_settings.settings + compaction = dataclasses.replace(real.compaction, keep_recent_tokens=tokens, **overrides) + monkeypatch.setattr( + config_settings, "settings", dataclasses.replace(real, compaction=compaction) + ) + + +def _message(svc: ChatService, message_id: str, role: str, content: str) -> None: + svc.message_repo.create( + {"message_id": message_id, "chat_id": CHAT_ID, "role": role, "content": content} + ) + + +def _conversation(svc: ChatService) -> None: + _message(svc, "m1", "user", "first question " * 40) + _message(svc, "m2", "assistant", "first answer " * 40) + _message(svc, "m3", "user", "second question") + _message(svc, "m4", "assistant", "second answer") + + +def _summary(monkeypatch, text: str = "the summary"): + calls = [] + + async def fake_summarize(history, *, timeout): + calls.append(history) + return text + + monkeypatch.setattr("core.chat.compaction.summary._summarize", fake_summarize) + return calls + + +async def _post(user_id: str = "owner", chat_id: str = CHAT_ID) -> httpx.Response: + app = FastAPI() + setup_error_handlers(app) + app.include_router(chats.router) + app.dependency_overrides[get_current_user] = lambda: UserContext( + user_id=user_id, user_center_id=user_id, username=user_id + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post(f"/v1/chats/{chat_id}/compact") + + +@pytest.mark.asyncio +async def test_compact_publishes_checkpoint_used_by_next_turn(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 10) + calls = _summary(monkeypatch) + + response = await _post() + + assert response.status_code == 200, response.text + data = response.json()["data"] + assert data["compacted"] is True + assert data["tokens_before"] > data["tokens_after"] > 0 + assert len(calls) == 1 + + svc.db.expire_all() + checkpoint = svc.get_latest_compaction_checkpoint(CHAT_ID) + assert checkpoint is not None + assert checkpoint.extra_data["notice_pending"] is True + # The next turn loads the replacement history, not the raw rows. + history = S._load_history(svc, CHAT_ID, repair=False) + assert C.is_summary_message(str(history[0]["content"])) + assert "the summary" in str(history[0]["content"]) + assert "second answer" in str(history[-1]["content"]) + assert not any("first question" in str(item["content"]) for item in history) + + # Compacting again right away finds nothing newer than the checkpoint. + again = await _post() + assert again.status_code == 200, again.text + assert again.json()["data"]["compacted"] is False + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_compact_reports_nothing_when_history_fits_the_kept_tail(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 1_000_000) + calls = _summary(monkeypatch) + + response = await _post() + + assert response.status_code == 200, response.text + data = response.json()["data"] + assert data["compacted"] is False + assert data["tokens_after"] is None + assert calls == [] + svc.db.expire_all() + assert svc.get_latest_compaction_checkpoint(CHAT_ID) is None + + +@pytest.mark.asyncio +async def test_compact_empty_chat_is_nothing_to_compact(svc, monkeypatch): + calls = _summary(monkeypatch) + + response = await _post() + + assert response.status_code == 200, response.text + assert response.json()["data"] == { + "compacted": False, + "tokens_before": None, + "tokens_after": None, + } + assert calls == [] + + +@pytest.mark.asyncio +async def test_compact_conflicts_with_an_active_run(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 10) + calls = _summary(monkeypatch) + svc.db.add( + ChatRun( + run_id="run_live", + chat_id=CHAT_ID, + user_id="owner", + message_id="m_live", + status="running", + ) + ) + svc.db.commit() + + response = await _post() + + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["code"] == "chat_busy" + assert detail["active_run"]["run_id"] == "run_live" + assert calls == [] + assert svc.get_latest_compaction_checkpoint(CHAT_ID) is None + + +@pytest.mark.asyncio +async def test_finished_run_does_not_block_compaction(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 10) + _summary(monkeypatch) + svc.db.add( + ChatRun( + run_id="run_done", + chat_id=CHAT_ID, + user_id="owner", + message_id="m4", + status="completed", + ) + ) + svc.db.commit() + + response = await _post() + + assert response.status_code == 200, response.text + assert response.json()["data"]["compacted"] is True + + +@pytest.mark.asyncio +async def test_other_users_chat_is_not_found(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 10) + calls = _summary(monkeypatch) + + response = await _post(user_id="intruder") + missing = await _post(chat_id="chat_does_not_exist") + + assert response.status_code == 404 + assert missing.status_code == 404 + assert calls == [] + assert svc.get_latest_compaction_checkpoint(CHAT_ID) is None + + +@pytest.mark.asyncio +async def test_compact_conflicts_with_a_running_compactor(svc, monkeypatch): + from datetime import timedelta + + from core.infra.time import utc_now + + _conversation(svc) + _keep_recent(monkeypatch, 10) + calls = _summary(monkeypatch) + state = svc._ensure_compaction_state(CHAT_ID) + state.lease_owner = "compactor:other" + state.lease_expires_at = utc_now() + timedelta(minutes=5) + svc.db.commit() + + response = await _post() + + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "compaction_busy" + assert calls == [] + + +@pytest.mark.asyncio +async def test_summary_failure_is_an_error_not_nothing_to_compact(svc, monkeypatch): + _conversation(svc) + _keep_recent(monkeypatch, 10) + _summary(monkeypatch, text="") + + response = await _post() + + assert response.status_code == 503 + svc.db.expire_all() + assert svc.get_latest_compaction_checkpoint(CHAT_ID) is None diff --git a/src/backend/tests/chat/test_mcp_result_payload.py b/src/backend/tests/chat/test_mcp_result_payload.py new file mode 100644 index 00000000..1d8bd012 --- /dev/null +++ b/src/backend/tests/chat/test_mcp_result_payload.py @@ -0,0 +1,78 @@ +"""MCP tool cards get the tool's own value, not the CallToolResult envelope. + +From 2026-10-22 the stream pushed ``metadata["mcp"]`` (the whole CallToolResult) +as the card result, so renderers keyed on the tool's fields found nothing (the +search card showed "暂无搜索结果"). Cards render the model-facing text again; the +structured value only stands in when that text is not JSON. +""" + +import asyncio +import json +from typing import Any, Dict + +from mcp.server.mcpserver import MCPServer + +from core.chat.tool_payloads import _build_mcp_result_payload, _sdk_wrapped_output + +DATASETS = {"public_datasets": [], "private_datasets": [], "total": 0} +SEARCH = {"result": {"status": "complete", "results": [{"title": "Doc", "url": "https://example.org"}]}} + + +def _schemas() -> dict: + server = MCPServer("schemas") + + @server.tool() + def typing_dict() -> Dict[str, Any]: + return {} + + @server.tool() + def builtin_dict() -> dict[str, Any]: + return {} + + @server.tool() + def text() -> str: + return "" + + return {tool.name: tool.output_schema for tool in asyncio.run(server.list_tools())} + + +def _envelope(structured) -> dict: + return {"content": [], "isError": False, "structuredContent": structured} + + +def test_sdk_wrap_detection_matches_the_installed_sdk(): + schemas = _schemas() + assert _sdk_wrapped_output(schemas["typing_dict"]) is True + assert _sdk_wrapped_output(schemas["text"]) is True + assert _sdk_wrapped_output(schemas["builtin_dict"]) is False + assert _sdk_wrapped_output({"x-fastmcp-wrap-result": True}) is True + assert _sdk_wrapped_output(None) is False + + +def test_complete_text_keeps_the_shape_and_citation_anchors(): + annotated = {"result": {"results": [{"title": "Doc", "cite_id": "e1"}]}} + text = json.dumps(annotated) + assert _build_mcp_result_payload(_envelope(SEARCH), None, text, annotated) == annotated + # A typing.Dict tool: structuredContent is wrapped, the text is the tool's own value. + wrapped_schema = _schemas()["typing_dict"] + assert ( + _build_mcp_result_payload(_envelope({"result": DATASETS}), wrapped_schema, json.dumps(DATASETS), DATASETS) + == DATASETS + ) + + +def test_truncated_text_falls_back_to_the_unwrapped_structured_value(): + schemas = _schemas() + clipped = '{"public_datasets": [〔截断' + legacy = {"result": clipped} + assert _build_mcp_result_payload(_envelope({"result": DATASETS}), schemas["typing_dict"], clipped, legacy) == DATASETS + assert _build_mcp_result_payload(_envelope(SEARCH), schemas["builtin_dict"], clipped, legacy) == SEARCH + # A wrapped string keeps the {"result": ...} shape the text path produces. + assert _build_mcp_result_payload(_envelope({"result": "full"}), schemas["text"], "fu", {"result": "fu"}) == { + "result": "full" + } + + +def test_tools_without_structured_content_keep_the_parsed_text(): + envelope = {"content": [{"type": "text", "text": "plain"}], "isError": False} + assert _build_mcp_result_payload(envelope, None, "plain", {"result": "plain"}) == {"result": "plain"} diff --git a/src/backend/tests/chat/test_message_length_limits.py b/src/backend/tests/chat/test_message_length_limits.py new file mode 100644 index 00000000..d8e2a36b --- /dev/null +++ b/src/backend/tests/chat/test_message_length_limits.py @@ -0,0 +1,52 @@ +"""User-message length limits: 100000 characters end to end.""" + +from __future__ import annotations + +import pytest +from core.chat.limits import CONTENT_MAX +from core.chat.operations.schemas import EditAndResendRequest, SteerChatRunRequest +from core.chat.schemas import ChatRequest +from core.chat.service import ChatService +from core.db.models import ChatMessage, ChatSession +from pydantic import ValidationError + +LIMIT = 100_000 + + +def test_limit_matches_the_stored_content_column(): + assert CONTENT_MAX == LIMIT + + +def test_chat_request_accepts_100000_and_rejects_100001(): + assert len(ChatRequest(chat_id="c", message="x" * LIMIT).message) == LIMIT + with pytest.raises(ValidationError): + ChatRequest(chat_id="c", message="x" * (LIMIT + 1)) + + +def test_steer_request_stays_within_the_steer_queue_limit(): + # chat_steer_queue.message keeps its 10000-character CHECK; the request must + # reject what the queue would refuse instead of failing inside it. + assert len(SteerChatRunRequest(steer_id="s", message="x" * 10_000).message) == 10_000 + with pytest.raises(ValidationError): + SteerChatRunRequest(steer_id="s", message="x" * 10_001) + + +def test_edit_and_resend_accepts_what_send_accepts(): + body = EditAndResendRequest(message_index=0, new_content="x" * LIMIT) + assert len(body.new_content) == LIMIT + with pytest.raises(ValidationError): + EditAndResendRequest(message_index=0, new_content="x" * (LIMIT + 1)) + + +def test_longest_accepted_message_is_stored_verbatim(db_session): + db_session.add(ChatSession(chat_id="chat_long", user_id="u1", title="long")) + db_session.commit() + line = "def f():\n return 1\n" + pasted = line * (LIMIT // len(line)) + "#" * (LIMIT % len(line)) + assert len(pasted) == LIMIT + + ChatService(db_session).add_message("chat_long", "user", pasted, message_id="m_long") + + db_session.expire_all() + stored = db_session.get(ChatMessage, "m_long").content + assert stored == pasted diff --git a/src/backend/tests/mcp/test_internet_search.py b/src/backend/tests/mcp/test_internet_search.py index 2486924c..d74c6e96 100644 --- a/src/backend/tests/mcp/test_internet_search.py +++ b/src/backend/tests/mcp/test_internet_search.py @@ -79,6 +79,38 @@ def test_langsearch_connectivity_check_rejects_api_error(monkeypatch) -> None: assert "invalid key" in result["error"] +def test_deepseek_connectivity_check_validates_api_payload(monkeypatch) -> None: + _FakeAsyncClient.calls = [] + _FakeAsyncClient.response = _FakeResponse({"results": []}) + monkeypatch.setattr(service_probes.httpx, "AsyncClient", _FakeAsyncClient) + + result = asyncio.run(service_probes.test_deepseek_search("test-key")) + + assert result["success"] is True + assert result["error"] is None + assert _FakeAsyncClient.calls[0] == { + "url": service_probes.DEEPSEEK_SEARCH_API_URL, + "headers": { + "Authorization": "Bearer test-key", + "Content-Type": "application/json", + }, + "json": {"query": "test", "count": 1}, + } + + +def test_deepseek_connectivity_check_rejects_http_error(monkeypatch) -> None: + _FakeAsyncClient.calls = [] + _FakeAsyncClient.response = _FakeResponse( + {"error": {"message": "Authentication Fails"}}, status_code=401 + ) + monkeypatch.setattr(service_probes.httpx, "AsyncClient", _FakeAsyncClient) + + result = asyncio.run(service_probes.test_deepseek_search("bad-key")) + + assert result["success"] is False + assert result["error"].startswith("HTTP 401") + + def test_service_group_uses_only_selected_engine_key(monkeypatch) -> None: class _FakeConfigService: values = { @@ -120,3 +152,13 @@ def test_langsearch_has_an_independent_system_config_key() -> None: assert config_keys.get_config_key_for_env("TAVILY_API_KEY") == ( "internet_search.tavily_api_key" ) + + +def test_deepseek_has_an_independent_system_config_key() -> None: + seed_keys = {item[0] for item in system_config_constants.SEED_CONFIGS} + + assert "internet_search.deepseek_api_key" in seed_keys + assert ( + config_keys.get_config_key_for_env("DEEPSEEK_SEARCH_API_KEY") + == "internet_search.deepseek_api_key" + ) diff --git a/src/backend/tests/mcp_servers/test_search_batch.py b/src/backend/tests/mcp_servers/test_search_batch.py index 89f7526b..bd7f5814 100644 --- a/src/backend/tests/mcp_servers/test_search_batch.py +++ b/src/backend/tests/mcp_servers/test_search_batch.py @@ -87,11 +87,12 @@ def test_query_normalization(): assert SearchRequest(queries=[" A ", "", "A", "B"]).query_list() == ["A", "B"] -@pytest.mark.parametrize("engine", ["baidu", "langsearch", "tavily"]) +@pytest.mark.parametrize("engine", ["baidu", "deepseek", "langsearch", "tavily"]) def test_provider_contract_and_country(monkeypatch, engine): settings = { "INTERNET_SEARCH_ENGINE": engine, "BAIDU_API_KEY": "test", + "DEEPSEEK_SEARCH_API_KEY": "test", "LANGSEARCH_API_KEY": "test", "TAVILY_API_KEY": "test", } @@ -112,6 +113,18 @@ async def handler(request): } if engine == "baidu": payload = {"references": [{**source, "date": "2026-09-20"}]} + elif engine == "deepseek": + payload = { + "results": [ + { + "title": source["title"], + "url": source["url"], + "summary": source["content"], + "date": "2026-09-20T16:00:00Z", + "rerank_score": 2.0, + } + ] + } elif engine == "langsearch": payload = { "code": 200, @@ -149,6 +162,101 @@ async def handler(request): asyncio.run(run()) +def test_deepseek_request_and_result_mapping(monkeypatch): + import json + from datetime import date + + monkeypatch.setattr( + "extensions.mcp_servers.internet_search_mcp.impl.get_runtime_value", + lambda name: {"INTERNET_SEARCH_ENGINE": "deepseek", "DEEPSEEK_SEARCH_API_KEY": "sk"}.get( + name + ), + ) + + async def run(): + calls = [] + + async def handler(request): + calls.append((request, json.loads(request.content))) + return httpx.Response( + 200, + json={ + "results": [ + { + "rerank_score": 2.4, + "title": "Relevant", + "url": "https://example.org/a", + "snippet": "short", + "summary": "long model summary", + "content": "full page", + "date": "2026-10-01T16:00:00Z", + "authority": 3, + }, + { + "rerank_score": 0.1, + "title": "Noise", + "url": "https://example.org/noise", + "snippet": "noise", + }, + {"title": "Unscored", "url": "https://example.org/b", "snippet": "only"}, + ] + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + service = SearchService(client) + result = await service.search( + SearchRequest(query="docs", max_results=3, include_raw_content=True) + ) + request, payload = calls[-1] + assert str(request.url) == "https://api.deepseek.com/v1/tools/search" + assert request.headers["Authorization"] == "Bearer sk" + assert payload == {"query": "docs", "count": 6, "need_content": True} + assert [r["url"] for r in result["results"]] == [ + "https://example.org/a", + "https://example.org/b", + ] + first = result["results"][0] + assert first["content"] == "long model summary" + assert first["raw_content"] == "full page" + assert first["published_date"] == "2026-10-01T16:00:00Z" + assert result["results"][1]["content"] == "only" + + await service.search(SearchRequest(query="fed rate", topic="news")) + news_query = calls[-1][1]["query"] + assert news_query.startswith("fed rate after:") + assert date.fromisoformat(news_query.rsplit(":", 1)[1]) < date.today() + assert calls[-1][1]["need_content"] is False + + sent = len(calls) + too_long = await service.search(SearchRequest(query="x" * 129)) + assert len(calls) == sent + assert too_long["status"] == "error" + assert too_long["query_statuses"][0]["error_code"] == "query_too_long" + + asyncio.run(run()) + + +def test_deepseek_rejects_malformed_results(monkeypatch): + monkeypatch.setattr( + "extensions.mcp_servers.internet_search_mcp.impl.get_runtime_value", + lambda name: {"INTERNET_SEARCH_ENGINE": "deepseek", "DEEPSEEK_SEARCH_API_KEY": "sk"}.get( + name + ), + ) + + async def run(): + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response(200, json={"results": ["not a row"]}) + ) + ) as client: + result = await SearchService(client).search(SearchRequest(query="a")) + assert result["query_statuses"][0]["error_code"] == "invalid_response" + + asyncio.run(run()) + + def test_partial_failure_still_gets_citations(monkeypatch): import json diff --git a/src/backend/tests/sandbox/test_os_sandbox.py b/src/backend/tests/sandbox/test_os_sandbox.py index 0262b490..4d374e3f 100644 --- a/src/backend/tests/sandbox/test_os_sandbox.py +++ b/src/backend/tests/sandbox/test_os_sandbox.py @@ -308,6 +308,137 @@ def test_seatbelt_carves_protected_metadata_out_of_the_write_grant(tmp_path): assert r"\.git" in profile +def _nested_workspace_policy(tmp_path): + """The desktop layout: the workspace lives inside the denied data directory.""" + state = tmp_path / "data" + workspace = state / "workspace" + workspace.mkdir(parents=True) + (state / "data.db").write_text("secret", encoding="utf-8") + script = workspace / "script.sh" + script.write_text("cat script.sh >/dev/null && echo NESTED_OK\n", encoding="utf-8") + policy = SandboxPolicy( + filesystem=build_filesystem_policy(writable_roots=[str(workspace)], denied_paths=[str(state)]) + ) + return policy, state, workspace, script + + +def test_seatbelt_reopens_reads_for_a_workspace_inside_a_denied_directory(tmp_path): + policy, state, workspace, _ = _nested_workspace_policy(tmp_path) + _, argv = _seatbelt_profile(policy, _context(tmp_path, cwd=str(workspace))) + readable = {a.split("=", 1)[1] for a in argv if a.startswith("-DREADABLE_ROOT_") and "_EXCLUDED_" not in a} + excluded = {a.split("=", 1)[1] for a in argv if a.startswith("-DREADABLE_ROOT_0_EXCLUDED_")} + assert os.path.realpath(workspace) in readable + assert os.path.realpath(state) in excluded + + +@pytest.mark.skipif( + sys.platform != "darwin" or not os.path.exists("/usr/bin/sandbox-exec"), + reason="needs macOS sandbox-exec", +) +def test_seatbelt_runs_a_script_in_a_nested_workspace_but_hides_the_data_directory(tmp_path): + import subprocess + + policy, state, workspace, script = _nested_workspace_policy(tmp_path) + _, argv = _seatbelt_profile(policy, _context(tmp_path, cwd=str(workspace))) + ran = subprocess.run([*argv, "/bin/bash", str(script)], cwd=workspace, capture_output=True, text=True) + assert ran.returncode == 0, ran.stderr + assert ran.stdout.strip() == "NESTED_OK" + leaked = subprocess.run([*argv, "/bin/cat", str(state / "data.db")], cwd=workspace, capture_output=True, text=True) + assert leaked.returncode != 0 and "secret" not in leaked.stdout + + +def _capability_store(tmp_path): + """A capability store laid out like the desktop one, credentials included.""" + caps = tmp_path / "caps" + builtin = caps / "skills" / "builtin" / "docx" / "rev1" + plugin = caps / "plugins" / "local" / "visualize" / "rev1" + plugin_skill = plugin / "package" / "skills" / "visualize" + view = caps / ".capabilities" / "views" / ("a" * 64) / "skills" + for directory in (builtin, plugin_skill / "scripts", view): + directory.mkdir(parents=True) + (plugin_skill / "scripts" / "render.py").write_text("print('RENDER_OK')\n", encoding="utf-8") + (plugin / "plugin.json").write_text('{"secret": "plugin-manifest"}', encoding="utf-8") + (caps / "mcp.json").write_text('{"token": "mcp-secret"}', encoding="utf-8") + outside = tmp_path / "outside" / "skills" + outside.mkdir(parents=True) + escaped = caps / "plugins" / "local" / "evil" / "rev1" / "package" + escaped.mkdir(parents=True) + (escaped / "skills").symlink_to(outside, target_is_directory=True) + return caps, plugin_skill + + +def test_only_skill_directories_of_the_capability_store_are_reopened(tmp_path, monkeypatch): + from core.sandbox.permissions import os_gate + + caps, plugin_skill = _capability_store(tmp_path) + monkeypatch.setenv("HUGAGENT_CAPS_ROOT", str(caps)) + roots = os_gate.capability_skill_roots() + assert roots == ( + str(caps / "skills"), + str(plugin_skill.parent), + str(caps / ".capabilities" / "views" / ("a" * 64) / "skills"), + ) + for credential in (caps / "mcp.json", caps / "plugins" / "local" / "visualize" / "rev1" / "plugin.json"): + assert not any(str(credential).startswith(root + os.sep) for root in roots) + monkeypatch.delenv("HUGAGENT_CAPS_ROOT") + assert os_gate.capability_skill_roots() == () + + +@pytest.mark.skipif( + sys.platform != "darwin" or not os.path.exists("/usr/bin/sandbox-exec"), + reason="needs macOS sandbox-exec", +) +def test_seatbelt_lets_a_skill_script_run_while_store_credentials_stay_hidden(tmp_path, monkeypatch): + import subprocess + + from core.sandbox.permissions import os_gate + + caps, plugin_skill = _capability_store(tmp_path) + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.setenv("HUGAGENT_CAPS_ROOT", str(caps)) + policy = SandboxPolicy( + filesystem=build_filesystem_policy( + writable_roots=[str(workspace)], + readable_roots=list(os_gate.capability_skill_roots()), + denied_paths=[str(caps)], + ) + ) + _, argv = _seatbelt_profile(policy, _context(tmp_path, cwd=str(workspace))) + + def cat(path): + return subprocess.run([*argv, "/bin/cat", str(path)], cwd=workspace, capture_output=True, text=True) + + assert cat(plugin_skill / "scripts" / "render.py").stdout.strip() == "print('RENDER_OK')" + for secret in (caps / "mcp.json", caps / "plugins" / "local" / "visualize" / "rev1" / "plugin.json"): + leaked = cat(secret) + assert leaked.returncode != 0 and "secret" not in leaked.stdout + wrote = subprocess.run( + [*argv, "/bin/sh", "-c", f"echo x > {plugin_skill / 'scripts' / 'render.py'}"], + cwd=workspace, capture_output=True, text=True, + ) + assert wrote.returncode != 0, "skill directories are reopened for reading only" + + +def test_bwrap_binds_readable_roots_back_inside_a_masked_directory(tmp_path): + state = tmp_path / "data" + workspace = state / "workspace" + skills = state / "plugins" / "local" / "visualize" / "rev1" / "package" / "skills" + workspace.mkdir(parents=True) + skills.mkdir(parents=True) + policy = SandboxPolicy( + filesystem=build_filesystem_policy( + writable_roots=[str(workspace)], readable_roots=[str(skills)], denied_paths=[str(state)] + ) + ) + resolved = policy.filesystem.resolve(_context(tmp_path, cwd=str(workspace))) + args = BubblewrapBackend()._denied_args(resolved, [str(workspace)]) + triples = [args[index : index + 3] for index in range(len(args) - 2)] + assert ["--bind", str(workspace), str(workspace)] in triples + assert ["--ro-bind", str(skills), str(skills)] in triples + assert args.index("--remount-ro") > args.index(str(skills)) + + # ── Windows ───────────────────────────────────────────────────────────────── diff --git a/src/backend/tests/sandbox/test_runner_user_env.py b/src/backend/tests/sandbox/test_runner_user_env.py new file mode 100644 index 00000000..97714121 --- /dev/null +++ b/src/backend/tests/sandbox/test_runner_user_env.py @@ -0,0 +1,119 @@ +"""Commands in a user's own folder get the user's environment; runner scratch keeps the clean one.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from core.sandbox.runner import user_env + + +def test_login_output_ignores_profile_noise_before_the_marker(): + raw = b"Welcome!\n" + user_env._MARKER.encode() + b"PATH=/opt/homebrew/bin:/usr/bin\0HOME=/Users/u\0MULTI=a\nb\0" + assert user_env._parse_login_output(raw) == { + "PATH": "/opt/homebrew/bin:/usr/bin", "HOME": "/Users/u", "MULTI": "a\nb", + } + assert user_env._parse_login_output(b"no marker at all") == {} + + +def test_credentials_and_backend_internals_never_follow(): + kept = user_env._filtered({ + "PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/agent", "LANG": "zh_CN.UTF-8", + "OPENAI_API_KEY": "x", "GITHUB_TOKEN": "x", "AWS_SECRET_ACCESS_KEY": "x", "DB_PASSWORD": "x", + "HUGAGENT_DESKTOP_BRIDGE_SECRET": "x", "DATABASE_URL": "x", "SANDBOX_RUNNER_URL": "x", "PWD": "/x", + }) + assert kept == {"PATH": "/usr/bin", "SSH_AUTH_SOCK": "/tmp/agent", "LANG": "zh_CN.UTF-8"} + + +def test_only_folders_outside_the_runner_workspace_count_as_the_users(tmp_path): + workspace = tmp_path / "workspace" + (workspace / ".sessions" / "abc").mkdir(parents=True) + project = tmp_path / "repo" + project.mkdir() + assert user_env.is_user_folder(project, str(workspace)) + assert not user_env.is_user_folder(workspace / ".sessions" / "abc", str(workspace)) + + +@pytest.fixture +def user_login(monkeypatch): + def install(environment): + monkeypatch.setattr(user_env, "login_environment", lambda: dict(environment)) + return install + + +def test_user_path_wins_and_managed_tools_follow(tmp_path, user_login): + user_login({"PATH": "/opt/homebrew/bin:/usr/bin", "HOME": "/Users/u", "NODE_PATH": "/u/node", + "OFFICECLI_RESIDENT_FLUSH": "off"}) + managed = {"PATH": "/venv/bin:/usr/local/bin:/usr/bin", "HOME": "/Users/u", "PY_BIN": "/venv/bin/python", + "NODE_PATH": "/caps/node_modules", "SITE_NODE_BASE": "/ws/.site-node", "XDG_CACHE_HOME": "/tmp/.cache", + "OFFICECLI_RESIDENT_FLUSH": "each"} + env = user_env.project_environment(managed, str(tmp_path / "workspace"), [str(tmp_path / "skills")]) + entries = env["PATH"].split(os.pathsep) + if os.name != "nt": + assert entries[0] == env[user_env.SHIM_DIR_ENV] + entries = entries[1:] + assert entries == ["/opt/homebrew/bin", "/usr/bin", "/venv/bin", "/usr/local/bin"] + assert env["PY_BIN"] == "/venv/bin/python" + assert env["SITE_NODE_BASE"] == "/ws/.site-node" + assert env["NODE_PATH"] == os.pathsep.join(["/caps/node_modules", "/u/node"]) + assert env["XDG_CACHE_HOME"] == "/tmp/.cache" # the user set none; the runner's stays + assert env["OFFICECLI_RESIDENT_FLUSH"] == "each" # the resident dies with each command + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX shim") +def test_shim_sends_skill_scripts_to_the_bundled_interpreter_and_the_rest_to_the_users(tmp_path, user_login): + def fake(directory, label): + directory.mkdir(parents=True) + target = directory / "python3" + target.write_text(f"#!/bin/sh\necho {label} \"$@\"\n") + target.chmod(0o755) + return target + + bundled = fake(tmp_path / "bundled", "BUNDLED") + fake(tmp_path / "userbin", "USER") + skills = tmp_path / "workspace" / "skills" + (skills / "visualize" / "scripts").mkdir(parents=True) + user_login({"PATH": f"{tmp_path / 'userbin'}:/usr/bin:/bin", "HOME": str(tmp_path)}) + env = user_env.project_environment( + {"PATH": "/usr/bin:/bin", "PY_BIN": str(bundled)}, str(tmp_path / "workspace"), [str(skills)] + ) + + def run(*args): + return subprocess.run(["python3", *args], env=env, capture_output=True, text=True, check=True).stdout.strip() + + script = str(skills / "visualize" / "scripts" / "render.py") + assert run(script, "a.html") == f"BUNDLED {script} a.html" + assert run("-u", script) == f"BUNDLED -u {script}" + assert run("-m", "pytest", "-q") == "USER -m pytest -q" + assert run(str(tmp_path / "repo" / "main.py")) == f"USER {tmp_path / 'repo' / 'main.py'}" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX shim") +def test_shim_falls_back_to_the_bundled_interpreter_when_the_user_has_none(tmp_path, user_login): + bundled = tmp_path / "bundled" / "python3" + bundled.parent.mkdir() + bundled.write_text("#!/bin/sh\necho BUNDLED\n") + bundled.chmod(0o755) + empty = tmp_path / "empty" + empty.mkdir() + user_login({"PATH": str(empty), "HOME": str(tmp_path)}) + env = user_env.project_environment({"PATH": str(empty), "PY_BIN": str(bundled)}, str(tmp_path / "ws"), []) + shim = os.path.join(env[user_env.SHIM_DIR_ENV], "python3") + done = subprocess.run([shim, "x.py"], env=env, capture_output=True, text=True, check=True) + assert done.stdout.strip() == "BUNDLED" + + +@pytest.mark.skipif(os.name == "nt" or not os.path.exists("/bin/zsh") and not os.path.exists("/bin/bash"), + reason="needs a POSIX login shell") +def test_the_real_login_shell_answers(monkeypatch): + user_env.login_environment.cache_clear() + try: + environment = user_env.login_environment() + finally: + user_env.login_environment.cache_clear() + assert environment.get("PATH") + assert not any(user_env._SECRET_NAME.search(name) for name in environment) + assert sys.executable # the capture never needs the backend's own interpreter diff --git a/src/backend/tests/services/test_local_instruction_hierarchy.py b/src/backend/tests/services/test_local_instruction_hierarchy.py new file mode 100644 index 00000000..1d7ff0bb --- /dev/null +++ b/src/backend/tests/services/test_local_instruction_hierarchy.py @@ -0,0 +1,200 @@ +"""Local-project AGENTS.md hierarchy (CLI contract §8.2): global, git root ... project.""" + +from __future__ import annotations + +import os + +import pytest + +from core.projects.instructions import inherited_local_instructions +from prompts import prompt_runtime # first: resolves the prompt_runtime <-> project_section cycle +from prompts import project_section +from prompts.project_section import _build_local_project_section + + +@pytest.fixture +def layout(tmp_path, monkeypatch): + home = tmp_path / "home" + data = tmp_path / "hugagent-home" + home.mkdir() + data.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HUGAGENT_HOME", str(data)) + return home, data + + +def _write(directory, text): + directory.mkdir(parents=True, exist_ok=True) + (directory / "AGENTS.md").write_text(text, encoding="utf-8") + + +def _paths(entries): + return [os.path.dirname(path) for path, _ in entries] + + +def test_order_is_global_then_git_root_down_and_excludes_project_root(layout): + home, data = layout + repo = home / "code" / "repo" + project = repo / "pkg" / "app" + project.mkdir(parents=True) + (repo / ".git").mkdir() + _write(data, "global") + _write(home, "home rules are above the git root") + _write(home / "code", "outside the repo") + _write(repo, "repo") + _write(repo / "pkg", "pkg") + _write(project, "project root stays with the instructions service") + + entries = inherited_local_instructions(str(project)) + assert [text for _, text in entries] == ["global", "repo", "pkg"] + assert _paths(entries) == [str(data), str(repo.resolve()), str((repo / "pkg").resolve())] + + +def test_git_worktree_file_marks_the_root(layout): + home, _ = layout + repo = home / "wt" + project = repo / "src" + project.mkdir(parents=True) + (repo / ".git").write_text("gitdir: /elsewhere\n") + _write(home, "home") + _write(repo, "worktree root") + assert [text for _, text in inherited_local_instructions(str(project))] == ["worktree root"] + + +def test_without_git_walks_up_to_home_but_never_above(layout, tmp_path): + home, _ = layout + project = home / "notes" / "2026" + project.mkdir(parents=True) + _write(tmp_path, "above home must never be read") + _write(home, "home") + _write(home / "notes", "notes") + entries = inherited_local_instructions(str(project)) + assert [text for _, text in entries] == ["home", "notes"] + + +def test_git_root_above_home_is_ignored(layout, tmp_path): + home, _ = layout + (tmp_path / ".git").mkdir() + _write(tmp_path, "above home") + project = home / "p" + project.mkdir() + _write(home, "home") + assert [text for _, text in inherited_local_instructions(str(project))] == ["home"] + + +def test_outside_home_without_git_reads_only_global(layout, tmp_path): + _, data = layout + project = tmp_path / "srv" / "proj" + project.mkdir(parents=True) + _write(tmp_path / "srv", "no bound, not read") + _write(data, "global") + assert [text for _, text in inherited_local_instructions(str(project))] == ["global"] + + +def test_project_at_git_root_or_home_has_no_ancestors(layout): + home, _ = layout + _write(home, "home is the project itself") + assert inherited_local_instructions(str(home)) == [] + repo = home / "repo" + (repo / ".git").mkdir(parents=True) + _write(repo, "repo root is the project itself") + assert inherited_local_instructions(str(repo)) == [] + + +def test_symlinked_oversized_invalid_and_unreadable_files_are_skipped(layout, tmp_path): + home, data = layout + project = home / "a" / "b" / "c" / "d" / "e" + project.mkdir(parents=True) + (tmp_path / "target.md").write_text("via symlink") + (data / "AGENTS.md").symlink_to(tmp_path / "target.md") + (home / "a" / "AGENTS.md").write_bytes(b"x" * (32 * 1024 + 1)) + (home / "a" / "b" / "AGENTS.md").write_bytes(b"\xff\xfe bad utf-8") + (home / "a" / "b" / "c" / "AGENTS.md").mkdir() + _write(home / "a" / "b" / "c" / "d", "kept") + unreadable = home / "AGENTS.md" + unreadable.write_text("secret") + unreadable.chmod(0) + try: + expected = ["kept"] if not os.access(unreadable, os.R_OK) else ["secret", "kept"] + assert [text for _, text in inherited_local_instructions(str(project))] == expected + finally: + unreadable.chmod(0o600) + + +def test_invalid_local_paths_yield_nothing(layout, tmp_path): + assert inherited_local_instructions("") == [] + assert inherited_local_instructions("relative") == [] + assert inherited_local_instructions(str(tmp_path / "missing")) == [] + + +def test_section_labels_each_file_with_root_last(): + section = _build_local_project_section( + project_name="P", + project_instructions="root rules", + local_path="/home/u/repo/app", + local_slug="p-1", + inherited_instructions=[("/home/u/.hugagent/AGENTS.md", "global"), ("/home/u/repo/AGENTS.md", "repo")], + ) + order = [section.index(marker) for marker in ( + "`/home/u/.hugagent/AGENTS.md`\nglobal", + "`/home/u/repo/AGENTS.md`\nrepo", + "`/home/u/repo/app/AGENTS.md`\nroot rules", + )] + assert order == sorted(order) + + +def test_section_without_inherited_files_is_unchanged(): + kwargs = dict(project_name="P", project_instructions="root", local_path="/p", local_slug="p") + assert _build_local_project_section(**kwargs) == _build_local_project_section( + **kwargs, inherited_instructions=[] + ) + assert "`/p/AGENTS.md`" not in _build_local_project_section(**kwargs) + + +@pytest.fixture +def plain_prompt(monkeypatch): + monkeypatch.setattr(prompt_runtime, "_load_db_prompt_parts", lambda: {}) + monkeypatch.setattr(prompt_runtime, "_get_db_prompt_version", lambda: "test") + monkeypatch.setattr(project_section, "_load_db_prompt_parts", lambda: {}) + prompt_runtime.invalidate_prompt_cache() + yield + prompt_runtime.invalidate_prompt_cache() + + +def _build(project, *, local): + from prompts.prompt_config import PromptConfig, SystemPromptConfig + + config = PromptConfig(system_prompt=SystemPromptConfig(provider="inline", inline_template="BASE")) + return prompt_runtime.build_system_prompt(config, ctx={ + "project_id": "prj_1", "project_name": "P", "project_instructions": "root rules", + "project_is_local": local, "project_kind": "local" if local else "personal", + "project_local_path": str(project), "project_local_slug": "p-1", "now": "2026-10-24", + }) + + +def test_system_prompt_injects_hierarchy_and_busts_cache(layout, plain_prompt, monkeypatch): + home, data = layout + monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: True) + project = home / "repo" / "app" + project.mkdir(parents=True) + (home / "repo" / ".git").mkdir() + _write(data, "GLOBAL-MARKER") + _write(home / "repo", "REPO-MARKER") + first = _build(project, local=True) + assert first.index("GLOBAL-MARKER") < first.index("REPO-MARKER") < first.index("root rules") + _write(home / "repo", "REPO-EDITED") + assert "REPO-EDITED" in _build(project, local=True) + + +def test_cloud_prompt_never_reads_host_files(layout, plain_prompt, monkeypatch): + home, data = layout + monkeypatch.setattr("core.config.local_mode.local_mode_enabled", lambda: False) + _write(data, "GLOBAL-MARKER") + project = home / "p" + project.mkdir() + called = [] + monkeypatch.setattr( + "core.projects.instructions.inherited_local_instructions", lambda path: called.append(path) or [] + ) + prompt = _build(project, local=False) + assert "GLOBAL-MARKER" not in prompt and called == [] diff --git a/src/backend/tests/test_cli_engine.py b/src/backend/tests/test_cli_engine.py new file mode 100644 index 00000000..38bfd6e3 --- /dev/null +++ b/src/backend/tests/test_cli_engine.py @@ -0,0 +1,346 @@ +"""`hugagent-server engine` and the lightweight session client's entry/transport. + +The engine contract is packages/cli/DESIGN.md §8.1. +""" + +import asyncio +import json +import os +import stat +import subprocess +import sys +import textwrap +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +import cli + +BACKEND = Path(__file__).resolve().parents[1] + + +class _Handler(BaseHTTPRequestHandler): + routes: dict = {} + + def do_GET(self): + self._reply() + + def do_POST(self): + self._reply() + + def do_DELETE(self): + self._reply() + + def _reply(self): + status, body = self.routes.get((self.command, self.path.split("?")[0]), (404, b"")) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +@pytest.fixture +def http_server(): + routes: dict = {} + handler = type("Handler", (_Handler,), {"routes": routes}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", routes + finally: + server.shutdown() + server.server_close() + + +@pytest.fixture +def home(tmp_path, monkeypatch): + path = (tmp_path / "home").resolve() + monkeypatch.setenv("HUGAGENT_HOME", str(path)) + return path + + +def _ready(url, home, pid=None): + return { + "version": 1, + "url": url, + "token": "sk-jx-test", + "pid": os.getpid() if pid is None else pid, + "started_at": "2026-10-24T00:00:00+00:00", + "home": str(home), + } + + +# ── ready file ──────────────────────────────────────────────────────────────── + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits") +def test_ready_file_is_private_and_replaced_atomically(tmp_path): + target = tmp_path / "nested" / "engine.json" + target.parent.mkdir() + target.write_text("old") + target.chmod(0o644) + payload = _ready("http://127.0.0.1:1234", tmp_path) + + cli._write_ready_file(target, payload) + + assert json.loads(target.read_text()) == payload + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + assert sorted(p.name for p in target.parent.iterdir()) == ["engine.json"] + + +def test_ready_file_is_removed_only_by_its_engine(tmp_path): + mine, theirs = tmp_path / "mine.json", tmp_path / "theirs.json" + cli._write_ready_file(mine, _ready("http://127.0.0.1:1", tmp_path)) + cli._write_ready_file(theirs, _ready("http://127.0.0.1:1", tmp_path, pid=os.getpid() + 1)) + + cli._remove_ready_file(mine) + cli._remove_ready_file(theirs) + cli._remove_ready_file(tmp_path / "missing.json") + + assert not mine.exists() and theirs.exists() + + +def test_live_engine_needs_matching_home_live_pid_and_health(tmp_path, http_server): + url, routes = http_server + ready = tmp_path / "engine.json" + routes[("GET", "/health")] = (200, b"{}") + + cli._write_ready_file(ready, _ready(url, tmp_path)) + assert cli._read_live_engine(ready, tmp_path)["url"] == url + assert cli._read_live_engine(ready, tmp_path / "other") is None + + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + cli._write_ready_file(ready, _ready(url, tmp_path, pid=dead.pid)) + assert cli._read_live_engine(ready, tmp_path) is None + + cli._write_ready_file(ready, _ready(url, tmp_path)) + routes[("GET", "/health")] = (503, b"{}") + assert cli._read_live_engine(ready, tmp_path) is None + + cli._write_ready_file(ready, _ready("http://example.com:80", tmp_path)) + assert cli._read_live_engine(ready, tmp_path) is None + + +# ── command line ───────────────────────────────────────────────────────────── + + +def test_engine_arguments(): + args = cli.build_parser().parse_args(["engine"]) + assert (args.command, args.port, args.idle_timeout, args.ready_file) == ("engine", 0, 900, None) + args = cli.build_parser().parse_args( + ["engine", "--port", "4100", "--idle-timeout", "5", "--ready-file", "/x/e.json"] + ) + assert (args.port, args.idle_timeout, args.ready_file) == (4100, 5, "/x/e.json") + assert args.func is cli.cmd_engine + + +def test_engine_refuses_uninitialized_home(home, capsys): + assert cli.main(["engine"]) == cli.EXIT_NOT_INITIALIZED == 3 + captured = capsys.readouterr() + assert json.loads(captured.err) == {"error": "not_initialized"} + # Nothing was started or created for the uninitialized home. + assert not (home / "data.db").exists() and not (home / "engine.json").exists() + + +def test_engine_reuses_live_engine_of_this_home(home, http_server, capsys): + url, routes = http_server + routes[("GET", "/health")] = (200, b"{}") + cli._write_ready_file(home / "engine.json", _ready(url, home)) + + assert cli.main(["engine"]) == 0 + assert json.loads(capsys.readouterr().out) == _ready(url, home) + + +def test_engine_lock_is_exclusive(tmp_path): + lock = cli._try_engine_lock(tmp_path / "engine.lock") + assert lock is not None + try: + probe = subprocess.run( + [sys.executable, "-c", textwrap.dedent(f""" + import sys; sys.path.insert(0, {str(BACKEND)!r}) + import cli + from pathlib import Path + print(cli._try_engine_lock(Path({str(tmp_path / 'engine.lock')!r})) is None) + """)], + capture_output=True, text=True, timeout=60, + ) + assert probe.stdout.strip() == "True", probe.stderr + finally: + lock.close() + + +def test_activity_tracker_counts_requests_in_flight(): + gate = asyncio.Event() + + async def app(scope, receive, send): + if scope["type"] == "http": + await gate.wait() + + tracker = cli._ActivityTracker(app) + + async def scenario(): + request = asyncio.ensure_future(tracker({"type": "http"}, None, None)) + await asyncio.sleep(0) + assert tracker.in_flight == 1 and tracker.idle_for() == 0.0 + await tracker({"type": "lifespan"}, None, None) + assert tracker.in_flight == 1 + gate.set() + await request + assert tracker.in_flight == 0 + + asyncio.run(scenario()) + tracker.last_activity -= 30 + assert tracker.idle_for() >= 30 + + +def test_engine_token_rotates_and_authenticates(tmp_path): + """Each start mints a fresh `hugagent-cli` key and revokes the previous one.""" + script = textwrap.dedent(""" + import json, cli + cli.apply_local_env(3999) + from hugagent_cli.schema import ensure_schema_and_seed + ensure_schema_and_seed() + assert cli._engine_admin_user_id() is None + user_id = cli.bootstrap_admin("admin", "Passw0rd!x") + assert cli._engine_admin_user_id() == user_id + import api.app # noqa: F401 (composes the run services, as _run_engine does) + first = cli.issue_engine_token(user_id) + from core.auth.api_keys import resolve_api_key_identity + from core.db.engine import SessionLocal + from core.db.models import ChatRun, ChatSession + from core.runs.journal import RunJournal + db = SessionLocal() + try: + # A run the previous engine started with its key and left unfinished. + _owner, first_key = resolve_api_key_identity(db, first) + db.add(ChatSession(chat_id="left-over", user_id=user_id)) + db.commit() + RunJournal(SessionLocal).accept( + run_id="run-left-over", message_id="reply-left-over", chat_id="left-over", + user_id=user_id, request_payload={"api_key_id": first_key.id}, + ) + finally: + db.close() + second = cli.issue_engine_token(user_id) + db = SessionLocal() + try: + print(json.dumps({ + "first": resolve_api_key_identity(db, first) is not None, + "second": resolve_api_key_identity(db, second) is not None, + "prefix": second.startswith("sk-jx-"), + "left_over": db.get(ChatRun, "run-left-over").status, + })) + finally: + db.close() + """) + keep = ("PATH", "HOME", "TMPDIR", "SYSTEMROOT", "LANG") + env = {k: v for k, v in os.environ.items() if k in keep} + env.update(HUGAGENT_HOME=str(tmp_path / "home"), PYTHONPATH=str(BACKEND)) + result = subprocess.run( + [sys.executable, "-c", script], cwd=BACKEND, env=env, + capture_output=True, text=True, timeout=300, + ) + assert result.returncode == 0, result.stderr[-4000:] + assert json.loads(result.stdout.strip().splitlines()[-1]) == { + "first": False, "second": True, "prefix": True, "left_over": "cancelled", + } + + +# ── lightweight session client ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "argv, command", + [ + (["serve"], "serve"), + (["--port", "3002", "serve"], "serve"), + (["--host", "0.0.0.0", "--port=3002", "onboard"], "onboard"), + (["--host=127.0.0.1", "engine", "--port", "0"], "engine"), + (["chat", "list"], None), + (["--server", "http://x", "serve"], None), + (["--port"], None), + ([], None), + ], +) +def test_session_client_finds_server_command_after_leading_options(argv, command): + from hugagent_cli import main as client + + assert client.server_command(argv) == command + + +def test_session_client_points_server_commands_to_hugagent_server(capsys): + from hugagent_cli import main as client + + assert client.main(["--port", "3002", "serve"]) == 2 + error = json.loads(capsys.readouterr().err)["error"] + assert "hugagent-server serve" in error and "lightweight client" in error + + +@pytest.mark.parametrize( + "body, expected", + [ + (b'{"detail": "Chat not found"}', "HugAgentOS HTTP 404: Chat not found"), + ( + b'{"detail": {"code": 30001, "message": "Authorization required"}}', + "HugAgentOS HTTP 404: Authorization required", + ), + (b'{"code": 40400, "message": "Gone\\nnow", "data": null}', "HugAgentOS HTTP 404: Gone now"), + (b'{"detail": [{"loc": ["body"], "msg": "field required"}]}', "HugAgentOS HTTP 404: field required"), + (b"bad gateway", "HugAgentOS HTTP 404: request refused"), + (b"", "HugAgentOS HTTP 404: request refused"), + ], +) +def test_transport_reports_server_error_detail(http_server, body, expected): + from hugagent_cli.transport import Client + + url, routes = http_server + routes[("GET", "/v1/x")] = (404, body) + with pytest.raises(RuntimeError) as error: + Client(url, "sk-jx-t").request("GET", "/v1/x") + assert str(error.value) == expected + + +def test_transport_bounds_error_detail(http_server): + from hugagent_cli.transport import DETAIL_LIMIT, Client + + url, routes = http_server + routes[("GET", "/v1/x")] = (400, json.dumps({"detail": "y" * 5000}).encode()) + with pytest.raises(RuntimeError) as error: + Client(url, "sk-jx-t").request("GET", "/v1/x") + assert len(str(error.value)) == len("HugAgentOS HTTP 400: ") + DETAIL_LIMIT + + +def test_transport_empty_body_means_deleted_only_for_delete(http_server): + from hugagent_cli.transport import Client + + url, routes = http_server + for method in ("GET", "POST", "DELETE"): + routes[(method, "/v1/x")] = (200, b"") + client = Client(url, "sk-jx-t") + assert client.request("DELETE", "/v1/x") == {"deleted": True} + assert client.request("GET", "/v1/x") == {} + assert client.request("POST", "/v1/x", {}) == {} + + +def test_activity_tracker_withdraws_ready_file_when_shutdown_begins(): + seen = [] + messages = iter([{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]) + + async def receive(): + return next(messages) + + async def app(scope, receive, send): + seen.append(((await receive())["type"], len(withdrawn))) + seen.append(((await receive())["type"], len(withdrawn))) + + withdrawn = [] + tracker = cli._ActivityTracker(app, on_shutdown=lambda: withdrawn.append(1)) + asyncio.run(tracker({"type": "lifespan"}, receive, None)) + assert seen == [("lifespan.startup", 0), ("lifespan.shutdown", 1)] diff --git a/src/backend/tests/test_personal_agent_fixes.py b/src/backend/tests/test_personal_agent_fixes.py index 92cc391f..439c2a2e 100644 --- a/src/backend/tests/test_personal_agent_fixes.py +++ b/src/backend/tests/test_personal_agent_fixes.py @@ -177,11 +177,10 @@ def test_settings_validate_concurrency_and_timezone(sessions): assert service.identity.configure({"concurrency": 5, "timezone": "America/Los_Angeles"})["config"]["timezone"] == "America/Los_Angeles" -def test_cli_hides_server_commands_without_server(monkeypatch, capsys): +def test_cli_hides_server_commands_without_server(capsys): from hugagent_cli import main as cli - monkeypatch.setattr(cli, "server_installed", lambda: False) assert cli.main(["onboard"]) == 2 - assert "lightweight client" in capsys.readouterr().out + assert "lightweight client" in capsys.readouterr().err assert "onboard" not in cli.parser().description diff --git a/src/frontend/package.json b/src/frontend/package.json index 5249b87c..ae2d4d8e 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -21,6 +21,7 @@ "test:history-merge": "esbuild scripts/test-history-merge.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-history-merge.mjs && node node_modules/.tmp/test-history-merge.mjs", "test:kb-assets": "node scripts/run-kb-asset-test.mjs", "test:citation-highlight": "esbuild scripts/test-citation-highlight.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-citation-highlight.mjs && node node_modules/.tmp/test-citation-highlight.mjs", + "test:mcp-tool-output": "esbuild scripts/test-mcp-tool-output.ts --bundle --platform=node --format=esm --define:import.meta.env={} --outfile=node_modules/.tmp/test-mcp-tool-output.mjs && node node_modules/.tmp/test-mcp-tool-output.mjs", "test:plugin-ui": "esbuild scripts/test-plugin-ui-pointer.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-plugin-ui-pointer.mjs && node node_modules/.tmp/test-plugin-ui-pointer.mjs", "test:ui-regressions": "esbuild scripts/test-ui-regressions.ts --bundle --platform=node --format=esm --define:import.meta.env='{\"VITE_DEFAULT_LANGUAGE\":\"zh-CN\"}' --outfile=node_modules/.tmp/test-ui-regressions.mjs && node node_modules/.tmp/test-ui-regressions.mjs", "test:memory-graph": "esbuild scripts/test-memory-graph.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-memory-graph.mjs && node node_modules/.tmp/test-memory-graph.mjs", diff --git a/src/frontend/scripts/test-mcp-tool-output.ts b/src/frontend/scripts/test-mcp-tool-output.ts new file mode 100644 index 00000000..d5f72e63 --- /dev/null +++ b/src/frontend/scripts/test-mcp-tool-output.ts @@ -0,0 +1,75 @@ +/** + * MCP 工具结果外壳自测(无浏览器依赖,node 直接跑)。 + * + * 2026-10-22 起有一段历史把 MCP 结果存成原始 CallToolResult,搜索卡片因此显示 + * 「暂无搜索结果」。这里盯住:外壳要拆成工具自己的值,其它形状一律不动。 + */ +import { unwrapMcpResult } from '../src/utils/mcpResult'; +import { extractArtifactOutputs } from '../src/utils/fileParser'; + +let failed = 0; +function check(name: string, cond: boolean, extra?: unknown) { + if (cond) { + console.log(` ok ${name}`); + } else { + failed += 1; + console.error(` FAIL ${name}`, extra ?? ''); + } +} + +const search = { result: { status: 'complete', results: [{ title: 'Doc', url: 'https://example.org' }] } }; + +const clipped = unwrapMcpResult({ + _meta: {}, + content: [{ type: 'text', text: '{"result": {"status": "complete", 〔截断' }], + structuredContent: search, + isError: false, + resultType: 'complete', +}); +check('clipped text falls back to structuredContent, single result key unwrapped', clipped === search.result, clipped); + +const full = unwrapMcpResult({ + content: [{ type: 'text', text: JSON.stringify(search) }], + structuredContent: search, + isError: false, +}); +check('complete text JSON is what the card renders', JSON.stringify(full) === JSON.stringify(search), full); + +// typing.Dict / str returns: the SDK wraps structuredContent, the text holds the tool's own value. +const datasets = { public_datasets: [], private_datasets: [], total: 0 }; +const wrapped = unwrapMcpResult({ + content: [{ type: 'text', text: JSON.stringify(datasets) }], + structuredContent: { result: datasets }, + isError: false, +}); +check('SDK-wrapped tool keeps the unwrapped text shape', JSON.stringify(wrapped) === JSON.stringify(datasets), wrapped); + +const wrappedString = unwrapMcpResult({ + content: [{ type: 'text', text: '✅ 查询成功〔截断' }], + structuredContent: { result: '✅ 查询成功 full' }, + isError: false, +}); +check('wrapped string stays {result: text}', JSON.stringify(wrappedString) === JSON.stringify({ result: '✅ 查询成功 full' }), wrappedString); + +const plain = unwrapMcpResult({ content: [{ type: 'text', text: 'hello' }], isError: true }); +check('non-JSON text without structured value becomes {result: text}', JSON.stringify(plain) === '{"result":"hello"}', plain); + +const image = { content: [{ type: 'image', data: 'x', mimeType: 'image/png' }], isError: false }; +check('envelope without text or structured value is kept', unwrapMcpResult(image) === image); + +const toolOwn = { content: [{ type: 'text', text: 'not an envelope' }], title: 'doc' }; +check('tool payload with a content array but no isError is kept', unwrapMcpResult(toolOwn) === toolOwn); +check('plain values pass through', unwrapMcpResult(search) === search && unwrapMcpResult('s') === 's'); + +const artifacts = extractArtifactOutputs({ + content: [], + isError: false, + structuredContent: { result: { file_id: 'f1', url: '/files/f1', name: 'a.docx' } }, +}); +check('artifacts are found inside an MCP envelope', artifacts.length === 1 && artifacts[0].file_id === 'f1', artifacts); + +if (failed) { + console.error(`\n${failed} check(s) failed`); + process.exit(1); +} +console.log('\nall MCP tool output checks passed'); diff --git a/src/frontend/src/components/projects/CreateProjectModal.tsx b/src/frontend/src/components/projects/CreateProjectModal.tsx index 29e8a6b0..8244231d 100644 --- a/src/frontend/src/components/projects/CreateProjectModal.tsx +++ b/src/frontend/src/components/projects/CreateProjectModal.tsx @@ -12,6 +12,8 @@ import type { PersonalFolderNode } from '../../types'; interface Props { onCreated?: (projectId: string) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; } interface FolderTreeNode { @@ -28,9 +30,11 @@ function foldersToTree(folders: PersonalFolderNode[]): FolderTreeNode[] { })); } -export default function CreateProjectModal({ onCreated }: Props) { - const open = useProjectStore((state) => state.createModalOpen); - const setOpen = useProjectStore((state) => state.setCreateModalOpen); +export default function CreateProjectModal({ onCreated, open: openProp, onOpenChange }: Props) { + const storeOpen = useProjectStore((state) => state.createModalOpen); + const setStoreOpen = useProjectStore((state) => state.setCreateModalOpen); + const open = openProp ?? storeOpen; + const setOpen = onOpenChange ?? setStoreOpen; const createPersonal = useProjectStore((state) => state.createPersonal); const isDesktop = useDeploymentModeStore((state) => state.isDesktop); const provisionMode = useDeploymentModeStore((state) => state.provisionMode); diff --git a/src/frontend/src/components/sidebar/ChatSidebar.tsx b/src/frontend/src/components/sidebar/ChatSidebar.tsx index 6f0ffea2..2c1b1e90 100644 --- a/src/frontend/src/components/sidebar/ChatSidebar.tsx +++ b/src/frontend/src/components/sidebar/ChatSidebar.tsx @@ -1,19 +1,101 @@ -import { CaretDownOutlined } from '@ant-design/icons'; +import { CaretDownOutlined, CheckOutlined, EditOutlined, EllipsisOutlined, FolderAddOutlined, LaptopOutlined, PlusOutlined } from '@ant-design/icons'; +import { Dropdown, Tooltip, type MenuProps } from 'antd'; import { AnimatePresence } from 'motion/react'; -import { useRef, useState, type ReactNode } from 'react'; +import { useMemo, useRef, useState, type ReactNode } from 'react'; import { t } from '../../i18n'; -import { useChatStore } from '../../stores'; +import { loadJsonPref, saveJsonPref } from '../../storage'; +import { useChatStore, useSidebarOrderStore } from '../../stores'; +import { projectCreationTargets, useDeploymentModeStore } from '../../stores/deploymentModeStore'; import { PersonalAgentSidebarEntry } from '../../personalAgentEdition'; +import CreateProjectModal from '../projects/CreateProjectModal'; import { ChatSidebarItem } from './ChatSidebarItem'; import { ProjectSidebarGroup } from './ProjectSidebarGroup'; import type { SidebarProps } from './sidebarTypes'; import { useSidebarHistory } from './useSidebarHistory'; +const SHOW_PROJECTS_PREF_KEY = 'hugagent_ui_sidebar_show_projects_v1'; +const PROJECT_SORT_PREF_KEY = 'hugagent_ui_sidebar_project_sort_v1'; +type ProjectSort = 'activity' | 'name'; +function pickLocalProjectFolder() { window.location.href = '/__desktop/pick-local-folder'; } +const check = (on: boolean) => on ? : null; export function ChatSidebar({ actions, children, navigation }: { actions: SidebarProps; children?: ReactNode; navigation?: ReactNode }) { const { projectGroups, groupedHistoryList } = useSidebarHistory(); const loading = useChatStore(s => s.chatsLoading); const [collapsed, setCollapsed] = useState>({}); const dragScopeRef = useRef(null); - const groups = [{ key: 'projects', label: t('项目') }, ...groupedHistoryList]; + const [showProjects, setShowProjects] = useState(() => loadJsonPref(SHOW_PROJECTS_PREF_KEY, { show: true }).show !== false); + const [projectSort, setProjectSort] = useState(() => loadJsonPref(PROJECT_SORT_PREF_KEY, { sort: 'activity' }).sort === 'name' ? 'name' : 'activity'); + const [createProjectOpen, setCreateProjectOpen] = useState(false); + const manualOrder = useSidebarOrderStore(s => s.order.length > 0); + const isDesktop = useDeploymentModeStore(s => s.isDesktop); + const provisionMode = useDeploymentModeStore(s => s.provisionMode); + const { cloud: canCreateCloudProject, local: canCreateLocalProject } = projectCreationTargets(isDesktop, provisionMode); + const sortedProjectGroups = useMemo(() => projectSort === 'name' + ? [...projectGroups].sort((a, b) => (Number(b.pinned) - Number(a.pinned)) || a.name.localeCompare(b.name)) + : projectGroups, [projectGroups, projectSort]); + const groups = [...(showProjects ? [{ key: 'projects', label: t('项目') }] : []), ...groupedHistoryList]; + const setAllCollapsed = (value: boolean) => setCollapsed(Object.fromEntries(groups.map(g => [g.key, value]))); + const toggleShowProjects = () => { + const show = !showProjects; + setShowProjects(show); + saveJsonPref(SHOW_PROJECTS_PREF_KEY, { show }); + }; + const changeProjectSort = (sort: ProjectSort) => { + setProjectSort(sort); + saveJsonPref(PROJECT_SORT_PREF_KEY, { sort }); + }; + const organizeItem = { key: 'organize', label: t('整理侧栏'), children: [ + { key: 'expand-all', label: t('全部展开'), onClick: () => setAllCollapsed(false) }, + { key: 'collapse-all', label: t('全部收起'), onClick: () => setAllCollapsed(true) }, + ] }; + const showItems: MenuProps['items'] = [ + { type: 'divider' }, + { key: 'show', type: 'group', label: t('显示'), children: [ + { key: 'show-projects', label: t('项目'), extra: check(showProjects), onClick: toggleShowProjects }, + ] }, + ]; + const historyMenu: MenuProps['items'] = [ + organizeItem, + { key: 'sort', label: t('排序方式'), children: [ + { key: 'sort-recent', label: t('最近更新'), extra: check(!manualOrder), onClick: () => { if (manualOrder) useSidebarOrderStore.getState().resetOrder(); } }, + { key: 'sort-manual', label: t('手动拖拽'), extra: check(manualOrder), disabled: !manualOrder, title: manualOrder ? undefined : t('拖拽对话即可调整顺序') }, + ] }, + ...showItems, + ]; + const projectMenu: MenuProps['items'] = [ + organizeItem, + { key: 'sort', label: t('排序方式'), children: [ + { key: 'sort-activity', label: t('最近活跃'), extra: check(projectSort === 'activity'), onClick: () => changeProjectSort('activity') }, + { key: 'sort-name', label: t('名称'), extra: check(projectSort === 'name'), onClick: () => changeProjectSort('name') }, + ] }, + { key: 'all-projects', label: t('查看全部项目'), onClick: () => actions.onSetPanel('projects') }, + ...showItems, + ]; + // 新建入口跟随运行形态(同项目面板):双模式给云端/本地二选一,单一形态直接进对应流程。 + const newProjectButton = (onClick?: () => void) => ( + + ); + const renderHeaderActions = (key: string) => key === 'projects' + ?
+ + + + {isDesktop && canCreateCloudProject && canCreateLocalProject + ? , label: t('新建云端项目'), onClick: () => setCreateProjectOpen(true) }, + { key: 'new-local-project', icon: , label: t('新建本地项目'), onClick: pickLocalProjectFolder }, + ] }}>{newProjectButton()} + : (canCreateCloudProject || canCreateLocalProject) && + {newProjectButton(canCreateCloudProject ? () => setCreateProjectOpen(true) : pickLocalProjectFolder)} + } +
+ :
+ + + + + + +
; return <> {navigation} @@ -24,11 +106,18 @@ export function ChatSidebar({ actions, children, navigation }: { actions: Sideba {Array.from({ length: 8 }, (_, i) =>
)}
} {!loading && groups.map(group =>
- +
+ + {renderHeaderActions(group.key)} +
- {group.key === 'projects' ? projectGroups.map(pg => ) : {groupedHistoryList[0].items.map(item => )}} + {group.key === 'projects' ? sortedProjectGroups.map(pg => ) : {groupedHistoryList[0].items.map(item => )}}
)} + {createProjectOpen && { + const created = projectGroups.find(pg => pg.projectId === pid); + actions.onNewProjectChat(pid, created?.name || t('项目')); + }} />} ; } diff --git a/src/frontend/src/components/tool/PluginToolOutputBody.tsx b/src/frontend/src/components/tool/PluginToolOutputBody.tsx index 95785ae4..9c2796a1 100644 --- a/src/frontend/src/components/tool/PluginToolOutputBody.tsx +++ b/src/frontend/src/components/tool/PluginToolOutputBody.tsx @@ -4,7 +4,9 @@ import { usePluginUiStore } from '../../stores/pluginUiStore'; import { useCanvasStore } from '../../stores/canvasStore'; import { PluginModuleFrame, PluginView, resolveText } from '../../plugin-ui'; import { renderToolOutputBody } from './ToolOutputRenderer'; -export function ToolOutputBody({ toolName, output }: { toolName: string; output: unknown }) { +import { unwrapMcpResult } from './renderers/utils'; +export function ToolOutputBody({ toolName, output: rawOutput }: { toolName: string; output: unknown }) { + const output = useMemo(() => unwrapMcpResult(rawOutput), [rawOutput]); const { setDetailModal } = useUIStore(); const items = usePluginUiStore((state) => state.items); const openPluginCanvas = useCanvasStore((state) => state.openPluginView); diff --git a/src/frontend/src/components/tool/ToolOutputRenderer.tsx b/src/frontend/src/components/tool/ToolOutputRenderer.tsx index c2a6e3be..fbdf778d 100644 --- a/src/frontend/src/components/tool/ToolOutputRenderer.tsx +++ b/src/frontend/src/components/tool/ToolOutputRenderer.tsx @@ -3,6 +3,7 @@ import { CheckCircleOutlined } from '@ant-design/icons'; import { renderRetrieveDatasetContent, renderRetrieveLocalKB } from './renderers/KBRenderer'; import { renderInternetSearch } from './renderers/SearchRenderer'; import { DataView } from './renderers/DataView'; +import { unwrapMcpResult } from './renderers/utils'; import { mdToHtml } from '../../utils/markdown'; import { t } from '../../i18n'; @@ -15,6 +16,7 @@ export function renderToolOutputBody( /** 被引用的证据片段:命中的内容会高亮并把滚动条送到跟前(从引用打开卡片时传入) */ highlight?: string, ): React.ReactNode { + out = unwrapMcpResult(out); const empty = (msg: string) =>
{msg}
; // Load skill: render the same detail as the capability center, avoiding stuffing the full SKILL.md into the card diff --git a/src/frontend/src/components/tool/renderers/utils.tsx b/src/frontend/src/components/tool/renderers/utils.tsx index 2807019d..e5bab9d5 100644 --- a/src/frontend/src/components/tool/renderers/utils.tsx +++ b/src/frontend/src/components/tool/renderers/utils.tsx @@ -1,5 +1,8 @@ import React from 'react'; import type { ToolCall } from '../../../types'; +import { unwrapMcpResult } from '../../../utils/mcpResult'; + +export { unwrapMcpResult }; export const PREVIEW_LEN = 90; @@ -14,8 +17,8 @@ export const preview = (s: string) => s.length > PREVIEW_LEN ? s.slice(0, PREVIE /** Parse tool output as JSON if possible; return the raw value otherwise. */ export function coerceOutput(raw: unknown): unknown { - if (typeof raw !== 'string') return raw; - try { return JSON.parse(raw); } catch { return raw; } + if (typeof raw !== 'string') return unwrapMcpResult(raw); + try { return unwrapMcpResult(JSON.parse(raw)); } catch { return raw; } } /** True if any tool in the list is still running. */ diff --git a/src/frontend/src/i18n/en/panels.ts b/src/frontend/src/i18n/en/panels.ts index ba7c8e9f..fed1e1cf 100644 --- a/src/frontend/src/i18n/en/panels.ts +++ b/src/frontend/src/i18n/en/panels.ts @@ -136,6 +136,17 @@ export const PANELS_DICT: Record = { '在项目面板中打开': 'Open in project panel', '移除项目': 'Remove project', '项目更多操作': 'More project actions', + '历史对话更多操作': 'More chat list actions', + '整理侧栏': 'Organize sidebar', + '全部展开': 'Expand all', + '全部收起': 'Collapse all', + '排序方式': 'Sort chats by', + '手动拖拽': 'Manual (drag)', + '拖拽对话即可调整顺序': 'Drag a chat to reorder', + '显示': 'Show', + '项目列表更多操作': 'More project list actions', + '最近活跃': 'Recent activity', + '查看全部项目': 'View all projects', '移除项目「{name}」?': 'Remove project "{name}"?', '项目已移除': 'Project removed', '移除失败': 'Failed to remove', diff --git a/src/frontend/src/i18n/en/settings.ts b/src/frontend/src/i18n/en/settings.ts index ef68b2a7..4b0c1575 100644 --- a/src/frontend/src/i18n/en/settings.ts +++ b/src/frontend/src/i18n/en/settings.ts @@ -719,6 +719,10 @@ export const SETTINGS_DICT: Record = { '百度搜索 API Key': 'Baidu Search API Key', '百度千帆 AppBuilder 搜索服务密钥': 'Baidu Qianfan AppBuilder search service key', 'LangSearch Web Search API 服务密钥': 'LangSearch Web Search API service key', + '互联网搜索引擎 (tavily / baidu / langsearch / deepseek)': + 'Internet search engine (Tavily / Baidu / LangSearch / DeepSeek)', + 'DeepSeek Search API 服务密钥(DeepSeek 开放平台 API Key)': + 'DeepSeek Search API service key (DeepSeek platform API key)', '主智能体代码执行能力': 'Main Agent Code Execution', '开启后主/计划/批量/智能体默认具备文件工具+沙箱+我的空间访问;关闭则仅 Lab 入口可用。本开关为唯一控制源,保存后 ≤30s 生效、无需重启。': 'When enabled, main, plan, workflow, and agents can use file tools, the sandbox, and My Space by default. When disabled, only Lab can use them. Changes take effect within 30 seconds without a restart.', diff --git a/src/frontend/src/styles/sidebar-history.css b/src/frontend/src/styles/sidebar-history.css index c53f42c3..e5177a1f 100644 --- a/src/frontend/src/styles/sidebar-history.css +++ b/src/frontend/src/styles/sidebar-history.css @@ -44,6 +44,31 @@ transition:color .12s; } .jx-historyGroupHeader:hover{ color:color-mix(in srgb, var(--color-text) 72%, transparent); } +.jx-historyGroupHeaderRow{ display:flex; align-items:center; gap:2px; padding-right:6px; } +.jx-historyGroupHeaderRow > .jx-historyGroupHeader{ flex:1; min-width:0; } +.jx-historyGroupHeaderRow:hover > .jx-historyGroupHeader{ color:color-mix(in srgb, var(--color-text) 72%, transparent); } +.jx-historyGroupCaret{ + font-size:10px; + opacity:0; + transition:opacity var(--motion-duration-instant) var(--motion-ease-standard), transform var(--motion-duration-fast) var(--motion-ease-standard); +} +.jx-historyGroupHeaderRow:hover .jx-historyGroupCaret, +.jx-historyGroupHeader:focus-visible .jx-historyGroupCaret, +.jx-historyGroup--collapsed .jx-historyGroupCaret{ opacity:1; } +.jx-historyGroup--collapsed .jx-historyGroupCaret{ transform:rotate(-90deg); } +.jx-historyGroupActions{ + display:flex; align-items:center; gap:2px; flex-shrink:0; margin-top:6px; + opacity:0; pointer-events:none; + transition:opacity var(--motion-duration-instant) var(--motion-ease-standard); +} +.jx-historyGroupHeaderRow:hover .jx-historyGroupActions, +.jx-historyGroupActions:focus-within, +.jx-historyGroupActions:has(.ant-dropdown-open){ opacity:1; pointer-events:auto; } +@media (hover: none){ + .jx-historyGroupActions{ opacity:1; pointer-events:auto; } +} +.jx-historyGroupMenu.ant-dropdown .ant-dropdown-menu{ min-width:180px; } +.jx-historyGroupMenu.ant-dropdown .ant-dropdown-menu-item-group-title{ padding:6px 10px 2px; font-size:11px; } .jx-historyGroupHeader:focus-visible{ outline:2px solid var(--color-primary-bg); outline-offset:0; } .jx-historyGroupTitle{ padding:0 10px; diff --git a/src/frontend/src/utils/citations.ts b/src/frontend/src/utils/citations.ts index 820b49de..60f1c1ca 100644 --- a/src/frontend/src/utils/citations.ts +++ b/src/frontend/src/utils/citations.ts @@ -1,5 +1,6 @@ import type { CitationItem, ToolCall, ChatMessage } from '../types'; import { t } from '../i18n'; +import { unwrapMcpResult } from './mcpResult'; /** * 引用标记的唯一真源正则(三种形态并行识别): @@ -50,8 +51,8 @@ function normalizeMaybeId(value: unknown): string | undefined { } function coerceToolOutput(raw: unknown): unknown { - if (typeof raw !== 'string') return raw; - try { return JSON.parse(raw); } catch { return raw; } + if (typeof raw !== 'string') return unwrapMcpResult(raw); + try { return unwrapMcpResult(JSON.parse(raw)); } catch { return raw; } } function getFallbackCitationOutput(citation: CitationItem): { toolName: string; output: unknown } { diff --git a/src/frontend/src/utils/fileParser.ts b/src/frontend/src/utils/fileParser.ts index b6d6788e..2294a27b 100644 --- a/src/frontend/src/utils/fileParser.ts +++ b/src/frontend/src/utils/fileParser.ts @@ -77,6 +77,8 @@ export function extractArtifactOutputs(raw: unknown): Record[] if (Array.isArray(record.artifacts)) visit(record.artifacts, origin); if (Array.isArray(record.files)) visit(record.files, origin); if (record.result && record.result !== candidate) visit(record.result, origin); + // MCP CallToolResult envelope (history stored 2026-10-22 onward): the tool's value lives here. + if (record.structuredContent && record.structuredContent !== candidate) visit(record.structuredContent, origin); }; visit(raw); diff --git a/src/frontend/src/utils/internetSearchConfig.ts b/src/frontend/src/utils/internetSearchConfig.ts index ffa870f1..06d6614a 100644 --- a/src/frontend/src/utils/internetSearchConfig.ts +++ b/src/frontend/src/utils/internetSearchConfig.ts @@ -1,4 +1,4 @@ -export type InternetSearchEngine = 'tavily' | 'baidu' | 'langsearch'; +export type InternetSearchEngine = 'tavily' | 'baidu' | 'langsearch' | 'deepseek'; export interface InternetSearchEngineMeta { value: InternetSearchEngine; @@ -26,6 +26,12 @@ export const INTERNET_SEARCH_ENGINES: InternetSearchEngineMeta[] = [ apiKeyConfigKey: 'internet_search.langsearch_api_key', apiKeyLabel: 'LangSearch API Key', }, + { + value: 'deepseek', + label: 'DeepSeek', + apiKeyConfigKey: 'internet_search.deepseek_api_key', + apiKeyLabel: 'DeepSeek Search API Key', + }, ]; const INTERNET_SEARCH_ENGINE_VALUES = new Set( diff --git a/src/frontend/src/utils/mcpResult.ts b/src/frontend/src/utils/mcpResult.ts new file mode 100644 index 00000000..074f17a5 --- /dev/null +++ b/src/frontend/src/utils/mcpResult.ts @@ -0,0 +1,33 @@ +/** 2026-10-22 起有一段时间,MCP 工具结果以原始 CallToolResult(content / + * structuredContent / isError / _meta)推到前端并落库;后端现已改回推工具自己的值, + * 这里兼容那批历史,换回卡片一直在渲染的形状: + * + * - 文本块是完整 JSON:就用它(与当时后端的 json.loads(content) 一致); + * - 文本被截断、解析不了:退回 structuredContent。SDK 会把非对象返回包成 + * `{"result": …}`,落库的外壳里没有 schema 可查,所以只有唯一键 result、值是 + * 对象/数组时才拆这一层(字符串保持包着,与文本路径的 {result: text} 一致); + * - 都没有:非 JSON 文本按 {result: text} 返回。其它形状原样返回。 */ +export function unwrapMcpResult(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const envelope = value as { content?: unknown; structuredContent?: unknown; isError?: unknown }; + if (!Array.isArray(envelope.content) || typeof envelope.isError !== 'boolean') return value; + const text = envelope.content + .filter((block): block is { type: 'text'; text: string } => + !!block && typeof block === 'object' + && (block as { type?: unknown }).type === 'text' + && typeof (block as { text?: unknown }).text === 'string') + .map((block) => block.text) + .join(''); + if (text) { + try { return JSON.parse(text); } catch { /* truncated: fall back below */ } + } + const structured = envelope.structuredContent; + if (structured != null && typeof structured === 'object' && !Array.isArray(structured)) { + const keys = Object.keys(structured); + const inner = (structured as { result?: unknown }).result; + if (keys.length === 1 && keys[0] === 'result' && inner && typeof inner === 'object') return inner; + return structured; + } + if (structured != null) return structured; + return text ? { result: text } : value; +}