mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 | ||
|
|
d185f3ef0d | ||
|
|
8c6c49ad52 | ||
|
|
809489a6e3 | ||
|
|
56147a708e | ||
|
|
81aad58d97 | ||
|
|
95ccd5bc32 | ||
|
|
284a6c86cb | ||
|
|
8f3b8f0d13 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd | ||
|
|
afb5010a46 | ||
|
|
30dd7c3112 | ||
|
|
e7d88b4b51 | ||
|
|
177442cfe4 | ||
|
|
190241bd59 | ||
|
|
ba6c026469 | ||
|
|
daf65e0af9 | ||
|
|
f401c66e81 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb | ||
|
|
b63fe696fd | ||
|
|
3fbbd28e4c | ||
|
|
b9ddd6f421 | ||
|
|
aaad4d2712 | ||
|
|
b4275e3504 | ||
|
|
5b2b4dea1a | ||
|
|
b5a78dca6f | ||
|
|
952788f72d | ||
|
|
f915f70e5a | ||
|
|
cdf9d839ae | ||
|
|
960db9660e | ||
|
|
b1ccb6d9c1 | ||
|
|
0bc8074c97 | ||
|
|
72eed06b4f | ||
|
|
33dc7ae2cc | ||
|
|
292e1edea5 |
No files matched your search
@@ -100,9 +100,10 @@ jobs:
|
|||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# govulncheck and deadcode resolve a toolchain from their own modules, which
|
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||||
# can drop below the version go.mod requires and then fail to load our
|
# their own modules, which can drop below the version go.mod requires and
|
||||||
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
|
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||||
|
# so all three run under it.
|
||||||
- name: Pin toolchain from go.mod
|
- name: Pin toolchain from go.mod
|
||||||
run: |
|
run: |
|
||||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||||
@@ -120,3 +121,13 @@ jobs:
|
|||||||
- name: deadcode (advisory)
|
- name: deadcode (advisory)
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||||
|
|
||||||
|
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||||
|
# doesn't, unused private functions/assignments reachable within a
|
||||||
|
# package but never actually called, plus staticcheck-style correctness
|
||||||
|
# and readability issues. Scoped to a few linters rather than the full
|
||||||
|
# default battery, and non-blocking, while the existing findings across
|
||||||
|
# the repo are cleaned up incrementally (see #527).
|
||||||
|
- name: golangci-lint (advisory)
|
||||||
|
continue-on-error: true
|
||||||
|
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||||
@@ -4,12 +4,14 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- action.yml
|
- action.yml
|
||||||
|
- docs/GITHUB_ACTION.md
|
||||||
- .github/workflows/zero-action-smoke.yml
|
- .github/workflows/zero-action-smoke.yml
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- action.yml
|
- action.yml
|
||||||
|
- docs/GITHUB_ACTION.md
|
||||||
- .github/workflows/zero-action-smoke.yml
|
- .github/workflows/zero-action-smoke.yml
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -29,7 +31,8 @@ jobs:
|
|||||||
import sys, yaml
|
import sys, yaml
|
||||||
|
|
||||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||||
doc = yaml.safe_load(handle)
|
action_raw = handle.read()
|
||||||
|
doc = yaml.safe_load(action_raw)
|
||||||
|
|
||||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||||
assert doc.get("name"), "action must declare a name"
|
assert doc.get("name"), "action must declare a name"
|
||||||
@@ -65,6 +68,19 @@ jobs:
|
|||||||
assert "--skip-permissions-unsafe" not in serialized, \
|
assert "--skip-permissions-unsafe" not in serialized, \
|
||||||
"action must never pass --skip-permissions-unsafe"
|
"action must never pass --skip-permissions-unsafe"
|
||||||
|
|
||||||
|
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||||
|
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||||
|
run_zero_script = run_zero.get("run") or ""
|
||||||
|
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||||
|
"Run ZERO must export the provider key for the current step"
|
||||||
|
assert "later steps inherit" not in action_raw.lower(), \
|
||||||
|
"provider keys must not be documented as inherited by later steps"
|
||||||
|
for line in run_zero_script.splitlines():
|
||||||
|
if "GITHUB_ENV" in line and (
|
||||||
|
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||||
|
):
|
||||||
|
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||||
|
|
||||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,13 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
/zero
|
/zero
|
||||||
/zero.exe
|
/zero.exe
|
||||||
|
/zero-skills
|
||||||
/snake-game.html
|
/snake-game.html
|
||||||
|
|
||||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
|
# Termux/Android build artifacts
|
||||||
|
zero-termux-arm64
|
||||||
|
zero-linux-sandbox
|
||||||
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
|
|||||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||||
|
|
||||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||||
|
|
||||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||||
|
|
||||||
@@ -47,6 +47,12 @@ Tips:
|
|||||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||||
|
|
||||||
|
### Personal guidelines, across every project
|
||||||
|
|
||||||
|
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||||
|
|
||||||
|
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||||
|
|
||||||
## 2. Custom specialists
|
## 2. Custom specialists
|
||||||
|
|
||||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||||
@@ -307,6 +313,7 @@ A team that wants every contributor's Zero to behave the same way commits:
|
|||||||
Each contributor adds only:
|
Each contributor adds only:
|
||||||
|
|
||||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||||
|
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||||
|
|
||||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
|
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||||
# lint` before opening a PR" — these targets back those instructions.
|
# lint` before opening a PR" — these targets back those instructions.
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||||
|
<br>
|
||||||
|
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||||
@@ -51,6 +53,31 @@ The npm package installs a small wrapper plus the matching Zero binary for your
|
|||||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||||
arm64.
|
arm64.
|
||||||
|
|
||||||
|
### Bun
|
||||||
|
|
||||||
|
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||||
|
that fetches the Zero binary is skipped and the first run fails with
|
||||||
|
`No native binary found next to the npm wrapper`.
|
||||||
|
|
||||||
|
The simplest fix is to trust the package after installing, which runs the
|
||||||
|
blocked postinstall. This works for project and global installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# project install
|
||||||
|
bun add @gitlawb/zero
|
||||||
|
bun pm trust @gitlawb/zero
|
||||||
|
|
||||||
|
# global install
|
||||||
|
bun add -g @gitlawb/zero
|
||||||
|
bun pm -g trust @gitlawb/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatives: allow the postinstall up front by adding
|
||||||
|
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||||
|
before `bun add`, or run the installer manually
|
||||||
|
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||||
|
that do not have `bun pm trust`.
|
||||||
|
|
||||||
### Install scripts
|
### Install scripts
|
||||||
|
|
||||||
Linux/macOS:
|
Linux/macOS:
|
||||||
@@ -121,6 +148,13 @@ the key in the wizard:
|
|||||||
export OPENAI_API_KEY=sk-...
|
export OPENAI_API_KEY=sk-...
|
||||||
export ANTHROPIC_API_KEY=...
|
export ANTHROPIC_API_KEY=...
|
||||||
export GEMINI_API_KEY=...
|
export GEMINI_API_KEY=...
|
||||||
|
export LONGCAT_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero providers setup longcat --set-active
|
||||||
```
|
```
|
||||||
|
|
||||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||||
@@ -152,6 +186,7 @@ Common slash commands:
|
|||||||
| `/spec`, `/plan` | draft and review a plan before building |
|
| `/spec`, `/plan` | draft and review a plan before building |
|
||||||
| `/image` | attach an image for vision-capable models |
|
| `/image` | attach an image for vision-capable models |
|
||||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||||
|
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||||
| `/compact`, `/context` | manage context usage |
|
| `/compact`, `/context` | manage context usage |
|
||||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||||
| `/add-dir` | allow an extra write directory for this session |
|
| `/add-dir` | allow an extra write directory for this session |
|
||||||
@@ -249,6 +284,39 @@ zero cron scheduled agent jobs
|
|||||||
zero update check for newer releases
|
zero update check for newer releases
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Extending Zero
|
||||||
|
|
||||||
|
### Project and personal instructions
|
||||||
|
|
||||||
|
Zero appends project-specific guidance to the system prompt from the first
|
||||||
|
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
|
||||||
|
the git root down to your current working directory (checked in that order
|
||||||
|
per directory). Files are injected general-to-specific, capped at 8 KiB per
|
||||||
|
file and 32 KiB total.
|
||||||
|
|
||||||
|
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
|
||||||
|
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
|
||||||
|
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
|
||||||
|
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
|
||||||
|
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
|
||||||
|
(project scope — resolved from the current working directory, not the repo
|
||||||
|
root), and managed with `zero plugins`. A manifest can declare:
|
||||||
|
|
||||||
|
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
|
||||||
|
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
|
||||||
|
auto-approval is enabled)
|
||||||
|
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
|
||||||
|
`sessionEnd`
|
||||||
|
- `prompts` and `skills` — additional prompt/skill files
|
||||||
|
|
||||||
|
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
|
||||||
|
the same extension points and can also be wired up outside of a plugin
|
||||||
|
manifest.
|
||||||
|
|
||||||
## Appearance And Accessibility
|
## Appearance And Accessibility
|
||||||
|
|
||||||
| Control | Effect |
|
| Control | Effect |
|
||||||
|
|||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||||
|
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||||
|
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||||
|
<br>
|
||||||
|
<a href="README.md">English</a> | <strong>中文</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero
|
||||||
|
zero exec "修复 ./pkg 中失败的测试"
|
||||||
|
zero exec --output-format stream-json < turns.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
## 为什么选择 Zero
|
||||||
|
|
||||||
|
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||||
|
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||||
|
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||||
|
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||||
|
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
|
||||||
|
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @gitlawb/zero
|
||||||
|
zero
|
||||||
|
```
|
||||||
|
|
||||||
|
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||||
|
|
||||||
|
### Bun
|
||||||
|
|
||||||
|
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||||
|
|
||||||
|
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 项目安装
|
||||||
|
bun add @gitlawb/zero
|
||||||
|
bun pm trust @gitlawb/zero
|
||||||
|
|
||||||
|
# 全局安装
|
||||||
|
bun add -g @gitlawb/zero
|
||||||
|
bun pm -g trust @gitlawb/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||||
|
|
||||||
|
### 安装脚本
|
||||||
|
|
||||||
|
Linux/macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 从源码构建
|
||||||
|
|
||||||
|
源码构建需要 Go 1.25+。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
|
cd zero
|
||||||
|
go run ./cmd/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o zero ./cmd/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||||
|
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||||
|
```
|
||||||
|
|
||||||
|
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||||
|
|
||||||
|
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
|
||||||
|
|
||||||
|
## 首次运行
|
||||||
|
|
||||||
|
启动 TUI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero
|
||||||
|
```
|
||||||
|
|
||||||
|
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero setup
|
||||||
|
zero providers list
|
||||||
|
zero models list
|
||||||
|
zero doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
export ANTHROPIC_API_KEY=...
|
||||||
|
export GEMINI_API_KEY=...
|
||||||
|
export LONGCAT_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero providers setup longcat --set-active
|
||||||
|
```
|
||||||
|
|
||||||
|
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||||
|
|
||||||
|
## 日常使用
|
||||||
|
|
||||||
|
### 交互式 TUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero
|
||||||
|
```
|
||||||
|
|
||||||
|
常用控制:
|
||||||
|
|
||||||
|
| 控制 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| `Enter` | 发送提示 |
|
||||||
|
| `/` | 打开斜杠命令建议 |
|
||||||
|
| `Shift+Tab` | 切换权限模式 |
|
||||||
|
| `Ctrl+B` | 显示/隐藏侧边栏 |
|
||||||
|
| `Ctrl+C` | 取消或退出 |
|
||||||
|
|
||||||
|
常用斜杠命令:
|
||||||
|
|
||||||
|
| 命令 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `/model`、`/provider` | 切换活动模型/提供商 |
|
||||||
|
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||||
|
| `/image` | 为视觉模型附加图片 |
|
||||||
|
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||||
|
| `/compact`、`/context` | 管理上下文使用 |
|
||||||
|
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||||
|
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||||
|
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
|
||||||
|
|
||||||
|
### 无头 `exec` 模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero exec "解释 internal/agent/loop.go"
|
||||||
|
zero exec --model claude-sonnet-4.5 "重构配置加载器"
|
||||||
|
zero exec --use-spec "为 API 客户端添加速率限制"
|
||||||
|
zero exec --worktree "在隔离的工作树中尝试迁移"
|
||||||
|
zero exec --resume
|
||||||
|
zero exec --fork <session-id> "尝试另一种方法"
|
||||||
|
```
|
||||||
|
|
||||||
|
编程使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
|
||||||
|
|
||||||
|
## 安全模型
|
||||||
|
|
||||||
|
Zero 旨在使副作用可见。
|
||||||
|
|
||||||
|
- 工作区读取默认允许。
|
||||||
|
- 文件写入限制在工作区内,除非你授予其他目录。
|
||||||
|
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
|
||||||
|
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
|
||||||
|
- 不安全/自主模式是显式选择加入。
|
||||||
|
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero --add-dir ../docs-site
|
||||||
|
zero exec --add-dir ../shared "更新两个仓库"
|
||||||
|
```
|
||||||
|
|
||||||
|
沙箱行为可以通过以下方式检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero sandbox policy
|
||||||
|
zero sandbox grants list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web 和本地控制
|
||||||
|
|
||||||
|
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
|
||||||
|
|
||||||
|
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
|
||||||
|
|
||||||
|
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```text
|
||||||
|
zero 交互式 TUI
|
||||||
|
zero exec 一次性或脚本化智能体运行
|
||||||
|
zero setup 首次运行提供商设置
|
||||||
|
zero auth 支持提供商的 OAuth/登录辅助
|
||||||
|
zero models 模型注册表和能力
|
||||||
|
zero providers 提供商配置和检测
|
||||||
|
zero doctor 设置、密钥和连接检查
|
||||||
|
zero context 上下文预算报告
|
||||||
|
zero repo-map 确定性仓库映射
|
||||||
|
zero repo-info 本地仓库摘要
|
||||||
|
zero search | find 搜索本地会话历史
|
||||||
|
zero sessions 检查、恢复、分叉和回滚会话
|
||||||
|
zero spec 管理规范模式草稿
|
||||||
|
zero specialist 管理专业子智能体
|
||||||
|
zero skills 管理 Markdown 指令技能
|
||||||
|
zero plugins 管理插件
|
||||||
|
zero hooks 管理生命周期钩子
|
||||||
|
zero mcp 管理 MCP 服务器和工具
|
||||||
|
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
|
||||||
|
zero sandbox 检查沙箱策略和授权
|
||||||
|
zero worktrees 准备隔离的 Git 工作树
|
||||||
|
zero verify 检测和运行本地验证检查
|
||||||
|
zero changes 检查和提交本地 Git 变更
|
||||||
|
zero usage Token 使用量和估算成本
|
||||||
|
zero cron 定时智能体任务
|
||||||
|
zero update 检查更新版本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 外观和无障碍
|
||||||
|
|
||||||
|
| 控制 | 效果 |
|
||||||
|
|---|---|
|
||||||
|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||||
|
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||||
|
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||||
|
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||||
|
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||||
|
|
||||||
|
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/zero-release build
|
||||||
|
go run ./cmd/zero-release smoke
|
||||||
|
go run ./cmd/zero-perf-bench
|
||||||
|
```
|
||||||
|
|
||||||
|
交叉编译示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||||
|
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [安装](docs/INSTALL.md)
|
||||||
|
- [更新流程](docs/UPDATE.md)
|
||||||
|
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||||
|
- [专家](docs/SPECIALISTS.md)
|
||||||
|
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||||
|
- [基准测试](docs/BENCHMARK.md)
|
||||||
|
- [性能](docs/PERFORMANCE.md)
|
||||||
|
- [智能体评估](docs/AGENT_EVALS.md)
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||||
|
|
||||||
|
安全报告应遵循 [SECURITY.md](SECURITY.md)。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
Zero 基于 [MIT 许可证](LICENSE) 发布。
|
||||||
+8
-9
@@ -27,14 +27,14 @@ inputs:
|
|||||||
api-key:
|
api-key:
|
||||||
description: >-
|
description: >-
|
||||||
The provider API key. Pass it from a repository or organization secret;
|
The provider API key. Pass it from a repository or organization secret;
|
||||||
it is exported into the step environment and never written to the log.
|
it is exported only for the ZERO step and never written to the log.
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
api-key-env:
|
api-key-env:
|
||||||
description: >-
|
description: >-
|
||||||
Name of the environment variable the chosen provider reads its key from
|
Name of the environment variable the chosen provider reads its key from
|
||||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||||
api-key, that variable is exported for the run. Leave the action
|
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||||
provider-agnostic by passing the env name your provider expects rather
|
provider-agnostic by passing the env name your provider expects rather
|
||||||
than hardcoding one here.
|
than hardcoding one here.
|
||||||
required: false
|
required: false
|
||||||
@@ -180,6 +180,7 @@ runs:
|
|||||||
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
||||||
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
||||||
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
||||||
|
ACTION_PATH: ${{ github.action_path }}
|
||||||
RUNNER_TEMP: ${{ runner.temp }}
|
RUNNER_TEMP: ${{ runner.temp }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -192,10 +193,9 @@ runs:
|
|||||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
# Export in-process so the zero invocation in this same run block sees
|
# Export in-process for this Run ZERO step so zero can read it; do
|
||||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
# not persist provider credentials to later job steps.
|
||||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
|
||||||
fi
|
fi
|
||||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||||
@@ -248,10 +248,9 @@ runs:
|
|||||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
# Best-effort one-line summary: last non-empty line of the captured output.
|
# Best-effort one-line summary. Structured output ends with lifecycle
|
||||||
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
|
# metadata, so parse final/error events instead of tailing stdout.
|
||||||
# Keep the summary single-line and bounded for the step output.
|
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
|
||||||
summary="$(printf '%s' "${summary}" | cut -c1-280)"
|
|
||||||
{
|
{
|
||||||
echo "summary<<ZERO_EOF"
|
echo "summary<<ZERO_EOF"
|
||||||
printf '%s\n' "${summary}"
|
printf '%s\n' "${summary}"
|
||||||
|
|||||||
+21
-1
@@ -54,8 +54,28 @@ const nativePath = join(packageRoot, zeroBinaryName());
|
|||||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||||
|
|
||||||
if (!existsSync(nativePath)) {
|
if (!existsSync(nativePath)) {
|
||||||
|
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||||
|
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||||
console.error(
|
console.error(
|
||||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||||
|
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||||
|
'which did not run (or was skipped) for this install.\n' +
|
||||||
|
'\n' +
|
||||||
|
'Fix it now by running the installer manually:\n' +
|
||||||
|
` node "${postinstallScript}"\n` +
|
||||||
|
'\n' +
|
||||||
|
(ranByBun
|
||||||
|
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||||
|
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||||
|
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||||
|
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||||
|
'On Bun versions without `bun pm trust`, add\n' +
|
||||||
|
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||||
|
'to your project package.json and reinstall.\n' +
|
||||||
|
'\n'
|
||||||
|
: '') +
|
||||||
|
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||||
|
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ jobs:
|
|||||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||||
> that variable for the run and never prints it. If your repository already
|
> that variable only for the ZERO step and never prints it. If your repository
|
||||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||||
|
> `provider`.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ jobs:
|
|||||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||||
@@ -162,9 +163,10 @@ jobs:
|
|||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
is passed only to the Slack post step. They are never echoed or persisted to
|
||||||
strings from its own output.
|
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||||
|
output.
|
||||||
- **The sandbox is always active.** This action never passes
|
- **The sandbox is always active.** This action never passes
|
||||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||||
|
|||||||
+100
@@ -24,6 +24,51 @@ Requirements:
|
|||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- network access to npm and GitHub Releases
|
- network access to npm and GitHub Releases
|
||||||
|
|
||||||
|
## Bun
|
||||||
|
|
||||||
|
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||||
|
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||||
|
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||||
|
`No native binary found next to the npm wrapper`.
|
||||||
|
|
||||||
|
The simplest fix is to trust the package after installing, which runs the
|
||||||
|
blocked postinstall. This works for project and global installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# project install
|
||||||
|
bun add @gitlawb/zero
|
||||||
|
bun pm trust @gitlawb/zero
|
||||||
|
|
||||||
|
# global install
|
||||||
|
bun add -g @gitlawb/zero
|
||||||
|
bun pm -g trust @gitlawb/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||||
|
you want to inspect before trusting.
|
||||||
|
|
||||||
|
Alternatively, allow the postinstall to run at install time by adding the
|
||||||
|
package to your project's `trustedDependencies` before installing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trustedDependencies": ["@gitlawb/zero"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun add @gitlawb/zero
|
||||||
|
```
|
||||||
|
|
||||||
|
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||||
|
after installing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||||
|
|
||||||
## Linux And macOS Script
|
## Linux And macOS Script
|
||||||
|
|
||||||
Install the latest release:
|
Install the latest release:
|
||||||
@@ -128,6 +173,61 @@ Linux native sandboxing also requires Bubblewrap to be installed.
|
|||||||
|
|
||||||
macOS uses the system sandbox and does not need an extra helper binary.
|
macOS uses the system sandbox and does not need an extra helper binary.
|
||||||
|
|
||||||
|
### Termux (Android)
|
||||||
|
|
||||||
|
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
|
||||||
|
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
|
||||||
|
seccomp filter on Android:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Go in Termux
|
||||||
|
pkg install golang
|
||||||
|
|
||||||
|
# Build Zero for Android
|
||||||
|
git clone https://github.com/Gitlawb/zero.git
|
||||||
|
cd zero
|
||||||
|
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||||
|
|
||||||
|
# Move into PATH
|
||||||
|
mv zero ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
|
||||||
|
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
|
||||||
|
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
|
||||||
|
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
|
||||||
|
|
||||||
|
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
|
||||||
|
needs one. Use `proot` to bind-mount Termux's resolver config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg install proot
|
||||||
|
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/data/data/com.termux/files/usr/bin/bash
|
||||||
|
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
|
||||||
|
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
|
||||||
|
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
|
||||||
|
to avoid PRoot's ptrace interference with the 1003 escape sequence.
|
||||||
|
|
||||||
|
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
|
||||||
|
example, to use OpenCode Zen's free tier:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
zero providers add opencode \
|
||||||
|
--name opencode \
|
||||||
|
--model deepseek-v4-flash-free \
|
||||||
|
--base-url https://opencode.ai/zen/v1 \
|
||||||
|
--set-active
|
||||||
|
```
|
||||||
|
|
||||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ events when present.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||||
|
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||||
@@ -51,6 +52,10 @@ events when present.
|
|||||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||||
|
stream them separately from answer text. They are liveness/progress events only:
|
||||||
|
they are not folded into `text` or the final answer.
|
||||||
|
|
||||||
Permission events may include structured sandbox metadata:
|
Permission events may include structured sandbox metadata:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||||
|
// omits (or malforms) additional_permissions can never succeed: the same
|
||||||
|
// validation runs again at grant time regardless of the user's decision. It
|
||||||
|
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||||
|
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||||
|
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
|
promptCalls := 0
|
||||||
|
result, err := executeToolCall(
|
||||||
|
context.Background(),
|
||||||
|
registry,
|
||||||
|
ToolCall{
|
||||||
|
ID: "c1",
|
||||||
|
Name: "bash",
|
||||||
|
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||||
|
},
|
||||||
|
PermissionModeAuto,
|
||||||
|
Options{
|
||||||
|
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||||
|
promptCalls++
|
||||||
|
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeToolCall: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != tools.StatusError {
|
||||||
|
t.Fatalf("status = %q, want error", result.Status)
|
||||||
|
}
|
||||||
|
if promptCalls != 0 {
|
||||||
|
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mismatched-flag case (additional_permissions present without the
|
||||||
|
// sandbox_permissions flag) must be rejected the same way.
|
||||||
|
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
|
promptCalls := 0
|
||||||
|
result, err := executeToolCall(
|
||||||
|
context.Background(),
|
||||||
|
registry,
|
||||||
|
ToolCall{
|
||||||
|
ID: "c1",
|
||||||
|
Name: "bash",
|
||||||
|
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||||
|
},
|
||||||
|
PermissionModeAuto,
|
||||||
|
Options{
|
||||||
|
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||||
|
promptCalls++
|
||||||
|
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeToolCall: %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != tools.StatusError {
|
||||||
|
t.Fatalf("status = %q, want error", result.Status)
|
||||||
|
}
|
||||||
|
if promptCalls != 0 {
|
||||||
|
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rejection message must show a concrete, correctly-shaped example: a
|
||||||
|
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||||
|
// from an earlier call, without a valid payload) needs enough in the error to
|
||||||
|
// self-correct on retry instead of repeating the identical mistake.
|
||||||
|
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
|
result, err := executeToolCall(
|
||||||
|
context.Background(),
|
||||||
|
registry,
|
||||||
|
ToolCall{
|
||||||
|
ID: "c1",
|
||||||
|
Name: "bash",
|
||||||
|
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||||
|
},
|
||||||
|
PermissionModeAuto,
|
||||||
|
Options{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeToolCall: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||||
|
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||||
|
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A well-formed additional_permissions payload must still reach the normal
|
||||||
|
// permission prompt: this fix only rejects malformed payloads.
|
||||||
|
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewBashTool(root))
|
||||||
|
|
||||||
|
promptCalls := 0
|
||||||
|
_, err := executeToolCall(
|
||||||
|
context.Background(),
|
||||||
|
registry,
|
||||||
|
ToolCall{
|
||||||
|
ID: "c1",
|
||||||
|
Name: "bash",
|
||||||
|
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||||
|
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||||
|
},
|
||||||
|
PermissionModeAuto,
|
||||||
|
Options{
|
||||||
|
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||||
|
promptCalls++
|
||||||
|
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeToolCall: %v", err)
|
||||||
|
}
|
||||||
|
if promptCalls != 1 {
|
||||||
|
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||||
|
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||||
|
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||||
|
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||||
|
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||||
|
// single worker checks it while the rest of the tool batch (and any
|
||||||
|
// self-correct verification) runs, and the loop drains completed results just
|
||||||
|
// before building the NEXT provider request, appending errors as a user-role
|
||||||
|
// nudge. The model always issues another request after an edit turn — it has
|
||||||
|
// to read its tool results — so it still sees introduced errors at the same
|
||||||
|
// decision point as before, without any tool call stalling on the language
|
||||||
|
// server.
|
||||||
|
//
|
||||||
|
// A file re-edited before its check runs is checked once in its final state
|
||||||
|
// (the checker re-reads the file); a file re-edited after its check completed
|
||||||
|
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||||
|
// drain time, the drain gives up after a short wait and the results are
|
||||||
|
// delivered on the following turn instead — they stay accurate because any
|
||||||
|
// further edit re-enqueues the file.
|
||||||
|
|
||||||
|
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||||
|
// check before deferring delivery to the next turn. Most checks finish well
|
||||||
|
// under it (debounce floor + analysis); a cold language server on a large
|
||||||
|
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||||
|
// A var so tests can shorten the wait.
|
||||||
|
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||||
|
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||||
|
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||||
|
// than silently dropping an error the last edit introduced. A var so tests
|
||||||
|
// can shorten the wait.
|
||||||
|
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||||
|
// the old inline block so the model reacts the same way.
|
||||||
|
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||||
|
|
||||||
|
type asyncDiagnostics struct {
|
||||||
|
check func(context.Context, string) string
|
||||||
|
workspaceRoot string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
queue []string
|
||||||
|
queued map[string]bool
|
||||||
|
results map[string]string
|
||||||
|
// working is non-nil while the worker goroutine runs and is closed when it
|
||||||
|
// exits, so drain can wait for quiescence without polling.
|
||||||
|
working chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||||
|
// receiver is a no-op — callers wire it unconditionally.
|
||||||
|
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||||
|
if check == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &asyncDiagnostics{
|
||||||
|
check: check,
|
||||||
|
workspaceRoot: workspaceRoot,
|
||||||
|
queued: map[string]bool{},
|
||||||
|
results: map[string]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue schedules changed files for a background check. Paths are
|
||||||
|
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||||
|
// extra write root), mirroring SelfCorrector's resolution.
|
||||||
|
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||||
|
if diagnostics == nil || len(files) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
defer diagnostics.mu.Unlock()
|
||||||
|
for _, file := range files {
|
||||||
|
path := diagnostics.absPath(file)
|
||||||
|
if diagnostics.queued[path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diagnostics.queued[path] = true
|
||||||
|
// A completed result for this file describes a pre-edit state now.
|
||||||
|
delete(diagnostics.results, path)
|
||||||
|
diagnostics.queue = append(diagnostics.queue, path)
|
||||||
|
}
|
||||||
|
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||||
|
done := make(chan struct{})
|
||||||
|
diagnostics.working = done
|
||||||
|
go diagnostics.work(ctx, done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||||
|
diagnostics.working = nil
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := diagnostics.queue[0]
|
||||||
|
diagnostics.queue = diagnostics.queue[1:]
|
||||||
|
// Un-mark before checking so an edit that lands DURING the check
|
||||||
|
// re-enqueues the file and it is checked again in its final state.
|
||||||
|
delete(diagnostics.queued, path)
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
|
||||||
|
block := diagnostics.check(ctx, path)
|
||||||
|
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
if block != "" {
|
||||||
|
diagnostics.results[path] = block
|
||||||
|
} else {
|
||||||
|
delete(diagnostics.results, path)
|
||||||
|
}
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||||
|
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||||
|
// to report or the worker is still busy (results then surface next turn).
|
||||||
|
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||||
|
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainFinal is drain with the finalization budget: called before the run's
|
||||||
|
// last model request, where an undelivered result would otherwise be lost.
|
||||||
|
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||||
|
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||||
|
if diagnostics == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
busy := diagnostics.working
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
if busy != nil {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-busy:
|
||||||
|
case <-timer.C:
|
||||||
|
return ""
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
defer diagnostics.mu.Unlock()
|
||||||
|
if len(diagnostics.results) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(diagnostics.results))
|
||||||
|
for path := range diagnostics.results {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
blocks := make([]string, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
blocks = append(blocks, diagnostics.results[path])
|
||||||
|
}
|
||||||
|
diagnostics.results = map[string]string{}
|
||||||
|
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||||
|
var diagnostics *asyncDiagnostics
|
||||||
|
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||||
|
t.Fatal("nil check must yield a nil collector")
|
||||||
|
}
|
||||||
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||||
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||||
|
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||||
|
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||||
|
// (no drive letter), which is exactly what the assertions below check.
|
||||||
|
root := t.TempDir()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var checked []string
|
||||||
|
check := func(_ context.Context, absPath string) string {
|
||||||
|
mu.Lock()
|
||||||
|
checked = append(checked, absPath)
|
||||||
|
mu.Unlock()
|
||||||
|
if filepath.Base(absPath) == "clean.go" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "ERR " + filepath.Base(absPath)
|
||||||
|
}
|
||||||
|
diagnostics := newAsyncDiagnostics(check, root)
|
||||||
|
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||||
|
|
||||||
|
nudge := diagnostics.drain(context.Background())
|
||||||
|
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||||
|
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||||
|
}
|
||||||
|
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||||
|
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
for _, path := range checked {
|
||||||
|
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||||
|
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if second := diagnostics.drain(context.Background()); second != "" {
|
||||||
|
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A check still running at drain time defers delivery to the next drain
|
||||||
|
// instead of blocking the turn.
|
||||||
|
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||||
|
previous := asyncDiagnosticsDrainTimeout
|
||||||
|
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||||
|
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
check := func(_ context.Context, absPath string) string {
|
||||||
|
<-release
|
||||||
|
return "ERR " + filepath.Base(absPath)
|
||||||
|
}
|
||||||
|
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||||
|
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||||
|
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > time.Second {
|
||||||
|
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||||
|
if !strings.Contains(nudge, "ERR slow.go") {
|
||||||
|
t.Fatalf("deferred nudge = %q", nudge)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("deferred diagnostics never delivered")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||||
|
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
response := "ERR old"
|
||||||
|
check := func(context.Context, string) string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||||
|
|
||||||
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||||
|
waitForIdle(t, diagnostics)
|
||||||
|
mu.Lock()
|
||||||
|
response = "ERR new"
|
||||||
|
mu.Unlock()
|
||||||
|
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||||
|
|
||||||
|
nudge := diagnostics.drain(context.Background())
|
||||||
|
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||||
|
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
diagnostics.mu.Lock()
|
||||||
|
busy := diagnostics.working
|
||||||
|
diagnostics.mu.Unlock()
|
||||||
|
if busy == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("worker never went idle")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||||
|
// touching the filesystem.
|
||||||
|
type changedFilesTool struct{}
|
||||||
|
|
||||||
|
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||||
|
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||||
|
func (changedFilesTool) Parameters() tools.Schema {
|
||||||
|
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||||
|
}
|
||||||
|
func (changedFilesTool) Safety() tools.Safety {
|
||||||
|
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||||
|
}
|
||||||
|
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||||
|
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||||
|
// result (the old inline path), but the next request contains the background
|
||||||
|
// diagnostics nudge, before the model finalizes.
|
||||||
|
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(changedFilesTool{})
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
var checkedPath string
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
Cwd: root,
|
||||||
|
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||||
|
mu.Lock()
|
||||||
|
checkedPath = absPath
|
||||||
|
mu.Unlock()
|
||||||
|
return "main.go:1:1 error: boom"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "done" {
|
||||||
|
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
if len(provider.requests) != 2 {
|
||||||
|
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tool result itself must NOT block on / embed diagnostics.
|
||||||
|
for _, message := range provider.requests[1].Messages {
|
||||||
|
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||||
|
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The nudge must be present in the second request as a user message.
|
||||||
|
found := false
|
||||||
|
for _, message := range provider.requests[1].Messages {
|
||||||
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||||
|
if !strings.Contains(message.Content, "boom") {
|
||||||
|
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||||
|
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||||
|
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||||
|
// error introduced by the last edit would go unreported in the summary.
|
||||||
|
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(changedFilesTool{})
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
Cwd: t.TempDir(),
|
||||||
|
MaxTurns: 1,
|
||||||
|
FileDiagnostics: func(context.Context, string) string {
|
||||||
|
return "main.go:1:1 error: boom"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "summary" {
|
||||||
|
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
if len(provider.requests) != 2 {
|
||||||
|
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, message := range provider.requests[1].Messages {
|
||||||
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the model finalizes while a slow check is still in flight (the
|
||||||
|
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||||
|
// the model one more turn with the nudge instead of dropping the errors.
|
||||||
|
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||||
|
previous := asyncDiagnosticsDrainTimeout
|
||||||
|
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||||
|
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||||
|
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(changedFilesTool{})
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
Cwd: t.TempDir(),
|
||||||
|
FileDiagnostics: func(context.Context, string) string {
|
||||||
|
// Slower than the (shortened) per-turn drain, well under the
|
||||||
|
// finalization budget.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
return "main.go:1:1 error: boom"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "done after fix" {
|
||||||
|
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
if len(provider.requests) != 3 {
|
||||||
|
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, message := range provider.requests[2].Messages {
|
||||||
|
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||||
|
if !strings.Contains(message.Content, "boom") {
|
||||||
|
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, tokens := range segments {
|
// Only propose approving a prefix of one segment when every other segment
|
||||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
// in the command is independently known-safe. Once a prefix is approved,
|
||||||
return append([]string(nil), tokens...)
|
// shellExecutionArgsForApproval escalates the whole command (every
|
||||||
|
// segment, not just the approved one) to bypass the sandbox, so offering a
|
||||||
|
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
|
||||||
|
// would let that segment run unsandboxed without ever being reviewed.
|
||||||
|
for index, tokens := range segments {
|
||||||
|
if knownSafeCommandSegment(tokens) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), tokens...)
|
||||||
}
|
}
|
||||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||||
return nil
|
return nil
|
||||||
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
|||||||
return append([]string(nil), segments[0]...)
|
return append([]string(nil), segments[0]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// otherSegmentsKnownSafe reports whether every segment other than the one at
|
||||||
|
// skip is known-safe on its own.
|
||||||
|
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
|
||||||
|
for index, tokens := range segments {
|
||||||
|
if index == skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !knownSafeCommandSegment(tokens) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||||
return sandbox.CommandPrefixGrant{}, false, false
|
return sandbox.CommandPrefixGrant{}, false, false
|
||||||
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
|
||||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||||
return false
|
return false
|
||||||
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
|
||||||
segments, ok := safeShellCommandSegments(command)
|
|
||||||
if !ok || len(segments) != 1 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return segments[0], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||||
command = strings.TrimSpace(command)
|
command = strings.TrimSpace(command)
|
||||||
if command == "" {
|
if command == "" {
|
||||||
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
|
|||||||
if len(command) == 0 {
|
if len(command) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch commandName(command[0]) {
|
name := commandName(command[0])
|
||||||
|
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||||
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
|||||||
|
|
||||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
||||||
|
// not offer "ps aux" as a reusable prefix here: approving it would
|
||||||
|
// escalate the whole command, including the uncovered head segment,
|
||||||
|
// to bypass the sandbox unreviewed. See
|
||||||
|
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
||||||
|
// the platform-independent regression coverage of this behavior.
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
want := []string{"ps", "aux"}
|
want := []string{"ps", "aux"}
|
||||||
if !equalStringSlices(got, want) {
|
if !equalStringSlices(got, want) {
|
||||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
||||||
|
// against proposedCommandPrefix offering to approve one segment of a
|
||||||
|
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
||||||
|
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
||||||
|
// entire command once any prefix is approved, so an uncovered unsafe segment
|
||||||
|
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
||||||
|
// any platform, so the assertion does not depend on runtime.GOOS.
|
||||||
|
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
||||||
|
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
||||||
|
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||||
got := proposedCommandPrefix("bash", map[string]any{
|
got := proposedCommandPrefix("bash", map[string]any{
|
||||||
"command": "git status --short && git status --branch",
|
"command": "git status --short && git status --branch",
|
||||||
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
|||||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||||
|
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
||||||
|
command := "ps aux | head -5"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = "ps aux | echo ok"
|
||||||
|
}
|
||||||
|
|
||||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
||||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("windows-only known-safe MSYS guard")
|
||||||
|
}
|
||||||
|
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
||||||
|
if knownSafeCommandSegment(command) {
|
||||||
|
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
||||||
|
t.Fatal("expected echo to remain known-safe on Windows")
|
||||||
|
}
|
||||||
|
if !tools.MsysProneCommandName("head") {
|
||||||
|
t.Fatal("expected head to be MSYS-prone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ import (
|
|||||||
// the agent loop owns the provider wiring.
|
// the agent loop owns the provider wiring.
|
||||||
|
|
||||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||||
// when the caller does not specify a preserve count.
|
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||||
const defaultCompactionPreserveLast = 8
|
// reconstruction default in internal/sessions/replay.go.
|
||||||
|
const defaultCompactionPreserveLast = 6
|
||||||
|
|
||||||
// compactionTriggerRatio is the fraction of the context window at which
|
// compactionTriggerRatio is the fraction of the context window at which
|
||||||
// proactive compaction kicks in (top of each turn).
|
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||||
const compactionTriggerRatio = 0.8
|
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||||
|
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||||
|
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||||
|
// summarization, raise it back toward 0.8.
|
||||||
|
const compactionTriggerRatio = 0.7
|
||||||
|
|
||||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||||
// transcript (and so tests can assert on it).
|
// transcript (and so tests can assert on it).
|
||||||
@@ -320,6 +325,43 @@ type compactionState struct {
|
|||||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||||
// but its token COST must still be counted so usage reports and budgets include it.
|
// but its token COST must still be counted so usage reports and budgets include it.
|
||||||
onUsage func(Usage)
|
onUsage func(Usage)
|
||||||
|
|
||||||
|
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||||
|
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||||
|
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
||||||
|
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||||
|
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||||
|
calibrationRatio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||||
|
// running calibration ratio. A single sample is clamped to a sane band so an
|
||||||
|
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
||||||
|
// estimate enough to disable or thrash compaction.
|
||||||
|
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
||||||
|
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
||||||
|
if sample < 0.5 {
|
||||||
|
sample = 0.5
|
||||||
|
} else if sample > 2.0 {
|
||||||
|
sample = 2.0
|
||||||
|
}
|
||||||
|
if state.calibrationRatio <= 0 {
|
||||||
|
state.calibrationRatio = 1.0
|
||||||
|
}
|
||||||
|
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
||||||
|
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
||||||
|
// arrives (ratio unset) it returns the raw estimate unchanged.
|
||||||
|
func (state *compactionState) calibratedTokens(raw int) int {
|
||||||
|
if state.calibrationRatio <= 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return int(float64(raw) * state.calibrationRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCompactionState(options Options) *compactionState {
|
func newCompactionState(options Options) *compactionState {
|
||||||
@@ -348,7 +390,7 @@ func (state *compactionState) maybeCompact(
|
|||||||
// the messages; both the threshold check and the shrink check below use the
|
// the messages; both the threshold check and the shrink check below use the
|
||||||
// same term so they stay consistent.
|
// same term so they stay consistent.
|
||||||
toolTokens := estimateToolDefTokens(tools)
|
toolTokens := estimateToolDefTokens(tools)
|
||||||
size := estimateTokens(messages) + toolTokens
|
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||||
if size <= state.threshold {
|
if size <= state.threshold {
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
@@ -365,7 +407,7 @@ func (state *compactionState) maybeCompact(
|
|||||||
// entirely and preserve recent turns verbatim.
|
// entirely and preserve recent turns verbatim.
|
||||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||||
messages = pruned
|
messages = pruned
|
||||||
size = estimateTokens(messages) + toolTokens
|
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||||
if size <= state.threshold {
|
if size <= state.threshold {
|
||||||
state.lowWaterMark = size
|
state.lowWaterMark = size
|
||||||
return messages
|
return messages
|
||||||
@@ -381,7 +423,7 @@ func (state *compactionState) maybeCompact(
|
|||||||
// later turn) can try again; we never drop messages on failure here.
|
// later turn) can try again; we never drop messages on failure here.
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
newSize := estimateTokens(compacted) + toolTokens
|
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
||||||
if newSize >= size {
|
if newSize >= size {
|
||||||
// Compaction did not actually shrink anything (e.g. nothing to
|
// Compaction did not actually shrink anything (e.g. nothing to
|
||||||
// summarize). Leave the history untouched and don't churn next turn.
|
// summarize). Leave the history untouched and don't churn next turn.
|
||||||
@@ -439,7 +481,7 @@ func (state *compactionState) recover(
|
|||||||
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
||||||
// proactive shrink-guard compares like with like.
|
// proactive shrink-guard compares like with like.
|
||||||
state.reactiveAttempted = true
|
state.reactiveAttempted = true
|
||||||
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
|
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||||
return result, true, nil
|
return result, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCalibrationConvergesTowardActual(t *testing.T) {
|
||||||
|
state := &compactionState{enabled: true}
|
||||||
|
|
||||||
|
// Before any sample, the estimate is unchanged.
|
||||||
|
if got := state.calibratedTokens(1000); got != 1000 {
|
||||||
|
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The estimator over-counts: our raw estimate is 1000 but the provider reports
|
||||||
|
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
|
||||||
|
// toward 0.85, so a 1000-token raw estimate calibrates downward.
|
||||||
|
for range 20 {
|
||||||
|
state.calibrate(1000, 850)
|
||||||
|
}
|
||||||
|
got := state.calibratedTokens(1000)
|
||||||
|
if got >= 1000 || got < 820 || got > 880 {
|
||||||
|
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
|
||||||
|
state := &compactionState{enabled: true}
|
||||||
|
state.calibrate(0, 500) // zero estimate
|
||||||
|
state.calibrate(500, 0) // zero actual
|
||||||
|
if state.calibrationRatio != 0 {
|
||||||
|
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
|
||||||
|
}
|
||||||
|
// Disabled state never calibrates.
|
||||||
|
disabled := &compactionState{enabled: false}
|
||||||
|
disabled.calibrate(1000, 850)
|
||||||
|
if disabled.calibrationRatio != 0 {
|
||||||
|
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalibrateClampsOutliers(t *testing.T) {
|
||||||
|
state := &compactionState{enabled: true}
|
||||||
|
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
|
||||||
|
// ratio and disable compaction.
|
||||||
|
for range 50 {
|
||||||
|
state.calibrate(1000, 10000)
|
||||||
|
}
|
||||||
|
if state.calibrationRatio > 2.0 {
|
||||||
|
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@ const (
|
|||||||
toolNameUpdatePlan = "update_plan"
|
toolNameUpdatePlan = "update_plan"
|
||||||
toolNameToolSearch = "tool_search"
|
toolNameToolSearch = "tool_search"
|
||||||
toolNameSkill = "skill"
|
toolNameSkill = "skill"
|
||||||
|
toolNameWriteFile = "write_file"
|
||||||
|
toolNameEditFile = "edit_file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRecentEdits caps how many edited files are carried across a compaction, and
|
||||||
|
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
|
||||||
|
// bloat the preserved-state block.
|
||||||
|
const (
|
||||||
|
maxRecentEdits = 20
|
||||||
|
maxEditNoteBytes = 160
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -100,12 +110,123 @@ func formatPlanArguments(arguments string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||||
// reused for loaded tools and project instruction blocks.
|
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||||
type skillEntry struct {
|
type skillEntry struct {
|
||||||
name string
|
name string
|
||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||||
|
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||||
|
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||||
|
// changed in each file (from the tool's result) so it need not re-read to
|
||||||
|
// rediscover its own footprint. Capped at maxRecentEdits paths.
|
||||||
|
//
|
||||||
|
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
|
||||||
|
// position so the tail cap below keeps the file the model most recently touched
|
||||||
|
// rather than an earlier, now-stale entry.
|
||||||
|
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||||
|
pathByID := map[string]string{}
|
||||||
|
sequence := make([]string, 0)
|
||||||
|
for _, message := range messages {
|
||||||
|
for _, call := range message.ToolCalls {
|
||||||
|
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := editPathFromArguments(call.Arguments)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if call.ID != "" {
|
||||||
|
pathByID[call.ID] = path
|
||||||
|
}
|
||||||
|
sequence = append(sequence, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := lastSeenOrder(sequence)
|
||||||
|
if len(order) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
noteByPath := map[string]string{}
|
||||||
|
for _, message := range messages {
|
||||||
|
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path, ok := pathByID[message.ToolCallID]; ok {
|
||||||
|
noteByPath[path] = editNote(message.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
|
||||||
|
if len(order) > maxRecentEdits {
|
||||||
|
order = order[len(order)-maxRecentEdits:]
|
||||||
|
}
|
||||||
|
entries := make([]skillEntry, 0, len(order))
|
||||||
|
for _, path := range order {
|
||||||
|
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
|
||||||
|
// chronological order otherwise. A re-edited path therefore lands at the newest
|
||||||
|
// position rather than staying pinned to where it first appeared.
|
||||||
|
func lastSeenOrder(paths []string) []string {
|
||||||
|
lastIdx := make(map[string]int, len(paths))
|
||||||
|
for i, p := range paths {
|
||||||
|
lastIdx[p] = i
|
||||||
|
}
|
||||||
|
order := make([]string, 0, len(lastIdx))
|
||||||
|
for i, p := range paths {
|
||||||
|
if lastIdx[p] == i {
|
||||||
|
order = append(order, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
// editPathFromArguments pulls the target file path from a write_file/edit_file
|
||||||
|
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
|
||||||
|
// malformed arguments or no path.
|
||||||
|
func editPathFromArguments(arguments string) string {
|
||||||
|
var parsed struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
File string `json:"file"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
|
||||||
|
if s := strings.TrimSpace(candidate); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// editNote reduces an edit tool's result to a single short line (its first
|
||||||
|
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
|
||||||
|
func editNote(content string) string {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
s := strings.TrimSpace(line)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(s) > maxEditNoteBytes {
|
||||||
|
limit := maxEditNoteBytes
|
||||||
|
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
||||||
|
limit--
|
||||||
|
}
|
||||||
|
s = s[:limit] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
||||||
// latest body per name, in first-seen order — matching each skill tool call to
|
// latest body per name, in first-seen order — matching each skill tool call to
|
||||||
// its tool result by id.
|
// its tool result by id.
|
||||||
@@ -310,11 +431,17 @@ func capBody(body string) string {
|
|||||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||||
type preservedState struct {
|
type preservedState struct {
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
|
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||||
Tools []preservedTool `json:"tools,omitempty"`
|
Tools []preservedTool `json:"tools,omitempty"`
|
||||||
Skills []preservedSkill `json:"skills,omitempty"`
|
Skills []preservedSkill `json:"skills,omitempty"`
|
||||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type preservedEdit struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type preservedTool struct {
|
type preservedTool struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -347,6 +474,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
|||||||
plan = priorState.Plan
|
plan = priorState.Plan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
|
||||||
|
// results in middle (newer note per path wins AND moves to the newest
|
||||||
|
// position), so the tail cap keeps the files the model most recently touched
|
||||||
|
// across repeated compactions rather than dropping a just-re-edited file.
|
||||||
|
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
|
||||||
|
|
||||||
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
||||||
// loads override older carried copies by name.
|
// loads override older carried copies by name.
|
||||||
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
||||||
@@ -360,19 +493,76 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
|||||||
projectInstructionEntries(middle),
|
projectInstructionEntries(middle),
|
||||||
)
|
)
|
||||||
|
|
||||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
|
||||||
summary += "\n\n" + block
|
summary += "\n\n" + block
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||||
|
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||||
|
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||||
|
// position and takes the fresh note, so capRecentEdits — which keeps only the
|
||||||
|
// most-recent tail — never drops a file the model just re-edited. Paths present
|
||||||
|
// only in the older set keep their relative order, ahead of the fresh ones.
|
||||||
|
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
|
||||||
|
if len(newer) == 0 {
|
||||||
|
return older
|
||||||
|
}
|
||||||
|
freshBody := make(map[string]string, len(newer))
|
||||||
|
freshOrder := make([]string, 0, len(newer))
|
||||||
|
for _, e := range newer {
|
||||||
|
if _, ok := freshBody[e.name]; !ok {
|
||||||
|
freshOrder = append(freshOrder, e.name)
|
||||||
|
}
|
||||||
|
freshBody[e.name] = e.body
|
||||||
|
}
|
||||||
|
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
|
||||||
|
// Older entries that were NOT re-edited keep their position, oldest first.
|
||||||
|
for _, e := range older {
|
||||||
|
if _, refreshed := freshBody[e.name]; refreshed {
|
||||||
|
continue // re-appended at its newest position below
|
||||||
|
}
|
||||||
|
merged = append(merged, e)
|
||||||
|
}
|
||||||
|
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
|
||||||
|
// the most recently touched files sit at the tail the cap preserves.
|
||||||
|
for _, name := range freshOrder {
|
||||||
|
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
|
||||||
|
// entries after a merge, so repeated compactions can't grow it without limit.
|
||||||
|
func capRecentEdits(entries []skillEntry) []skillEntry {
|
||||||
|
if len(entries) > maxRecentEdits {
|
||||||
|
return entries[len(entries)-maxRecentEdits:]
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||||
|
entries := make([]skillEntry, 0, len(edits))
|
||||||
|
for _, e := range edits {
|
||||||
|
if e.Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
// formatPreservedState renders state as the labelled, single-line
|
// formatPreservedState renders state as the labelled, single-line
|
||||||
// JSON block. Returns "" when there is nothing to preserve.
|
// JSON block. Returns "" when there is nothing to preserve.
|
||||||
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
|
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
state := preservedState{Plan: plan}
|
state := preservedState{Plan: plan}
|
||||||
|
for _, e := range edits {
|
||||||
|
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||||
|
}
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||||
|
// matching tool result, latest note per path in last-seen order.
|
||||||
|
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||||
|
messages := []zeroruntime.Message{
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
|
||||||
|
}},
|
||||||
|
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||||
|
}},
|
||||||
|
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||||
|
}
|
||||||
|
|
||||||
|
edits := recentEdits(messages)
|
||||||
|
if len(edits) != 2 {
|
||||||
|
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||||
|
}
|
||||||
|
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||||
|
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||||
|
}
|
||||||
|
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||||
|
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After compaction elides the editing turns, the preserved-state block still
|
||||||
|
// names the edited files and what changed, so the model needn't re-read them.
|
||||||
|
func TestCompactionPreservesRecentEdits(t *testing.T) {
|
||||||
|
messages := []zeroruntime.Message{
|
||||||
|
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
|
||||||
|
}},
|
||||||
|
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||||
|
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||||
|
}
|
||||||
|
summary := compactStateConversation(t, messages)
|
||||||
|
|
||||||
|
state := parsePreservedStateBlock(summary)
|
||||||
|
if len(state.RecentEdits) != 1 {
|
||||||
|
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
|
||||||
|
}
|
||||||
|
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
|
||||||
|
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh edit note for a path overrides the one carried from an earlier
|
||||||
|
// compaction (newer wins) and moves the path to the newest position rather than
|
||||||
|
// duplicating it or leaving it pinned to its old slot.
|
||||||
|
func TestRecentEditsMergeNewerWins(t *testing.T) {
|
||||||
|
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
|
||||||
|
older := preservedEditsToEntries(prior.RecentEdits)
|
||||||
|
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
|
||||||
|
|
||||||
|
merged := mergeRecentEdits(older, newer)
|
||||||
|
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
|
||||||
|
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
|
||||||
|
if len(merged) != 3 {
|
||||||
|
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
|
||||||
|
}
|
||||||
|
if merged[0].name != "z.go" {
|
||||||
|
t.Fatalf("untouched z.go should stay first, got %#v", merged)
|
||||||
|
}
|
||||||
|
if merged[1].name != "a.go" || merged[1].body != "new note" {
|
||||||
|
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
|
||||||
|
}
|
||||||
|
if merged[2].name != "b.go" {
|
||||||
|
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
|
||||||
|
// an early file must move it into the capped tail rather than leaving it to be
|
||||||
|
// dropped — otherwise the next compaction omits exactly the file the model most
|
||||||
|
// recently touched.
|
||||||
|
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
|
||||||
|
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
|
||||||
|
older := make([]skillEntry, 0, maxRecentEdits)
|
||||||
|
for i := 0; i < maxRecentEdits; i++ {
|
||||||
|
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
|
||||||
|
}
|
||||||
|
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
|
||||||
|
// merged list exceeds the cap and something must be dropped.
|
||||||
|
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
|
||||||
|
|
||||||
|
merged := capRecentEdits(mergeRecentEdits(older, newer))
|
||||||
|
if len(merged) != maxRecentEdits {
|
||||||
|
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
|
||||||
|
}
|
||||||
|
|
||||||
|
var f0 *skillEntry
|
||||||
|
for i := range merged {
|
||||||
|
if merged[i].name == "f0.go" {
|
||||||
|
f0 = &merged[i]
|
||||||
|
}
|
||||||
|
if merged[i].name == "f1.go" {
|
||||||
|
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f0 == nil {
|
||||||
|
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
|
||||||
|
}
|
||||||
|
if f0.body != "re-edited" {
|
||||||
|
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/lsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||||
|
// slow or wedged language server can never hang a tool call; on timeout the
|
||||||
|
// edit simply reports without a diagnostics block.
|
||||||
|
const fileDiagnosticsTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||||
|
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||||
|
// checks it against the file's language server, and formats error-severity
|
||||||
|
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||||
|
// style on every edit is noise, while a type error the edit just introduced is
|
||||||
|
// exactly what the model should see before its next step. Diagnostics are
|
||||||
|
// rendered with workspace-relative paths: the absolute path would put the
|
||||||
|
// local username/home directory into the model prompt and session transcript
|
||||||
|
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
|
||||||
|
// entirely.
|
||||||
|
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
|
||||||
|
if manager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, absPath string) string {
|
||||||
|
text, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||||
|
defer cancel()
|
||||||
|
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||||
|
if len(errors) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnosticsDisplayPath renders absPath relative to the workspace root for
|
||||||
|
// model-facing output, falling back to the file's base name when the path is
|
||||||
|
// outside the workspace (a bare name still identifies the file without
|
||||||
|
// exposing the directory layout).
|
||||||
|
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
|
||||||
|
if workspaceRoot != "" {
|
||||||
|
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Base(absPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diagnostics are model-facing: absolute paths would leak the local username
|
||||||
|
// and directory layout into the prompt and session transcript on every edit.
|
||||||
|
func TestDiagnosticsDisplayPath(t *testing.T) {
|
||||||
|
root := filepath.Join("/Users", "someone", "project")
|
||||||
|
cases := []struct {
|
||||||
|
root, abs, want string
|
||||||
|
}{
|
||||||
|
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
|
||||||
|
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
|
||||||
|
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
|
||||||
|
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,12 @@ const (
|
|||||||
// calls have executed since the last update_plan call.
|
// calls have executed since the last update_plan call.
|
||||||
staleToolCallThreshold = 10
|
staleToolCallThreshold = 10
|
||||||
|
|
||||||
|
// stalePlanTurnThreshold injects the same one-shot reminder once this many
|
||||||
|
// turns have passed since the last update_plan while plan items are still
|
||||||
|
// pending — the turn-based complement to staleToolCallThreshold, catching a
|
||||||
|
// plan that drifts stale across many turns that each make few tool calls.
|
||||||
|
stalePlanTurnThreshold = 8
|
||||||
|
|
||||||
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
||||||
// many consecutive turns contain tool calls but no visible assistant text.
|
// many consecutive turns contain tool calls but no visible assistant text.
|
||||||
// It does not stop the run; it tells the model to synthesize what it already
|
// It does not stop the run; it tells the model to synthesize what it already
|
||||||
@@ -43,8 +49,12 @@ const (
|
|||||||
toolFailureHintAt = 2
|
toolFailureHintAt = 2
|
||||||
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
||||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||||
// bad call.
|
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
|
||||||
toolFailureStopAt = 4
|
// and a model iterating on a genuinely tricky edit can legitimately fail a few
|
||||||
|
// times after the hint while converging — stopping at 4 cut those runs short.
|
||||||
|
// The streak still resets the moment the tool succeeds or hits a different
|
||||||
|
// error, so this only affects true same-error loops.
|
||||||
|
toolFailureStopAt = 6
|
||||||
|
|
||||||
// maxContinueNudges bounds how many times the headless completion gate
|
// maxContinueNudges bounds how many times the headless completion gate
|
||||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||||
@@ -354,8 +364,13 @@ type guardState struct {
|
|||||||
emptyTurns int
|
emptyTurns int
|
||||||
totalToolCalls int
|
totalToolCalls int
|
||||||
toolCallsSincePlanUpdate int
|
toolCallsSincePlanUpdate int
|
||||||
planEverCalled bool
|
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
|
||||||
notCalledReminderSent bool
|
// update_plan, so a plan that goes stale across many low-tool-call turns is
|
||||||
|
// still caught — the tool-call counter alone can take many turns to trip when
|
||||||
|
// the model makes only one call per turn.
|
||||||
|
turnsSincePlanUpdate int
|
||||||
|
planEverCalled bool
|
||||||
|
notCalledReminderSent bool
|
||||||
// staleReminderSent records whether the stale reminder has already fired for
|
// staleReminderSent records whether the stale reminder has already fired for
|
||||||
// the current stale interval. It is cleared when a plan update opens a new
|
// the current stale interval. It is cleared when a plan update opens a new
|
||||||
// interval, making the reminder one-shot per interval rather than per turn.
|
// interval, making the reminder one-shot per interval rather than per turn.
|
||||||
@@ -414,8 +429,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
|||||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||||
hasToolCalls := len(collected.ToolCalls) > 0
|
hasToolCalls := len(collected.ToolCalls) > 0
|
||||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||||
|
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||||
|
|
||||||
if hasToolCalls || hasVisibleText {
|
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||||
state.emptyTurns = 0
|
state.emptyTurns = 0
|
||||||
} else {
|
} else {
|
||||||
state.emptyTurns++
|
state.emptyTurns++
|
||||||
@@ -427,11 +443,16 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
|||||||
state.toolOnlyReminderSent = false
|
state.toolOnlyReminderSent = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One turn has passed; the plan-update below resets this to 0 when the model
|
||||||
|
// refreshes the plan this turn.
|
||||||
|
state.turnsSincePlanUpdate++
|
||||||
|
|
||||||
for _, call := range collected.ToolCalls {
|
for _, call := range collected.ToolCalls {
|
||||||
state.totalToolCalls++
|
state.totalToolCalls++
|
||||||
if call.Name == planToolName {
|
if call.Name == planToolName {
|
||||||
state.planEverCalled = true
|
state.planEverCalled = true
|
||||||
state.toolCallsSincePlanUpdate = 0
|
state.toolCallsSincePlanUpdate = 0
|
||||||
|
state.turnsSincePlanUpdate = 0
|
||||||
// A fresh plan update opens a new stale interval.
|
// A fresh plan update opens a new stale interval.
|
||||||
state.staleReminderSent = false
|
state.staleReminderSent = false
|
||||||
// Record how many items remain so the completion gate knows whether
|
// Record how many items remain so the completion gate knows whether
|
||||||
@@ -485,10 +506,12 @@ func (state *guardState) progressReminder() string {
|
|||||||
// number of turns completed so far).
|
// number of turns completed so far).
|
||||||
func (state *guardState) planReminder(turn int) string {
|
func (state *guardState) planReminder(turn int) string {
|
||||||
// STALE reminder takes priority: a long run without a plan update is the
|
// STALE reminder takes priority: a long run without a plan update is the
|
||||||
// stronger signal. One-shot per stale interval.
|
// stronger signal. Fires on either the tool-call streak OR a turn streak with
|
||||||
if state.planEverCalled &&
|
// pending items (so a plan drifting stale across many low-call turns is caught,
|
||||||
!state.staleReminderSent &&
|
// while a fully-completed plan is left alone). One-shot per stale interval.
|
||||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
if state.planEverCalled && !state.staleReminderSent &&
|
||||||
|
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
|
||||||
|
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
|
||||||
state.staleReminderSent = true
|
state.staleReminderSent = true
|
||||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reasoningTurn produces live reasoning without visible assistant text.
|
||||||
|
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||||
|
return []zeroruntime.StreamEvent{
|
||||||
|
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||||
return []zeroruntime.StreamEvent{
|
return []zeroruntime.StreamEvent{
|
||||||
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
reasoningTurn("thinking 1"),
|
||||||
|
reasoningTurn("thinking 2"),
|
||||||
|
reasoningTurn("thinking 3"),
|
||||||
|
textTurn("done"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "go", provider, Options{
|
||||||
|
Registry: tools.NewRegistry(),
|
||||||
|
MaxTurns: 12,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "done" {
|
||||||
|
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
if len(provider.requests) != 4 {
|
||||||
|
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||||
|
|||||||
+254
-67
@@ -25,7 +25,12 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
|
|||||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||||
// safe recovery for a stalled/dead pooled connection.
|
// safe recovery for a stalled/dead pooled connection.
|
||||||
const maxStreamStallRetries = 2
|
//
|
||||||
|
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||||
|
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||||
|
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||||
|
// while bounding the worst case to ~2× the idle timeout.
|
||||||
|
const maxStreamStallRetries = 1
|
||||||
|
|
||||||
const (
|
const (
|
||||||
toolResultMetaControl = "control"
|
toolResultMetaControl = "control"
|
||||||
@@ -132,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
guards := newGuardState()
|
guards := newGuardState()
|
||||||
compactor := newCompactionState(options)
|
compactor := newCompactionState(options)
|
||||||
|
|
||||||
|
// Background post-edit diagnostics: files changed by mutating tools are
|
||||||
|
// checked off the tool-call critical path and any errors are appended as a
|
||||||
|
// nudge before the NEXT request (see async_diagnostics.go). nil when no
|
||||||
|
// FileDiagnostics callback is wired; every method no-ops on nil.
|
||||||
|
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
|
||||||
|
|
||||||
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
||||||
// during THIS run. It is consulted by partitionTools each turn to expose a
|
// during THIS run. It is consulted by partitionTools each turn to expose a
|
||||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||||
@@ -145,15 +156,31 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
// has already been demanded this run, so it fires at most once.
|
// has already been demanded this run, so it fires at most once.
|
||||||
acceptanceRequested := false
|
acceptanceRequested := false
|
||||||
|
|
||||||
|
// toolDefCache memoizes each tool's rendered JSON-schema definition across
|
||||||
|
// turns (a tool's advertised schema is stable for the run), so partitionTools
|
||||||
|
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||||||
|
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||||||
|
|
||||||
result := Result{Messages: copyMessages(messages)}
|
result := Result{Messages: copyMessages(messages)}
|
||||||
for turn := 0; turn < maxTurns; turn++ {
|
for turn := 0; turn < maxTurns; turn++ {
|
||||||
result.Turns = turn + 1
|
result.Turns = turn + 1
|
||||||
|
|
||||||
|
// Deliver background post-edit diagnostics from the previous turn's edits
|
||||||
|
// BEFORE compaction so the nudge is part of the request being budgeted.
|
||||||
|
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
|
||||||
|
// simply delivers on a later turn.
|
||||||
|
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
|
||||||
|
messages = append(messages, zeroruntime.Message{
|
||||||
|
Role: zeroruntime.MessageRoleUser,
|
||||||
|
Content: nudge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Build the per-turn tool list first so proactive compaction can include
|
// Build the per-turn tool list first so proactive compaction can include
|
||||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||||
// the messages, so computing it before compaction is safe.
|
// the messages, so computing it before compaction is safe.
|
||||||
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
|
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||||
|
|
||||||
// PROACTIVE compaction: if the history is approaching the model's
|
// PROACTIVE compaction: if the history is approaching the model's
|
||||||
// context window, summarize the oldest middle before building the
|
// context window, summarize the oldest middle before building the
|
||||||
@@ -163,6 +190,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
Messages: copyMessages(messages),
|
Messages: copyMessages(messages),
|
||||||
Tools: exposed,
|
Tools: exposed,
|
||||||
ReasoningEffort: options.ReasoningEffort,
|
ReasoningEffort: options.ReasoningEffort,
|
||||||
|
PromptCacheKey: options.SessionID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report the per-category context budget for this turn so a surface can
|
// Report the per-category context budget for this turn so a surface can
|
||||||
@@ -198,6 +226,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
Messages: copyMessages(messages),
|
Messages: copyMessages(messages),
|
||||||
Tools: exposed,
|
Tools: exposed,
|
||||||
ReasoningEffort: options.ReasoningEffort,
|
ReasoningEffort: options.ReasoningEffort,
|
||||||
|
PromptCacheKey: options.SessionID,
|
||||||
}
|
}
|
||||||
// Pre-content connect after a context-limit compaction: route through the
|
// Pre-content connect after a context-limit compaction: route through the
|
||||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||||
@@ -258,6 +287,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
Messages: copyMessages(messages),
|
Messages: copyMessages(messages),
|
||||||
Tools: exposed,
|
Tools: exposed,
|
||||||
ReasoningEffort: options.ReasoningEffort,
|
ReasoningEffort: options.ReasoningEffort,
|
||||||
|
PromptCacheKey: options.SessionID,
|
||||||
}
|
}
|
||||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||||
if retryStreamErr != nil {
|
if retryStreamErr != nil {
|
||||||
@@ -316,6 +346,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
Messages: copyMessages(messages),
|
Messages: copyMessages(messages),
|
||||||
Tools: exposed,
|
Tools: exposed,
|
||||||
ReasoningEffort: options.ReasoningEffort,
|
ReasoningEffort: options.ReasoningEffort,
|
||||||
|
PromptCacheKey: options.SessionID,
|
||||||
}
|
}
|
||||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||||
if retryErr != nil {
|
if retryErr != nil {
|
||||||
@@ -340,6 +371,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calibrate the compaction token estimator against the provider's real
|
||||||
|
// prompt-token count for the request we just sent, so later turns trigger
|
||||||
|
// compaction near true capacity instead of ~15% early on code-heavy history.
|
||||||
|
// Recompute the estimate HERE (not at request-build time): a reactive
|
||||||
|
// compaction may have replaced `messages` with a smaller set and re-sent, so
|
||||||
|
// this reflects the request that actually produced collected.Usage. The
|
||||||
|
// assistant reply is appended below, after this, so `messages` is still the
|
||||||
|
// sent request.
|
||||||
|
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
|
||||||
|
|
||||||
// Carry the turn's terminal stop reason so a final answer cut off at the
|
// Carry the turn's terminal stop reason so a final answer cut off at the
|
||||||
// output token cap (or by a content filter) is reported as truncated. A
|
// output token cap (or by a content filter) is reported as truncated. A
|
||||||
// tool-call turn normalizes to "" and clears any prior reason.
|
// tool-call turn normalizes to "" and clears any prior reason.
|
||||||
@@ -450,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Finalization diagnostics gate: edits from this run may still have
|
||||||
|
// checks in flight — the per-turn drain waits only briefly and defers
|
||||||
|
// to "a later turn", but a final answer means there is no later turn.
|
||||||
|
// Wait out the full inline-era budget once; an introduced error gives
|
||||||
|
// the model one more turn to see (and fix) it instead of being lost.
|
||||||
|
// Free for runs that never edited: an idle collector returns "".
|
||||||
|
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||||
|
messages = append(messages, zeroruntime.Message{
|
||||||
|
Role: zeroruntime.MessageRoleUser,
|
||||||
|
Content: nudge,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
result.FinalAnswer = collected.Text
|
result.FinalAnswer = collected.Text
|
||||||
result.Messages = copyMessages(messages)
|
result.Messages = copyMessages(messages)
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -475,11 +529,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
// between tool_results breaks strict provider replay) — same after-batch
|
// between tool_results breaks strict provider replay) — same after-batch
|
||||||
// rationale as turnRequestedModel above.
|
// rationale as turnRequestedModel above.
|
||||||
var changedFilesThisBatch []string
|
var changedFilesThisBatch []string
|
||||||
|
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||||
|
// executed concurrently, consumed strictly in order below.
|
||||||
|
var precomputed []precomputedToolResult
|
||||||
|
precomputedStart, precomputedEnd := 0, 0
|
||||||
for index, call := range collected.ToolCalls {
|
for index, call := range collected.ToolCalls {
|
||||||
|
// When this call starts a consecutive run of >= 2 auto-allowed read-only
|
||||||
|
// calls, execute the whole run concurrently now (see parallel_tools.go).
|
||||||
|
// The scan happens lazily at the run's first index — never ahead of a
|
||||||
|
// pending mutating call — so read-after-write ordering is preserved.
|
||||||
|
if index >= precomputedEnd {
|
||||||
|
runEnd := index
|
||||||
|
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||||||
|
runEnd++
|
||||||
|
}
|
||||||
|
if runEnd-index >= 2 {
|
||||||
|
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||||||
|
precomputedStart, precomputedEnd = index, runEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
if options.OnToolCall != nil {
|
if options.OnToolCall != nil {
|
||||||
options.OnToolCall(call)
|
options.OnToolCall(call)
|
||||||
}
|
}
|
||||||
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
var toolResult ToolResult
|
||||||
|
var abortErr error
|
||||||
|
if index >= precomputedStart && index < precomputedEnd {
|
||||||
|
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
|
||||||
|
} else {
|
||||||
|
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
|
||||||
|
}
|
||||||
if options.OnToolResult != nil {
|
if options.OnToolResult != nil {
|
||||||
options.OnToolResult(toolResult)
|
options.OnToolResult(toolResult)
|
||||||
}
|
}
|
||||||
@@ -542,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-edit self-correction: collect the files this successful mutating
|
// Collect the files this successful mutating tool changed. Self-correct
|
||||||
// tool changed; verification runs once over the union after the batch.
|
// verification runs once over the union after the batch; background
|
||||||
// A read-only tool (no ChangedFiles) never contributes.
|
// diagnostics start NOW so the language server analyzes while the rest
|
||||||
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
// of the batch executes. A read-only tool (no ChangedFiles) never
|
||||||
|
// contributes.
|
||||||
|
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||||||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||||||
|
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +710,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
|||||||
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
||||||
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
||||||
// not success, so a run that loops to the turn limit isn't reported as done.
|
// not success, so a run that loops to the turn limit isn't reported as done.
|
||||||
|
//
|
||||||
|
// The final-answer call is one more model request, so diagnostics from the
|
||||||
|
// LAST turn's edits get a drain too — with the finalization budget, since
|
||||||
|
// there is no later turn to defer to — otherwise an error introduced by
|
||||||
|
// the final edit would go unreported in the summary.
|
||||||
|
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||||
|
messages = append(messages, zeroruntime.Message{
|
||||||
|
Role: zeroruntime.MessageRoleUser,
|
||||||
|
Content: nudge,
|
||||||
|
})
|
||||||
|
}
|
||||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||||
result.FinalAnswer = answer
|
result.FinalAnswer = answer
|
||||||
result.FinishReason = finishReason
|
result.FinishReason = finishReason
|
||||||
@@ -661,6 +753,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
|||||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||||
Messages: copyMessages(finalMessages),
|
Messages: copyMessages(finalMessages),
|
||||||
ReasoningEffort: options.ReasoningEffort,
|
ReasoningEffort: options.ReasoningEffort,
|
||||||
|
PromptCacheKey: options.SessionID,
|
||||||
}, reconnectNoticeFor(options))
|
}, reconnectNoticeFor(options))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", messages, ""
|
return "", messages, ""
|
||||||
@@ -816,6 +909,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A shell command that sets sandbox_permissions: with_additional_permissions
|
||||||
|
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
|
||||||
|
// is the single source of truth for that shape, and buildPermissionEvent (below)
|
||||||
|
// calls it again to render the prompt's scope text. Check it here, before any
|
||||||
|
// permission prompt is shown: a malformed payload can never be satisfied no
|
||||||
|
// matter what the user decides, so surfacing it as a plain tool error (which the
|
||||||
|
// model can see and retry) is both clearer and avoids presenting a normal-looking
|
||||||
|
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
|
||||||
|
if toolFound && isShellCommandTool(call.Name) {
|
||||||
|
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
|
||||||
|
return ToolResult{
|
||||||
|
ToolCallID: call.ID,
|
||||||
|
Name: call.Name,
|
||||||
|
Status: tools.StatusError,
|
||||||
|
Output: "Error: " + err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ask_user is intercepted in the loop (like permissions) so the question can
|
// ask_user is intercepted in the loop (like permissions) so the question can
|
||||||
// be routed to an interactive front-end instead of blocking inside the tool.
|
// be routed to an interactive front-end instead of blocking inside the tool.
|
||||||
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
||||||
@@ -1032,6 +1144,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
|||||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||||
// a file that changed on disk outside Zero since it was last read.
|
// a file that changed on disk outside Zero since it was last read.
|
||||||
FileTracker: options.FileTracker,
|
FileTracker: options.FileTracker,
|
||||||
|
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||||
|
// synchronously inside edit_file/write_file and block every mutating tool
|
||||||
|
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||||
|
// now checks changed files in the background and nudges the model before
|
||||||
|
// its next request instead (see async_diagnostics.go). The tools' inline
|
||||||
|
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
|
||||||
// Forward the run's operator tool filters so a filter-aware tool
|
// Forward the run's operator tool filters so a filter-aware tool
|
||||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||||
EnabledTools: options.EnabledTools,
|
EnabledTools: options.EnabledTools,
|
||||||
@@ -2159,6 +2277,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A command that explicitly requests additional sandbox permissions
|
||||||
|
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||||
|
// must consent to, even when the base command's sandbox decision was Allow.
|
||||||
|
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||||
|
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||||
|
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||||
|
// dropped it, deadlocking the run. Keep this consistent with
|
||||||
|
// shouldRequestPermission's own condition.
|
||||||
|
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||||
|
action = PermissionActionPrompt
|
||||||
|
}
|
||||||
|
|
||||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||||
return PermissionEvent{}, false
|
return PermissionEvent{}, false
|
||||||
}
|
}
|
||||||
@@ -2310,7 +2440,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
|||||||
}
|
}
|
||||||
raw, ok := args["additional_permissions"]
|
raw, ok := args["additional_permissions"]
|
||||||
if !ok || raw == nil {
|
if !ok || raw == nil {
|
||||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||||
|
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||||
|
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||||
|
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||||
|
"omit sandbox_permissions entirely and retry",
|
||||||
|
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(raw)
|
data, err := json.Marshal(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2325,7 +2460,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
|||||||
return sandbox.RequestPermissionProfile{}, true, err
|
return sandbox.RequestPermissionProfile{}, true, err
|
||||||
}
|
}
|
||||||
if normalized.Empty() {
|
if normalized.Empty() {
|
||||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||||
|
"additional_permissions must include at least one of network or file_system, for example " +
|
||||||
|
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||||
}
|
}
|
||||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2424,6 +2561,17 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
|||||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||||
// so the inactive path is stable.
|
// so the inactive path is stable.
|
||||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||||
|
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionToolsCached is partitionTools with an optional per-tool definition
|
||||||
|
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
|
||||||
|
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
|
||||||
|
// swarm tools un-defer once a swarm is active). Only the expensive part —
|
||||||
|
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
|
||||||
|
// tool's advertised name/description/schema is stable for the run. defCache nil
|
||||||
|
// disables caching (used by tests and the plain partitionTools entrypoint).
|
||||||
|
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]zeroruntime.ToolDefinition, string) {
|
||||||
registeredTools := registry.All()
|
registeredTools := registry.All()
|
||||||
|
|
||||||
visible := make([]tools.Tool, 0, len(registeredTools))
|
visible := make([]tools.Tool, 0, len(registeredTools))
|
||||||
@@ -2458,76 +2606,115 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
|||||||
|
|
||||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||||
|
|
||||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||||||
exposedNames := make(map[string]bool, len(visible))
|
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||||||
|
// and byte-identical to the pre-deferral output.
|
||||||
|
if !active {
|
||||||
|
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||||
|
for _, tool := range visible {
|
||||||
|
if tool.Name() == tools.ToolSearchToolName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
|
||||||
|
}
|
||||||
|
sort.Slice(definitions, func(left int, right int) bool {
|
||||||
|
return definitions[left].Name < definitions[right].Name
|
||||||
|
})
|
||||||
|
return definitions, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||||||
|
// deferred tool loads mid-session. The tools block is part of the provider's
|
||||||
|
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||||||
|
// through the system prompt and messages — on EVERY load. Previously the whole
|
||||||
|
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||||||
|
// middle and shifted every later definition. Instead we build three append-only
|
||||||
|
// regions:
|
||||||
|
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||||||
|
// 2. tool_search — always present, right after the stable block;
|
||||||
|
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||||
|
// transition grows the tail instead of inserting into the eager block.
|
||||||
|
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||||
|
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||||
|
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||||
|
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||||
|
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||||
var hiddenTools []tools.Tool
|
var hiddenTools []tools.Tool
|
||||||
for _, tool := range visible {
|
for _, tool := range visible {
|
||||||
name := tool.Name()
|
name := tool.Name()
|
||||||
deferred := tools.IsDeferred(tool)
|
if name == tools.ToolSearchToolName {
|
||||||
|
continue // added explicitly between the eager block and the loaded tail
|
||||||
if !active {
|
}
|
||||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
if tools.IsDeferred(tool) {
|
||||||
if name == tools.ToolSearchToolName {
|
if loaded[name] {
|
||||||
continue
|
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
|
||||||
|
} else {
|
||||||
|
hiddenTools = append(hiddenTools, tool)
|
||||||
}
|
}
|
||||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
||||||
Name: name,
|
|
||||||
Description: tool.Description(),
|
|
||||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
|
||||||
})
|
|
||||||
exposedNames[name] = true
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
|
||||||
// Active path.
|
|
||||||
if deferred && !loaded[name] {
|
|
||||||
hiddenTools = append(hiddenTools, tool)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
||||||
Name: name,
|
|
||||||
Description: tool.Description(),
|
|
||||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
|
||||||
})
|
|
||||||
exposedNames[name] = true
|
|
||||||
}
|
}
|
||||||
|
sort.Slice(eager, func(left int, right int) bool {
|
||||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
return eager[left].Name < eager[right].Name
|
||||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
})
|
||||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
sort.Slice(loadedTail, func(left int, right int) bool {
|
||||||
// tools, not the loader). Expose its full definition whenever it is not
|
return loadedTail[left].Name < loadedTail[right].Name
|
||||||
// already in the exposed set. This never runs on the inactive path, so the
|
|
||||||
// byte-identical below-threshold output is preserved.
|
|
||||||
discovery := ""
|
|
||||||
if active && len(hiddenTools) > 0 {
|
|
||||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
|
||||||
}
|
|
||||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
|
||||||
description := loader.Description()
|
|
||||||
if discovery != "" {
|
|
||||||
description = discovery
|
|
||||||
}
|
|
||||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
|
||||||
Name: loader.Name(),
|
|
||||||
Description: description,
|
|
||||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
|
||||||
})
|
|
||||||
} else if active && discovery != "" {
|
|
||||||
for i := range definitions {
|
|
||||||
if definitions[i].Name == tools.ToolSearchToolName {
|
|
||||||
definitions[i].Description = discovery
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(definitions, func(left int, right int) bool {
|
|
||||||
return definitions[left].Name < definitions[right].Name
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
discovery := ""
|
||||||
|
if len(hiddenTools) > 0 {
|
||||||
|
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||||
|
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||||
|
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||||
|
// eager block and carries the discovery list for still-hidden tools.
|
||||||
|
description := loader.Description()
|
||||||
|
if discovery != "" {
|
||||||
|
description = discovery
|
||||||
|
}
|
||||||
|
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||||||
|
definitions = append(definitions, eager...)
|
||||||
|
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||||
|
Name: loader.Name(),
|
||||||
|
Description: description,
|
||||||
|
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||||
|
})
|
||||||
|
definitions = append(definitions, loadedTail...)
|
||||||
|
|
||||||
return definitions, discovery
|
return definitions, discovery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
|
||||||
|
// cached render when defCache holds one for this tool name. A tool's advertised
|
||||||
|
// definition is stable across a run, so caching skips the recursive schema→map
|
||||||
|
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
|
||||||
|
// turn. tool_search is excluded by its callers (its description is dynamic), so it
|
||||||
|
// never poisons the cache. A nil cache computes fresh.
|
||||||
|
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
|
||||||
|
if defCache == nil {
|
||||||
|
return runtimeToolDefinition(tool)
|
||||||
|
}
|
||||||
|
if def, ok := defCache[tool.Name()]; ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
def := runtimeToolDefinition(tool)
|
||||||
|
defCache[tool.Name()] = def
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||||
|
// JSON-schema parameters) as sent to the provider.
|
||||||
|
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||||
|
return zeroruntime.ToolDefinition{
|
||||||
|
Name: tool.Name(),
|
||||||
|
Description: tool.Description(),
|
||||||
|
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -37,6 +38,35 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
|||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type recordingWebSearchTool struct {
|
||||||
|
calls []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
|
||||||
|
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
|
||||||
|
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
|
||||||
|
return tools.Schema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]tools.PropertySchema{
|
||||||
|
"query": {Type: "string"},
|
||||||
|
},
|
||||||
|
Required: []string{"query"},
|
||||||
|
AdditionalProperties: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (tool *recordingWebSearchTool) Safety() tools.Safety {
|
||||||
|
return tools.Safety{
|
||||||
|
SideEffect: tools.SideEffectNetwork,
|
||||||
|
Permission: tools.PermissionPrompt,
|
||||||
|
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||||
|
AdvertiseInAuto: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||||
|
tool.calls = append(tool.calls, cloneArgs(args))
|
||||||
|
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
|
||||||
|
}
|
||||||
|
|
||||||
type sandboxDeniedRetryTool struct {
|
type sandboxDeniedRetryTool struct {
|
||||||
calls []map[string]any
|
calls []map[string]any
|
||||||
}
|
}
|
||||||
@@ -865,7 +895,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
for _, tool := range tools.CoreNetworkTools() {
|
for _, tool := range tools.CoreNetworkTools() {
|
||||||
@@ -896,6 +926,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||||
|
search := &recordingWebSearchTool{}
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(search)
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||||
|
{Type: zeroruntime.StreamEventDone},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var requests []PermissionRequest
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), "search", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
PermissionMode: PermissionModeAsk,
|
||||||
|
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||||
|
requests = append(requests, request)
|
||||||
|
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.FinalAnswer != "done" {
|
||||||
|
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
|
||||||
|
}
|
||||||
|
if len(requests) != 1 {
|
||||||
|
t.Fatalf("expected one permission request, got %#v", requests)
|
||||||
|
}
|
||||||
|
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
|
||||||
|
t.Fatalf("unexpected permission request: %#v", requests[0])
|
||||||
|
}
|
||||||
|
if len(search.calls) != 0 {
|
||||||
|
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
@@ -1870,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
|
|||||||
|
|
||||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
segmentedCommand := `ps aux | head -5`
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
|
||||||
|
segmentedCommand = `ps aux | echo ok`
|
||||||
|
}
|
||||||
retryTool := &sandboxDeniedRetryTool{}
|
retryTool := &sandboxDeniedRetryTool{}
|
||||||
registry := tools.NewRegistry()
|
registry := tools.NewRegistry()
|
||||||
registry.Register(retryTool)
|
registry.Register(retryTool)
|
||||||
@@ -1883,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||||
{Type: zeroruntime.StreamEventDone},
|
{Type: zeroruntime.StreamEventDone},
|
||||||
},
|
},
|
||||||
@@ -2832,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
|||||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
|
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
|
||||||
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
|
if !strings.Contains(prompt, want) {
|
||||||
|
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parallel read-ahead for tool batches. When a turn requests several
|
||||||
|
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||||
|
// them one after another serializes pure I/O waits. A consecutive run of
|
||||||
|
// auto-allowed read-only calls is executed concurrently instead; results are
|
||||||
|
// then consumed in the original call order, so guard counters, message
|
||||||
|
// ordering, abort semantics, and the surface's call/result event pairing are
|
||||||
|
// byte-identical to sequential execution. Runs never span a mutating call: a
|
||||||
|
// read that follows a write must observe the write, so eligibility is decided
|
||||||
|
// per consecutive run, not per batch.
|
||||||
|
|
||||||
|
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
||||||
|
const maxParallelReadTools = 8
|
||||||
|
|
||||||
|
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
||||||
|
// its batch index by the caller.
|
||||||
|
type precomputedToolResult struct {
|
||||||
|
result ToolResult
|
||||||
|
abortErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||||
|
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
||||||
|
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
||||||
|
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
||||||
|
// and tool_search (mutates the deferred-tool set) stay sequential.
|
||||||
|
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
||||||
|
switch call.Name {
|
||||||
|
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tool, found := registry.Get(call.Name)
|
||||||
|
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
args := map[string]any{}
|
||||||
|
if call.Arguments != "" {
|
||||||
|
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effectivePermission(tool, args) == tools.PermissionAllow
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
||||||
|
// maxParallelReadTools) and returns results indexed relative to start. All
|
||||||
|
// execution-side callbacks that can fire inside executeToolCall are serialized
|
||||||
|
// behind one mutex: a permission prompt (a sandbox preflight can demand one
|
||||||
|
// even for an auto-allowed read) must never appear twice at once on an
|
||||||
|
// interactive front-end, and OnPermission event handlers append to shared
|
||||||
|
// session-recording state without their own locking — two batched reads under
|
||||||
|
// a granted extra root would otherwise race (the pre-batch serial loop never
|
||||||
|
// had two callbacks in flight at once).
|
||||||
|
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
||||||
|
batchOptions := options
|
||||||
|
var callbackMutex sync.Mutex
|
||||||
|
if options.OnPermissionRequest != nil {
|
||||||
|
inner := options.OnPermissionRequest
|
||||||
|
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||||
|
callbackMutex.Lock()
|
||||||
|
defer callbackMutex.Unlock()
|
||||||
|
return inner(ctx, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if options.OnPermission != nil {
|
||||||
|
inner := options.OnPermission
|
||||||
|
batchOptions.OnPermission = func(event PermissionEvent) {
|
||||||
|
callbackMutex.Lock()
|
||||||
|
defer callbackMutex.Unlock()
|
||||||
|
inner(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]precomputedToolResult, end-start)
|
||||||
|
semaphore := make(chan struct{}, maxParallelReadTools)
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
semaphore <- struct{}{}
|
||||||
|
defer func() { <-semaphore }()
|
||||||
|
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
||||||
|
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTool records execution overlap and ordering so tests can assert what
|
||||||
|
// actually ran concurrently.
|
||||||
|
type probeTool struct {
|
||||||
|
name string
|
||||||
|
sideEffect tools.SideEffect
|
||||||
|
delay time.Duration
|
||||||
|
// shared, when set, receives this probe's start/end entries too, so a test
|
||||||
|
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
|
||||||
|
shared *probeLog
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
active int
|
||||||
|
maxActive int
|
||||||
|
log []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeLog is a mutex-guarded event log shared between probes.
|
||||||
|
type probeLog struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (log *probeLog) append(entry string) {
|
||||||
|
log.mu.Lock()
|
||||||
|
defer log.mu.Unlock()
|
||||||
|
log.entries = append(log.entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (log *probeLog) snapshot() []string {
|
||||||
|
log.mu.Lock()
|
||||||
|
defer log.mu.Unlock()
|
||||||
|
return append([]string(nil), log.entries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tool *probeTool) Name() string { return tool.name }
|
||||||
|
func (tool *probeTool) Description() string { return "test probe tool" }
|
||||||
|
func (tool *probeTool) Parameters() tools.Schema {
|
||||||
|
return tools.Schema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
||||||
|
AdditionalProperties: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (tool *probeTool) Safety() tools.Safety {
|
||||||
|
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||||
|
}
|
||||||
|
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
tool.mu.Lock()
|
||||||
|
tool.active++
|
||||||
|
if tool.active > tool.maxActive {
|
||||||
|
tool.maxActive = tool.active
|
||||||
|
}
|
||||||
|
tool.log = append(tool.log, "start:"+id)
|
||||||
|
tool.mu.Unlock()
|
||||||
|
if tool.shared != nil {
|
||||||
|
tool.shared.append("start:" + id)
|
||||||
|
}
|
||||||
|
time.Sleep(tool.delay)
|
||||||
|
if tool.shared != nil {
|
||||||
|
tool.shared.append("end:" + id)
|
||||||
|
}
|
||||||
|
tool.mu.Lock()
|
||||||
|
tool.active--
|
||||||
|
tool.log = append(tool.log, "end:"+id)
|
||||||
|
tool.mu.Unlock()
|
||||||
|
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
||||||
|
return []zeroruntime.StreamEvent{
|
||||||
|
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
||||||
|
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParallelSafeToolCall(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||||
|
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||||
|
|
||||||
|
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
||||||
|
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
||||||
|
t.Fatal("auto-allowed read tool must be parallel-safe")
|
||||||
|
}
|
||||||
|
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
||||||
|
t.Fatal("mutating tool must not be parallel-safe")
|
||||||
|
}
|
||||||
|
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
||||||
|
t.Fatal("unknown tool must not be parallel-safe")
|
||||||
|
}
|
||||||
|
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
||||||
|
t.Fatal("undecodable arguments must not be parallel-safe")
|
||||||
|
}
|
||||||
|
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||||
|
t.Fatal("loop-intercepted tools must stay sequential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||||
|
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(probe)
|
||||||
|
|
||||||
|
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
||||||
|
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
||||||
|
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
turnOne,
|
||||||
|
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []ToolResult
|
||||||
|
_, err := Run(context.Background(), "probe", provider, Options{
|
||||||
|
Registry: registry,
|
||||||
|
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if probe.maxActive < 2 {
|
||||||
|
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
||||||
|
}
|
||||||
|
// Results must still be recorded in original call order.
|
||||||
|
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
||||||
|
t.Fatalf("tool results out of order: %#v", results)
|
||||||
|
}
|
||||||
|
messages := provider.requests[1].Messages
|
||||||
|
var toolOrder []string
|
||||||
|
for _, message := range messages {
|
||||||
|
if message.Role == zeroruntime.MessageRoleTool {
|
||||||
|
toolOrder = append(toolOrder, message.ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
||||||
|
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||||
|
shared := &probeLog{}
|
||||||
|
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
|
||||||
|
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(read)
|
||||||
|
registry.Register(write)
|
||||||
|
|
||||||
|
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
||||||
|
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
||||||
|
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
||||||
|
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
||||||
|
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||||
|
provider := &mockProvider{
|
||||||
|
turns: [][]zeroruntime.StreamEvent{
|
||||||
|
turnOne,
|
||||||
|
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-probe ordering on the SHARED log: the write must start only after
|
||||||
|
// both first-batch reads finished, and both second-batch reads must start
|
||||||
|
// only after the write finished — batches never cross a mutating call.
|
||||||
|
log := shared.snapshot()
|
||||||
|
index := func(entry string) int {
|
||||||
|
for i, e := range log {
|
||||||
|
if e == entry {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("entry %q missing from shared log: %v", entry, log)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
writeStart, writeEnd := index("start:w"), index("end:w")
|
||||||
|
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
|
||||||
|
t.Fatalf("write started before the first read batch finished: %v", log)
|
||||||
|
}
|
||||||
|
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
||||||
|
t.Fatalf("second read batch started before the write finished: %v", log)
|
||||||
|
}
|
||||||
|
if read.maxActive < 2 {
|
||||||
|
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||||
|
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||||
|
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||||
|
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||||
|
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||||
|
// "read_file".
|
||||||
|
root := t.TempDir()
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||||
|
registry.Register(fakeToolSearchTool{})
|
||||||
|
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||||
|
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||||
|
|
||||||
|
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||||
|
|
||||||
|
pos := func(name string) int {
|
||||||
|
for i := range exposed {
|
||||||
|
if exposed[i].Name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||||
|
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||||
|
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||||
|
}
|
||||||
|
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||||
|
if readPos >= alphaPos || searchPos >= alphaPos {
|
||||||
|
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||||
|
}
|
||||||
|
// beta was never loaded — it stays hidden.
|
||||||
|
if pos("mcp__srv__beta") != -1 {
|
||||||
|
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingSchemaTool records how many times its Parameters() schema is read, so a
|
||||||
|
// test can prove the definition cache avoids re-rendering across turns.
|
||||||
|
type countingSchemaTool struct {
|
||||||
|
name string
|
||||||
|
calls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t countingSchemaTool) Name() string { return t.name }
|
||||||
|
func (t countingSchemaTool) Description() string { return "counts schema reads" }
|
||||||
|
func (t countingSchemaTool) Parameters() tools.Schema {
|
||||||
|
*t.calls++
|
||||||
|
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
|
||||||
|
"x": {Type: "string"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
func (t countingSchemaTool) Safety() tools.Safety {
|
||||||
|
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||||
|
}
|
||||||
|
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||||
|
return tools.Result{Status: tools.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||||
|
// output is identical to the uncached path.
|
||||||
|
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
|
||||||
|
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
|
||||||
|
|
||||||
|
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||||
|
|
||||||
|
base := calls
|
||||||
|
cache := map[string]zeroruntime.ToolDefinition{}
|
||||||
|
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||||
|
rendersAfterFirst := calls - base
|
||||||
|
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||||
|
rendersAfterSecond := calls - base
|
||||||
|
|
||||||
|
if rendersAfterFirst != 2 {
|
||||||
|
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
|
||||||
|
}
|
||||||
|
if rendersAfterSecond != 2 {
|
||||||
|
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first, second) {
|
||||||
|
t.Fatal("cached calls must return identical definitions")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(first, uncached) {
|
||||||
|
t.Fatal("cached output must match the uncached partitionTools output")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||||
|
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||||
|
type promptShellTool struct{}
|
||||||
|
|
||||||
|
func (promptShellTool) Name() string { return "bash" }
|
||||||
|
func (promptShellTool) Description() string { return "run a shell command" }
|
||||||
|
func (promptShellTool) Parameters() tools.Schema {
|
||||||
|
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||||
|
}
|
||||||
|
func (promptShellTool) Safety() tools.Safety {
|
||||||
|
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||||
|
}
|
||||||
|
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||||
|
return tools.Result{Status: tools.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A command whose sandbox decision is Allow but that explicitly requests
|
||||||
|
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||||
|
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||||
|
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||||
|
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||||
|
// folder-creation shape captured in the reported hang.
|
||||||
|
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||||
|
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||||
|
args := map[string]any{
|
||||||
|
"command": "mkdir foo && cd foo && go mod init foo",
|
||||||
|
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||||
|
}
|
||||||
|
// Base command allowed by the sandbox…
|
||||||
|
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||||
|
|
||||||
|
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||||
|
}
|
||||||
|
if event.Action != PermissionActionPrompt {
|
||||||
|
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||||
|
}
|
||||||
|
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||||
|
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||||
|
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||||
|
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||||
|
// not over-reach and prompt for ordinary allowed commands.
|
||||||
|
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||||
|
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||||
|
args := map[string]any{"command": "ls"}
|
||||||
|
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||||
|
|
||||||
|
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||||
|
if event.Action == PermissionActionPrompt {
|
||||||
|
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||||
|
}
|
||||||
|
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||||
|
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func planUpdateTurn(status string) zeroruntime.CollectedStream {
|
||||||
|
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
|
||||||
|
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn-based staleness: a plan that goes many turns without an update — while
|
||||||
|
// items are still pending — draws the stale reminder even though few tool calls
|
||||||
|
// have run (the tool-call streak alone would not have tripped).
|
||||||
|
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
|
||||||
|
state := newGuardState()
|
||||||
|
state.observeTurn(planUpdateTurn("in_progress"))
|
||||||
|
|
||||||
|
// Text-only turns advance the turn counter without adding tool calls.
|
||||||
|
for range stalePlanTurnThreshold {
|
||||||
|
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||||
|
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
|
||||||
|
}
|
||||||
|
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
|
||||||
|
t.Fatalf("expected a turn-based stale reminder, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fully-completed plan is NOT stale: no pending items means the turn-based
|
||||||
|
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
|
||||||
|
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
|
||||||
|
state := newGuardState()
|
||||||
|
state.observeTurn(planUpdateTurn("completed"))
|
||||||
|
|
||||||
|
for range stalePlanTurnThreshold + 3 {
|
||||||
|
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
|
||||||
|
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plan update resets the turn counter, so the stale reminder does not fire
|
||||||
|
// right after a fresh update.
|
||||||
|
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
|
||||||
|
state := newGuardState()
|
||||||
|
state.observeTurn(planUpdateTurn("in_progress"))
|
||||||
|
for range stalePlanTurnThreshold - 1 {
|
||||||
|
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
|
||||||
|
}
|
||||||
|
// Refresh the plan — resets the turn streak.
|
||||||
|
state.observeTurn(planUpdateTurn("in_progress"))
|
||||||
|
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
|
||||||
|
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||||
|
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||||
|
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||||
|
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||||
|
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||||
|
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||||
|
//
|
||||||
|
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||||
|
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||||
|
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||||
|
// only advertise in other modes are excluded, matching what a real turn actually
|
||||||
|
// pays.
|
||||||
|
const (
|
||||||
|
maxBaseSystemPromptTokens = 3500
|
||||||
|
maxEagerToolSchemaTokens = 2700
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||||
|
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||||
|
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||||
|
// fixed base every session pays regardless of the workspace it runs in.
|
||||||
|
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||||
|
got := ApproxTextTokens(prompt)
|
||||||
|
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||||
|
if got > maxBaseSystemPromptTokens {
|
||||||
|
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||||
|
registry := tools.NewRegistry()
|
||||||
|
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||||
|
registry.Register(tool)
|
||||||
|
}
|
||||||
|
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||||
|
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||||
|
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||||
|
got := estimateToolDefTokens(exposed)
|
||||||
|
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||||
|
if got > maxEagerToolSchemaTokens {
|
||||||
|
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/errhint"
|
||||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,10 +19,20 @@ import (
|
|||||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||||
// already-forwarded OnText is ever duplicated.
|
// already-forwarded OnText is ever duplicated.
|
||||||
const (
|
const (
|
||||||
maxStreamReconnects = 2
|
// maxStreamReconnects is how many times the connect is re-issued after a
|
||||||
streamReconnectBase = 500 * time.Millisecond
|
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
|
||||||
|
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
|
||||||
|
// dying on a 2s hiccup and re-burning every token on a restart.
|
||||||
|
maxStreamReconnects = 4
|
||||||
|
// streamReconnectMax caps a single backoff so the tail attempts don't wait
|
||||||
|
// minutes on a long outage.
|
||||||
|
streamReconnectMax = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
|
||||||
|
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
|
||||||
|
var streamReconnectBase = 500 * time.Millisecond
|
||||||
|
|
||||||
// reconnectNotifier is called before each retry with the 1-based attempt number
|
// reconnectNotifier is called before each retry with the 1-based attempt number
|
||||||
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
||||||
// fine.
|
// fine.
|
||||||
@@ -73,7 +85,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
|
|||||||
if notify != nil {
|
if notify != nil {
|
||||||
notify(attempt, maxStreamReconnects)
|
notify(attempt, maxStreamReconnects)
|
||||||
}
|
}
|
||||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
|
||||||
return nil, err // ctx cancelled while waiting; surface the original error
|
return nil, err // ctx cancelled while waiting; surface the original error
|
||||||
}
|
}
|
||||||
stream, err = provider.StreamCompletion(ctx, request)
|
stream, err = provider.StreamCompletion(ctx, request)
|
||||||
@@ -96,6 +108,19 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
|||||||
if isContextLimitError(msg) || isImageRejectionError(err) {
|
if isContextLimitError(msg) || isImageRejectionError(err) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
|
||||||
|
// transport substring match below — otherwise "504 Gateway Timeout" would slip
|
||||||
|
// through on the generic "timeout" needle. 503 already exhausted
|
||||||
|
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
|
||||||
|
// are non-idempotent by providerio's rule — the completion POST may already
|
||||||
|
// have reached the model before the upstream/gateway gave up, so replaying the
|
||||||
|
// connect risks duplicate billable work. Digit-boundary matched so an
|
||||||
|
// incidental "504" inside a latency/id number is not mistaken for a status.
|
||||||
|
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
|
||||||
|
// refused, timeout) means no response was received, which is safe to reconnect.
|
||||||
for _, needle := range []string{
|
for _, needle := range []string{
|
||||||
"eof",
|
"eof",
|
||||||
"connection reset",
|
"connection reset",
|
||||||
@@ -106,8 +131,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
|||||||
"timed out",
|
"timed out",
|
||||||
"temporarily unavailable",
|
"temporarily unavailable",
|
||||||
"i/o timeout",
|
"i/o timeout",
|
||||||
"503",
|
|
||||||
"502",
|
|
||||||
"server closed",
|
"server closed",
|
||||||
"unexpected end",
|
"unexpected end",
|
||||||
} {
|
} {
|
||||||
@@ -118,14 +141,31 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||||
|
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||||
func backoffFor(attempt int) time.Duration {
|
func backoffFor(attempt int) time.Duration {
|
||||||
d := streamReconnectBase
|
d := streamReconnectBase
|
||||||
for i := 1; i < attempt; i++ {
|
for i := 1; i < attempt; i++ {
|
||||||
|
if d >= streamReconnectMax {
|
||||||
|
return streamReconnectMax
|
||||||
|
}
|
||||||
d *= 2
|
d *= 2
|
||||||
}
|
}
|
||||||
|
if d > streamReconnectMax {
|
||||||
|
d = streamReconnectMax
|
||||||
|
}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
|
||||||
|
// runs (swarm members, a cron fleet) that all trip on the same outage don't
|
||||||
|
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
|
||||||
|
// deterministic base, so backoff still grows attempt over attempt.
|
||||||
|
func jitteredBackoff(attempt int) time.Duration {
|
||||||
|
base := backoffFor(attempt)
|
||||||
|
return base + time.Duration(rand.Int63n(int64(base/2)+1))
|
||||||
|
}
|
||||||
|
|
||||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||||
timer := time.NewTimer(d)
|
timer := time.NewTimer(d)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
||||||
|
// Shrink the backoff so exhausting all retries stays fast (real base would
|
||||||
|
// sleep ~7.5s across 4 attempts).
|
||||||
|
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||||
|
streamReconnectBase = time.Millisecond
|
||||||
// Always fails with a disconnect error → exhausts retries and returns it.
|
// Always fails with a disconnect error → exhausts retries and returns it.
|
||||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
||||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||||
@@ -87,7 +91,7 @@ func TestShouldReconnectClassification(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
disconnects := []string{
|
disconnects := []string{
|
||||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
"i/o timeout", "server closed the connection", "connection refused",
|
||||||
}
|
}
|
||||||
for _, m := range disconnects {
|
for _, m := range disconnects {
|
||||||
if !shouldReconnect(ctx, errors.New(m)) {
|
if !shouldReconnect(ctx, errors.New(m)) {
|
||||||
@@ -97,6 +101,13 @@ func TestShouldReconnectClassification(t *testing.T) {
|
|||||||
notDisconnects := []string{
|
notDisconnects := []string{
|
||||||
"context length exceeded", "invalid api key", "model not found",
|
"context length exceeded", "invalid api key", "model not found",
|
||||||
"400 bad request: unsupported parameter",
|
"400 bad request: unsupported parameter",
|
||||||
|
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
|
||||||
|
// non-idempotent (500/502/504); the reconnect path must not double-retry
|
||||||
|
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
|
||||||
|
// transport signal — a gateway timeout is non-idempotent, since the upstream
|
||||||
|
// may have already processed the completion POST before giving up.
|
||||||
|
"503 Service Unavailable", "502 Bad Gateway",
|
||||||
|
"504 Gateway Timeout", "500 Internal Server Error",
|
||||||
}
|
}
|
||||||
for _, m := range notDisconnects {
|
for _, m := range notDisconnects {
|
||||||
if shouldReconnect(ctx, errors.New(m)) {
|
if shouldReconnect(ctx, errors.New(m)) {
|
||||||
@@ -112,6 +123,24 @@ func TestBackoffGrows(t *testing.T) {
|
|||||||
if backoffFor(2) != 2*streamReconnectBase {
|
if backoffFor(2) != 2*streamReconnectBase {
|
||||||
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
||||||
}
|
}
|
||||||
|
// The exponential base is capped so late attempts don't wait minutes.
|
||||||
|
if got := backoffFor(20); got != streamReconnectMax {
|
||||||
|
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJitteredBackoffStaysInBounds(t *testing.T) {
|
||||||
|
// Jitter never drops below the deterministic base and never exceeds base*1.5,
|
||||||
|
// so backoff still grows attempt over attempt while decorrelating retries.
|
||||||
|
for attempt := 1; attempt <= 5; attempt++ {
|
||||||
|
base := backoffFor(attempt)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
got := jitteredBackoff(attempt)
|
||||||
|
if got < base || got > base+base/2 {
|
||||||
|
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||||
|
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
|
||||||
|
// the 200-rune truncation) total well past the 4096-byte list budget, so the
|
||||||
|
// overflow summary must kick in while the block stays bounded.
|
||||||
|
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
|
||||||
|
skills := make([]SkillInfo, 0, 60)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
n := strconv.Itoa(i)
|
||||||
|
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||||
|
}
|
||||||
|
got := skillsContext(Options{Skills: skills})
|
||||||
|
if len(got) > skillsContextListBudget+1000 {
|
||||||
|
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "more (call skill") {
|
||||||
|
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||||
|
}
|
||||||
|
// The first skill is always listed regardless of budget.
|
||||||
|
if !strings.Contains(got, "- skill-0:") {
|
||||||
|
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
|
||||||
|
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
|
||||||
|
// budget, under which skills past ~#6 were invisible to the model and therefore
|
||||||
|
// never triggered.
|
||||||
|
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
|
||||||
|
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
|
||||||
|
skills := make([]SkillInfo, 0, 20)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
|
||||||
|
}
|
||||||
|
got := skillsContext(Options{Skills: skills})
|
||||||
|
if strings.Contains(got, "more (call skill") {
|
||||||
|
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
|
||||||
|
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsContext(t *testing.T) {
|
||||||
|
if got := skillsContext(Options{}); got != "" {
|
||||||
|
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||||
|
}
|
||||||
|
got := skillsContext(Options{Skills: []SkillInfo{
|
||||||
|
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||||
|
{Name: " ", Description: "nameless, should be skipped"},
|
||||||
|
{Name: "reviewer"},
|
||||||
|
}})
|
||||||
|
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||||
|
t.Fatalf("missing available_skills block: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||||
|
t.Fatalf("missing commit-writer line: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "- reviewer\n") {
|
||||||
|
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "nameless") {
|
||||||
|
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||||
|
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||||
|
{Name: "commit-writer", Description: "Write a commit message."},
|
||||||
|
}})
|
||||||
|
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||||
|
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||||
|
}
|
||||||
|
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||||
|
without := buildSystemPrompt(Options{})
|
||||||
|
if strings.Contains(without, "<available_skills>") {
|
||||||
|
t.Fatalf("available_skills block must not appear without skills")
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
-23
@@ -5,9 +5,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/repomap"
|
"github.com/Gitlawb/zero/internal/repomap"
|
||||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||||
)
|
)
|
||||||
@@ -37,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
|
|||||||
// general-to-specific order.
|
// general-to-specific order.
|
||||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||||
|
|
||||||
|
// userContextFile is the per-user instruction file, resolved under
|
||||||
|
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
|
||||||
|
// (config.json, commands, specialists) so users can keep personal guidance out
|
||||||
|
// of individual repositories.
|
||||||
|
const userContextFile = "ZERO.md"
|
||||||
|
|
||||||
|
var userConfigDirForPrompt = config.UserConfigDir
|
||||||
|
|
||||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||||
// a large guidelines file can't blow the context budget.
|
// a large guidelines file can't blow the context budget.
|
||||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||||
@@ -80,12 +90,22 @@ func buildSystemPrompt(options Options) string {
|
|||||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||||
sections = append(sections, seed)
|
sections = append(sections, seed)
|
||||||
}
|
}
|
||||||
|
// User guidelines are injected before workspace/project guidelines so the
|
||||||
|
// project's AGENTS.md/ZERO.md is the later, more specific instruction
|
||||||
|
// block. See userGuidelines for the explicit precedence note carried in
|
||||||
|
// the section text itself.
|
||||||
|
if user := userGuidelines(); user != "" {
|
||||||
|
sections = append(sections, user)
|
||||||
|
}
|
||||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||||
sections = append(sections, ws)
|
sections = append(sections, ws)
|
||||||
}
|
}
|
||||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||||
sections = append(sections, delegation)
|
sections = append(sections, delegation)
|
||||||
}
|
}
|
||||||
|
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||||
|
sections = append(sections, skillsBlock)
|
||||||
|
}
|
||||||
if style := responseStyleContext(options); style != "" {
|
if style := responseStyleContext(options); style != "" {
|
||||||
sections = append(sections, style)
|
sections = append(sections, style)
|
||||||
}
|
}
|
||||||
@@ -164,6 +184,75 @@ func specialistDelegationContext(options Options) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||||
|
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||||
|
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||||
|
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||||
|
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||||
|
// byte-for-byte.
|
||||||
|
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||||
|
// workspace with an extreme number of them can't bloat every turn's prompt.
|
||||||
|
// Skills past the budget are summarized as a count rather than dropped — the
|
||||||
|
// model can still load any of them by name (an unknown name returns the full
|
||||||
|
// list from the skill tool).
|
||||||
|
//
|
||||||
|
// The budget is deliberately generous — roughly 18 skills with maximally long
|
||||||
|
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
|
||||||
|
// descriptions consume it faster since the budget counts bytes. This list is
|
||||||
|
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
|
||||||
|
// never triggers. The previous 640-byte cap silently broke invocation for
|
||||||
|
// anyone with more than ~6 skills — the model cannot match a request against a
|
||||||
|
// skill it cannot see.
|
||||||
|
const skillsContextListBudget = 4096
|
||||||
|
|
||||||
|
func skillsContext(options Options) string {
|
||||||
|
if len(options.Skills) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("<available_skills>\n")
|
||||||
|
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
|
||||||
|
listed, spent, omitted := 0, 0, 0
|
||||||
|
for _, info := range options.Skills {
|
||||||
|
name := strings.TrimSpace(info.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line := "- " + name
|
||||||
|
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||||
|
line += ": " + truncateForSkillLine(desc)
|
||||||
|
}
|
||||||
|
line += "\n"
|
||||||
|
// Always list at least one skill; past the budget, summarize the remainder
|
||||||
|
// as a count instead of silently dropping it.
|
||||||
|
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||||
|
omitted++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(line)
|
||||||
|
spent += len(line)
|
||||||
|
listed++
|
||||||
|
}
|
||||||
|
if omitted > 0 {
|
||||||
|
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||||
|
}
|
||||||
|
b.WriteString("</available_skills>")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||||
|
// verbose description can't dominate the skills-list budget. The cap is roomy on
|
||||||
|
// purpose: the description carries the skill's trigger conditions ("use when…"),
|
||||||
|
// and truncating those is what stops the model from ever invoking the skill.
|
||||||
|
func truncateForSkillLine(desc string) string {
|
||||||
|
const maxDescRunes = 200
|
||||||
|
runes := []rune(desc)
|
||||||
|
if len(runes) <= maxDescRunes {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
func sessionRuntimeContext(options Options) string {
|
func sessionRuntimeContext(options Options) string {
|
||||||
provider := strings.TrimSpace(options.ProviderName)
|
provider := strings.TrimSpace(options.ProviderName)
|
||||||
model := strings.TrimSpace(options.Model)
|
model := strings.TrimSpace(options.Model)
|
||||||
@@ -197,7 +286,7 @@ func workspaceContext(cwd string) string {
|
|||||||
b.WriteString("Working directory: " + cwd + "\n")
|
b.WriteString("Working directory: " + cwd + "\n")
|
||||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
|
||||||
} else {
|
} else {
|
||||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||||
}
|
}
|
||||||
@@ -250,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
|||||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||||
limit = remaining
|
limit = remaining
|
||||||
}
|
}
|
||||||
if len(content) > limit {
|
content = truncateGuidelineContent(content, limit)
|
||||||
cut := limit
|
|
||||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
|
||||||
cut--
|
|
||||||
}
|
|
||||||
content = content[:cut] + "\n… (truncated)"
|
|
||||||
}
|
|
||||||
label := projectGuidelineLabel(match, gitRoot)
|
label := projectGuidelineLabel(match, gitRoot)
|
||||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||||
totalUsed += len(content)
|
totalUsed += len(content)
|
||||||
@@ -264,6 +347,92 @@ func projectGuidelines(cwd, gitRoot string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userGuidelines returns the per-user ZERO.md instructions block, if present.
|
||||||
|
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
|
||||||
|
// per-user config; the basename match is case-insensitive so a file saved as
|
||||||
|
// zero.md still resolves on case-sensitive filesystems, mirroring the project
|
||||||
|
// guideline loader. The section carries an explicit precedence note because
|
||||||
|
// this is a global, personal preferences file: it is injected earlier in the
|
||||||
|
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
|
||||||
|
// the note keeps that precedence unambiguous even if a model weighs later
|
||||||
|
// context more heavily than section order alone implies.
|
||||||
|
func userGuidelines() string {
|
||||||
|
configDir, err := userConfigDirForPrompt()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
configDir = strings.TrimSpace(configDir)
|
||||||
|
if configDir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
|
||||||
|
if match == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(match)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(string(data))
|
||||||
|
if content == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
content = truncateGuidelineContent(content, maxProjectContextBytes)
|
||||||
|
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
|
||||||
|
"These are the operator's personal preferences, not project policy. " +
|
||||||
|
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncationMarker is appended to guideline content that was cut short.
|
||||||
|
const truncationMarker = "\n… (truncated)"
|
||||||
|
|
||||||
|
// truncateGuidelineContent caps content at limit bytes without splitting a
|
||||||
|
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
|
||||||
|
// the marker is reserved before choosing the cut point. When limit is too
|
||||||
|
// small to fit the marker itself, the marker is dropped instead, so the
|
||||||
|
// returned string never exceeds limit for any non-negative limit.
|
||||||
|
func truncateGuidelineContent(content string, limit int) string {
|
||||||
|
if len(content) <= limit {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cut := limit - len(truncationMarker)
|
||||||
|
if cut < 0 {
|
||||||
|
cut = 0
|
||||||
|
}
|
||||||
|
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||||
|
cut--
|
||||||
|
}
|
||||||
|
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
// limit is smaller than the marker itself: fall back to a hard,
|
||||||
|
// rune-safe cut at limit bytes with no marker rather than exceeding it.
|
||||||
|
end := limit
|
||||||
|
for end > 0 && !utf8.RuneStart(content[end]) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return content[:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
|
||||||
|
// whose name matches basename case-insensitively, or "" when absent.
|
||||||
|
func findCaseInsensitiveFile(dir, basename string) string {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
|
||||||
|
return filepath.Join(dir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||||
// from cwd, the chain collapses to [cwd].
|
// from cwd, the chain collapses to [cwd].
|
||||||
@@ -310,29 +479,22 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
|||||||
|
|
||||||
// findProjectContextFile returns the first matching project guideline file
|
// findProjectContextFile returns the first matching project guideline file
|
||||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
// filename returned, so a git-tracked file like AGENTS.md still resolves to
|
||||||
// still resolves to its true filename on case-sensitive filesystems — the
|
// its true filename on case-sensitive filesystems — the returned path is what
|
||||||
// returned path is what should appear in the project guidelines label.
|
// should appear in the project guidelines label.
|
||||||
// Returns "" when nothing matches.
|
// Returns "" when nothing matches.
|
||||||
func findProjectContextFile(dir string) string {
|
func findProjectContextFile(dir string) string {
|
||||||
for _, name := range projectContextFiles {
|
for _, name := range projectContextFiles {
|
||||||
baseLower := strings.ToLower(filepath.Base(name))
|
|
||||||
// Walk to the file's parent through dir with case-insensitive segment
|
// Walk to the file's parent through dir with case-insensitive segment
|
||||||
// matching, then find a regular file with the same lowercased
|
// matching, then find a regular file with the same basename. This works
|
||||||
// basename. This works on both case-sensitive and case-insensitive
|
// on both case-sensitive and case-insensitive filesystems and always
|
||||||
// filesystems and always returns the on-disk filename.
|
// returns the on-disk filename.
|
||||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(parent)
|
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||||
if err != nil {
|
return match
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
|
||||||
return filepath.Join(parent, e.Name())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -46,14 +46,10 @@ work.
|
|||||||
formatting-only edits unless the user asked for them.
|
formatting-only edits unless the user asked for them.
|
||||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||||
mandatory.
|
mandatory.
|
||||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||||
summary — never a single throwaway line. Use short headings and bullets: a
|
line; a substantial change earns a few short bullets covering what changed, where
|
||||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||||
fit together; **How each requirement is met** — walk through each requirement
|
pad — do not restate the task or narrate steps the user already watched you take.
|
||||||
from the request and name the specifics that satisfy it; what validation ran and
|
|
||||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
|
||||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
|
||||||
a line or two.
|
|
||||||
|
|
||||||
## Editing discipline
|
## Editing discipline
|
||||||
|
|
||||||
@@ -64,7 +60,13 @@ work.
|
|||||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||||
shell or script invocation.
|
shell or script invocation.
|
||||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||||
|
file's comment density: do not add explanatory comments unless the user asks or
|
||||||
|
the code is already comment-dense.
|
||||||
|
- Solve the problem as posed, not a more general version of it. Add no
|
||||||
|
speculative abstraction, configurability, or handling for cases that cannot
|
||||||
|
occur, and nothing the user did not ask for. A small diff can still be
|
||||||
|
over-built; if a 200-line solution could be 50, rewrite it.
|
||||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||||
you did not author unless the task requires it; if you must, say so.
|
you did not author unless the task requires it; if you must, say so.
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import "strings"
|
|||||||
//
|
//
|
||||||
// Different model families respond to different framing: the GPT family benefits
|
// Different model families respond to different framing: the GPT family benefits
|
||||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||||
// Claude family is already well-aligned with the core prompt and only needs a
|
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||||
// block appended after the core prompt. Classification is by model id (the agent
|
// modelPromptAddendum returns the family-specific block appended after the core
|
||||||
// only has the model string — no registry dependency).
|
// prompt, or "" when the family is unknown or needs no specialization.
|
||||||
|
// Classification is by model id (the agent only has the model string — no registry
|
||||||
|
// dependency).
|
||||||
|
|
||||||
const (
|
const (
|
||||||
familyOpenAI = "openai"
|
familyOpenAI = "openai"
|
||||||
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
|
|||||||
return openAIPromptAddendum
|
return openAIPromptAddendum
|
||||||
case familyGemini:
|
case familyGemini:
|
||||||
return geminiPromptAddendum
|
return geminiPromptAddendum
|
||||||
case familyAnthropic:
|
|
||||||
return anthropicPromptAddendum
|
|
||||||
default:
|
default:
|
||||||
|
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||||
|
// family-specific block; comment discipline now lives in the core prompt
|
||||||
|
// for every model.
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
|||||||
in one short clause why it is needed.
|
in one short clause why it is needed.
|
||||||
- Use update_plan for any multi-step task and keep it current.
|
- Use update_plan for any multi-step task and keep it current.
|
||||||
</model_guidance>`
|
</model_guidance>`
|
||||||
|
|
||||||
const anthropicPromptAddendum = `<model_guidance>
|
|
||||||
- Do not add code comments unless the user asks or the surrounding code is
|
|
||||||
comment-dense; match the file's existing comment density.
|
|
||||||
</model_guidance>`
|
|
||||||
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
|||||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||||
}
|
}
|
||||||
|
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||||
|
// comment discipline is universal; it must not pick up another family's block.
|
||||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
if strings.Contains(claude, "<model_guidance>") {
|
||||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||||
}
|
}
|
||||||
if strings.Contains(claude, openAIPromptAddendum) {
|
if strings.Contains(claude, openAIPromptAddendum) {
|
||||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/sandbox"
|
"github.com/Gitlawb/zero/internal/sandbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||||
|
// directory for every test in this package. Without this, buildSystemPrompt
|
||||||
|
// falls back to the real config.UserConfigDir, so any developer with a
|
||||||
|
// personal ~/.config/zero/ZERO.md would get its content folded into
|
||||||
|
// otherwise-unrelated prompt assertions, making test runs non-deterministic
|
||||||
|
// across contributor machines. Tests that specifically exercise user
|
||||||
|
// guidelines stub userConfigDirForPrompt themselves via
|
||||||
|
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||||
|
code := m.Run()
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||||
|
|
||||||
@@ -23,7 +42,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
|||||||
"avoid broad refactors",
|
"avoid broad refactors",
|
||||||
"search the web before answering",
|
"search the web before answering",
|
||||||
"do not recognize",
|
"do not recognize",
|
||||||
"how each requirement is met",
|
"scaled to the work",
|
||||||
|
"comment density",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(prompt, want) {
|
if !strings.Contains(prompt, want) {
|
||||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||||
@@ -127,8 +147,90 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
|
||||||
|
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||||
|
|
||||||
|
prompt := buildSystemPrompt(Options{})
|
||||||
|
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
|
||||||
|
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||||
|
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
if strings.Contains(prompt, " Prefer concise summaries. ") {
|
||||||
|
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
|
||||||
|
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||||
|
|
||||||
|
prompt := buildSystemPrompt(Options{})
|
||||||
|
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
|
||||||
|
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||||
|
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
|
||||||
|
// User guidelines are global personal preferences; project guidelines
|
||||||
|
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
|
||||||
|
// and the user section must say so explicitly so the precedence holds
|
||||||
|
// even if a model otherwise weighs later context more heavily.
|
||||||
|
configDir := t.TempDir()
|
||||||
|
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
|
||||||
|
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||||
|
|
||||||
|
cwd := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||||
|
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
|
||||||
|
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
|
||||||
|
if userIdx < 0 || projectIdx < 0 {
|
||||||
|
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
if userIdx > projectIdx {
|
||||||
|
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
|
||||||
|
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
|
||||||
|
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
|
||||||
|
|
||||||
|
prompt := buildSystemPrompt(Options{})
|
||||||
|
if strings.Contains(prompt, "## User guidelines") {
|
||||||
|
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
|
||||||
|
t.Helper()
|
||||||
|
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
|
||||||
|
t.Helper()
|
||||||
|
old := userConfigDirForPrompt
|
||||||
|
userConfigDirForPrompt = fn
|
||||||
|
return func() {
|
||||||
|
userConfigDirForPrompt = old
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||||
cwd := t.TempDir()
|
cwd := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||||
@@ -256,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
|
||||||
|
limit := 64
|
||||||
|
content := strings.Repeat("x", limit+1)
|
||||||
|
got := truncateGuidelineContent(content, limit)
|
||||||
|
if len(got) > limit {
|
||||||
|
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "truncated") {
|
||||||
|
t.Fatalf("expected truncation marker, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
|
||||||
|
// Limits at or below the truncation marker's own length can't fit the
|
||||||
|
// marker at all; the helper must still never exceed the requested limit.
|
||||||
|
content := strings.Repeat("x", 100)
|
||||||
|
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
|
||||||
|
got := truncateGuidelineContent(content, limit)
|
||||||
|
if len(got) > limit {
|
||||||
|
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||||
root := filepath.Join("r")
|
root := filepath.Join("r")
|
||||||
leaf := filepath.Join(root, "a", "b")
|
leaf := filepath.Join(root, "a", "b")
|
||||||
|
|||||||
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
|
|||||||
WhenToUse string
|
WhenToUse string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||||
|
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||||
|
// the right skill with the skill tool on the first try instead of guessing a name
|
||||||
|
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||||
|
// package needs no dependency on internal/skills.
|
||||||
|
type SkillInfo struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
MaxTurns int
|
MaxTurns int
|
||||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||||
@@ -189,6 +199,12 @@ type Options struct {
|
|||||||
// populated only where the Task tool is actually registered, so an empty slice
|
// populated only where the Task tool is actually registered, so an empty slice
|
||||||
// (the default) reproduces the previous prompt byte-for-byte.
|
// (the default) reproduces the previous prompt byte-for-byte.
|
||||||
Specialists []SpecialistInfo
|
Specialists []SpecialistInfo
|
||||||
|
// Skills lists the reusable skills installed for this run (the default skills
|
||||||
|
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||||
|
// gains an <available_skills> block naming them so the model loads the right one
|
||||||
|
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||||
|
// previous prompt byte-for-byte.
|
||||||
|
Skills []SkillInfo
|
||||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||||
// the specialist runtime in later slices.
|
// the specialist runtime in later slices.
|
||||||
SessionID string
|
SessionID string
|
||||||
@@ -269,6 +285,14 @@ type Options struct {
|
|||||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||||
// byte-identical to before). One instance per run — it holds attempt state.
|
// byte-identical to before). One instance per run — it holds attempt state.
|
||||||
SelfCorrect *SelfCorrector
|
SelfCorrect *SelfCorrector
|
||||||
|
// FileDiagnostics, when set, checks files changed by mutating tools for
|
||||||
|
// error-severity language diagnostics IN THE BACKGROUND and appends any
|
||||||
|
// errors as a nudge before the model's next request — the model still sees
|
||||||
|
// an error it introduced at its next decision point, but no tool call ever
|
||||||
|
// blocks on the language server (the old inline path stalled every edit on
|
||||||
|
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
|
||||||
|
// disables post-edit diagnostics.
|
||||||
|
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||||
|
|
||||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||||
// any assistant turn that produces text but no tool call is accepted as the
|
// any assistant turn that produces text but no tool call is accepted as the
|
||||||
|
|||||||
+113
-32
@@ -12,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
@@ -19,9 +20,11 @@ import (
|
|||||||
"github.com/Gitlawb/zero/internal/hooks"
|
"github.com/Gitlawb/zero/internal/hooks"
|
||||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||||
"github.com/Gitlawb/zero/internal/mcp"
|
"github.com/Gitlawb/zero/internal/mcp"
|
||||||
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||||
"github.com/Gitlawb/zero/internal/observability"
|
"github.com/Gitlawb/zero/internal/observability"
|
||||||
"github.com/Gitlawb/zero/internal/plugins"
|
"github.com/Gitlawb/zero/internal/plugins"
|
||||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||||
"github.com/Gitlawb/zero/internal/providers"
|
"github.com/Gitlawb/zero/internal/providers"
|
||||||
"github.com/Gitlawb/zero/internal/redaction"
|
"github.com/Gitlawb/zero/internal/redaction"
|
||||||
@@ -53,32 +56,36 @@ type appDeps struct {
|
|||||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||||
// inject it). nil ⇒ no export.
|
// inject it). nil ⇒ no export.
|
||||||
exportActiveProvider func(providerName string)
|
exportActiveProvider func(providerName string)
|
||||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||||
newSessionStore func() *sessions.Store
|
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
newSessionStore func() *sessions.Store
|
||||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||||
skillsDir func() string
|
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||||
pluginsDir func() string
|
skillsDir func() string
|
||||||
toolsDir func() string
|
pluginsDir func() string
|
||||||
newMCPStore func() (*mcp.PermissionStore, error)
|
toolsDir func() string
|
||||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
newMCPStore func() (*mcp.PermissionStore, error)
|
||||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||||
detectVerifyPlan func(string) (verify.Plan, error)
|
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
detectVerifyPlan func(string) (verify.Plan, error)
|
||||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||||
runTUI func(context.Context, tui.Options) int
|
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||||
runEditor func(string) error
|
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||||
now func() time.Time
|
runTUI func(context.Context, tui.Options) int
|
||||||
|
runEditor func(string) error
|
||||||
|
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||||
|
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type mcpToolRuntime interface {
|
type mcpToolRuntime interface {
|
||||||
@@ -134,8 +141,9 @@ func defaultAppDeps() appDeps {
|
|||||||
OAuthLoginKey: loginKey,
|
OAuthLoginKey: loginKey,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
probeProviderHealth: providerhealth.Probe,
|
probeProviderHealth: providerhealth.Probe,
|
||||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
discoverProviderModels: defaultDiscoverProviderModels,
|
||||||
|
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||||
newSessionStore: func() *sessions.Store {
|
newSessionStore: func() *sessions.Store {
|
||||||
return sessions.NewStore(sessions.StoreOptions{})
|
return sessions.NewStore(sessions.StoreOptions{})
|
||||||
},
|
},
|
||||||
@@ -174,9 +182,12 @@ func defaultAppDeps() appDeps {
|
|||||||
runAgentEval: defaultRunAgentEval,
|
runAgentEval: defaultRunAgentEval,
|
||||||
inspectChanges: zerogit.Inspect,
|
inspectChanges: zerogit.Inspect,
|
||||||
commitChanges: zerogit.Commit,
|
commitChanges: zerogit.Commit,
|
||||||
|
pushChanges: zerogit.Push,
|
||||||
|
createPR: zerogit.CreatePR,
|
||||||
runTUI: tui.Run,
|
runTUI: tui.Run,
|
||||||
runEditor: openEditor,
|
runEditor: openEditor,
|
||||||
checkUpdate: update.Check,
|
checkUpdate: update.Check,
|
||||||
|
applyUpdate: update.Apply,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +235,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
|||||||
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
||||||
deps = fillAppDeps(deps)
|
deps = fillAppDeps(deps)
|
||||||
|
|
||||||
|
// CLI runs opt into the models.dev overlay (cached live context limits and
|
||||||
|
// pricing on top of the curated catalog). Explicitly enabled here — and only
|
||||||
|
// here — so library consumers and hermetic tests are never perturbed by a
|
||||||
|
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||||
|
modelregistry.EnableModelsDevOverlay()
|
||||||
|
|
||||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, err.Error(), 1)
|
return writeAppError(stderr, err.Error(), 1)
|
||||||
@@ -380,6 +397,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
|||||||
return runSandbox(args[1:], stdout, stderr, deps)
|
return runSandbox(args[1:], stdout, stderr, deps)
|
||||||
case "update":
|
case "update":
|
||||||
return runUpdate(args[1:], stdout, stderr, deps)
|
return runUpdate(args[1:], stdout, stderr, deps)
|
||||||
|
case "upgrade":
|
||||||
|
return runUpgrade(args[1:], stdout, stderr, deps)
|
||||||
case "worktrees", "worktree":
|
case "worktrees", "worktree":
|
||||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||||
case "verify":
|
case "verify":
|
||||||
@@ -440,6 +459,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
if deps.probeProviderHealth == nil {
|
if deps.probeProviderHealth == nil {
|
||||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||||
}
|
}
|
||||||
|
if deps.discoverProviderModels == nil {
|
||||||
|
deps.discoverProviderModels = defaults.discoverProviderModels
|
||||||
|
}
|
||||||
if deps.detectLocalRuntimes == nil {
|
if deps.detectLocalRuntimes == nil {
|
||||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||||
}
|
}
|
||||||
@@ -500,6 +522,12 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
if deps.commitChanges == nil {
|
if deps.commitChanges == nil {
|
||||||
deps.commitChanges = defaults.commitChanges
|
deps.commitChanges = defaults.commitChanges
|
||||||
}
|
}
|
||||||
|
if deps.pushChanges == nil {
|
||||||
|
deps.pushChanges = defaults.pushChanges
|
||||||
|
}
|
||||||
|
if deps.createPR == nil {
|
||||||
|
deps.createPR = defaults.createPR
|
||||||
|
}
|
||||||
if deps.runTUI == nil {
|
if deps.runTUI == nil {
|
||||||
deps.runTUI = defaults.runTUI
|
deps.runTUI = defaults.runTUI
|
||||||
}
|
}
|
||||||
@@ -509,6 +537,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
|||||||
if deps.checkUpdate == nil {
|
if deps.checkUpdate == nil {
|
||||||
deps.checkUpdate = defaults.checkUpdate
|
deps.checkUpdate = defaults.checkUpdate
|
||||||
}
|
}
|
||||||
|
if deps.applyUpdate == nil {
|
||||||
|
deps.applyUpdate = defaults.applyUpdate
|
||||||
|
}
|
||||||
if deps.now == nil {
|
if deps.now == nil {
|
||||||
deps.now = defaults.now
|
deps.now = defaults.now
|
||||||
}
|
}
|
||||||
@@ -531,6 +562,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||||
|
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||||
|
// the overlay is read at registry construction from the cache file, so this
|
||||||
|
// benefits the next run and never blocks or fails this one.
|
||||||
|
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||||
|
|
||||||
workspaceRoot, err := deps.getwd()
|
workspaceRoot, err := deps.getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||||
@@ -551,8 +587,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||||
return writeAppError(stderr, err.Error(), 1)
|
return writeAppError(stderr, err.Error(), 1)
|
||||||
}
|
}
|
||||||
resolved = config.ResolvedConfig{}
|
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
|
||||||
forceSetup = true
|
// or "providers ARE configured, just none marked active" (e.g. config.json's
|
||||||
|
// activeProvider is blank/stale). In the second case resolved.Providers still
|
||||||
|
// carries the already-normalized list — prefer falling back to one of those
|
||||||
|
// over wiping everything and forcing the user to re-enter credentials they
|
||||||
|
// already saved.
|
||||||
|
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
|
||||||
|
resolved.Provider = usable
|
||||||
|
resolved.ActiveProvider = usable.Name
|
||||||
|
} else {
|
||||||
|
resolved = config.ResolvedConfig{}
|
||||||
|
forceSetup = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userConfigPath, err := deps.userConfigPath()
|
userConfigPath, err := deps.userConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -734,9 +781,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
|||||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||||
Specialists: specialistRuntime.specialists,
|
Specialists: specialistRuntime.specialists,
|
||||||
|
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||||
},
|
},
|
||||||
|
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||||
|
// It resolves against the same merged set (default dir + plugin skill
|
||||||
|
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||||
|
// newly installed skills work without a restart.
|
||||||
|
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
|
||||||
|
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||||
|
return merged
|
||||||
|
}),
|
||||||
PermissionMode: permissionMode,
|
PermissionMode: permissionMode,
|
||||||
Notify: resolved.Notify,
|
Notify: resolved.Notify,
|
||||||
|
KeyBindings: resolved.KeyBindings,
|
||||||
Setup: tui.SetupOptions{
|
Setup: tui.SetupOptions{
|
||||||
Visible: setupVisible,
|
Visible: setupVisible,
|
||||||
Required: needsSetup,
|
Required: needsSetup,
|
||||||
@@ -1152,7 +1209,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||||
|
// provider like Ollama/LM Studio that needs no API key.
|
||||||
func baseURLIsLoopback(baseURL string) bool {
|
func baseURLIsLoopback(baseURL string) bool {
|
||||||
u := strings.TrimSpace(baseURL)
|
u := strings.TrimSpace(baseURL)
|
||||||
if u == "" {
|
if u == "" {
|
||||||
@@ -1167,7 +1225,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if addr, err := netip.ParseAddr(host); err == nil {
|
if addr, err := netip.ParseAddr(host); err == nil {
|
||||||
return addr.IsLoopback()
|
return addr.IsLoopback() || addr.IsPrivate()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1228,3 +1286,26 @@ Flags:
|
|||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
|
||||||
|
// calls the loader from autocomplete on every "/x" keystroke; without a cache
|
||||||
|
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
|
||||||
|
// that a newly installed skill still shows up "immediately" while typing.
|
||||||
|
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
cached []skills.Skill
|
||||||
|
)
|
||||||
|
const ttl = 2 * time.Second
|
||||||
|
return func() []skills.Skill {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if cached != nil && time.Since(at) < ttl {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
cached = load()
|
||||||
|
at = time.Now()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cwd := t.TempDir()
|
||||||
|
setCLIUserConfigRoot(t)
|
||||||
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||||
|
var launchedOptions tui.Options
|
||||||
|
launched := false
|
||||||
|
var providerProfile config.ProviderProfile
|
||||||
|
fake := &cliFakeProvider{}
|
||||||
|
|
||||||
|
usable := config.ProviderProfile{
|
||||||
|
Name: "work",
|
||||||
|
ProviderKind: config.ProviderKindOpenAI,
|
||||||
|
BaseURL: config.OpenAIBaseURL,
|
||||||
|
APIKey: "sk-test",
|
||||||
|
Model: "gpt-test",
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||||
|
getwd: func() (string, error) {
|
||||||
|
return cwd, nil
|
||||||
|
},
|
||||||
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
// config.json has providers configured but none marked active (e.g. a
|
||||||
|
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||||
|
// normalized providers list alongside ErrNoActiveProvider.
|
||||||
|
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||||
|
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||||
|
},
|
||||||
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||||
|
providerProfile = profile
|
||||||
|
return fake, nil
|
||||||
|
},
|
||||||
|
userConfigPath: func() (string, error) {
|
||||||
|
return userConfigPath, nil
|
||||||
|
},
|
||||||
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||||
|
return noopMCPRuntime{}, nil
|
||||||
|
},
|
||||||
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||||
|
launched = true
|
||||||
|
launchedOptions = options
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if !launched {
|
||||||
|
t.Fatal("TUI was not launched")
|
||||||
|
}
|
||||||
|
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
||||||
|
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
||||||
|
}
|
||||||
|
if providerProfile.Name != "work" {
|
||||||
|
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
||||||
|
applyResult := update.ApplyResult{
|
||||||
|
Result: update.Result{
|
||||||
|
CurrentVersion: "dev",
|
||||||
|
LatestVersion: "0.2.0",
|
||||||
|
UpdateAvailable: true,
|
||||||
|
},
|
||||||
|
Applied: true,
|
||||||
|
InstallMethod: update.InstallMethodStandalone,
|
||||||
|
BinaryPath: "/usr/local/bin/zero",
|
||||||
|
Message: "updated to 0.2.0",
|
||||||
|
}
|
||||||
|
var gotOptions update.Options
|
||||||
|
deps := appDeps{
|
||||||
|
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
||||||
|
gotOptions = options
|
||||||
|
return applyResult, nil
|
||||||
|
},
|
||||||
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||||
|
t.Fatal("checkUpdate should not run for --apply")
|
||||||
|
return update.Result{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
||||||
|
t.Fatalf("unexpected apply text: %q", stdout.String())
|
||||||
|
}
|
||||||
|
if gotOptions.CurrentVersion != "dev" {
|
||||||
|
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Applied bool `json:"applied"`
|
||||||
|
InstallMethod string `json:"installMethod"`
|
||||||
|
BinaryPath string `json:"binaryPath"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
||||||
|
t.Fatalf("unexpected apply JSON: %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
||||||
|
var applyCalled bool
|
||||||
|
deps := appDeps{
|
||||||
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||||
|
applyCalled = true
|
||||||
|
return update.ApplyResult{Message: "already up to date"}, nil
|
||||||
|
},
|
||||||
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||||
|
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
||||||
|
return update.Result{}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if !applyCalled {
|
||||||
|
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
||||||
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||||
|
t.Fatal("checkUpdate should not run")
|
||||||
|
return update.Result{}, nil
|
||||||
|
},
|
||||||
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||||
|
t.Fatal("applyUpdate should not run")
|
||||||
|
return update.ApplyResult{}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != exitUsage {
|
||||||
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
||||||
|
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"update", "--apply", "--target", "linux-arm64"},
|
||||||
|
{"upgrade", "--target", "linux-arm64"},
|
||||||
|
} {
|
||||||
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||||
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||||
|
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
||||||
|
return update.ApplyResult{}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != exitUsage {
|
||||||
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
||||||
|
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUpdateReportsApplyError(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
||||||
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||||
|
return update.ApplyResult{}, errors.New("download failed")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode == exitSuccess {
|
||||||
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
||||||
|
t.Fatalf("expected apply error, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertHelpOutput(t *testing.T, args []string) {
|
func assertHelpOutput(t *testing.T, args []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
|||||||
if len(parsed.positional) != 1 {
|
if len(parsed.positional) != 1 {
|
||||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||||
}
|
}
|
||||||
|
provider := parsed.positional[0]
|
||||||
|
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
|
||||||
|
// auth/callback) and mandatory authorize params (id_token_add_organizations,
|
||||||
|
// codex_cli_simplified_flow, originator) that the generic loopback flow
|
||||||
|
// cannot supply. Route it to the dedicated ChatGPT login so
|
||||||
|
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
|
||||||
|
if strings.EqualFold(provider, "chatgpt") {
|
||||||
|
if parsed.device {
|
||||||
|
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
|
||||||
|
}
|
||||||
|
if len(parsed.scopes) > 0 {
|
||||||
|
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
|
||||||
|
}
|
||||||
|
return runAuthChatGPT(nil, stdout, stderr, deps)
|
||||||
|
}
|
||||||
manager, err := newAuthManager(deps, stdout)
|
manager, err := newAuthManager(deps, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||||
}
|
}
|
||||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||||
Provider: parsed.positional[0],
|
Provider: provider,
|
||||||
Device: parsed.device,
|
Device: parsed.device,
|
||||||
ExtraScopes: parsed.scopes,
|
ExtraScopes: parsed.scopes,
|
||||||
})
|
})
|
||||||
@@ -489,8 +504,9 @@ Commands:
|
|||||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
|
||||||
below. For a custom provider named <name>, set:
|
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
|
||||||
|
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
|
||||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||||
|
|||||||
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||||
|
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||||
|
// authorize params), not the generic manager path. The generic login accepts
|
||||||
|
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||||
|
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||||
|
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||||
|
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||||
|
withAuthStore(t)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||||
|
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||||
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||||
|
}
|
||||||
|
// Case-insensitive provider name should still route.
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||||
|
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||||
|
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||||
|
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||||
|
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||||
|
// scopes are rejected up front rather than plumbed through.
|
||||||
|
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||||
|
withAuthStore(t)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||||
|
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||||
|
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
|||||||
if command == "detect" {
|
if command == "detect" {
|
||||||
return runProvidersDetect(args, stdout, stderr, deps)
|
return runProvidersDetect(args, stdout, stderr, deps)
|
||||||
}
|
}
|
||||||
|
if command == "models" {
|
||||||
|
return runProvidersModels(args, stdout, stderr, deps)
|
||||||
|
}
|
||||||
if command != "list" && command != "current" && command != "catalog" {
|
if command != "list" && command != "current" && command != "catalog" {
|
||||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||||
}
|
}
|
||||||
@@ -465,9 +468,11 @@ func writeProvidersHelp(w io.Writer) error {
|
|||||||
zero providers use <name> [flags]
|
zero providers use <name> [flags]
|
||||||
zero providers setup <catalog-id> [flags]
|
zero providers setup <catalog-id> [flags]
|
||||||
zero providers detect [flags]
|
zero providers detect [flags]
|
||||||
|
zero providers models [name] [flags]
|
||||||
|
|
||||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||||
|
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--json Print JSON summary
|
--json Print JSON summary
|
||||||
|
|||||||
+37
-13
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Gitlawb/zero/internal/agent"
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/config"
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/errhint"
|
||||||
"github.com/Gitlawb/zero/internal/imageinput"
|
"github.com/Gitlawb/zero/internal/imageinput"
|
||||||
"github.com/Gitlawb/zero/internal/lsp"
|
"github.com/Gitlawb/zero/internal/lsp"
|
||||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||||
@@ -135,6 +136,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
|||||||
return exitSuccess
|
return exitSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||||
|
// the overlay is read at registry construction from the cache file, so this
|
||||||
|
// benefits the next run and never blocks or fails this one.
|
||||||
|
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||||
|
|
||||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||||
// front — before tool-filter validation and the --list-tools branch — so a
|
// front — before tool-filter validation and the --list-tools branch — so a
|
||||||
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
||||||
@@ -499,15 +505,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
|||||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||||
// changed files; the autonomy gate inside the corrector still decides whether
|
// changed files; the autonomy gate inside the corrector still decides whether
|
||||||
// failures auto-fix or just report. lspShutdown tears down any language-server
|
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
|
||||||
// sessions the LSP half spawned (no-op when self-correct is off).
|
// edit_file/write_file inline error diagnostics for the file they just wrote.
|
||||||
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
// lspShutdown tears down any language-server sessions either half spawned.
|
||||||
|
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||||
defer lspShutdown()
|
defer lspShutdown()
|
||||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||||
MaxTurns: resolved.MaxTurns,
|
MaxTurns: resolved.MaxTurns,
|
||||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||||
DeferThreshold: effectiveDeferThreshold,
|
DeferThreshold: effectiveDeferThreshold,
|
||||||
Specialists: specialistRuntime.specialistInfos(),
|
Specialists: specialistRuntime.specialistInfos(),
|
||||||
|
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||||
SessionID: preparedSession.Session.SessionID,
|
SessionID: preparedSession.Session.SessionID,
|
||||||
CallingSessionID: options.callingSessionID,
|
CallingSessionID: options.callingSessionID,
|
||||||
CallingToolUseID: options.callingToolUseID,
|
CallingToolUseID: options.callingToolUseID,
|
||||||
@@ -524,6 +532,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
|||||||
PermissionMode: permissionMode,
|
PermissionMode: permissionMode,
|
||||||
Autonomy: options.autonomy,
|
Autonomy: options.autonomy,
|
||||||
SelfCorrect: selfCorrector,
|
SelfCorrect: selfCorrector,
|
||||||
|
FileDiagnostics: fileDiagnostics,
|
||||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||||
@@ -535,6 +544,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
|||||||
EnabledTools: options.enabledTools,
|
EnabledTools: options.enabledTools,
|
||||||
DisabledTools: options.disabledTools,
|
DisabledTools: options.disabledTools,
|
||||||
OnText: writer.text,
|
OnText: writer.text,
|
||||||
|
OnReasoning: writer.reasoning,
|
||||||
OnToolCall: func(call agent.ToolCall) {
|
OnToolCall: func(call agent.ToolCall) {
|
||||||
writer.toolCall(call, registry)
|
writer.toolCall(call, registry)
|
||||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||||
@@ -699,23 +709,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
|||||||
// unless a changed file's language actually has one installed on PATH. The
|
// unless a changed file's language actually has one installed on PATH. The
|
||||||
// returned cleanup shuts that manager down (terminating any spawned server
|
// returned cleanup shuts that manager down (terminating any spawned server
|
||||||
// sessions); it is a no-op when self-correct is off.
|
// sessions); it is a no-op when self-correct is off.
|
||||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
|
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
|
||||||
if !enabled {
|
// The manager is created regardless of --self-correct: it also backs the
|
||||||
return nil, func() {}
|
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
|
||||||
}
|
// collected off the tool-call path by the loop), and it stays lazy — no
|
||||||
|
// language server is spawned unless an edited file's language actually has
|
||||||
|
// one installed on PATH.
|
||||||
manager := lsp.NewManager(workspaceRoot)
|
manager := lsp.NewManager(workspaceRoot)
|
||||||
|
cleanup := func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = manager.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
|
||||||
|
if !enabled {
|
||||||
|
return nil, fileDiagnostics, cleanup
|
||||||
|
}
|
||||||
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
IncludeTests: true,
|
IncludeTests: true,
|
||||||
IncludeLSP: true,
|
IncludeLSP: true,
|
||||||
Autonomy: autonomy,
|
Autonomy: autonomy,
|
||||||
})
|
})
|
||||||
cleanup := func() {
|
return corrector, fileDiagnostics, cleanup
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = manager.Shutdown(ctx)
|
|
||||||
}
|
|
||||||
return corrector, cleanup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||||
@@ -948,6 +964,14 @@ func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutpu
|
|||||||
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
|
||||||
return exitCrash
|
return exitCrash
|
||||||
}
|
}
|
||||||
|
// Append a one-line next step for recognized provider failures (auth /
|
||||||
|
// rate-limit / connectivity / …). The classifier gates on a provider-origin
|
||||||
|
// marker, so non-provider codes (sandbox_error, mcp_error) never draw a hint.
|
||||||
|
if hint := errhint.CLIHint(errors.New(message)); hint != "" {
|
||||||
|
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", hint); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
return exitProvider
|
return exitProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -529,6 +529,45 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
|
||||||
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||||
|
cwd := t.TempDir()
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
|
||||||
|
getwd: func() (string, error) {
|
||||||
|
return cwd, nil
|
||||||
|
},
|
||||||
|
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return execResolvedConfig(), nil
|
||||||
|
},
|
||||||
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||||
|
return reasoningExecProvider{}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
events := decodeJSONLines(t, stdout.String())
|
||||||
|
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
|
||||||
|
if reasoning["delta"] != "Thinking. " {
|
||||||
|
t.Fatalf("unexpected reasoning event: %#v", reasoning)
|
||||||
|
}
|
||||||
|
textIdx, text := findJSONEventIndex(t, events, "text")
|
||||||
|
if text["delta"] != "done" {
|
||||||
|
t.Fatalf("unexpected text event: %#v", text)
|
||||||
|
}
|
||||||
|
if reasoningIdx >= textIdx {
|
||||||
|
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
|
||||||
|
}
|
||||||
|
final := findJSONEvent(t, events, "final")
|
||||||
|
if final["text"] != "done" {
|
||||||
|
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
writer := execEventWriter{
|
writer := execEventWriter{
|
||||||
@@ -751,6 +790,17 @@ type toolCallingExecProvider struct {
|
|||||||
answer string
|
answer string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reasoningExecProvider struct{}
|
||||||
|
|
||||||
|
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||||
|
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||||
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
|
||||||
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||||
|
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||||
for _, message := range request.Messages {
|
for _, message := range request.Messages {
|
||||||
if message.Role == zeroruntime.MessageRoleTool {
|
if message.Role == zeroruntime.MessageRoleTool {
|
||||||
@@ -777,13 +827,19 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
|||||||
|
|
||||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, event := range events {
|
_, event := findJSONEventIndex(t, events, eventType)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
for idx, event := range events {
|
||||||
if event["type"] == eventType {
|
if event["type"] == eventType {
|
||||||
return event
|
return idx, event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||||
return nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A recognized provider error in text mode prints the raw message plus a one-line
|
||||||
|
// actionable hint; a non-provider error (or JSON mode) prints no hint.
|
||||||
|
func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
|
||||||
|
t.Run("provider auth error gets a hint", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := writeExecProviderError(&stdout, &stderr, execOutputText, "provider_error",
|
||||||
|
"auth error: your API key is missing or invalid")
|
||||||
|
if code != exitProvider {
|
||||||
|
t.Fatalf("exit code = %d, want exitProvider", code)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "auth error:") {
|
||||||
|
t.Fatalf("expected raw message, got %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "zero auth") {
|
||||||
|
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-provider error gets no hint", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
writeExecProviderError(&stdout, &stderr, execOutputText, "sandbox_error",
|
||||||
|
"sandbox setup failed: permission denied")
|
||||||
|
out := stderr.String()
|
||||||
|
// Exactly one "[zero]" line — no spurious hint attached to a local error.
|
||||||
|
if n := strings.Count(out, "[zero]"); n != 1 {
|
||||||
|
t.Fatalf("expected exactly one [zero] line for a non-provider error, got %d:\n%s", n, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("json mode never appends a hint line", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
writeExecProviderError(&stdout, &stderr, execOutputJSON, "provider_error",
|
||||||
|
"auth error: bad key")
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "zero auth") {
|
||||||
|
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -80,6 +80,16 @@ func (writer *execEventWriter) text(delta string) {
|
|||||||
writer.writeStdout(delta)
|
writer.writeStdout(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (writer *execEventWriter) reasoning(delta string) {
|
||||||
|
if writer.format == execOutputJSON {
|
||||||
|
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if writer.format == execOutputStreamJSON {
|
||||||
|
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
|
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
|
||||||
if writer.format == execOutputJSON {
|
if writer.format == execOutputJSON {
|
||||||
writer.writeJSON(map[string]any{
|
writer.writeJSON(map[string]any{
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/agent"
|
||||||
"github.com/Gitlawb/zero/internal/hooks"
|
"github.com/Gitlawb/zero/internal/hooks"
|
||||||
"github.com/Gitlawb/zero/internal/plugins"
|
"github.com/Gitlawb/zero/internal/plugins"
|
||||||
"github.com/Gitlawb/zero/internal/tools"
|
"github.com/Gitlawb/zero/internal/tools"
|
||||||
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
|||||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||||
|
// the default skills dir merged with any plugin skill roots, the same set the
|
||||||
|
// plugin-aware skill tool resolves against — as plain data for the agent's system
|
||||||
|
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
|
||||||
|
// the prompt byte-identical.
|
||||||
|
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
|
||||||
|
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
|
||||||
|
if len(merged) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
infos := make([]agent.SkillInfo, 0, len(merged))
|
||||||
|
for _, skill := range merged {
|
||||||
|
name := strings.TrimSpace(skill.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
|
||||||
|
}
|
||||||
|
return infos
|
||||||
|
}
|
||||||
|
|
||||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||||
// line, prefixing the diagnostic kind and appending the most specific locator
|
// line, prefixing the diagnostic kind and appending the most specific locator
|
||||||
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||||
|
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
|
||||||
|
output := runProviderCatalogOnboarding(t)
|
||||||
|
block := providerCatalogOnboardingBlock(t, output, "longcat")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"requiresAuth=true",
|
||||||
|
"authEnvVars=LONGCAT_API_KEY",
|
||||||
|
"setup: zero providers setup longcat --set-active",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(block, want) {
|
||||||
|
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
|
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
|
||||||
output := runProviderCatalogOnboarding(t)
|
output := runProviderCatalogOnboarding(t)
|
||||||
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
|
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
type providerModelsOptions struct {
|
||||||
|
name string
|
||||||
|
json bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// runProvidersModels lists the models a saved provider actually serves by probing
|
||||||
|
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
|
||||||
|
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
|
||||||
|
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
|
||||||
|
// models no longer needs a config object per model — configure the provider once,
|
||||||
|
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
|
||||||
|
// model ids through to the provider).
|
||||||
|
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
options, help, err := parseProviderModelsArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
if err := writeProvidersHelp(stdout); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
return exitCode
|
||||||
|
}
|
||||||
|
profile, err := selectProviderForCheck(resolved, options.name)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signalContext()
|
||||||
|
defer stop()
|
||||||
|
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||||
|
if err != nil {
|
||||||
|
return writeAppError(stderr, err.Error(), exitProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.json {
|
||||||
|
items := make([]map[string]any, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
entry := map[string]any{"id": model.ID}
|
||||||
|
if description := strings.TrimSpace(model.Description); description != "" {
|
||||||
|
entry["description"] = description
|
||||||
|
}
|
||||||
|
items = append(items, entry)
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"provider": profile.Name,
|
||||||
|
"count": len(items),
|
||||||
|
"models": items,
|
||||||
|
}
|
||||||
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(profile.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "provider"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
for _, model := range models {
|
||||||
|
line := strings.TrimSpace(model.ID)
|
||||||
|
if description := strings.TrimSpace(model.Description); description != "" {
|
||||||
|
line += " — " + description
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suffix := "s"
|
||||||
|
if len(models) == 1 {
|
||||||
|
suffix = ""
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
if len(models) > 0 {
|
||||||
|
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveryCredentialProfile resolves the profile's API key the same way the
|
||||||
|
// runtime does — inline, then the stored credential, then the configured env var —
|
||||||
|
// so a `providers models` probe authenticates exactly like a real request. Mirrors
|
||||||
|
// discoveredModelContextWindow's credential resolution.
|
||||||
|
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
|
||||||
|
authed := profile
|
||||||
|
if strings.TrimSpace(authed.APIKey) == "" {
|
||||||
|
if store, err := config.ProviderKeyStore(); err == nil {
|
||||||
|
authed = config.ApplyStoredAPIKey(authed, store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||||
|
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||||
|
}
|
||||||
|
return authed
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
|
||||||
|
// the provider's model-listing endpoint with no curated-catalog merge or
|
||||||
|
// coding-model filtering, so a custom provider's full model list is returned.
|
||||||
|
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||||
|
options := providerModelsOptions{}
|
||||||
|
for index := 0; index < len(args); index++ {
|
||||||
|
arg := args[index]
|
||||||
|
switch {
|
||||||
|
case arg == "-h" || arg == "--help" || arg == "help":
|
||||||
|
return options, true, nil
|
||||||
|
case arg == "--json":
|
||||||
|
options.json = true
|
||||||
|
case strings.HasPrefix(arg, "-"):
|
||||||
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||||
|
default:
|
||||||
|
if options.name != "" {
|
||||||
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||||
|
}
|
||||||
|
options.name = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options, false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
deps := commandCenterDeps(t)
|
||||||
|
var probed config.ProviderProfile
|
||||||
|
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
probed = profile
|
||||||
|
return []providermodeldiscovery.Model{
|
||||||
|
{ID: "team/model-a", Description: "first"},
|
||||||
|
{ID: "team/model-b"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||||
|
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
// No name given → the active provider is probed, with its resolved credential.
|
||||||
|
if probed.Name != "work" {
|
||||||
|
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProvidersModelsJSON(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
deps := commandCenterDeps(t)
|
||||||
|
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
|
||||||
|
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Models []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode json: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
|
||||||
|
t.Fatalf("payload = %#v", payload)
|
||||||
|
}
|
||||||
|
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
|
||||||
|
t.Fatalf("model[0] = %#v", payload.Models[0])
|
||||||
|
}
|
||||||
|
// A model with no description omits the field rather than emitting an empty string.
|
||||||
|
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
|
||||||
|
t.Fatalf("model[1] = %#v", payload.Models[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
deps := commandCenterDeps(t)
|
||||||
|
custom := config.ProviderProfile{
|
||||||
|
Name: "self-hosted",
|
||||||
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||||
|
BaseURL: "https://models.internal/v1",
|
||||||
|
APIKey: "sk-internal",
|
||||||
|
Model: "house-model",
|
||||||
|
}
|
||||||
|
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
|
||||||
|
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
|
||||||
|
}
|
||||||
|
var probed config.ProviderProfile
|
||||||
|
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
probed = profile
|
||||||
|
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
|
||||||
|
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||||
|
}
|
||||||
|
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
|
||||||
|
t.Fatalf("probed = %#v, want the named custom provider", probed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
deps := commandCenterDeps(t)
|
||||||
|
called := false
|
||||||
|
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
called = true
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
|
||||||
|
|
||||||
|
if exitCode == exitSuccess {
|
||||||
|
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("discovery must not run for an unknown provider")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
|
||||||
|
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
deps := commandCenterDeps(t)
|
||||||
|
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||||
|
return nil, errors.New("models endpoint returned 404")
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||||
|
|
||||||
|
if exitCode != exitProvider {
|
||||||
|
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
|
||||||
|
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,15 @@ func formatRepoInfo(info repoinfo.Info) string {
|
|||||||
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
|
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
|
||||||
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
|
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
|
||||||
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
|
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
|
||||||
|
if info.CommitCount != nil {
|
||||||
|
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
|
||||||
|
}
|
||||||
|
if info.BranchCount != nil {
|
||||||
|
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
|
||||||
|
}
|
||||||
|
if info.TagCount != nil {
|
||||||
|
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
|
||||||
|
}
|
||||||
if info.PrimaryLanguage != "" {
|
if info.PrimaryLanguage != "" {
|
||||||
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
|
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
|
|||||||
contrib := 3
|
contrib := 3
|
||||||
info := repoInfoTestData(age, contrib)
|
info := repoInfoTestData(age, contrib)
|
||||||
out := formatRepoInfo(info)
|
out := formatRepoInfo(info)
|
||||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
|
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("text output missing %q:\n%s", want, out)
|
t.Fatalf("text output missing %q:\n%s", want, out)
|
||||||
}
|
}
|
||||||
@@ -113,12 +113,16 @@ func initTempGitRepo(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func repoInfoTestData(age, contrib int) repoinfo.Info {
|
func repoInfoTestData(age, contrib int) repoinfo.Info {
|
||||||
|
commits := 100
|
||||||
|
branches := 5
|
||||||
|
tags := 10
|
||||||
return repoinfo.Info{
|
return repoinfo.Info{
|
||||||
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
|
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
|
||||||
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
|
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
|
||||||
PrimaryLanguage: "TypeScript", LanguageCount: 1,
|
PrimaryLanguage: "TypeScript", LanguageCount: 1,
|
||||||
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
|
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
|
||||||
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
|
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
|
||||||
|
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
|||||||
case "localhost", "127.0.0.1", "::1":
|
case "localhost", "127.0.0.1", "::1":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
|
||||||
|
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
|
||||||
|
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-8
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type updateOptions struct {
|
type updateOptions struct {
|
||||||
check bool
|
check bool
|
||||||
|
apply bool
|
||||||
json bool
|
json bool
|
||||||
repository string
|
repository string
|
||||||
endpoint string
|
endpoint string
|
||||||
@@ -20,6 +21,18 @@ type updateOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
return runUpdateCommand(args, stdout, stderr, deps, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
return runUpdateCommand(args, stdout, stderr, deps, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
|
||||||
|
// makes `--apply` the implicit behavior for `zero upgrade` when neither
|
||||||
|
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
|
||||||
|
// one of the two so existing scripts around `zero update --check` don't change.
|
||||||
|
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
|
||||||
options, help, err := parseUpdateArgs(args)
|
options, help, err := parseUpdateArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeUsageError(stderr, err.Error())
|
return writeUsageError(stderr, err.Error())
|
||||||
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
|||||||
}
|
}
|
||||||
return exitSuccess
|
return exitSuccess
|
||||||
}
|
}
|
||||||
if !options.check {
|
if options.check && options.apply {
|
||||||
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
|
return writeUsageError(stderr, "Pass only one of --check or --apply.")
|
||||||
}
|
}
|
||||||
checkOptions := update.Options{
|
if !options.check && !options.apply {
|
||||||
|
if !defaultApply {
|
||||||
|
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
|
||||||
|
}
|
||||||
|
options.apply = true
|
||||||
|
}
|
||||||
|
if options.apply && options.target != "" {
|
||||||
|
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
|
||||||
|
}
|
||||||
|
updateOptions := update.Options{
|
||||||
CurrentVersion: version,
|
CurrentVersion: version,
|
||||||
Repository: options.repository,
|
Repository: options.repository,
|
||||||
Endpoint: options.endpoint,
|
Endpoint: options.endpoint,
|
||||||
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return writeUsageError(stderr, err.Error())
|
return writeUsageError(stderr, err.Error())
|
||||||
}
|
}
|
||||||
checkOptions.GOOS = target.GOOS
|
updateOptions.GOOS = target.GOOS
|
||||||
checkOptions.GOARCH = target.GOARCH
|
updateOptions.GOARCH = target.GOARCH
|
||||||
}
|
}
|
||||||
result, err := deps.checkUpdate(context.Background(), checkOptions)
|
if options.apply {
|
||||||
|
result, err := deps.applyUpdate(context.Background(), updateOptions)
|
||||||
|
if err != nil {
|
||||||
|
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
|
||||||
|
}
|
||||||
|
if options.json {
|
||||||
|
if err := writePrettyJSON(stdout, result); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
result, err := deps.checkUpdate(context.Background(), updateOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
||||||
}
|
}
|
||||||
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
|
|||||||
return options, true, nil
|
return options, true, nil
|
||||||
case arg == "--check":
|
case arg == "--check":
|
||||||
options.check = true
|
options.check = true
|
||||||
|
case arg == "--apply":
|
||||||
|
options.apply = true
|
||||||
case arg == "--json":
|
case arg == "--json":
|
||||||
options.json = true
|
options.json = true
|
||||||
case arg == "--repo":
|
case arg == "--repo":
|
||||||
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
|
|||||||
func writeUpdateHelp(w io.Writer) error {
|
func writeUpdateHelp(w io.Writer) error {
|
||||||
_, err := fmt.Fprint(w, `Usage:
|
_, err := fmt.Fprint(w, `Usage:
|
||||||
zero update --check [flags]
|
zero update --check [flags]
|
||||||
|
zero update --apply [flags]
|
||||||
|
zero upgrade [flags]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--check Check the latest GitHub release without installing
|
--check Check the latest GitHub release without installing
|
||||||
--json Print the update check result as JSON
|
--apply Download, verify, and install the latest release
|
||||||
|
--json Print the update result as JSON
|
||||||
--repo <owner/repo> Repository to check when no endpoint is provided
|
--repo <owner/repo> Repository to check when no endpoint is provided
|
||||||
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
||||||
--timeout <duration> Release check timeout (default 5s)
|
--timeout <duration> Release check timeout (default 5s)
|
||||||
--target <platform> Release target to verify (for example windows-x64)
|
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
|
|||||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseChangesArgsAuto(t *testing.T) {
|
||||||
|
// 1. Correct parsing of auto
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"--auto"},
|
||||||
|
{"-a"},
|
||||||
|
} {
|
||||||
|
options, help, err := parseChangesArgs(args, "commit")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
||||||
|
}
|
||||||
|
if !options.auto {
|
||||||
|
t.Fatalf("auto = false, want true (args %v)", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reject --auto on inspect
|
||||||
|
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "--auto") {
|
||||||
|
t.Fatalf("expected --auto rejection on inspect, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reject both --message and --auto on commit
|
||||||
|
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||||
|
t.Fatalf("expected --message and --auto conflict error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Reject both empty message and --auto on commit
|
||||||
|
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||||
|
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCommitMsgProvider struct {
|
||||||
|
response string
|
||||||
|
req zeroruntime.CompletionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||||
|
p.req = request
|
||||||
|
events := make(chan zeroruntime.StreamEvent, 2)
|
||||||
|
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
|
||||||
|
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||||
|
close(events)
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunChangesCommitAuto(t *testing.T) {
|
||||||
|
cwd := t.TempDir()
|
||||||
|
summary := zerogit.ChangeSummary{
|
||||||
|
Root: cwd,
|
||||||
|
Branch: "main",
|
||||||
|
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
|
||||||
|
Diff: "some diff content with ghp_SECRETKEYHERE",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockProv := &mockCommitMsgProvider{
|
||||||
|
response: "```\nfeat: auto commit message\n```",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
commitCalled := false
|
||||||
|
|
||||||
|
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||||
|
getwd: func() (string, error) { return cwd, nil },
|
||||||
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||||
|
return summary, nil
|
||||||
|
},
|
||||||
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
cfg := execResolvedConfig()
|
||||||
|
cfg.Provider.Name = "openai"
|
||||||
|
cfg.Provider.Model = "gpt-4o"
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||||
|
return mockProv, nil
|
||||||
|
},
|
||||||
|
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
||||||
|
commitCalled = true
|
||||||
|
if options.Message != "feat: auto commit message" {
|
||||||
|
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
|
||||||
|
}
|
||||||
|
return zerogit.CommitResult{
|
||||||
|
Root: cwd,
|
||||||
|
Message: options.Message,
|
||||||
|
Committed: true,
|
||||||
|
CommitHash: "abc1234",
|
||||||
|
Before: summary,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode != exitSuccess {
|
||||||
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if !commitCalled {
|
||||||
|
t.Fatal("expected commitChanges to be called")
|
||||||
|
}
|
||||||
|
// Verify that secret in the diff was redacted
|
||||||
|
promptContent := mockProv.req.Messages[0].Content
|
||||||
|
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
|
||||||
|
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
|
||||||
|
}
|
||||||
|
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
|
||||||
|
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
|
||||||
|
t.Fatalf("expected status message in stdout, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("EmptyLLMResponse", func(t *testing.T) {
|
||||||
|
mockProvEmpty := &mockCommitMsgProvider{
|
||||||
|
response: " \n\n ",
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||||
|
getwd: func() (string, error) { return cwd, nil },
|
||||||
|
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||||
|
return summary, nil
|
||||||
|
},
|
||||||
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||||
|
return execResolvedConfig(), nil
|
||||||
|
},
|
||||||
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||||
|
return mockProvEmpty, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if code == exitSuccess {
|
||||||
|
t.Fatal("expected command to fail when LLM returns empty response, got success")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "empty commit message") {
|
||||||
|
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+291
-5
@@ -6,13 +6,16 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/config"
|
||||||
"github.com/Gitlawb/zero/internal/redaction"
|
"github.com/Gitlawb/zero/internal/redaction"
|
||||||
"github.com/Gitlawb/zero/internal/selfverify"
|
"github.com/Gitlawb/zero/internal/selfverify"
|
||||||
"github.com/Gitlawb/zero/internal/testrunner"
|
"github.com/Gitlawb/zero/internal/testrunner"
|
||||||
"github.com/Gitlawb/zero/internal/verify"
|
"github.com/Gitlawb/zero/internal/verify"
|
||||||
"github.com/Gitlawb/zero/internal/worktrees"
|
"github.com/Gitlawb/zero/internal/worktrees"
|
||||||
"github.com/Gitlawb/zero/internal/zerogit"
|
"github.com/Gitlawb/zero/internal/zerogit"
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type worktreeCommandOptions struct {
|
type worktreeCommandOptions struct {
|
||||||
@@ -35,8 +38,17 @@ type changesCommandOptions struct {
|
|||||||
cwd string
|
cwd string
|
||||||
baseRef string
|
baseRef string
|
||||||
message string
|
message string
|
||||||
|
hasMessage bool
|
||||||
dryRun bool
|
dryRun bool
|
||||||
maxDiffBytes int
|
maxDiffBytes int
|
||||||
|
remote string
|
||||||
|
force bool
|
||||||
|
title string
|
||||||
|
body string
|
||||||
|
fill bool
|
||||||
|
draft bool
|
||||||
|
yes bool
|
||||||
|
auto bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
@@ -198,9 +210,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
|||||||
}
|
}
|
||||||
return exitSuccess
|
return exitSuccess
|
||||||
case "commit":
|
case "commit":
|
||||||
|
var message string
|
||||||
|
if options.auto {
|
||||||
|
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
||||||
|
Cwd: workspaceRoot,
|
||||||
|
MaxDiffBytes: options.maxDiffBytes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
|
||||||
|
}
|
||||||
|
if summary.Clean {
|
||||||
|
return writeExecUsageError(stderr, "no changes to commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
|
||||||
|
}
|
||||||
|
if !config.HasProviderProfile(resolved.Provider) {
|
||||||
|
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
|
||||||
|
}
|
||||||
|
provider, err := deps.newProvider(resolved.Provider)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !options.json {
|
||||||
|
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSummary := redactChangeSummary(summary)
|
||||||
|
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
|
||||||
|
}
|
||||||
|
message = msg
|
||||||
|
} else {
|
||||||
|
message = options.message
|
||||||
|
}
|
||||||
|
|
||||||
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
||||||
Cwd: workspaceRoot,
|
Cwd: workspaceRoot,
|
||||||
Message: options.message,
|
Message: message,
|
||||||
DryRun: options.dryRun,
|
DryRun: options.dryRun,
|
||||||
MaxDiffBytes: options.maxDiffBytes,
|
MaxDiffBytes: options.maxDiffBytes,
|
||||||
})
|
})
|
||||||
@@ -218,6 +273,10 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
|||||||
return exitCrash
|
return exitCrash
|
||||||
}
|
}
|
||||||
return exitSuccess
|
return exitSuccess
|
||||||
|
case "push":
|
||||||
|
return runChangesPush(args, stdout, stderr, deps)
|
||||||
|
case "pr", "pull-request":
|
||||||
|
return runChangesPR(args, stdout, stderr, deps)
|
||||||
default:
|
default:
|
||||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
||||||
}
|
}
|
||||||
@@ -395,9 +454,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
|||||||
return options, false, err
|
return options, false, err
|
||||||
}
|
}
|
||||||
options.message = value
|
options.message = value
|
||||||
|
options.hasMessage = true
|
||||||
index = next
|
index = next
|
||||||
case strings.HasPrefix(arg, "--message="):
|
case strings.HasPrefix(arg, "--message="):
|
||||||
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
||||||
|
options.hasMessage = true
|
||||||
case arg == "--diff-bytes":
|
case arg == "--diff-bytes":
|
||||||
value, next, err := nextFlagValue(args, index, arg)
|
value, next, err := nextFlagValue(args, index, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -415,18 +476,73 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
|||||||
return options, false, err
|
return options, false, err
|
||||||
}
|
}
|
||||||
options.maxDiffBytes = maxDiffBytes
|
options.maxDiffBytes = maxDiffBytes
|
||||||
|
case arg == "--remote":
|
||||||
|
value, next, err := nextFlagValue(args, index, arg)
|
||||||
|
if err != nil {
|
||||||
|
return options, false, err
|
||||||
|
}
|
||||||
|
options.remote = strings.TrimSpace(value)
|
||||||
|
index = next
|
||||||
|
case strings.HasPrefix(arg, "--remote="):
|
||||||
|
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
|
||||||
|
case arg == "--force":
|
||||||
|
options.force = true
|
||||||
|
case arg == "--title":
|
||||||
|
value, next, err := nextFlagValue(args, index, arg)
|
||||||
|
if err != nil {
|
||||||
|
return options, false, err
|
||||||
|
}
|
||||||
|
options.title = value
|
||||||
|
index = next
|
||||||
|
case strings.HasPrefix(arg, "--title="):
|
||||||
|
options.title = strings.TrimPrefix(arg, "--title=")
|
||||||
|
case arg == "--body":
|
||||||
|
value, next, err := nextFlagValue(args, index, arg)
|
||||||
|
if err != nil {
|
||||||
|
return options, false, err
|
||||||
|
}
|
||||||
|
options.body = value
|
||||||
|
index = next
|
||||||
|
case strings.HasPrefix(arg, "--body="):
|
||||||
|
options.body = strings.TrimPrefix(arg, "--body=")
|
||||||
|
case arg == "--fill":
|
||||||
|
options.fill = true
|
||||||
|
case arg == "--draft":
|
||||||
|
options.draft = true
|
||||||
|
case arg == "--yes":
|
||||||
|
options.yes = true
|
||||||
|
case arg == "-a" || arg == "--auto":
|
||||||
|
options.auto = true
|
||||||
case strings.HasPrefix(arg, "-"):
|
case strings.HasPrefix(arg, "-"):
|
||||||
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
||||||
default:
|
default:
|
||||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if command != "commit" && (options.message != "" || options.dryRun) {
|
if command != "commit" && options.message != "" {
|
||||||
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
|
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||||
}
|
}
|
||||||
if command == "commit" && options.baseRef != "" {
|
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||||
|
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||||
|
}
|
||||||
|
if command == "commit" && options.hasMessage && options.auto {
|
||||||
|
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||||
|
}
|
||||||
|
if command != "commit" && command != "push" && options.dryRun {
|
||||||
|
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||||
|
}
|
||||||
|
if command != "inspect" && options.baseRef != "" {
|
||||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||||
}
|
}
|
||||||
|
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
|
||||||
|
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
|
||||||
|
}
|
||||||
|
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
|
||||||
|
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
|
||||||
|
}
|
||||||
|
if command != "push" && command != "pr" && options.yes {
|
||||||
|
return options, false, execUsageError{"--yes is only valid with push or pr"}
|
||||||
|
}
|
||||||
return options, false, nil
|
return options, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,17 +820,187 @@ func writeChangesHelp(w io.Writer) error {
|
|||||||
_, err := fmt.Fprint(w, `Usage:
|
_, err := fmt.Fprint(w, `Usage:
|
||||||
zero changes inspect [flags]
|
zero changes inspect [flags]
|
||||||
zero changes commit [flags]
|
zero changes commit [flags]
|
||||||
|
zero changes push [flags]
|
||||||
|
zero changes pr [flags]
|
||||||
|
|
||||||
Inspects local git changes and optionally creates a commit.
|
Inspects, commits, pushes, and creates pull requests for local git changes.
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-C, --cwd <path> Workspace directory
|
-C, --cwd <path> Workspace directory
|
||||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||||
--diff-bytes <n> Maximum diff bytes to include
|
--diff-bytes <n> Maximum diff bytes to include
|
||||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||||
|
--dry-run Preview commit metadata / push without mutating git state
|
||||||
|
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
|
||||||
|
--force Use force-with-lease when pushing
|
||||||
|
--title <text> PR title
|
||||||
|
--body <text> PR body
|
||||||
|
--fill Automatically populate PR title and body from commits
|
||||||
|
--draft Create PR as a draft
|
||||||
|
--yes Confirm pushing to a default/protected branch
|
||||||
|
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||||
--dry-run Preview commit metadata without mutating git state
|
--dry-run Preview commit metadata without mutating git state
|
||||||
--json Print JSON output
|
--json Print JSON output
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
options, help, err := parseChangesArgs(args, "push")
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
if err := writeChangesHelp(stdout); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||||
|
Cwd: workspaceRoot,
|
||||||
|
Remote: options.remote,
|
||||||
|
Force: options.force,
|
||||||
|
DryRun: options.dryRun,
|
||||||
|
AllowPushDefaultBranch: options.yes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.json {
|
||||||
|
if err := writePrettyJSON(stdout, result); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
dryRunStr := ""
|
||||||
|
if options.dryRun {
|
||||||
|
dryRunStr = " (dry run)"
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
if result.Output != "" {
|
||||||
|
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||||
|
options, help, err := parseChangesArgs(args, "pr")
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
if help {
|
||||||
|
if err := writeChangesHelp(stdout); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
if !options.fill && options.title == "" {
|
||||||
|
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
|
||||||
|
}
|
||||||
|
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !options.json {
|
||||||
|
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||||
|
Cwd: workspaceRoot,
|
||||||
|
Remote: options.remote,
|
||||||
|
Force: options.force,
|
||||||
|
AllowPushDefaultBranch: options.yes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
|
||||||
|
}
|
||||||
|
if !options.json {
|
||||||
|
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
|
||||||
|
Cwd: workspaceRoot,
|
||||||
|
Fill: options.fill,
|
||||||
|
Draft: options.draft,
|
||||||
|
Title: options.title,
|
||||||
|
Body: options.body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return writeExecUsageError(stderr, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.json {
|
||||||
|
type prJSONResult struct {
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Remote string `json:"remote"`
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
res := prJSONResult{
|
||||||
|
Branch: pushResult.Branch,
|
||||||
|
Remote: pushResult.Remote,
|
||||||
|
Output: strings.TrimSpace(prResult.Output),
|
||||||
|
}
|
||||||
|
if err := writePrettyJSON(stdout, res); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
|
||||||
|
return exitCrash
|
||||||
|
}
|
||||||
|
return exitSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||||
|
var promptBuilder strings.Builder
|
||||||
|
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||||
|
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
|
||||||
|
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
|
||||||
|
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
|
||||||
|
promptBuilder.WriteString("Git Diff:\n")
|
||||||
|
promptBuilder.WriteString(summary.Diff)
|
||||||
|
|
||||||
|
request := zeroruntime.CompletionRequest{
|
||||||
|
Messages: []zeroruntime.Message{
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
stream, err := provider.StreamCompletion(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
collected := zeroruntime.CollectStream(ctx, stream)
|
||||||
|
if collected.Error != "" {
|
||||||
|
return "", fmt.Errorf("%s", collected.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.TrimSpace(collected.Text)
|
||||||
|
if strings.HasPrefix(msg, "```") {
|
||||||
|
if idx := strings.Index(msg, "\n"); idx != -1 {
|
||||||
|
msg = msg[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = strings.TrimSuffix(msg, "```")
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg == "" {
|
||||||
|
return "", fmt.Errorf("provider returned empty commit message")
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
|||||||
|
|
||||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ResolvedConfig{}, err
|
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||||
|
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||||
|
// back to an already-configured usable provider instead of treating this
|
||||||
|
// like a config with nothing set up at all.
|
||||||
|
return ResolvedConfig{Providers: providers}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResolvedConfig{
|
return ResolvedConfig{
|
||||||
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
|||||||
Tools: cfg.Tools,
|
Tools: cfg.Tools,
|
||||||
Swarm: cfg.Swarm,
|
Swarm: cfg.Swarm,
|
||||||
Preferences: cfg.Preferences,
|
Preferences: cfg.Preferences,
|
||||||
|
KeyBindings: cfg.KeyBindings,
|
||||||
LocalControl: cfg.LocalControl,
|
LocalControl: cfg.LocalControl,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
|||||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||||
}
|
}
|
||||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||||
|
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||||
@@ -246,8 +252,17 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
|||||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||||
// itself write access outside the workspace. Global config and CLI flags
|
// itself write access outside the workspace. Global config and CLI flags
|
||||||
// are the only config sources for write roots.
|
// are the only config sources for write roots.
|
||||||
|
//
|
||||||
|
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
|
||||||
|
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
|
||||||
|
// access for shell commands. Matches the AdditionalWriteRoots posture.
|
||||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||||
dst.Sandbox.Network = network
|
// Normalize: anything that isn't literally "allow" counts as deny.
|
||||||
|
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
|
||||||
|
dst.Sandbox.Network = network
|
||||||
|
}
|
||||||
|
// Silently ignore "allow" from project config — not an error, just
|
||||||
|
// a privilege the project scope does not have.
|
||||||
}
|
}
|
||||||
if src.Sandbox.BlockUnixSockets {
|
if src.Sandbox.BlockUnixSockets {
|
||||||
dst.Sandbox.BlockUnixSockets = true
|
dst.Sandbox.BlockUnixSockets = true
|
||||||
@@ -268,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
|||||||
if src.Swarm.MaxTeamSize != 0 {
|
if src.Swarm.MaxTeamSize != 0 {
|
||||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||||
}
|
}
|
||||||
|
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||||
// Local control is intentionally user-config/override only. A cloned project
|
// Local control is intentionally user-config/override only. A cloned project
|
||||||
// must not be able to make browser, desktop, or terminal automation tools
|
// must not be able to make browser, desktop, or terminal automation tools
|
||||||
// appear in the model's tool surface.
|
// appear in the model's tool surface.
|
||||||
@@ -574,9 +590,17 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
|
|||||||
if profile.Name == "" {
|
if profile.Name == "" {
|
||||||
profile.Name = string(providerKind)
|
profile.Name = string(providerKind)
|
||||||
}
|
}
|
||||||
|
// A non-official baseURL from the environment signals a proxy/gateway, so promote
|
||||||
|
// the first-party kind to its -compatible transport (which accepts a custom URL).
|
||||||
|
// Env-only by design: a custom baseURL in a project config.json is left as-is and
|
||||||
|
// rejected by Resolve, so an untrusted project can't redirect a first-party key to
|
||||||
|
// another host.
|
||||||
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
|
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
|
||||||
profile.ProviderKind = ProviderKindOpenAICompatible
|
profile.ProviderKind = ProviderKindOpenAICompatible
|
||||||
}
|
}
|
||||||
|
if providerKind == ProviderKindAnthropic && baseURL != "" && !isOfficialAnthropicBaseURL(baseURL) {
|
||||||
|
profile.ProviderKind = ProviderKindAnthropicCompat
|
||||||
|
}
|
||||||
// When the env supplies only credentials (no baseURL) for a provider name that
|
// When the env supplies only credentials (no baseURL) for a provider name that
|
||||||
// already exists, don't force the standard transport kind — a same-named
|
// already exists, don't force the standard transport kind — a same-named
|
||||||
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
|
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
|
||||||
@@ -656,6 +680,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
|||||||
cfg.Tools.deferThresholdSet = true
|
cfg.Tools.deferThresholdSet = true
|
||||||
}
|
}
|
||||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||||
|
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||||
for _, provider := range overrides.Providers {
|
for _, provider := range overrides.Providers {
|
||||||
mergeProvider(cfg, provider)
|
mergeProvider(cfg, provider)
|
||||||
}
|
}
|
||||||
@@ -691,6 +716,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||||
|
if src.ToggleDetailed != "" {
|
||||||
|
dst.ToggleDetailed = src.ToggleDetailed
|
||||||
|
}
|
||||||
|
if src.ToggleMouse != "" {
|
||||||
|
dst.ToggleMouse = src.ToggleMouse
|
||||||
|
}
|
||||||
|
if src.CycleReasoning != "" {
|
||||||
|
dst.CycleReasoning = src.CycleReasoning
|
||||||
|
}
|
||||||
|
if src.TogglePlan != "" {
|
||||||
|
dst.TogglePlan = src.TogglePlan
|
||||||
|
}
|
||||||
|
if src.ToggleSidebar != "" {
|
||||||
|
dst.ToggleSidebar = src.ToggleSidebar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||||
if len(src.Servers) == 0 {
|
if len(src.Servers) == 0 {
|
||||||
return
|
return
|
||||||
@@ -825,7 +868,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !activeFound {
|
if !activeFound {
|
||||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
// Return the successfully normalized list alongside the error (rather than
|
||||||
|
// nil) so a caller like the interactive TUI can still fall back to an
|
||||||
|
// already-configured, usable provider instead of forcing a full
|
||||||
|
// re-onboarding wizard just because none was marked active.
|
||||||
|
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||||
}
|
}
|
||||||
if active.Model == "" {
|
if active.Model == "" {
|
||||||
return nil, ProviderProfile{}, &setupFixableError{
|
return nil, ProviderProfile{}, &setupFixableError{
|
||||||
|
|||||||
@@ -617,6 +617,35 @@ func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveUsesAnthropicEnvBaseURLAsCompatible(t *testing.T) {
|
||||||
|
// ANTHROPIC_BASE_URL pointing at a proxy/gateway must resolve to an
|
||||||
|
// anthropic-compatible provider (mirroring OPENAI_BASE_URL), not the "requires
|
||||||
|
// official baseURL" rejection — issue #479. The env is user-controlled, so a
|
||||||
|
// custom URL there is a deliberate gateway choice, unlike a project config.json.
|
||||||
|
resolved, err := Resolve(ResolveOptions{
|
||||||
|
Env: map[string]string{
|
||||||
|
"ANTHROPIC_API_KEY": "sk-ant-env",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
|
||||||
|
"ANTHROPIC_MODEL": "claude-custom",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.ActiveProvider != "anthropic" {
|
||||||
|
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
|
||||||
|
}
|
||||||
|
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
|
||||||
|
t.Fatalf("ProviderKind = %q, want anthropic-compatible", resolved.Provider.ProviderKind)
|
||||||
|
}
|
||||||
|
if resolved.Provider.BaseURL != "https://gateway.example/anthropic" {
|
||||||
|
t.Fatalf("BaseURL = %q, want gateway URL", resolved.Provider.BaseURL)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-custom" {
|
||||||
|
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
|
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
|
||||||
resolved, err := Resolve(ResolveOptions{
|
resolved, err := Resolve(ResolveOptions{
|
||||||
Env: map[string]string{
|
Env: map[string]string{
|
||||||
@@ -916,6 +945,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||||
|
// Multiple providers configured (e.g. via `zero provider add`) but
|
||||||
|
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||||
|
// needs the normalized list to fall back to an already-usable provider
|
||||||
|
// instead of forcing a full re-onboarding wizard.
|
||||||
|
path := writeConfig(t, `{"providers":[
|
||||||
|
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||||
|
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||||
|
if !errors.Is(err, ErrNoActiveProvider) {
|
||||||
|
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||||
|
}
|
||||||
|
if len(resolved.Providers) != 2 {
|
||||||
|
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||||
path := writeConfig(t, `{
|
path := writeConfig(t, `{
|
||||||
"activeProvider": "custom",
|
"activeProvider": "custom",
|
||||||
@@ -1059,6 +1107,130 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "mini-cn",
|
||||||
|
"providers": [{
|
||||||
|
"name": "mini-cn",
|
||||||
|
"catalog_id": "minimaxi-cn"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
resolved, err := Resolve(ResolveOptions{
|
||||||
|
ProjectConfigPath: path,
|
||||||
|
Env: map[string]string{
|
||||||
|
"MINIMAXI_API_KEY": "sk-cn-mini",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Provider.CatalogID != "minimaxi-cn" {
|
||||||
|
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
|
||||||
|
}
|
||||||
|
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
|
||||||
|
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
|
||||||
|
}
|
||||||
|
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
|
||||||
|
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "MiniMax-M3" {
|
||||||
|
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
|
||||||
|
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKey != "sk-cn-mini" {
|
||||||
|
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
|
||||||
|
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
|
||||||
|
// for users who still target the China hosting; "zai" itself now points at
|
||||||
|
// api.z.ai (international). The two env-vars are intentionally distinct
|
||||||
|
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
|
||||||
|
// one side cannot be silently used against the other.
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "zai-cn",
|
||||||
|
"providers": [{
|
||||||
|
"name": "zai-cn",
|
||||||
|
"catalog_id": "zai-cn"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
resolved, err := Resolve(ResolveOptions{
|
||||||
|
ProjectConfigPath: path,
|
||||||
|
Env: map[string]string{
|
||||||
|
"ZHIPU_API_KEY": "sk-zhipu-cn",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Provider.CatalogID != "zai-cn" {
|
||||||
|
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
|
||||||
|
}
|
||||||
|
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||||
|
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||||
|
}
|
||||||
|
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
|
||||||
|
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "glm-4.5" {
|
||||||
|
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
|
||||||
|
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKey != "sk-zhipu-cn" {
|
||||||
|
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
|
||||||
|
// "zai" now resolves to the international endpoint; the catalog default
|
||||||
|
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
|
||||||
|
path := writeConfig(t, `{
|
||||||
|
"activeProvider": "zai-intl",
|
||||||
|
"providers": [{
|
||||||
|
"name": "zai-intl",
|
||||||
|
"catalog_id": "zai"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
resolved, err := Resolve(ResolveOptions{
|
||||||
|
ProjectConfigPath: path,
|
||||||
|
Env: map[string]string{
|
||||||
|
"ZAI_API_KEY": "sk-zai-intl",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Provider.CatalogID != "zai" {
|
||||||
|
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
|
||||||
|
}
|
||||||
|
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||||
|
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||||
|
}
|
||||||
|
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
|
||||||
|
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
|
||||||
|
}
|
||||||
|
if resolved.Provider.Model != "glm-4.5" {
|
||||||
|
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
|
||||||
|
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if resolved.Provider.APIKey != "sk-zai-intl" {
|
||||||
|
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
|
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
|
||||||
path := writeConfig(t, `{
|
path := writeConfig(t, `{
|
||||||
"activeProvider": "local",
|
"activeProvider": "local",
|
||||||
@@ -1273,6 +1445,45 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||||
|
// 1. Project config tries to set network to "allow" (should be ignored).
|
||||||
|
userPath := writeConfig(t, `{}`)
|
||||||
|
projectPath := writeConfig(t, `{
|
||||||
|
"sandbox": {"network": "allow"}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
resolved, err := Resolve(ResolveOptions{
|
||||||
|
UserConfigPath: userPath,
|
||||||
|
ProjectConfigPath: projectPath,
|
||||||
|
Env: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Sandbox.Network == "allow" {
|
||||||
|
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
|
||||||
|
userPath2 := writeConfig(t, `{
|
||||||
|
"sandbox": {"network": "allow"}
|
||||||
|
}`)
|
||||||
|
projectPath2 := writeConfig(t, `{
|
||||||
|
"sandbox": {"network": "deny"}
|
||||||
|
}`)
|
||||||
|
resolved2, err := Resolve(ResolveOptions{
|
||||||
|
UserConfigPath: userPath2,
|
||||||
|
ProjectConfigPath: projectPath2,
|
||||||
|
Env: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved2.Sandbox.Network != "deny" {
|
||||||
|
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveNotifyValid(t *testing.T) {
|
func TestResolveNotifyValid(t *testing.T) {
|
||||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||||
|
|||||||
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
|
|||||||
return p.Recaps == nil || *p.Recaps
|
return p.Recaps == nil || *p.Recaps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
|
||||||
|
// can remap. An empty string means "use the built-in default" for that action.
|
||||||
|
type KeyBindingDef string
|
||||||
|
|
||||||
|
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
|
||||||
|
// via config.json. Each field defaults to a sensible built-in when empty.
|
||||||
|
type KeyBindingsConfig struct {
|
||||||
|
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
|
||||||
|
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
|
||||||
|
// ToggleMouse toggles mouse capture release (default: ctrl+e).
|
||||||
|
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||||
|
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||||
|
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||||
|
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||||
|
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||||
|
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||||
|
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||||
// background probe is required during startup.
|
// background probe is required during startup.
|
||||||
@@ -206,6 +225,7 @@ type FileConfig struct {
|
|||||||
Tools ToolsConfig `json:"tools,omitempty"`
|
Tools ToolsConfig `json:"tools,omitempty"`
|
||||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||||
|
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
|||||||
Tools ToolsConfig `json:"tools,omitempty"`
|
Tools ToolsConfig `json:"tools,omitempty"`
|
||||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||||
|
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||||
}
|
}
|
||||||
raw := rawConfig{
|
raw := rawConfig{
|
||||||
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
|||||||
Tools: cfg.Tools,
|
Tools: cfg.Tools,
|
||||||
Swarm: cfg.Swarm,
|
Swarm: cfg.Swarm,
|
||||||
Preferences: cfg.Preferences,
|
Preferences: cfg.Preferences,
|
||||||
|
KeyBindings: cfg.KeyBindings,
|
||||||
}
|
}
|
||||||
if !cfg.LocalControl.Empty() {
|
if !cfg.LocalControl.Empty() {
|
||||||
raw.LocalControl = &cfg.LocalControl
|
raw.LocalControl = &cfg.LocalControl
|
||||||
@@ -256,6 +278,7 @@ type Overrides struct {
|
|||||||
Sandbox SandboxConfig
|
Sandbox SandboxConfig
|
||||||
Notify NotifyConfig
|
Notify NotifyConfig
|
||||||
Tools ToolsConfig
|
Tools ToolsConfig
|
||||||
|
KeyBindings KeyBindingsConfig
|
||||||
LocalControl LocalControlConfig
|
LocalControl LocalControlConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
|
|||||||
Tools ToolsConfig
|
Tools ToolsConfig
|
||||||
Swarm SwarmConfig
|
Swarm SwarmConfig
|
||||||
Preferences PreferencesConfig
|
Preferences PreferencesConfig
|
||||||
|
KeyBindings KeyBindingsConfig
|
||||||
LocalControl LocalControlConfig
|
LocalControl LocalControlConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
|||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Swarm SwarmConfig `json:"swarm"`
|
Swarm SwarmConfig `json:"swarm"`
|
||||||
Preferences PreferencesConfig `json:"preferences"`
|
Preferences PreferencesConfig `json:"preferences"`
|
||||||
|
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||||
LocalControl LocalControlConfig `json:"localControl"`
|
LocalControl LocalControlConfig `json:"localControl"`
|
||||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||||
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
|||||||
cfg.Tools = raw.Tools
|
cfg.Tools = raw.Tools
|
||||||
cfg.Swarm = raw.Swarm
|
cfg.Swarm = raw.Swarm
|
||||||
cfg.Preferences = raw.Preferences
|
cfg.Preferences = raw.Preferences
|
||||||
|
cfg.KeyBindings = raw.KeyBindings
|
||||||
cfg.LocalControl = raw.LocalControl
|
cfg.LocalControl = raw.LocalControl
|
||||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||||
|
|||||||
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
|||||||
// lspServersCheck reports which language servers ZERO would use are present on
|
// lspServersCheck reports which language servers ZERO would use are present on
|
||||||
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
||||||
// those languages — so the worst status is WARN, and each missing server gets an
|
// those languages — so the worst status is WARN, and each missing server gets an
|
||||||
// actionable install command keyed by its binary name.
|
// actionable install command keyed by its binary name. Only the tier-1 servers
|
||||||
|
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
|
||||||
|
// for breadth are listed informationally under missingOptional, because warning
|
||||||
|
// about a missing zls on a machine with no Zig code would be permanent noise.
|
||||||
func lspServersCheck(lookup func(string) (string, error)) Check {
|
func lspServersCheck(lookup func(string) (string, error)) Check {
|
||||||
if lookup == nil {
|
if lookup == nil {
|
||||||
lookup = exec.LookPath
|
lookup = exec.LookPath
|
||||||
}
|
}
|
||||||
|
core := map[string]bool{}
|
||||||
|
for _, binary := range lsp.CoreServerBinaries() {
|
||||||
|
core[binary] = true
|
||||||
|
}
|
||||||
present := map[string]any{}
|
present := map[string]any{}
|
||||||
missing := map[string]any{}
|
missing := map[string]any{}
|
||||||
|
missingOptional := map[string]any{}
|
||||||
for _, binary := range lsp.ServerBinaries() {
|
for _, binary := range lsp.ServerBinaries() {
|
||||||
if _, err := lookup(binary); err == nil {
|
if _, err := lookup(binary); err == nil {
|
||||||
present[binary] = "on PATH"
|
present[binary] = "on PATH"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
missing[binary] = lspRemedy(binary)
|
if core[binary] {
|
||||||
|
missing[binary] = lspRemedy(binary)
|
||||||
|
} else {
|
||||||
|
missingOptional[binary] = lspRemedy(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details := map[string]any{"present": present}
|
||||||
|
if len(missingOptional) > 0 {
|
||||||
|
details["missingOptional"] = missingOptional
|
||||||
}
|
}
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
|
message := "All core language servers are available on PATH."
|
||||||
"present": present,
|
if len(missingOptional) > 0 {
|
||||||
})
|
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
|
||||||
|
}
|
||||||
|
return check("lsp.servers", "LSP servers", StatusPass, message, details)
|
||||||
}
|
}
|
||||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
|
details["missing"] = missing
|
||||||
"present": present,
|
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
|
||||||
"missing": missing,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lspRemedy returns an actionable install command for a missing language-server
|
// lspRemedy returns an actionable install command for a missing language-server
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Package errhint classifies provider/model failures into a few user-actionable
|
||||||
|
// categories and turns them into a one-line "next step" hint.
|
||||||
|
//
|
||||||
|
// Provider errors already arrive with a classified string prefix from
|
||||||
|
// providerio.ClassifiedError ("auth error:", "rate limit error:", …); lower-level
|
||||||
|
// failures (DNS, TLS, timeouts, context-length) arrive as raw driver or library
|
||||||
|
// messages. Classify matches both so the interactive error row (TUI) and the
|
||||||
|
// `zero exec` provider-error path (CLI) can append one concrete next step instead
|
||||||
|
// of dumping an identical red blob for every failure mode.
|
||||||
|
package errhint
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Category buckets a provider/model failure into a small set of classes that each
|
||||||
|
// map to a distinct recovery action.
|
||||||
|
type Category int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Unknown means the error didn't match any known signature; callers should
|
||||||
|
// emit no hint rather than guess.
|
||||||
|
Unknown Category = iota
|
||||||
|
Auth
|
||||||
|
RateLimit
|
||||||
|
Connectivity
|
||||||
|
ModelNotFound
|
||||||
|
ContextOverflow
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerMarkers are the prefixes the provider layer attaches to every
|
||||||
|
// provider-originated failure: providerio.ClassifiedError emits "auth error:",
|
||||||
|
// "rate limit error:", "provider error:", and "provider request error:", and the
|
||||||
|
// streaming paths wrap transport/decoding failures as "provider stream error:".
|
||||||
|
// A UI surface's error can also be a *local* failure (a tool's "permission
|
||||||
|
// denied", a "file does not exist", a config error), so Classify only proceeds
|
||||||
|
// past this gate for messages that are recognizably from the provider — otherwise
|
||||||
|
// a broad substring like "does not exist" would attach a bogus /model hint to an
|
||||||
|
// unrelated local error.
|
||||||
|
var providerMarkers = []string{
|
||||||
|
"auth error:",
|
||||||
|
"rate limit error:",
|
||||||
|
"provider error:",
|
||||||
|
"provider request error:",
|
||||||
|
"provider stream error:",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify buckets err by scanning its message for known signatures. It is a
|
||||||
|
// deliberately conservative string heuristic — the provider layer has already
|
||||||
|
// discarded the numeric HTTP status by the time the error reaches a UI surface,
|
||||||
|
// so the message is all we have. It first gates on a provider-origin marker (see
|
||||||
|
// providerMarkers) so local failures never draw a provider hint, then sub-classifies.
|
||||||
|
// Order matters: more specific signatures are tested before broader ones (e.g.
|
||||||
|
// "context length" as overflow before the generic "timeout" as connectivity).
|
||||||
|
func Classify(err error) Category {
|
||||||
|
if err == nil {
|
||||||
|
return Unknown
|
||||||
|
}
|
||||||
|
m := strings.ToLower(err.Error())
|
||||||
|
if !containsAny(m, providerMarkers...) {
|
||||||
|
return Unknown
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||||
|
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||||
|
return Auth
|
||||||
|
case containsAny(m, "rate limit", "rate_limit", "too many requests", "quota",
|
||||||
|
"resource_exhausted", "overloaded") || containsStatusCode(m, "429", "529"):
|
||||||
|
return RateLimit
|
||||||
|
case containsAny(m, "context length", "context window", "maximum context", "context_length_exceeded",
|
||||||
|
"too many tokens", "prompt is too long", "reduce the length", "maximum context length"):
|
||||||
|
return ContextOverflow
|
||||||
|
case containsAny(m, "model not found", "model_not_found", "does not exist", "unknown model",
|
||||||
|
"no such model", "unsupported model", "invalid model", "model is not"):
|
||||||
|
return ModelNotFound
|
||||||
|
case containsAny(m, "dial tcp", "no such host", "connection refused", "network is unreachable",
|
||||||
|
"i/o timeout", "context deadline exceeded", "tls handshake", "connection reset",
|
||||||
|
"unexpected eof", "lookup ", "timeout"):
|
||||||
|
return Connectivity
|
||||||
|
default:
|
||||||
|
return Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUIHint returns a one-line hint referencing interactive slash commands, or ""
|
||||||
|
// when the category is Unknown. Meant to sit under the raw error in the live
|
||||||
|
// error row.
|
||||||
|
func TUIHint(err error) string {
|
||||||
|
switch Classify(err) {
|
||||||
|
case Auth:
|
||||||
|
return "API key rejected — run /provider to re-check your credentials"
|
||||||
|
case RateLimit:
|
||||||
|
return "Rate limited — wait a moment, or switch model with /model"
|
||||||
|
case Connectivity:
|
||||||
|
return "Can't reach the provider — run /doctor --connectivity"
|
||||||
|
case ModelNotFound:
|
||||||
|
return "Model unavailable — pick another with /model"
|
||||||
|
case ContextOverflow:
|
||||||
|
return "Context window full — run /compact to free space"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLIHint returns a one-line hint referencing `zero …` subcommands, or "" when the
|
||||||
|
// category is Unknown. Meant for the non-interactive `zero exec` error path, where
|
||||||
|
// slash commands don't apply.
|
||||||
|
func CLIHint(err error) string {
|
||||||
|
switch Classify(err) {
|
||||||
|
case Auth:
|
||||||
|
return "API key rejected — run `zero auth` or set the provider's API key, then retry"
|
||||||
|
case RateLimit:
|
||||||
|
return "Rate limited — wait a moment, or switch model with --model"
|
||||||
|
case Connectivity:
|
||||||
|
return "Can't reach the provider — run `zero doctor`"
|
||||||
|
case ModelNotFound:
|
||||||
|
return "Model unavailable — run `zero doctor` or pick another with --model"
|
||||||
|
case ContextOverflow:
|
||||||
|
return "Context window full — shorten the prompt or start a fresh session"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(haystack string, needles ...string) bool {
|
||||||
|
for _, n := range needles {
|
||||||
|
if strings.Contains(haystack, n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsStatusCode reports whether haystack contains any of the given HTTP
|
||||||
|
// status codes as a standalone number — not embedded in a longer digit run like
|
||||||
|
// "completed in 4290ms" or "request id 14015" — so an incidental number can't be
|
||||||
|
// mis-bucketed as an auth/rate-limit failure.
|
||||||
|
func containsStatusCode(haystack string, codes ...string) bool {
|
||||||
|
return HasStatusCode(haystack, codes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasStatusCode reports whether haystack contains any of the given HTTP status
|
||||||
|
// codes as a standalone number — not embedded in a longer digit run like
|
||||||
|
// "completed in 4290ms" or "request id 14015". Exported so other packages (e.g.
|
||||||
|
// the reconnect classifier) can gate on a status code without re-implementing
|
||||||
|
// the digit-boundary check.
|
||||||
|
func HasStatusCode(haystack string, codes ...string) bool {
|
||||||
|
for _, code := range codes {
|
||||||
|
for from := 0; ; {
|
||||||
|
rel := strings.Index(haystack[from:], code)
|
||||||
|
if rel < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos := from + rel
|
||||||
|
beforeOK := pos == 0 || !isASCIIDigit(haystack[pos-1])
|
||||||
|
end := pos + len(code)
|
||||||
|
afterOK := end >= len(haystack) || !isASCIIDigit(haystack[end])
|
||||||
|
if beforeOK && afterOK {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
from = pos + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigit(b byte) bool {
|
||||||
|
return b >= '0' && b <= '9'
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package errhint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassify(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg string
|
||||||
|
want Category
|
||||||
|
}{
|
||||||
|
{"nil-ish empty", "", Unknown},
|
||||||
|
{"providerio auth prefix", "auth error: your API key is missing or invalid — run `zero auth`", Auth},
|
||||||
|
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||||
|
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||||
|
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||||
|
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||||
|
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||||
|
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||||
|
{"context_length_exceeded", "provider request error: context_length_exceeded", ContextOverflow},
|
||||||
|
{"model not found", "provider request error: 404 the model `gpt-9` does not exist", ModelNotFound},
|
||||||
|
{"unknown model", "provider request error: unknown model: sonnet-99", ModelNotFound},
|
||||||
|
{"dns failure", "provider stream error: Post \"https://api...\": dial tcp: lookup api.foo.com: no such host", Connectivity},
|
||||||
|
{"connection refused", "provider stream error: dial tcp 127.0.0.1:443: connection refused", Connectivity},
|
||||||
|
{"deadline", "provider stream error: context deadline exceeded (Client.Timeout exceeded)", Connectivity},
|
||||||
|
{"provider-marked but no sub-signature", "provider error: something totally unexpected happened", Unknown},
|
||||||
|
|
||||||
|
// An incidental number that merely contains a status-code digit run must NOT
|
||||||
|
// be mis-bucketed (digit-boundary check), even behind a provider marker.
|
||||||
|
{"incidental latency number", "provider error: request completed in 4290ms then failed", Unknown},
|
||||||
|
{"incidental request id", "provider error: request id 14015 failed unexpectedly", Unknown},
|
||||||
|
{"standalone 401 still auth", "provider request error: got 401 from upstream", Auth},
|
||||||
|
{"standalone 429 still rate limit", "provider error: 429 returned", RateLimit},
|
||||||
|
|
||||||
|
// Local (non-provider) failures must NOT be classified — no provider marker,
|
||||||
|
// so no bogus recovery hint attaches to them.
|
||||||
|
{"local fs permission denied", "open /etc/shadow: permission denied", Unknown},
|
||||||
|
{"local file missing", "stat foo.go: no such file or directory", Unknown},
|
||||||
|
{"local model config typo", "unknown model: sonnet-99", Unknown},
|
||||||
|
{"tool timeout local", "tool bash timed out after 600s", Unknown},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Classify(errors.New(tc.msg)); got != tc.want {
|
||||||
|
t.Fatalf("Classify(%q) = %v, want %v", tc.msg, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyNil(t *testing.T) {
|
||||||
|
if got := Classify(nil); got != Unknown {
|
||||||
|
t.Fatalf("Classify(nil) = %v, want Unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context-overflow must win over the connectivity "timeout" catch-all: a message
|
||||||
|
// mentioning both "context length" and a timeout is an overflow, not a network
|
||||||
|
// problem.
|
||||||
|
func TestContextOverflowBeatsConnectivity(t *testing.T) {
|
||||||
|
err := errors.New("provider request error: maximum context length exceeded; request timeout")
|
||||||
|
if got := Classify(err); got != ContextOverflow {
|
||||||
|
t.Fatalf("Classify = %v, want ContextOverflow", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHintsPresentForKnownCategories(t *testing.T) {
|
||||||
|
known := []error{
|
||||||
|
errors.New("auth error: bad key"),
|
||||||
|
errors.New("rate limit error: 429"),
|
||||||
|
errors.New("provider stream error: dial tcp: no such host"),
|
||||||
|
errors.New("provider request error: model does not exist"),
|
||||||
|
errors.New("provider request error: maximum context length is 128000 tokens"),
|
||||||
|
}
|
||||||
|
for _, err := range known {
|
||||||
|
if h := TUIHint(err); strings.TrimSpace(h) == "" {
|
||||||
|
t.Fatalf("TUIHint(%q) empty, want a hint", err)
|
||||||
|
}
|
||||||
|
if h := CLIHint(err); strings.TrimSpace(h) == "" {
|
||||||
|
t.Fatalf("CLIHint(%q) empty, want a hint", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unknown yields no hint on either surface.
|
||||||
|
unknown := errors.New("provider error: mystery")
|
||||||
|
if h := TUIHint(unknown); h != "" {
|
||||||
|
t.Fatalf("TUIHint(unknown) = %q, want empty", h)
|
||||||
|
}
|
||||||
|
if h := CLIHint(unknown); h != "" {
|
||||||
|
t.Fatalf("CLIHint(unknown) = %q, want empty", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
-15
@@ -9,26 +9,134 @@ import (
|
|||||||
|
|
||||||
// serverCommands maps a file extension to the language-server command (argv) ZERO
|
// serverCommands maps a file extension to the language-server command (argv) ZERO
|
||||||
// will spawn. The first element is the binary looked up on PATH; missing binaries
|
// will spawn. The first element is the binary looked up on PATH; missing binaries
|
||||||
// are not an error, the agent just degrades to text-only for that file.
|
// are not an error, the agent just degrades to text-only for that file. Every
|
||||||
|
// command is the language's community-standard server invoked in stdio mode, so
|
||||||
|
// a wrong guess can never spawn something surprising — only fail the PATH check.
|
||||||
var serverCommands = map[string][]string{
|
var serverCommands = map[string][]string{
|
||||||
".go": {"gopls", "serve"},
|
".go": {"gopls", "serve"},
|
||||||
".ts": {"typescript-language-server", "--stdio"},
|
".ts": {"typescript-language-server", "--stdio"},
|
||||||
".tsx": {"typescript-language-server", "--stdio"},
|
".tsx": {"typescript-language-server", "--stdio"},
|
||||||
".js": {"typescript-language-server", "--stdio"},
|
".mts": {"typescript-language-server", "--stdio"},
|
||||||
".jsx": {"typescript-language-server", "--stdio"},
|
".cts": {"typescript-language-server", "--stdio"},
|
||||||
".py": {"pyright-langserver", "--stdio"},
|
".js": {"typescript-language-server", "--stdio"},
|
||||||
".rs": {"rust-analyzer"},
|
".jsx": {"typescript-language-server", "--stdio"},
|
||||||
|
".mjs": {"typescript-language-server", "--stdio"},
|
||||||
|
".cjs": {"typescript-language-server", "--stdio"},
|
||||||
|
".py": {"pyright-langserver", "--stdio"},
|
||||||
|
".pyi": {"pyright-langserver", "--stdio"},
|
||||||
|
".rs": {"rust-analyzer"},
|
||||||
|
".c": {"clangd"},
|
||||||
|
".h": {"clangd"},
|
||||||
|
".cpp": {"clangd"},
|
||||||
|
".cc": {"clangd"},
|
||||||
|
".cxx": {"clangd"},
|
||||||
|
".hpp": {"clangd"},
|
||||||
|
".java": {"jdtls"},
|
||||||
|
".kt": {"kotlin-language-server"},
|
||||||
|
".kts": {"kotlin-language-server"},
|
||||||
|
".rb": {"ruby-lsp"},
|
||||||
|
".php": {"intelephense", "--stdio"},
|
||||||
|
".zig": {"zls"},
|
||||||
|
".lua": {"lua-language-server"},
|
||||||
|
".ex": {"elixir-ls"},
|
||||||
|
".exs": {"elixir-ls"},
|
||||||
|
".hs": {"haskell-language-server-wrapper", "--lsp"},
|
||||||
|
".swift": {"sourcekit-lsp"},
|
||||||
|
".ml": {"ocamllsp"},
|
||||||
|
".mli": {"ocamllsp"},
|
||||||
|
".scala": {"metals"},
|
||||||
|
".clj": {"clojure-lsp"},
|
||||||
|
".cljs": {"clojure-lsp"},
|
||||||
|
".cljc": {"clojure-lsp"},
|
||||||
|
".dart": {"dart", "language-server"},
|
||||||
|
".gleam": {"gleam", "lsp"},
|
||||||
|
".nix": {"nixd"},
|
||||||
|
".tf": {"terraform-ls", "serve"},
|
||||||
|
".sh": {"bash-language-server", "start"},
|
||||||
|
".bash": {"bash-language-server", "start"},
|
||||||
|
".yaml": {"yaml-language-server", "--stdio"},
|
||||||
|
".yml": {"yaml-language-server", "--stdio"},
|
||||||
|
".json": {"vscode-json-language-server", "--stdio"},
|
||||||
|
".css": {"vscode-css-language-server", "--stdio"},
|
||||||
|
".scss": {"vscode-css-language-server", "--stdio"},
|
||||||
|
".less": {"vscode-css-language-server", "--stdio"},
|
||||||
|
".html": {"vscode-html-language-server", "--stdio"},
|
||||||
|
".svelte": {"svelteserver", "--stdio"},
|
||||||
|
".vue": {"vue-language-server", "--stdio"},
|
||||||
|
".astro": {"astro-ls", "--stdio"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// languageIDs maps a file extension to the LSP languageId used in didOpen.
|
// languageIDs maps a file extension to the LSP languageId used in didOpen.
|
||||||
var languageIDs = map[string]string{
|
var languageIDs = map[string]string{
|
||||||
".go": "go",
|
".go": "go",
|
||||||
".ts": "typescript",
|
".ts": "typescript",
|
||||||
".tsx": "typescriptreact",
|
".tsx": "typescriptreact",
|
||||||
".js": "javascript",
|
".mts": "typescript",
|
||||||
".jsx": "javascriptreact",
|
".cts": "typescript",
|
||||||
".py": "python",
|
".js": "javascript",
|
||||||
".rs": "rust",
|
".jsx": "javascriptreact",
|
||||||
|
".mjs": "javascript",
|
||||||
|
".cjs": "javascript",
|
||||||
|
".py": "python",
|
||||||
|
".pyi": "python",
|
||||||
|
".rs": "rust",
|
||||||
|
".c": "c",
|
||||||
|
".h": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".cc": "cpp",
|
||||||
|
".cxx": "cpp",
|
||||||
|
".hpp": "cpp",
|
||||||
|
".java": "java",
|
||||||
|
".kt": "kotlin",
|
||||||
|
".kts": "kotlin",
|
||||||
|
".rb": "ruby",
|
||||||
|
".php": "php",
|
||||||
|
".zig": "zig",
|
||||||
|
".lua": "lua",
|
||||||
|
".ex": "elixir",
|
||||||
|
".exs": "elixir",
|
||||||
|
".hs": "haskell",
|
||||||
|
".swift": "swift",
|
||||||
|
".ml": "ocaml",
|
||||||
|
".mli": "ocaml",
|
||||||
|
".scala": "scala",
|
||||||
|
".clj": "clojure",
|
||||||
|
".cljs": "clojure",
|
||||||
|
".cljc": "clojure",
|
||||||
|
".dart": "dart",
|
||||||
|
".gleam": "gleam",
|
||||||
|
".nix": "nix",
|
||||||
|
".tf": "terraform",
|
||||||
|
".sh": "shellscript",
|
||||||
|
".bash": "shellscript",
|
||||||
|
".yaml": "yaml",
|
||||||
|
".yml": "yaml",
|
||||||
|
".json": "json",
|
||||||
|
".css": "css",
|
||||||
|
".scss": "scss",
|
||||||
|
".less": "less",
|
||||||
|
".html": "html",
|
||||||
|
".svelte": "svelte",
|
||||||
|
".vue": "vue",
|
||||||
|
".astro": "astro",
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
|
||||||
|
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
|
||||||
|
// configured above for breadth is reported informationally only — warning about
|
||||||
|
// a missing zls on a machine with no Zig code would be permanent noise.
|
||||||
|
var coreServerBinaries = []string{
|
||||||
|
"gopls",
|
||||||
|
"typescript-language-server",
|
||||||
|
"pyright-langserver",
|
||||||
|
"rust-analyzer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
|
||||||
|
func CoreServerBinaries() []string {
|
||||||
|
binaries := append([]string(nil), coreServerBinaries...)
|
||||||
|
sort.Strings(binaries)
|
||||||
|
return binaries
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerBinaries returns the unique set of language-server binaries ZERO may
|
// ServerBinaries returns the unique set of language-server binaries ZERO may
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.URL.Path == "/mcp" {
|
||||||
|
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
|
||||||
|
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
|
||||||
|
http.Error(response, "bad request", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||||
|
t.Errorf("X-Api-Key = %q, want secret", got)
|
||||||
|
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := readHTTPRPCMessage(t, request)
|
||||||
|
switch message.Method {
|
||||||
|
case "initialize":
|
||||||
|
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
|
||||||
|
case "notifications/initialized":
|
||||||
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
default:
|
||||||
|
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer testServer.Close()
|
||||||
|
|
||||||
|
client, err := Connect(ctx, Server{
|
||||||
|
Name: "docs",
|
||||||
|
Type: ServerTypeHTTP,
|
||||||
|
URL: testServer.URL + "/mcp",
|
||||||
|
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connect() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := client.Close(); err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var targetHits int32
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
atomic.AddInt32(&targetHits, 1)
|
||||||
|
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||||
|
t.Errorf("redirect target received X-Api-Key = %q", got)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||||
|
}))
|
||||||
|
defer redirector.Close()
|
||||||
|
|
||||||
|
_, err := Connect(ctx, Server{
|
||||||
|
Name: "docs",
|
||||||
|
Type: ServerTypeHTTP,
|
||||||
|
URL: redirector.URL + "/mcp",
|
||||||
|
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||||
|
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||||
|
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var targetHits int32
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
atomic.AddInt32(&targetHits, 1)
|
||||||
|
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||||
|
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||||
|
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
|
||||||
|
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
|
||||||
|
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
|
||||||
|
http.Error(response, "bad request", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
|
||||||
|
if flusher, ok := response.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
_, err := Connect(ctx, Server{
|
||||||
|
Name: "docs",
|
||||||
|
Type: ServerTypeSSE,
|
||||||
|
URL: stream.URL + "/sse",
|
||||||
|
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
|
||||||
|
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||||
|
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
|
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
||||||
}
|
}
|
||||||
return baseURL.ResolveReference(endpointURL).String(), nil
|
resolvedURL := baseURL.ResolveReference(endpointURL)
|
||||||
|
if !sameMCPOrigin(baseURL, resolvedURL) {
|
||||||
|
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
|
||||||
|
}
|
||||||
|
return resolvedURL.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcResponseKey(id any) string {
|
func rpcResponseKey(id any) string {
|
||||||
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
|||||||
|
|
||||||
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
|
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
|
||||||
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
|
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
|
||||||
// not declare OAuth get the default client unchanged.
|
// not declare OAuth use the default transport with the same MCP redirect guard.
|
||||||
func oauthHTTPClient(server Server) (*http.Client, error) {
|
func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||||
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
||||||
return http.DefaultClient, nil
|
return mcpHTTPClient(server, nil), nil
|
||||||
}
|
}
|
||||||
store, err := NewTokenStore(TokenStoreOptions{})
|
store, err := NewTokenStore(TokenStoreOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
|
|||||||
httpClient: http.DefaultClient,
|
httpClient: http.DefaultClient,
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
|
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
|
||||||
|
source := &fakeTokenSource{}
|
||||||
|
source.access.Store("access-secret")
|
||||||
|
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
|
||||||
|
|
||||||
|
var targetHits int32
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&targetHits, 1)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("redirect target received Authorization = %q", got)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||||
|
}))
|
||||||
|
defer redirector.Close()
|
||||||
|
|
||||||
|
client := mcpHTTPClient(
|
||||||
|
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
|
||||||
|
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
|
||||||
|
)
|
||||||
|
resp, err := client.Get(redirector.URL + "/mcp")
|
||||||
|
if resp != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||||
|
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||||
|
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "https default port",
|
||||||
|
left: "https://example.test/mcp",
|
||||||
|
right: "https://EXAMPLE.test:443/other",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http default port",
|
||||||
|
left: "http://example.test/mcp",
|
||||||
|
right: "http://example.test:80/other",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different scheme",
|
||||||
|
left: "https://example.test/mcp",
|
||||||
|
right: "http://example.test:443/other",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different port",
|
||||||
|
left: "https://example.test/mcp",
|
||||||
|
right: "https://example.test:8443/other",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
left, err := url.Parse(tc.left)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
right, err := url.Parse(tc.right)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := sameMCPOrigin(left, right); got != tc.want {
|
||||||
|
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
base string
|
||||||
|
endpoint string
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative endpoint",
|
||||||
|
base: "https://example.test/sse",
|
||||||
|
endpoint: "/messages",
|
||||||
|
want: "https://example.test/messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute same origin",
|
||||||
|
base: "https://example.test/sse",
|
||||||
|
endpoint: "https://example.test/messages",
|
||||||
|
want: "https://example.test/messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default port equivalent",
|
||||||
|
base: "https://example.test/sse",
|
||||||
|
endpoint: "https://example.test:443/messages",
|
||||||
|
want: "https://example.test:443/messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cross origin",
|
||||||
|
base: "https://example.test/sse",
|
||||||
|
endpoint: "https://evil.test/messages",
|
||||||
|
wantErr: "differs from configured server origin",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
||||||
// Regression: a server without auth must not get a bearer header or any
|
// Regression: a server without auth must not get a bearer header or any
|
||||||
// OAuth round tripper, and must connect normally.
|
// OAuth round tripper, and must connect normally.
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxMCPRedirects = 10
|
||||||
|
|
||||||
|
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: checkMCPRedirect(server),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
|
||||||
|
return func(request *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= maxMCPRedirects {
|
||||||
|
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
|
||||||
|
}
|
||||||
|
if len(via) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !sameMCPOrigin(via[0].URL, request.URL) {
|
||||||
|
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||||
|
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||||
|
effectiveMCPPort(left) == effectiveMCPPort(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveMCPPort(value *url.URL) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if port := value.Port(); port != "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
switch strings.ToLower(value.Scheme) {
|
||||||
|
case "http":
|
||||||
|
return "80"
|
||||||
|
case "https":
|
||||||
|
return "443"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpOrigin(value *url.URL) string {
|
||||||
|
if value == nil {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
host := value.Hostname()
|
||||||
|
port := effectiveMCPPort(value)
|
||||||
|
if port != "" {
|
||||||
|
host = net.JoinHostPort(host, port)
|
||||||
|
}
|
||||||
|
if value.Scheme == "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return strings.ToLower(value.Scheme) + "://" + host
|
||||||
|
}
|
||||||
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
|
|||||||
redirect := parsed.Query().Get("redirect_uri")
|
redirect := parsed.Query().Get("redirect_uri")
|
||||||
go func() {
|
go func() {
|
||||||
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
|
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
|
||||||
_, _ = http.Get(cbURL)
|
for i := 0; i < 20; i++ {
|
||||||
|
resp, err := http.Get(cbURL)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
|
|||||||
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
||||||
}
|
}
|
||||||
decorateModelDepth(entries)
|
decorateModelDepth(entries)
|
||||||
|
// Overlay volatile facts (context limits, base pricing) from a cached
|
||||||
|
// models.dev snapshot when one is present and fresh — see modelsdev.go.
|
||||||
|
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
|
||||||
|
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
|
||||||
return cloneModelEntries(entries)
|
return cloneModelEntries(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package modelregistry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Live models.dev overlay for the curated catalog. The hand-maintained
|
||||||
|
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
|
||||||
|
// match patterns, deprecations, escalation targets — but its VOLATILE facts
|
||||||
|
// (context window, max output tokens, per-million pricing) go stale between
|
||||||
|
// releases. When a cached snapshot of https://models.dev/api.json is present,
|
||||||
|
// those fields are refreshed from it at registry construction; everything else
|
||||||
|
// stays curated. The overlay never adds models (an auto-added entry would lack
|
||||||
|
// aliases, match patterns, and provider wiring) and never touches the network
|
||||||
|
// on the registry hot path — fetching happens only in the explicit background
|
||||||
|
// refresh, cached to disk with a TTL.
|
||||||
|
|
||||||
|
const (
|
||||||
|
modelsDevDefaultURL = "https://models.dev/api.json"
|
||||||
|
// modelsDevRefreshAfter is how old the cache may get before a background
|
||||||
|
// refresh re-fetches it.
|
||||||
|
modelsDevRefreshAfter = 24 * time.Hour
|
||||||
|
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
|
||||||
|
// this the curated catalog (updated with the binary) is likely fresher than
|
||||||
|
// the snapshot, so a stale file is ignored rather than trusted.
|
||||||
|
modelsDevMaxAge = 7 * 24 * time.Hour
|
||||||
|
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
|
||||||
|
modelsDevFetchWindow = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelsDevModel is the subset of a models.dev model record the overlay uses.
|
||||||
|
type modelsDevModel struct {
|
||||||
|
Limit struct {
|
||||||
|
Context int `json:"context"`
|
||||||
|
Output int `json:"output"`
|
||||||
|
} `json:"limit"`
|
||||||
|
Cost struct {
|
||||||
|
Input float64 `json:"input"`
|
||||||
|
Output float64 `json:"output"`
|
||||||
|
CacheRead float64 `json:"cache_read"`
|
||||||
|
CacheWrite float64 `json:"cache_write"`
|
||||||
|
} `json:"cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsDevProvider matches one provider object in api.json.
|
||||||
|
type modelsDevProvider struct {
|
||||||
|
Models map[string]modelsDevModel `json:"models"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseModelsDev decodes an api.json document into provider-slug -> api-model
|
||||||
|
// -> record. api.json is a top-level object keyed by provider id.
|
||||||
|
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
|
||||||
|
var doc map[string]modelsDevProvider
|
||||||
|
if err := json.Unmarshal(data, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
|
||||||
|
}
|
||||||
|
providers := make(map[string]map[string]modelsDevModel, len(doc))
|
||||||
|
for slug, provider := range doc {
|
||||||
|
if len(provider.Models) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
providers[slug] = provider.Models
|
||||||
|
}
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
|
||||||
|
}
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
|
||||||
|
// worth checking. Only first-party slugs are used: router entries on models.dev
|
||||||
|
// carry generic or stale numbers.
|
||||||
|
func modelsDevSlugs(kind ProviderKind) []string {
|
||||||
|
switch kind {
|
||||||
|
case ProviderAnthropic:
|
||||||
|
return []string{"anthropic"}
|
||||||
|
case ProviderOpenAI:
|
||||||
|
return []string{"openai"}
|
||||||
|
case ProviderGoogle:
|
||||||
|
return []string{"google", "google-vertex"}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyModelsDevOverrides refreshes each curated entry's context limits and
|
||||||
|
// base pricing from the snapshot, when the snapshot knows the model. Tiered
|
||||||
|
// pricing is never touched: models.dev has no tier data, and mixing a live
|
||||||
|
// base rate with curated tiers would misprice the tier boundaries.
|
||||||
|
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
entry := &entries[i]
|
||||||
|
var record modelsDevModel
|
||||||
|
found := false
|
||||||
|
for _, slug := range modelsDevSlugs(entry.Provider) {
|
||||||
|
if models, ok := providers[slug]; ok {
|
||||||
|
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
|
||||||
|
record = candidate
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Limit.Context > 0 {
|
||||||
|
entry.ContextLimits.ContextWindow = record.Limit.Context
|
||||||
|
}
|
||||||
|
if record.Limit.Output > 0 {
|
||||||
|
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
|
||||||
|
}
|
||||||
|
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
|
||||||
|
entry.Cost.InputPerMillion = record.Cost.Input
|
||||||
|
entry.Cost.OutputPerMillion = record.Cost.Output
|
||||||
|
if record.Cost.CacheRead > 0 {
|
||||||
|
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
|
||||||
|
}
|
||||||
|
if record.Cost.CacheWrite > 0 {
|
||||||
|
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
|
||||||
|
}
|
||||||
|
entry.Cost.Source = "models.dev/api.json (cached)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
|
||||||
|
// overrides it (used by tests and unusual setups).
|
||||||
|
func modelsDevCachePath() (string, error) {
|
||||||
|
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
|
||||||
|
return override, nil
|
||||||
|
}
|
||||||
|
base, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "zero", "modelsdev.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
modelsDevOnce sync.Once
|
||||||
|
modelsDevCached map[string]map[string]modelsDevModel
|
||||||
|
modelsDevEnabled atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnableModelsDevOverlay opts the process into applying the cached models.dev
|
||||||
|
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
|
||||||
|
// consumers and tests that never do get the curated catalog byte-identical to
|
||||||
|
// before, so hermetic tests can't be perturbed by a cache file on the machine.
|
||||||
|
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
|
||||||
|
func EnableModelsDevOverlay() {
|
||||||
|
modelsDevEnabled.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedModelsDevProviders loads the cached snapshot once per process. Not
|
||||||
|
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
|
||||||
|
// the curated catalog is used untouched. Read once deliberately:
|
||||||
|
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
|
||||||
|
// re-stat the file every time; a background refresh benefits the NEXT process.
|
||||||
|
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||||
|
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
modelsDevOnce.Do(func() {
|
||||||
|
path, err := modelsDevCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providers, err := parseModelsDev(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelsDevCached = providers
|
||||||
|
})
|
||||||
|
return modelsDevCached
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||||
|
// disables the overlay.
|
||||||
|
func resetModelsDevCacheForTest() {
|
||||||
|
modelsDevOnce = sync.Once{}
|
||||||
|
modelsDevCached = nil
|
||||||
|
modelsDevEnabled.Store(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||||
|
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||||
|
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||||
|
// current process's registry (see cachedModelsDevProviders). Disabled entirely
|
||||||
|
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
|
||||||
|
func RefreshModelsDevCache(ctx context.Context) error {
|
||||||
|
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path, err := modelsDevCachePath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
|
||||||
|
if url == "" {
|
||||||
|
url = modelsDevDefaultURL
|
||||||
|
}
|
||||||
|
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
|
||||||
|
defer cancel()
|
||||||
|
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Validate before persisting: a bad body must never clobber a good cache.
|
||||||
|
if _, err := parseModelsDev(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := temp.Write(data); err != nil {
|
||||||
|
_ = temp.Close()
|
||||||
|
_ = os.Remove(temp.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := temp.Close(); err != nil {
|
||||||
|
_ = os.Remove(temp.Name())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temp.Name(), path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package modelregistry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleModelsDev = `{
|
||||||
|
"anthropic": {
|
||||||
|
"id": "anthropic",
|
||||||
|
"models": {
|
||||||
|
"claude-sonnet-4-5-20250929": {
|
||||||
|
"limit": {"context": 1000000, "output": 64000},
|
||||||
|
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"google": {
|
||||||
|
"id": "google",
|
||||||
|
"models": {
|
||||||
|
"gemini-2.5-pro": {
|
||||||
|
"limit": {"context": 2097152, "output": 65536},
|
||||||
|
"cost": {"input": 9.99, "output": 9.99}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestParseModelsDev(t *testing.T) {
|
||||||
|
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected anthropic sonnet record")
|
||||||
|
}
|
||||||
|
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
|
||||||
|
t.Fatalf("unexpected record: %+v", record)
|
||||||
|
}
|
||||||
|
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
|
||||||
|
t.Fatal("empty document must be rejected")
|
||||||
|
}
|
||||||
|
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
|
||||||
|
t.Fatal("malformed document must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyModelsDevOverrides(t *testing.T) {
|
||||||
|
// Point the cache at a non-existent file so DefaultModelEntries returns the
|
||||||
|
// pure curated catalog, then apply the sample snapshot explicitly.
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
|
||||||
|
resetModelsDevCacheForTest()
|
||||||
|
t.Cleanup(resetModelsDevCacheForTest)
|
||||||
|
|
||||||
|
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
|
||||||
|
|
||||||
|
var sonnet, geminiPro, opus ModelEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
switch entry.ID {
|
||||||
|
case "claude-sonnet-4.5":
|
||||||
|
sonnet = entry
|
||||||
|
case "gemini-2.5-pro":
|
||||||
|
geminiPro = entry
|
||||||
|
case "claude-opus-4.1":
|
||||||
|
opus = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known model: limits and base pricing refreshed from the snapshot.
|
||||||
|
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
|
||||||
|
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
|
||||||
|
}
|
||||||
|
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
|
||||||
|
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
|
||||||
|
}
|
||||||
|
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
|
||||||
|
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
|
||||||
|
// has curated tiers and the snapshot's flat 9.99 would misprice them).
|
||||||
|
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
|
||||||
|
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
|
||||||
|
}
|
||||||
|
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
|
||||||
|
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model absent from the snapshot: untouched.
|
||||||
|
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
|
||||||
|
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
|
||||||
|
hits := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits++
|
||||||
|
_, _ = w.Write([]byte(sampleModelsDev))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||||
|
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||||
|
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||||
|
|
||||||
|
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("expected 1 fetch, got %d", hits)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cachePath); err != nil {
|
||||||
|
t.Fatalf("cache file missing: %v", err)
|
||||||
|
}
|
||||||
|
// Fresh cache: second call must not re-fetch.
|
||||||
|
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits != 1 {
|
||||||
|
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("not json"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stale := time.Now().Add(-48 * time.Hour)
|
||||||
|
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||||
|
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||||
|
|
||||||
|
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||||
|
t.Fatal("bad body must return an error")
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil || string(content) != sampleModelsDev {
|
||||||
|
t.Fatal("bad fetch must not clobber the existing cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("fetch must not happen when disabled")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
|
||||||
|
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||||
|
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||||
|
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||||
|
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
|
||||||
|
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||||
|
resetModelsDevCacheForTest()
|
||||||
|
t.Cleanup(resetModelsDevCacheForTest)
|
||||||
|
EnableModelsDevOverlay()
|
||||||
|
|
||||||
|
if providers := cachedModelsDevProviders(); providers != nil {
|
||||||
|
t.Fatal("stale cache must be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||||
|
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||||
|
resetModelsDevCacheForTest()
|
||||||
|
t.Cleanup(resetModelsDevCacheForTest)
|
||||||
|
|
||||||
|
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
|
||||||
|
// library consumers and hermetic tests get the pure curated catalog.
|
||||||
|
if providers := cachedModelsDevProviders(); providers != nil {
|
||||||
|
t.Fatal("overlay must be opt-in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||||
|
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||||
|
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||||
|
resetModelsDevCacheForTest()
|
||||||
|
t.Cleanup(resetModelsDevCacheForTest)
|
||||||
|
EnableModelsDevOverlay()
|
||||||
|
|
||||||
|
for _, entry := range DefaultModelEntries() {
|
||||||
|
if entry.ID == "claude-sonnet-4.5" {
|
||||||
|
if entry.ContextLimits.ContextWindow != 1_000_000 {
|
||||||
|
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("claude-sonnet-4.5 not found")
|
||||||
|
}
|
||||||
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return writeNewSecretFile(path)
|
return writeNewSecretFile(path)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, os.ErrExist) {
|
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||||
|
// "delete pending" state, so an O_EXCL create races it with
|
||||||
|
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||||
|
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||||
|
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||||
|
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||||
}
|
}
|
||||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||||
|
|||||||
@@ -123,8 +123,10 @@ var descriptors = []Descriptor{
|
|||||||
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
||||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||||
|
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||||
|
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
|
||||||
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
|
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
|
||||||
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
|
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
|
||||||
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
|
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
|
||||||
@@ -133,7 +135,11 @@ var descriptors = []Descriptor{
|
|||||||
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
||||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
|
||||||
|
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
|
||||||
|
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
|
||||||
|
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
|
||||||
|
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
|
||||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ var expectedCatalogIDs = []string{
|
|||||||
"together",
|
"together",
|
||||||
"dashscope",
|
"dashscope",
|
||||||
"moonshot",
|
"moonshot",
|
||||||
|
"longcat",
|
||||||
"nvidia-nim",
|
"nvidia-nim",
|
||||||
"minimax",
|
"minimax",
|
||||||
|
"minimaxi-cn",
|
||||||
"mistral",
|
"mistral",
|
||||||
"github",
|
"github",
|
||||||
"bedrock",
|
"bedrock",
|
||||||
@@ -34,6 +36,10 @@ var expectedCatalogIDs = []string{
|
|||||||
"xiaomi-mimo",
|
"xiaomi-mimo",
|
||||||
"bankr",
|
"bankr",
|
||||||
"zai",
|
"zai",
|
||||||
|
"zai-cn",
|
||||||
|
"kilocode",
|
||||||
|
"opencode",
|
||||||
|
"opencode-go",
|
||||||
"atomic-chat",
|
"atomic-chat",
|
||||||
"chatgpt-proxy",
|
"chatgpt-proxy",
|
||||||
"custom-openai-compatible",
|
"custom-openai-compatible",
|
||||||
@@ -96,6 +102,28 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLongCatDescriptor(t *testing.T) {
|
||||||
|
descriptor, err := Require("longcat")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Require(longcat) error = %v", err)
|
||||||
|
}
|
||||||
|
if descriptor.Name != "LongCat" {
|
||||||
|
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
|
||||||
|
}
|
||||||
|
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
|
||||||
|
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||||
|
}
|
||||||
|
if descriptor.DefaultModel != "LongCat-2.0" {
|
||||||
|
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
|
||||||
|
}
|
||||||
|
if descriptor.Transport != TransportOpenAICompatible {
|
||||||
|
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
|
||||||
|
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||||
for _, descriptor := range All() {
|
for _, descriptor := range All() {
|
||||||
if descriptor.ID == "" {
|
if descriptor.ID == "" {
|
||||||
@@ -263,8 +291,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
|||||||
TransportGoogle: {"google"},
|
TransportGoogle: {"google"},
|
||||||
TransportBedrock: {"bedrock"},
|
TransportBedrock: {"bedrock"},
|
||||||
TransportVertex: {"vertex"},
|
TransportVertex: {"vertex"},
|
||||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
|
||||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for transport, wantIDs := range cases {
|
for transport, wantIDs := range cases {
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
|||||||
if err := validateEndpoint(ctx, ep, r, true); err != nil {
|
if err := validateEndpoint(ctx, ep, r, true); err != nil {
|
||||||
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
|
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
|
||||||
}
|
}
|
||||||
if !endpointIsLoopback(ep) {
|
if !endpointIsLoopbackOrPrivate(ep) {
|
||||||
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
|
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
|||||||
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
|
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
|
||||||
// link-local, private) stay blocked even for a local provider config.
|
// link-local and other special ranges (cloud metadata, documentation) stay
|
||||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
|
// blocked even for a local provider config.
|
||||||
|
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
|
||||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
||||||
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
|
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
|
||||||
}
|
}
|
||||||
if endpointIsLoopback(ep) {
|
if endpointIsLoopbackOrPrivate(ep) {
|
||||||
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
|
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
|
||||||
|
// now allowed when the flag is true — the user is pointing at their own
|
||||||
|
// LAN box (e.g. llama.cpp on another local machine).
|
||||||
|
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
|
||||||
|
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
|
||||||
|
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
|
||||||
|
}
|
||||||
|
if !endpointIsLoopbackOrPrivate(ep) {
|
||||||
|
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
|||||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
|
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
category := CategoryConfig
|
category := CategoryConfig
|
||||||
var safety endpointSafetyError
|
var safety endpointSafetyError
|
||||||
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
|||||||
}
|
}
|
||||||
client := options.HTTPClient
|
client := options.HTTPClient
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
|
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||||
}
|
}
|
||||||
response, err := client.Do(request)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
|||||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||||
allowLoopback := endpointIsLoopback(endpoint)
|
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
|
||||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
|
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
|||||||
request.Header.Set("User-Agent", options.UserAgent)
|
request.Header.Set("User-Agent", options.UserAgent)
|
||||||
}
|
}
|
||||||
applyAuth(request, profile, kind)
|
applyAuth(request, profile, kind)
|
||||||
return request, allowLoopback, nil
|
return request, allowLoopbackOrPrivate, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
|
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
|
||||||
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
|
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
|
||||||
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
|
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
|
||||||
// set true exclusively for the user's own explicitly-configured local provider
|
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
|
||||||
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
|
// is set true exclusively for the user's own explicitly-configured base_url
|
||||||
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
|
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
|
||||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
|
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
|
||||||
parsed, err := url.Parse(endpoint)
|
parsed, err := url.Parse(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
|||||||
}
|
}
|
||||||
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
|
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
|
||||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||||
if allowLoopback {
|
if allowLoopbackOrPrivate {
|
||||||
return nil // user-configured local provider; loopback is intentional
|
return nil // user-configured local provider; loopback is intentional
|
||||||
}
|
}
|
||||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||||
}
|
}
|
||||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
|||||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||||
}
|
}
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
|
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
|
||||||
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
|
// or private-network host the user typed themselves — a local provider on the LAN
|
||||||
// decide whether the connectivity probe may reach the user's own local provider.
|
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
|
||||||
func endpointIsLoopback(endpoint string) bool {
|
// connectivity probe may relax address-blocking and reach the user's own
|
||||||
|
// explicitly-configured provider.
|
||||||
|
func endpointIsLoopbackOrPrivate(endpoint string) bool {
|
||||||
parsed, err := url.Parse(endpoint)
|
parsed, err := url.Parse(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if addr, err := netip.ParseAddr(host); err == nil {
|
if addr, err := netip.ParseAddr(host); err == nil {
|
||||||
return addr.IsLoopback()
|
return addr.IsLoopback() || addr.IsPrivate()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
|
|||||||
// time and then dials the validated IP literal, closing the TOCTOU window
|
// time and then dials the validated IP literal, closing the TOCTOU window
|
||||||
// between the pre-flight validateEndpoint check and the actual connection
|
// between the pre-flight validateEndpoint check and the actual connection
|
||||||
// (DNS rebinding).
|
// (DNS rebinding).
|
||||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
|
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
|
||||||
if resolver == nil {
|
if resolver == nil {
|
||||||
resolver = defaultResolver{}
|
resolver = defaultResolver{}
|
||||||
}
|
}
|
||||||
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
|||||||
} else {
|
} else {
|
||||||
transport = &http.Transport{}
|
transport = &http.Transport{}
|
||||||
}
|
}
|
||||||
transport.DialContext = safeDialContext(resolver, allowLoopback)
|
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
||||||
// allowLoopback covers only the user's own configured base_url, not a 3xx
|
// allowLoopbackOrPrivate covers only the user's own configured base_url,
|
||||||
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||||
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
|
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
|
|||||||
// safeDialContext returns a dial function that resolves the target host, refuses
|
// safeDialContext returns a dial function that resolves the target host, refuses
|
||||||
// the connection if any resolved address is blocked, then dials the validated IP
|
// the connection if any resolved address is blocked, then dials the validated IP
|
||||||
// literal so the kernel cannot re-resolve to a different address after the check.
|
// literal so the kernel cannot re-resolve to a different address after the check.
|
||||||
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
||||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(address)
|
host, port, err := net.SplitHostPort(address)
|
||||||
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||||
}
|
}
|
||||||
return dialer.DialContext(ctx, network, address)
|
return dialer.DialContext(ctx, network, address)
|
||||||
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
|||||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||||
}
|
}
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ type Model struct {
|
|||||||
Source string
|
Source string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
|
||||||
|
// per call, so the shared backing slice cannot be mutated by callers.
|
||||||
|
var minimaxCuratedModels = []Model{
|
||||||
|
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||||
|
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
|
||||||
|
// open.bigmodel.cn); same model lineup on both endpoints.
|
||||||
|
var zaiCuratedModels = []Model{
|
||||||
|
{ID: "glm-4.5", Description: "catalog default"},
|
||||||
|
{ID: "glm-4.5-air", Description: "fast model"},
|
||||||
|
{ID: "glm-4.6", Description: "latest general model"},
|
||||||
|
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||||
|
}
|
||||||
|
|
||||||
var curatedModels = map[string][]Model{
|
var curatedModels = map[string][]Model{
|
||||||
"ollama-cloud": {
|
"ollama-cloud": {
|
||||||
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
||||||
@@ -88,10 +104,8 @@ var curatedModels = map[string][]Model{
|
|||||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||||
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
|
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
|
||||||
},
|
},
|
||||||
"minimax": {
|
"minimax": minimaxCuratedModels,
|
||||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
"minimaxi-cn": minimaxCuratedModels,
|
||||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
|
||||||
},
|
|
||||||
"mistral": {
|
"mistral": {
|
||||||
{ID: "mistral-large-latest", Description: "catalog default"},
|
{ID: "mistral-large-latest", Description: "catalog default"},
|
||||||
{ID: "codestral-latest", Description: "coding model"},
|
{ID: "codestral-latest", Description: "coding model"},
|
||||||
@@ -123,12 +137,8 @@ var curatedModels = map[string][]Model{
|
|||||||
"bankr": {
|
"bankr": {
|
||||||
{ID: "bankr-large", Description: "catalog default"},
|
{ID: "bankr-large", Description: "catalog default"},
|
||||||
},
|
},
|
||||||
"zai": {
|
"zai": zaiCuratedModels,
|
||||||
{ID: "glm-4.5", Description: "catalog default"},
|
"zai-cn": zaiCuratedModels,
|
||||||
{ID: "glm-4.5-air", Description: "fast model"},
|
|
||||||
{ID: "glm-4.6", Description: "latest general model"},
|
|
||||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
|
||||||
},
|
|
||||||
// OpenGateway smart-routes by model id across its upstream providers
|
// OpenGateway smart-routes by model id across its upstream providers
|
||||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
|||||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provider: "minimaxi-cn",
|
||||||
|
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
|
||||||
|
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "zai-cn",
|
||||||
|
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
|
||||||
|
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -88,3 +98,66 @@ func modelIDs(models []Model) []string {
|
|||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
|
||||||
|
// international provider; a divergence here is almost always a mistake.
|
||||||
|
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
|
||||||
|
intl, ok := providercatalog.Get("minimax")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("provider minimax missing from catalog")
|
||||||
|
}
|
||||||
|
cn, ok := providercatalog.Get("minimaxi-cn")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("provider minimaxi-cn missing from catalog")
|
||||||
|
}
|
||||||
|
intlModels := Models(intl)
|
||||||
|
cnModels := Models(cn)
|
||||||
|
if len(intlModels) != len(cnModels) {
|
||||||
|
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||||
|
}
|
||||||
|
intlIDs := map[string]string{}
|
||||||
|
for _, m := range intlModels {
|
||||||
|
intlIDs[m.ID] = m.Description
|
||||||
|
}
|
||||||
|
for _, m := range cnModels {
|
||||||
|
wantDesc, ok := intlIDs[m.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||||
|
}
|
||||||
|
if m.Description != wantDesc {
|
||||||
|
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
|
||||||
|
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
|
||||||
|
// same model lineup.
|
||||||
|
func TestZaiCNModelsMirrorInternational(t *testing.T) {
|
||||||
|
intl, ok := providercatalog.Get("zai")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("provider zai missing from catalog")
|
||||||
|
}
|
||||||
|
cn, ok := providercatalog.Get("zai-cn")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("provider zai-cn missing from catalog")
|
||||||
|
}
|
||||||
|
intlModels := Models(intl)
|
||||||
|
cnModels := Models(cn)
|
||||||
|
if len(intlModels) != len(cnModels) {
|
||||||
|
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||||
|
}
|
||||||
|
intlIDs := map[string]string{}
|
||||||
|
for _, m := range intlModels {
|
||||||
|
intlIDs[m.ID] = m.Description
|
||||||
|
}
|
||||||
|
for _, m := range cnModels {
|
||||||
|
wantDesc, ok := intlIDs[m.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||||
|
}
|
||||||
|
if m.Description != wantDesc {
|
||||||
|
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,10 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
|||||||
return "nvidia"
|
return "nvidia"
|
||||||
case "xiaomi-mimo":
|
case "xiaomi-mimo":
|
||||||
return "xiaomi"
|
return "xiaomi"
|
||||||
|
case "zai-cn":
|
||||||
|
return "zai"
|
||||||
|
case "minimaxi-cn":
|
||||||
|
return "minimax"
|
||||||
default:
|
default:
|
||||||
return strings.TrimSpace(provider.ID)
|
return strings.TrimSpace(provider.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
|||||||
"xiaomi-mimo": "xiaomi",
|
"xiaomi-mimo": "xiaomi",
|
||||||
"dashscope": "alibaba",
|
"dashscope": "alibaba",
|
||||||
"ollama-cloud": "ollama-cloud",
|
"ollama-cloud": "ollama-cloud",
|
||||||
|
"zai-cn": "zai",
|
||||||
|
"minimaxi-cn": "minimax",
|
||||||
}
|
}
|
||||||
for zeroID, want := range tests {
|
for zeroID, want := range tests {
|
||||||
provider, ok := providercatalog.Get(zeroID)
|
provider, ok := providercatalog.Get(zeroID)
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The final two messages must carry a cache_control breakpoint on their last
|
||||||
|
// cacheable block so the conversation transcript — not just system prompt and
|
||||||
|
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
|
||||||
|
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
|
||||||
|
// these two).
|
||||||
|
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||||
|
var gotBody map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New returned error: %v", err)
|
||||||
|
}
|
||||||
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||||
|
Messages: []zeroruntime.Message{
|
||||||
|
{Role: zeroruntime.MessageRoleUser, Content: "first"},
|
||||||
|
{
|
||||||
|
Role: zeroruntime.MessageRoleAssistant,
|
||||||
|
Content: "calling a tool",
|
||||||
|
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
|
||||||
|
},
|
||||||
|
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||||
|
}
|
||||||
|
drain(stream)
|
||||||
|
|
||||||
|
messages := gotBody["messages"].([]any)
|
||||||
|
if len(messages) != 3 {
|
||||||
|
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
|
||||||
|
}
|
||||||
|
lastBlockHasCache := func(message any) bool {
|
||||||
|
blocks, ok := message.(map[string]any)["content"].([]any)
|
||||||
|
if !ok || len(blocks) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
last := blocks[len(blocks)-1].(map[string]any)
|
||||||
|
control, ok := last["cache_control"].(map[string]any)
|
||||||
|
return ok && control["type"] == "ephemeral"
|
||||||
|
}
|
||||||
|
if lastBlockHasCache(messages[0]) {
|
||||||
|
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
|
||||||
|
}
|
||||||
|
if !lastBlockHasCache(messages[1]) {
|
||||||
|
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
|
||||||
|
}
|
||||||
|
if !lastBlockHasCache(messages[2]) {
|
||||||
|
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A string-content message must be converted to a block array so it can carry
|
||||||
|
// the breakpoint, and thinking blocks must never carry cache_control (the API
|
||||||
|
// rejects them) — the marker goes on the last cacheable block instead.
|
||||||
|
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
|
||||||
|
messages := []anthropicMessage{
|
||||||
|
{Role: "user", Content: "plain string"},
|
||||||
|
{Role: "assistant", Content: []map[string]any{
|
||||||
|
{"type": "text", "text": "answer"},
|
||||||
|
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
applyMessageCacheBreakpoints(messages)
|
||||||
|
|
||||||
|
userBlocks := contentBlocks(messages[0].Content)
|
||||||
|
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
|
||||||
|
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
|
||||||
|
}
|
||||||
|
assistantBlocks := contentBlocks(messages[1].Content)
|
||||||
|
if assistantBlocks[1]["cache_control"] != nil {
|
||||||
|
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
|
||||||
|
}
|
||||||
|
if assistantBlocks[0]["cache_control"] == nil {
|
||||||
|
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
|
|||||||
}
|
}
|
||||||
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
|
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
|
||||||
}
|
}
|
||||||
|
applyMessageCacheBreakpoints(mapped.Messages)
|
||||||
return mapped, nil
|
return mapped, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyMessageCacheBreakpoints marks the last content block of the final two
|
||||||
|
// messages with cache_control so the conversation transcript is cached
|
||||||
|
// turn-over-turn, not just the system prompt and tool definitions. Two message
|
||||||
|
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
|
||||||
|
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
|
||||||
|
// request: system + tools use two above, these use the remaining two. Thinking
|
||||||
|
// blocks cannot carry cache_control, so the marker goes on the last block that
|
||||||
|
// can.
|
||||||
|
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
|
||||||
|
marked := 0
|
||||||
|
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
|
||||||
|
blocks := contentBlocks(messages[i].Content)
|
||||||
|
for j := len(blocks) - 1; j >= 0; j-- {
|
||||||
|
blockType, _ := blocks[j]["type"].(string)
|
||||||
|
if blockType == "thinking" || blockType == "redacted_thinking" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
|
||||||
|
messages[i].Content = blocks
|
||||||
|
marked++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
||||||
systemParts := []string{}
|
systemParts := []string{}
|
||||||
mapped := []anthropicMessage{}
|
mapped := []anthropicMessage{}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Options struct {
|
|||||||
ModelRegistry *modelregistry.Registry
|
ModelRegistry *modelregistry.Registry
|
||||||
// OAuthResolver, when set, lets the provider authenticate model calls with an
|
// OAuthResolver, when set, lets the provider authenticate model calls with an
|
||||||
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
||||||
// Applied to the OpenAI and Anthropic providers.
|
// Applied to the OpenAI, Anthropic, and Google (Gemini) providers.
|
||||||
OAuthResolver providerio.TokenResolver
|
OAuthResolver providerio.TokenResolver
|
||||||
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
|
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
|
||||||
// there is no OAuth login). It is the SAME selection the bearer resolver made,
|
// there is no OAuth login). It is the SAME selection the bearer resolver made,
|
||||||
@@ -91,6 +91,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
|||||||
AuthScheme: profile.AuthScheme,
|
AuthScheme: profile.AuthScheme,
|
||||||
AuthHeaderValue: profile.AuthHeaderValue,
|
AuthHeaderValue: profile.AuthHeaderValue,
|
||||||
CustomHeaders: profile.CustomHeaders,
|
CustomHeaders: profile.CustomHeaders,
|
||||||
|
OAuthResolver: options.OAuthResolver,
|
||||||
MaxTokens: resolved.maxOutputTokens,
|
MaxTokens: resolved.maxOutputTokens,
|
||||||
HTTPClient: options.HTTPClient,
|
HTTPClient: options.HTTPClient,
|
||||||
UserAgent: options.UserAgent,
|
UserAgent: options.UserAgent,
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type Options struct {
|
|||||||
CustomHeaders map[string]string
|
CustomHeaders map[string]string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
UserAgent string
|
UserAgent string
|
||||||
|
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
|
||||||
|
// is retried once with a forced token refresh after an upstream 401 (matching
|
||||||
|
// the OpenAI and Anthropic providers). Nil falls back to plain API-key auth.
|
||||||
|
OAuthResolver providerio.TokenResolver
|
||||||
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
||||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||||
@@ -67,6 +71,7 @@ type Provider struct {
|
|||||||
customHeaders map[string]string
|
customHeaders map[string]string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
userAgent string
|
userAgent string
|
||||||
|
oauthResolver providerio.TokenResolver
|
||||||
streamIdleTimeout time.Duration
|
streamIdleTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,7 @@ func New(options Options) (*Provider, error) {
|
|||||||
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
|
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
|
||||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||||
userAgent: options.UserAgent,
|
userAgent: options.UserAgent,
|
||||||
|
oauthResolver: options.OAuthResolver,
|
||||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -127,20 +133,22 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
|||||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||||
defer cancelStream()
|
defer cancelStream()
|
||||||
|
|
||||||
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body, func(request *http.Request) {
|
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body,
|
||||||
request.Header.Set("Content-Type", "application/json")
|
providerio.AuthHeaders{
|
||||||
if provider.userAgent != "" {
|
|
||||||
request.Header.Set("User-Agent", provider.userAgent)
|
|
||||||
}
|
|
||||||
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
|
|
||||||
APIKey: provider.apiKey,
|
APIKey: provider.apiKey,
|
||||||
DefaultAuthHeader: "x-goog-api-key",
|
DefaultAuthHeader: "x-goog-api-key",
|
||||||
AuthHeader: provider.authHeader,
|
AuthHeader: provider.authHeader,
|
||||||
AuthScheme: provider.authScheme,
|
AuthScheme: provider.authScheme,
|
||||||
AuthHeaderValue: provider.authHeaderValue,
|
AuthHeaderValue: provider.authHeaderValue,
|
||||||
CustomHeaders: provider.customHeaders,
|
CustomHeaders: provider.customHeaders,
|
||||||
})
|
},
|
||||||
}, 0)
|
provider.oauthResolver,
|
||||||
|
func(request *http.Request) {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
if provider.userAgent != "" {
|
||||||
|
request.Header.Set("User-Agent", provider.userAgent)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -370,6 +370,57 @@ func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 401 with an OAuth resolver is retried once with a force-refreshed token; the
|
||||||
|
// replayed request carries the refreshed bearer and succeeds.
|
||||||
|
func TestStreamCompletionRetries401WithRefreshedToken(t *testing.T) {
|
||||||
|
var attempts int
|
||||||
|
var secondAuth string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts == 1 {
|
||||||
|
http.Error(w, `{"error":{"message":"token expired"}}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secondAuth = r.Header.Get("Authorization")
|
||||||
|
writeSSE(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var forceRefreshOnRetry bool
|
||||||
|
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||||
|
if forceRefresh {
|
||||||
|
forceRefreshOnRetry = true
|
||||||
|
return "Authorization", "Bearer refreshed", true, nil
|
||||||
|
}
|
||||||
|
return "Authorization", "Bearer stale", true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := New(Options{
|
||||||
|
APIKey: "sk-google",
|
||||||
|
BaseURL: server.URL,
|
||||||
|
Model: "gemini-test",
|
||||||
|
OAuthResolver: resolver,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New returned error: %v", err)
|
||||||
|
}
|
||||||
|
stream, err := provider.StreamCompletion(context.Background(), validRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||||
|
}
|
||||||
|
drain(stream)
|
||||||
|
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("attempts = %d, want 2 (initial 401 + one refreshed retry)", attempts)
|
||||||
|
}
|
||||||
|
if !forceRefreshOnRetry {
|
||||||
|
t.Fatalf("resolver was not called with forceRefresh on the retry")
|
||||||
|
}
|
||||||
|
if secondAuth != "Bearer refreshed" {
|
||||||
|
t.Fatalf("retry Authorization = %q, want refreshed bearer", secondAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
|
||||||
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
|
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -283,10 +284,10 @@ func (provider *Provider) emitChunk(
|
|||||||
events chan<- zeroruntime.StreamEvent,
|
events chan<- zeroruntime.StreamEvent,
|
||||||
) {
|
) {
|
||||||
for _, choice := range chunk.Choices {
|
for _, choice := range chunk.Choices {
|
||||||
if choice.Delta.ReasoningContent != "" {
|
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
|
||||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||||
Type: zeroruntime.StreamEventReasoning,
|
Type: zeroruntime.StreamEventReasoning,
|
||||||
Content: choice.Delta.ReasoningContent,
|
Content: reasoning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if choice.Delta.Content != "" {
|
if choice.Delta.Content != "" {
|
||||||
@@ -317,6 +318,13 @@ func (provider *Provider) emitChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (delta streamDelta) reasoningText() string {
|
||||||
|
if delta.ReasoningContent != "" {
|
||||||
|
return delta.ReasoningContent
|
||||||
|
}
|
||||||
|
return delta.Reasoning
|
||||||
|
}
|
||||||
|
|
||||||
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
|
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
|
||||||
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
|
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
|
||||||
func mapFinishReason(reason string) string {
|
func mapFinishReason(reason string) string {
|
||||||
@@ -414,6 +422,12 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
|||||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||||
mapped.ReasoningEffort = effort
|
mapped.ReasoningEffort = effort
|
||||||
}
|
}
|
||||||
|
// prompt_cache_key is a documented OpenAI parameter; compatible servers
|
||||||
|
// ignore unknown fields, but a strict endpoint that rejects it can be
|
||||||
|
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
|
||||||
|
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
|
||||||
|
mapped.PromptCacheKey = key
|
||||||
|
}
|
||||||
if len(request.Tools) > 0 {
|
if len(request.Tools) > 0 {
|
||||||
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
|
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
|
||||||
for _, tool := range request.Tools {
|
for _, tool := range request.Tools {
|
||||||
@@ -430,6 +444,15 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
|||||||
return mapped
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
|
||||||
|
// kill switch is set to a truthy value. "0" and "false" (any case) are
|
||||||
|
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
|
||||||
|
// explicitly-disabled toggle never flips the behavior it names.
|
||||||
|
func promptCacheKeyDisabled() bool {
|
||||||
|
value := strings.TrimSpace(os.Getenv("ZERO_DISABLE_PROMPT_CACHE_KEY"))
|
||||||
|
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||||
|
}
|
||||||
|
|
||||||
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
|
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
|
||||||
// completions API accepts, or "" to omit the field. "none" (and anything else)
|
// completions API accepts, or "" to omit the field. "none" (and anything else)
|
||||||
// is dropped rather than risking a 400 on an unrecognized enum.
|
// is dropped rather than risking a 400 on an unrecognized enum.
|
||||||
|
|||||||
@@ -227,6 +227,39 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
|
||||||
|
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
|
||||||
|
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
|
||||||
|
writeSSE(w, `[DONE]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
events := collectProviderEvents(t, provider)
|
||||||
|
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||||
|
if len(reasoning) != 2 {
|
||||||
|
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
|
||||||
|
}
|
||||||
|
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
|
||||||
|
t.Fatalf("unexpected reasoning events: %#v", reasoning)
|
||||||
|
}
|
||||||
|
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
|
||||||
|
t.Fatalf("reasoning must not emit text events, got %#v", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
|
||||||
|
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
|
||||||
|
writeSSE(w, `[DONE]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
events := collectProviderEvents(t, provider)
|
||||||
|
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||||
|
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
|
||||||
|
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
||||||
@@ -1226,3 +1259,61 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
|||||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
|
||||||
|
// session-carrying request serializes the key so the backend can route to a
|
||||||
|
// replica holding the cached prefix, a keyless request omits the field
|
||||||
|
// entirely (strict servers see byte-identical requests to before), and
|
||||||
|
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
|
||||||
|
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
|
||||||
|
provider, err := New(Options{Model: "gpt-test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New returned error: %v", err)
|
||||||
|
}
|
||||||
|
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}
|
||||||
|
|
||||||
|
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||||
|
Messages: messages,
|
||||||
|
PromptCacheKey: "sess_123",
|
||||||
|
})
|
||||||
|
if req.PromptCacheKey != "sess_123" {
|
||||||
|
t.Fatalf("PromptCacheKey = %q, want sess_123", req.PromptCacheKey)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"prompt_cache_key":"sess_123"`) {
|
||||||
|
t.Fatalf("prompt_cache_key not serialized: %s", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = provider.openAIRequest(zeroruntime.CompletionRequest{Messages: messages})
|
||||||
|
if data, err = json.Marshal(req); err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "prompt_cache_key") {
|
||||||
|
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
|
||||||
|
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||||
|
Messages: messages,
|
||||||
|
PromptCacheKey: "sess_123",
|
||||||
|
})
|
||||||
|
if req.PromptCacheKey != "" {
|
||||||
|
t.Fatalf("kill switch ignored; PromptCacheKey = %q", req.PromptCacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly-falsy kill switch values must NOT disable forwarding — only
|
||||||
|
// truthy values flip the toggle (same parsing as ZERO_FORMAT_ON_WRITE).
|
||||||
|
for _, value := range []string{"0", "false", "FALSE"} {
|
||||||
|
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", value)
|
||||||
|
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||||
|
Messages: messages,
|
||||||
|
PromptCacheKey: "sess_123",
|
||||||
|
})
|
||||||
|
if req.PromptCacheKey != "sess_123" {
|
||||||
|
t.Fatalf("ZERO_DISABLE_PROMPT_CACHE_KEY=%q must be a no-op; PromptCacheKey = %q", value, req.PromptCacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ type chatCompletionRequest struct {
|
|||||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||||
|
// PromptCacheKey asks the backend to route the request to a replica that
|
||||||
|
// already holds this conversation's prefix in its prompt cache (the OpenAI
|
||||||
|
// `prompt_cache_key` parameter). Omitted when the caller carries no session
|
||||||
|
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
|
||||||
|
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// streamOptions requests the final usage chunk on a streaming response. Without
|
// streamOptions requests the final usage chunk on a streaming response. Without
|
||||||
@@ -78,6 +83,7 @@ type streamChoice struct {
|
|||||||
type streamDelta struct {
|
type streamDelta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReasoningContent string `json:"reasoning_content"`
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ import (
|
|||||||
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
||||||
// body starts streaming it is never re-issued.
|
// body starts streaming it is never re-issued.
|
||||||
|
|
||||||
const defaultMaxRetryAttempts = 3
|
const defaultMaxRetryAttempts = 6
|
||||||
|
|
||||||
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
|
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
|
||||||
// stall the agent for minutes.
|
// stall the agent for minutes.
|
||||||
const maxBackoff = 30 * time.Second
|
const maxBackoff = 30 * time.Second
|
||||||
|
|
||||||
|
// retryBackoffBase is the first wait when the server supplied no Retry-After.
|
||||||
|
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
|
||||||
|
// after 400ms almost always burns the attempt while still limited, so the
|
||||||
|
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
|
||||||
|
var retryBackoffBase = 2 * time.Second
|
||||||
|
|
||||||
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
||||||
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
||||||
// between tries and honoring a server Retry-After header and context
|
// between tries and honoring a server Retry-After header and context
|
||||||
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backoff waits before retry attempt N (1-based), returning false if the context
|
// Backoff waits before retry attempt N (1-based), returning false if the context
|
||||||
// is cancelled during the wait. The wait is attempt*400ms unless the server
|
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
|
||||||
// supplied a (positive) Retry-After, and is capped at maxBackoff.
|
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
|
||||||
|
// wait is capped at maxBackoff.
|
||||||
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||||
wait := time.Duration(attempt) * 400 * time.Millisecond
|
timer := time.NewTimer(backoffWait(attempt, retryAfter))
|
||||||
if retryAfter > 0 {
|
|
||||||
wait = retryAfter
|
|
||||||
}
|
|
||||||
if wait > maxBackoff {
|
|
||||||
wait = maxBackoff
|
|
||||||
}
|
|
||||||
timer := time.NewTimer(wait)
|
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
|
||||||
|
// when supplied, else exponential from retryBackoffBase, both capped at
|
||||||
|
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
|
||||||
|
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
|
||||||
|
wait := retryAfter
|
||||||
|
if wait <= 0 {
|
||||||
|
exponent := attempt - 1
|
||||||
|
if exponent > 5 {
|
||||||
|
exponent = 5
|
||||||
|
}
|
||||||
|
if exponent < 0 {
|
||||||
|
exponent = 0
|
||||||
|
}
|
||||||
|
wait = retryBackoffBase * time.Duration(1<<exponent)
|
||||||
|
}
|
||||||
|
if wait > maxBackoff {
|
||||||
|
wait = maxBackoff
|
||||||
|
}
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
|
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
|
||||||
// date) into a positive duration, or 0 when absent/unparseable. The result is
|
// date) into a positive duration, or 0 when absent/unparseable. The result is
|
||||||
// capped at maxBackoff by Backoff.
|
// capped at maxBackoff by Backoff.
|
||||||
|
|||||||
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shrinkBackoff makes retry waits negligible for the duration of a test.
|
||||||
|
func shrinkBackoff(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
saved := retryBackoffBase
|
||||||
|
retryBackoffBase = time.Millisecond
|
||||||
|
t.Cleanup(func() { retryBackoffBase = saved })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackoffWaitSchedule(t *testing.T) {
|
||||||
|
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
|
||||||
|
// a supplied Retry-After wins but is capped too.
|
||||||
|
cases := []struct {
|
||||||
|
attempt int
|
||||||
|
retryAfter time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{1, 0, 2 * time.Second},
|
||||||
|
{2, 0, 4 * time.Second},
|
||||||
|
{3, 0, 8 * time.Second},
|
||||||
|
{4, 0, 16 * time.Second},
|
||||||
|
{5, 0, 30 * time.Second}, // 32s capped
|
||||||
|
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
|
||||||
|
{1, 7 * time.Second, 7 * time.Second},
|
||||||
|
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
|
||||||
|
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
||||||
|
shrinkBackoff(t)
|
||||||
var hits int32
|
var hits int32
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if atomic.AddInt32(&hits, 1) == 1 {
|
if atomic.AddInt32(&hits, 1) == 1 {
|
||||||
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
||||||
|
shrinkBackoff(t)
|
||||||
var hits int32
|
var hits int32
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
atomic.AddInt32(&hits, 1)
|
atomic.AddInt32(&hits, 1)
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type Info struct {
|
|||||||
AgeDays *int `json:"ageDays,omitempty"`
|
AgeDays *int `json:"ageDays,omitempty"`
|
||||||
Contributors90d *int `json:"contributors90d,omitempty"`
|
Contributors90d *int `json:"contributors90d,omitempty"`
|
||||||
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
|
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
|
||||||
|
BranchCount *int `json:"branchCount,omitempty"`
|
||||||
|
CommitCount *int `json:"commitCount,omitempty"`
|
||||||
|
TagCount *int `json:"tagCount,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
|
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
|
||||||
@@ -229,6 +232,43 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
|
|||||||
info.CommitVelocity30d = &n
|
info.CommitVelocity30d = &n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
|
||||||
|
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
|
||||||
|
info.CommitCount = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
|
||||||
|
set := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(branches, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.Contains(line, "->") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
|
||||||
|
line = strings.TrimPrefix(line, "* ")
|
||||||
|
line = strings.TrimPrefix(line, "+ ")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "(") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If it's a remote branch, extract the branch name.
|
||||||
|
// E.g., remotes/origin/main -> main
|
||||||
|
// E.g., remotes/origin/feature/xyz -> feature/xyz
|
||||||
|
if strings.HasPrefix(line, "remotes/") {
|
||||||
|
line = strings.TrimPrefix(line, "remotes/")
|
||||||
|
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
|
||||||
|
line = line[slashIdx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set[line] = true
|
||||||
|
}
|
||||||
|
n := len(set)
|
||||||
|
info.BranchCount = &n
|
||||||
|
}
|
||||||
|
if tags, err := run(ctx, dir, "tag"); err == nil {
|
||||||
|
n := countNonEmptyLines(tags)
|
||||||
|
info.TagCount = &n
|
||||||
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
@@ -328,3 +368,13 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
|
|||||||
}
|
}
|
||||||
return stdout.String(), nil
|
return stdout.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countNonEmptyLines(s string) int {
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ func TestCollectCoreMetrics(t *testing.T) {
|
|||||||
"remote": "https://github.com/x/y.git\n",
|
"remote": "https://github.com/x/y.git\n",
|
||||||
"log": "0\n",
|
"log": "0\n",
|
||||||
"rev-list": "12\n",
|
"rev-list": "12\n",
|
||||||
|
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
|
||||||
|
"tag": "v0.1.0\nv0.2.0\n",
|
||||||
}, nil),
|
}, nil),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,6 +87,15 @@ func TestCollectCoreMetrics(t *testing.T) {
|
|||||||
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
|
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
|
||||||
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
|
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
|
||||||
}
|
}
|
||||||
|
if info.CommitCount == nil || *info.CommitCount != 12 {
|
||||||
|
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
|
||||||
|
}
|
||||||
|
if info.BranchCount == nil || *info.BranchCount != 2 {
|
||||||
|
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
|
||||||
|
}
|
||||||
|
if info.TagCount == nil || *info.TagCount != 2 {
|
||||||
|
t.Fatalf("TagCount=%v want 2", info.TagCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectContributorsUnique(t *testing.T) {
|
func TestCollectContributorsUnique(t *testing.T) {
|
||||||
@@ -252,12 +263,13 @@ func TestCollectNotGitRepo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
|
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
|
||||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
|
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
|
||||||
var used []string
|
var used []string
|
||||||
_, err := Collect(context.Background(), Options{
|
_, err := Collect(context.Background(), Options{
|
||||||
Now: time.Unix(0, 0),
|
Now: time.Unix(0, 0),
|
||||||
RunGit: fakeGit(t, map[string]string{
|
RunGit: fakeGit(t, map[string]string{
|
||||||
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
|
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
|
||||||
|
"branch": "main\n", "tag": "v0.1.0\n",
|
||||||
}, &used),
|
}, &used),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,22 +9,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
|
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
|
||||||
// sandbox network policy gates sandboxed shell egress, not first-party
|
// that first-party in-process network tools are governed by their permission
|
||||||
// in-process web tools.
|
// metadata, not by the sandboxed shell egress policy.
|
||||||
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
|
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
|
||||||
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
|
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
|
||||||
|
|
||||||
engine := NewEngine(EngineOptions{Policy: base})
|
engine := NewEngine(EngineOptions{Policy: base})
|
||||||
d := engine.Evaluate(context.Background(), Request{
|
d := engine.Evaluate(context.Background(), Request{
|
||||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
|
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
|
||||||
})
|
})
|
||||||
if d.Action != ActionAllow || d.Block != nil {
|
if d.Action != ActionPrompt || d.Block != nil {
|
||||||
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
|
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
|
||||||
|
}
|
||||||
|
if d.Reason == ReasonNetworkBlocked {
|
||||||
|
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed := engine.Evaluate(context.Background(), Request{
|
allowed := engine.Evaluate(context.Background(), Request{
|
||||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||||
})
|
})
|
||||||
if allowed.Action != ActionAllow || allowed.Block != nil {
|
if allowed.Action != ActionAllow || allowed.Block != nil {
|
||||||
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
|
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
|
||||||
|
|||||||
+77
-10
@@ -3,8 +3,9 @@ package sandbox
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
||||||
@@ -19,6 +20,13 @@ var errWindowsSandboxNotInitialized = errors.New(
|
|||||||
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
|
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
|
||||||
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
|
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
|
||||||
|
|
||||||
|
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
|
||||||
|
// the unelevated tier: the restricted-token write-jail is enforced (the command
|
||||||
|
// runner applies the workspace ACLs itself, which needs no Administrator
|
||||||
|
// rights), but the WFP network filters are Administrator-only and absent.
|
||||||
|
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
|
||||||
|
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
|
||||||
|
|
||||||
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
|
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
|
||||||
// marker exists. A missing marker is the common fresh-install state, so the
|
// marker exists. A missing marker is the common fresh-install state, so the
|
||||||
// caller degrades rather than bricking the command. Indirected through a var so
|
// caller degrades rather than bricking the command. Indirected through a var so
|
||||||
@@ -114,9 +122,55 @@ func (manager SandboxManager) Backend() Backend {
|
|||||||
return manager.backend
|
return manager.backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isExecutable checks whether a file is executable. On Unix, this checks the
|
||||||
|
// execute permission bits. On Windows, Go's os.FileMode does not set execute
|
||||||
|
// bits, so we check for common executable extensions instead.
|
||||||
|
func isExecutable(fi os.FileInfo) bool {
|
||||||
|
if !fi.Mode().IsRegular() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name := strings.ToLower(fi.Name())
|
||||||
|
return strings.HasSuffix(name, ".exe") ||
|
||||||
|
strings.HasSuffix(name, ".com") ||
|
||||||
|
strings.HasSuffix(name, ".bat") ||
|
||||||
|
strings.HasSuffix(name, ".cmd")
|
||||||
|
}
|
||||||
|
return fi.Mode()&0o111 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupExecutable checks whether a named binary exists and is executable,
|
||||||
|
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
|
||||||
|
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
|
||||||
|
func lookupExecutable(name string) (string, error) {
|
||||||
|
if !strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
path := os.Getenv("PATH")
|
||||||
|
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
|
||||||
|
if dir == "" {
|
||||||
|
continue // skip empty entries (e.g. trailing colon)
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(dir, name)
|
||||||
|
if fi, err := os.Stat(candidate); err == nil {
|
||||||
|
if isExecutable(fi) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("executable file not found in $PATH")
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if isExecutable(fi) {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("executable file not found")
|
||||||
|
}
|
||||||
|
|
||||||
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
|
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
|
||||||
if lookup == nil {
|
if lookup == nil {
|
||||||
lookup = exec.LookPath
|
lookup = lookupExecutable
|
||||||
}
|
}
|
||||||
switch goos {
|
switch goos {
|
||||||
case "linux":
|
case "linux":
|
||||||
@@ -174,19 +228,28 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
|||||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||||
}
|
}
|
||||||
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
|
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
|
||||||
// applies WFP network filters + workspace ACLs and writes a marker). Without
|
// (it applies WFP network filters + workspace ACLs and writes a marker).
|
||||||
// it the command runner hard-fails — so on the default preference, DEGRADE to
|
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
|
||||||
// the in-process policy gate + per-command approval (matching Linux/macOS when
|
// tier: the command runner applies the workspace ACL plan itself (DACL edits
|
||||||
// their sandbox is unavailable) instead of bricking every command. A strict
|
// on user-owned roots need no Administrator rights) and wraps the command in
|
||||||
// caller (--sandbox require) still gets a clear error pointing at setup.
|
// the write-restricted token, so the filesystem write-jail holds. Only the
|
||||||
|
// WFP network filters are Administrator-only, so network stays with the
|
||||||
|
// in-process approval gate at that tier. A profile that needs the sandbox
|
||||||
|
// solely for network isolation gains nothing from the unelevated tier and
|
||||||
|
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
|
||||||
|
// still gets a clear error pointing at setup.
|
||||||
windowsNeedsSetup := false
|
windowsNeedsSetup := false
|
||||||
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
|
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
|
||||||
if preference == SandboxPreferenceRequire {
|
if preference == SandboxPreferenceRequire {
|
||||||
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
|
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
|
||||||
}
|
}
|
||||||
enforcementLevel = EnforcementDegraded
|
|
||||||
windowsNeedsSetup = true
|
windowsNeedsSetup = true
|
||||||
|
if profile.FileSystem.Kind == FileSystemRestricted {
|
||||||
|
enforcementLevel = EnforcementUnelevated
|
||||||
|
} else {
|
||||||
|
enforcementLevel = EnforcementDegraded
|
||||||
|
}
|
||||||
}
|
}
|
||||||
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
|
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
|
||||||
downgradeReason := ""
|
downgradeReason := ""
|
||||||
@@ -196,7 +259,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
|||||||
downgradeReason = windowsSetupDowngradeReason
|
downgradeReason = windowsSetupDowngradeReason
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
if enforcementLevel == EnforcementUnelevated {
|
||||||
|
downgradeReason = windowsUnelevatedDowngradeReason
|
||||||
|
}
|
||||||
|
wrapped := backend.CommandWrapping && backend.Available &&
|
||||||
|
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
|
||||||
markers := backend.SandboxEnvMarkers(policy)
|
markers := backend.SandboxEnvMarkers(policy)
|
||||||
if !wrapped && backend.Name != BackendWSL {
|
if !wrapped && backend.Name != BackendWSL {
|
||||||
markers = nil
|
markers = nil
|
||||||
|
|||||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user