mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad34dc8d81 | ||
|
|
6edf9a8b78 | ||
|
|
90dcfd127e | ||
|
|
01c3498205 | ||
|
|
1ec7219aee | ||
|
|
1a81292d3f | ||
|
|
a78c6c357e | ||
|
|
8dd33ec39e | ||
|
|
d7ac85cbf9 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.7.0"}
|
||||
{".":"0.8.0"}
|
||||
@@ -111,3 +111,42 @@ These classes drive multi-round reviews. Fix them before requesting review:
|
||||
shipped. Wire advertised entry points or shrink the claim. Do not bundle
|
||||
unrelated fixes. Do not widen security allowlists by name alone; check
|
||||
classification and side effects too.
|
||||
- **Host tool version floors:** Zero shells out to `git` and other host tools,
|
||||
and the project does not declare a minimum version for any of them. A flag,
|
||||
subcommand, or config key that is not long-established therefore raises that
|
||||
floor silently, and the failure on an older host is an unhelpful usage error
|
||||
rather than a clear message. Before adding one, check when it landed and say
|
||||
so where a caller would look. Record the floor for the exact subcommand you
|
||||
call, not for the flag in general: `--end-of-options` reached Git's shared
|
||||
parser in 2.24, but `git rev-parse` only accepted it from 2.30, and
|
||||
`git checkout` and `git reset` later still. Likewise `--show-current` is
|
||||
`git branch` 2.22, and `branch.autoSetupMerge=inherit` is 2.35. Then either
|
||||
keep a fallback for older versions or gate the call. A test that depends on a
|
||||
newer feature must skip below its floor instead of failing, and say which
|
||||
version it needs.
|
||||
- **Prove the test fails without the change:** A regression test earns its
|
||||
place only if it fails on the unfixed code, for the reason it claims. Run it
|
||||
both ways and quote the failure in the PR. The recurring miss is not a
|
||||
missing test but a test that never reaches the behavior it names, because an
|
||||
earlier guard rejects the input first, so it passes with and without the fix.
|
||||
When a test targets a specific layer, call that layer directly rather than
|
||||
relying on a public entry point that may refuse earlier.
|
||||
- **Hermetic tests:** Tests must not read or write the developer's real config,
|
||||
cache, or state directories. Redirect every root the code under test
|
||||
resolves, not just config. `os.UserConfigDir` and `os.UserCacheDir` read
|
||||
`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` only on Linux and the BSDs. On macOS
|
||||
they derive from `HOME`, and on Windows from `%AppData%` and `%LocalAppData%`.
|
||||
Setting the XDG variables alone still leaves a macOS run writing to the real
|
||||
home, so set `HOME` as well, or take the directory as a parameter. Apply it to
|
||||
every test that reaches the same storage, not only the one where it was first
|
||||
needed; a test that passes on a clean machine and pollutes a real one is a
|
||||
blocker.
|
||||
- **Error codes vary by platform and by flag combination:** A check written
|
||||
against the code one target returns can be silently inert on another while
|
||||
still looking correct. `openat(O_DIRECTORY|O_NOFOLLOW)` on a symlink may
|
||||
report `ENOTDIR` or `ELOOP` depending on the platform, because the kernel can
|
||||
reject the directory mismatch before it ever resolves the link; accept both
|
||||
rather than assuming the one POSIX names. On Windows, `\\?\` and `\\.\`
|
||||
prefixes are not UNC paths, and an `NtCreateFile` information class must match
|
||||
the struct actually passed. Confirm the code each supported target returns
|
||||
instead of reasoning from the man page.
|
||||
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **security:** path-jail primitive, credential-store locking, worktree git hardening ([#891](https://github.com/Gitlawb/zero/issues/891)) ([060d38c](https://github.com/Gitlawb/zero/commit/060d38ce716feb7c5b6657b082cd35cb8dda3d75))
|
||||
* **update:** recognise Homebrew installs and add mise, go install docs ([#910](https://github.com/Gitlawb/zero/issues/910)) ([01c3498](https://github.com/Gitlawb/zero/commit/01c3498205e69d26e5f929225f9455b276444f39))
|
||||
* use Exa as default search provider ([#926](https://github.com/Gitlawb/zero/issues/926)) ([1ec7219](https://github.com/Gitlawb/zero/commit/1ec7219aee7b2840481ab0905f131644e27d2a4e))
|
||||
* **zerogit:** auto-create a conventional branch before push/pr on default branch ([#671](https://github.com/Gitlawb/zero/issues/671)) ([d065467](https://github.com/Gitlawb/zero/commit/d065467cd3ec3ed91c5dc60554c36fc4b4d6de4c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** three defects a desktop ACP client hits ([#915](https://github.com/Gitlawb/zero/issues/915)) ([a78c6c3](https://github.com/Gitlawb/zero/commit/a78c6c357e1d21146d7cd2e8030a754e06f7f1f5))
|
||||
* bump Go to 1.26.6 for stdlib vulnerability fixes ([#903](https://github.com/Gitlawb/zero/issues/903)) ([dc15e82](https://github.com/Gitlawb/zero/commit/dc15e822987d8d7de467c62c375518f435b74d8c))
|
||||
* **daemon:** clean up child after startup timeout ([#774](https://github.com/Gitlawb/zero/issues/774)) ([9c29209](https://github.com/Gitlawb/zero/commit/9c292092a26c9092fd873d072b0af1b0d9d39ef0))
|
||||
* **dictation:** redact API keys from streaming transcriber errors ([#852](https://github.com/Gitlawb/zero/issues/852)) ([64a783b](https://github.com/Gitlawb/zero/commit/64a783b08caa2c7b21f188033b9ed84a14e00e84))
|
||||
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
|
||||
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
|
||||
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))
|
||||
|
||||
## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.5+。
|
||||
源码构建需要 Go 1.26.6+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
|
||||
+1
-1
@@ -33,4 +33,4 @@ Please include, where possible:
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to the documented Firecrawl endpoint — see the README).
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
||||
+83
-8
@@ -1,14 +1,30 @@
|
||||
# Installing Zero
|
||||
|
||||
Zero is distributed as:
|
||||
Pick whichever fits how you already install things. None of these is the
|
||||
blessed one.
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
| Method | Command | Self-update |
|
||||
| --- | --- | --- |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
`zero upgrade` follows whatever owns the binary, with one exception it cannot
|
||||
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
|
||||
keg and names `brew upgrade zero`, and replaces the binary in place for
|
||||
everything else — including a mise-managed one, which is the exception.
|
||||
|
||||
mise is the one case it cannot detect: a mise-managed binary is an ordinary
|
||||
standalone install on disk, so `zero upgrade` will replace it and leave mise
|
||||
describing a version that is no longer there. Use `mise upgrade` instead.
|
||||
|
||||
Release archives are the substrate for most of the above: the install scripts
|
||||
and the npm fallback both download a platform archive from a published GitHub
|
||||
Release and verify its checksum.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -111,6 +127,45 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## mise
|
||||
|
||||
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
|
||||
archives through its `ubi` backend, so there is nothing extra to publish and no
|
||||
registry in the middle:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/zero
|
||||
```
|
||||
|
||||
Pin a version the same way you would any other tool:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Gitlawb/[email protected]
|
||||
```
|
||||
|
||||
Updates come from `mise upgrade`. `zero upgrade` also works, because a
|
||||
mise-managed binary is an ordinary standalone install, but then mise's records
|
||||
describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
|
||||
## go install
|
||||
|
||||
```bash
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
not installed by this command. See
|
||||
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
|
||||
Without it, native sandboxing is unavailable.
|
||||
- `zero upgrade` treats the result as a standalone install and will replace the
|
||||
binary with a release build rather than rebuilding from source. If you chose
|
||||
`go install` deliberately, rerun it instead.
|
||||
|
||||
macOS and Windows need no extra helper.
|
||||
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
@@ -125,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.26.6+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -242,3 +297,23 @@ zero upgrade
|
||||
```
|
||||
|
||||
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
|
||||
`zero upgrade` behaves differently depending on how Zero was installed, because
|
||||
overwriting a binary a package manager owns leaves that manager describing a
|
||||
version that is no longer there:
|
||||
|
||||
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
|
||||
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
|
||||
keg binary directly would be reverted by the next `brew upgrade` or
|
||||
`brew reinstall`.
|
||||
- **everything else**: downloads the verified release archive and replaces the
|
||||
binary in place.
|
||||
|
||||
Homebrew is detected by the binary living inside a Cellar keg that carries
|
||||
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
|
||||
directory tree you built yourself can have exactly that shape, and refusing to
|
||||
update an install Homebrew never touched would be a real cost for a made-up
|
||||
reason. The receipt is the thing only Homebrew writes.
|
||||
|
||||
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
|
||||
where that is also the Homebrew prefix.
|
||||
+16
-2
@@ -296,6 +296,16 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return StopCancelled, nil
|
||||
}
|
||||
// A CLIENT THAT CANCELLED A PERMISSION PROMPT DID NOT FAIL. Only
|
||||
// context.Canceled was matched here, so dismissing a permission dialog —
|
||||
// a deliberate action, and for apply_patch the only refusal a client is
|
||||
// offered — came back as JSON-RPC -32603 carrying the internal sentinel
|
||||
// text. Editors and the desktop app both render that as a crashed turn,
|
||||
// so declining a tool looked like ZERO falling over. It is a
|
||||
// cancellation, and StopCancelled is what ACP has for saying so.
|
||||
if errors.Is(err, agent.ErrPermissionApprovalCanceled) {
|
||||
return StopCancelled, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason == "length" {
|
||||
@@ -311,10 +321,14 @@ func stopReasonFor(result agent.Result, err error) (string, error) {
|
||||
// session/request_permission request and maps the outcome back. Failure to reach
|
||||
// the client fails closed to deny.
|
||||
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
// Built ONCE and used for both halves. Sending one list and validating the
|
||||
// reply against another is the defect this whole path had; see
|
||||
// decisionFromOutcome.
|
||||
options := buildPermissionOptions(req)
|
||||
params := RequestPermissionParams{
|
||||
SessionID: sessionID,
|
||||
ToolCall: permissionToolCall(req),
|
||||
Options: buildPermissionOptions(req),
|
||||
Options: options,
|
||||
}
|
||||
var result RequestPermissionResult
|
||||
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
||||
@@ -323,7 +337,7 @@ func (a *Agent) requestPermission(ctx context.Context, sessionID string, req age
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
|
||||
}
|
||||
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
|
||||
return decisionFromOutcome(result.Outcome, options), nil
|
||||
}
|
||||
|
||||
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package acp
|
||||
|
||||
// desktopinterop_test.go covers three defects found by driving the real `zero
|
||||
// acp` binary from a desktop ACP client. Each is a case where ZERO and a
|
||||
// conforming client disagreed about the wire, and none of them failed loudly —
|
||||
// they produced a silent denial, a spurious crash, or two buttons the user
|
||||
// could not tell apart.
|
||||
//
|
||||
// A fourth, session/load restoring history without replaying it, is fixed by
|
||||
// #914 rather than here: that one also gives the replayed messages stable ids
|
||||
// and keeps session/resume replay-free, which this did not.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
// ---- an offered option is an acceptable answer ----
|
||||
|
||||
// The list the options were BUILT from and the list the answer was CHECKED
|
||||
// against were different whenever ZERO did not enumerate: the client was sent
|
||||
// Allow and Reject, and its reply was validated against an empty slice, so
|
||||
// every button it could possibly show failed closed to deny. The user clicked
|
||||
// Allow and ZERO recorded a denial, with nothing on screen to say so.
|
||||
func TestAnOfferedOptionIsAccepted(t *testing.T) {
|
||||
// No AvailableDecisions: the fallback path, which is every permission event
|
||||
// that is not a prompt.
|
||||
req := agent.PermissionRequest{ToolName: "bash"}
|
||||
|
||||
options := buildPermissionOptions(req)
|
||||
if len(options) == 0 {
|
||||
t.Fatal("no options were offered")
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
||||
buildPermissionOptions(req),
|
||||
)
|
||||
if string(decision.Action) != option.OptionID {
|
||||
t.Fatalf("option %q was answered with %q (%s) — an offered option must be accepted",
|
||||
option.OptionID, decision.Action, decision.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The narrowing this validation exists for still holds: a client must not be
|
||||
// able to return a broader grant than it was shown.
|
||||
func TestAnOptionThatWasNotOfferedIsStillRefused(t *testing.T) {
|
||||
req := agent.PermissionRequest{ToolName: "bash"}
|
||||
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAlwaysAllow)},
|
||||
buildPermissionOptions(req),
|
||||
)
|
||||
if decision.Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("an unoffered broader grant was accepted as %q", decision.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver is the single source of truth for both sides, which is what
|
||||
// makes the two agree by construction rather than by being kept in step.
|
||||
func TestOfferedDecisionsMatchWhatWasSent(t *testing.T) {
|
||||
for _, req := range []agent.PermissionRequest{
|
||||
{ToolName: "bash"},
|
||||
{ToolName: "bash", AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowForSession,
|
||||
agent.PermissionDecisionDeny,
|
||||
}},
|
||||
} {
|
||||
offered := buildPermissionOptions(req)
|
||||
for _, option := range buildPermissionOptions(req) {
|
||||
if !actionOffered(option.OptionID, offered) {
|
||||
t.Fatalf("option %q was sent but is not in the offered set %v", option.OptionID, offered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- two options the user can tell apart ----
|
||||
|
||||
// request_permissions offers plain allow and strict-review allow together, and
|
||||
// both were labelled "Allow". The label is the only thing an ACP client shows,
|
||||
// so the panel presented two identical buttons where one silently enabled
|
||||
// strict auto-review of what was granted.
|
||||
func TestEveryOfferedOptionHasADistinctLabel(t *testing.T) {
|
||||
req := agent.PermissionRequest{
|
||||
ToolName: "request_permissions",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowStrict,
|
||||
agent.PermissionDecisionAllowForSession,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
|
||||
seen := map[string]string{}
|
||||
for _, option := range buildPermissionOptions(req) {
|
||||
if previous, clash := seen[option.Name]; clash {
|
||||
t.Fatalf("options %q and %q share the label %q — a user cannot tell them apart",
|
||||
previous, option.OptionID, option.Name)
|
||||
}
|
||||
seen[option.Name] = option.OptionID
|
||||
}
|
||||
}
|
||||
|
||||
// The two remain distinct decisions: the label changed, the round trip did not.
|
||||
func TestStrictAllowStillRoundTrips(t *testing.T) {
|
||||
req := agent.PermissionRequest{
|
||||
ToolName: "request_permissions",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowStrict,
|
||||
},
|
||||
}
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(agent.PermissionDecisionAllowStrict)},
|
||||
buildPermissionOptions(req),
|
||||
)
|
||||
if decision.Action != agent.PermissionDecisionAllowStrict {
|
||||
t.Fatalf("strict allow round-tripped as %q", decision.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cancelling a permission is a cancellation, not a crash ----
|
||||
|
||||
// Only context.Canceled was recognised, so dismissing a permission dialog came
|
||||
// back as JSON-RPC -32603 carrying the internal sentinel text. Clients render
|
||||
// that as a failed turn, so declining a tool looked like ZERO falling over —
|
||||
// and for apply_patch, dismissing is the ONLY refusal a client is offered.
|
||||
func TestCancellingAPermissionEndsTheTurnAsCancelled(t *testing.T) {
|
||||
canceled := fmt.Errorf("%w for apply_patch", agent.ErrPermissionApprovalCanceled)
|
||||
|
||||
reason, err := stopReasonFor(agent.Result{}, canceled)
|
||||
if err != nil {
|
||||
t.Fatalf("stopReasonFor returned an error for a cancellation: %v", err)
|
||||
}
|
||||
if reason != StopCancelled {
|
||||
t.Fatalf("stop reason = %q, want %q", reason, StopCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuine failure must still be an error. Mapping too much to cancelled would
|
||||
// hide real faults behind a clean ending, which is the opposite mistake.
|
||||
func TestARealFailureIsStillAnError(t *testing.T) {
|
||||
failure := errors.New("provider exploded")
|
||||
|
||||
if _, err := stopReasonFor(agent.Result{}, failure); !errors.Is(err, failure) {
|
||||
t.Fatalf("stopReasonFor(%v) = %v, want the failure preserved", failure, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The sentinel is matched through errors.Is, so it survives the wrapping every
|
||||
// return site applies — each one adds the tool name.
|
||||
func TestPermissionCancellationSurvivesWrapping(t *testing.T) {
|
||||
if !errors.Is(fmt.Errorf("%w for bash", agent.ErrPermissionApprovalCanceled), agent.ErrPermissionApprovalCanceled) {
|
||||
t.Fatal("a wrapped permission cancellation was not recognised")
|
||||
}
|
||||
if errors.Is(errors.New("something else"), agent.ErrPermissionApprovalCanceled) {
|
||||
t.Fatal("an unrelated error was reported as a permission cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// A decision that never became an option cannot be selected.
|
||||
//
|
||||
// PermissionDecisionCancel is the case that exists today: optionKindFor drops
|
||||
// it because ACP expresses cancellation through the outcome, yet ZERO
|
||||
// enumerates it in AvailableDecisions for shell commands and apply_patch. While
|
||||
// the reply was validated against the DECISIONS rather than the sent options, a
|
||||
// client could return {"outcome":"selected","optionId":"cancel"} — an id it was
|
||||
// never shown — and abort the whole turn.
|
||||
//
|
||||
// Written over every option-less decision rather than over "cancel", so a
|
||||
// future action that optionKindFor declines to render is covered the day it is
|
||||
// added rather than the day someone remembers this test.
|
||||
func TestADecisionThatIsNotAnOptionCannotBeSelected(t *testing.T) {
|
||||
for _, toolName := range []string{"bash", "apply_patch"} {
|
||||
req := agent.PermissionRequest{
|
||||
ToolName: toolName,
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel,
|
||||
},
|
||||
}
|
||||
options := buildPermissionOptions(req)
|
||||
|
||||
for _, action := range req.AvailableDecisions {
|
||||
if actionOffered(string(action), options) {
|
||||
continue // it was sent; selecting it is legitimate
|
||||
}
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: string(action)},
|
||||
options,
|
||||
)
|
||||
if decision.Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("%s: %q was never sent as an option but was accepted as %q",
|
||||
toolName, action, decision.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling is still reachable — through the outcome, which is where ACP puts
|
||||
// it. Closing the selected-id route must not close the real one.
|
||||
func TestCancellingThroughTheOutcomeStillWorks(t *testing.T) {
|
||||
options := buildPermissionOptions(agent.PermissionRequest{ToolName: "apply_patch"})
|
||||
|
||||
decision := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, options)
|
||||
if decision.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("outcome=cancelled gave %q, want cancel", decision.Action)
|
||||
}
|
||||
}
|
||||
+57
-18
@@ -15,14 +15,7 @@ import (
|
||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
actions := req.AvailableDecisions
|
||||
if len(actions) == 0 {
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
actions = []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
actions := offeredDecisions(req)
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
|
||||
@@ -38,6 +31,32 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
return options
|
||||
}
|
||||
|
||||
// offeredDecisions resolves what this request actually offers the client.
|
||||
//
|
||||
// ONE RESOLVER, USED BY BOTH SIDES, and that is the entire point of it existing.
|
||||
// The fallback below used to live inside buildPermissionOptions while
|
||||
// requestPermission validated the client's answer against the raw
|
||||
// req.AvailableDecisions. When ZERO did not enumerate — which is every
|
||||
// permission event that is not a prompt, and includes a shell call whose sandbox
|
||||
// decision came back deny — the two disagreed completely: the client was sent
|
||||
// "Allow" and "Reject", and validation was performed against an EMPTY list, so
|
||||
// every option the client could possibly click failed closed to deny with
|
||||
// "permission option was not offered".
|
||||
//
|
||||
// The user clicked Allow and ZERO recorded a denial. Nothing surfaced it,
|
||||
// because a denial is a legitimate answer — the tool was simply refused and the
|
||||
// turn carried on as though the user had rejected it.
|
||||
func offeredDecisions(req agent.PermissionRequest) []agent.PermissionDecisionAction {
|
||||
if len(req.AvailableDecisions) > 0 {
|
||||
return req.AvailableDecisions
|
||||
}
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
return []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
|
||||
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||
// outcome rather than an option (cancel).
|
||||
@@ -49,8 +68,16 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
// disclosure cannot be TUI-only.
|
||||
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
case agent.PermissionDecisionAllow:
|
||||
return PermAllowOnce, "Allow"
|
||||
case agent.PermissionDecisionAllowStrict:
|
||||
// SPLIT FROM PLAIN ALLOW because the label is the only thing an ACP
|
||||
// client shows. Both were labelled "Allow", and request_permissions
|
||||
// offers them together — so the panel presented two identical buttons,
|
||||
// and whichever the user picked looked the same while one of them
|
||||
// silently turned on strict auto-review of what was granted. The option
|
||||
// id carries no room for the distinction, so it has to be in the name.
|
||||
return PermAllowOnce, "Allow with strict review"
|
||||
case agent.PermissionDecisionAllowForSession:
|
||||
return PermAllowAlways, "Allow for this session"
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
@@ -70,19 +97,31 @@ func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind,
|
||||
|
||||
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
||||
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||
// action verbatim (validated against what was offered); anything unrecognized
|
||||
// fails closed to deny.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||
// action verbatim (validated against what was actually sent); anything
|
||||
// unrecognized fails closed to deny.
|
||||
//
|
||||
// IT TAKES THE OPTIONS THAT WERE SENT, not the decisions the request offered,
|
||||
// and the two are not the same list. Some actions never become options —
|
||||
// PermissionDecisionCancel is dropped by optionKindFor because ACP expresses
|
||||
// cancellation through the outcome instead — yet it IS enumerated in
|
||||
// AvailableDecisions for shell commands and apply_patch, the two commonest
|
||||
// prompts. Validating against the decisions therefore accepted
|
||||
// {"outcome":"selected","optionId":"cancel"}: an identifier no client was ever
|
||||
// shown, aborting the whole turn.
|
||||
//
|
||||
// Passing the sent options makes "what we offered" and "what we accept" the
|
||||
// same slice rather than two things kept in step, so anything optionKindFor
|
||||
// declines to render is excluded structurally instead of by name.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []PermissionOption) agent.PermissionDecision {
|
||||
switch outcome.Outcome {
|
||||
case OutcomeCancelled:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||
case OutcomeSelected:
|
||||
action := agent.PermissionDecisionAction(outcome.OptionID)
|
||||
// Bind to what was actually offered for THIS call: a client must not be able
|
||||
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||
// presented. Anything not offered fails closed to deny.
|
||||
if actionOffered(action, offered) {
|
||||
return agent.PermissionDecision{Action: action}
|
||||
if actionOffered(outcome.OptionID, offered) {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionAction(outcome.OptionID)}
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||
default:
|
||||
@@ -90,9 +129,9 @@ func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.Permi
|
||||
}
|
||||
}
|
||||
|
||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||
for _, a := range offered {
|
||||
if a == action {
|
||||
func actionOffered(optionID string, offered []PermissionOption) bool {
|
||||
for _, option := range offered {
|
||||
if option.OptionID == optionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,11 @@ func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
|
||||
// what was offered, so decorating it would fail every prefix approval closed.
|
||||
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
|
||||
request := escalationRequest(true)
|
||||
for _, option := range buildPermissionOptions(request) {
|
||||
options := buildPermissionOptions(request)
|
||||
for _, option := range options {
|
||||
decision := decisionFromOutcome(
|
||||
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
|
||||
request.AvailableDecisions,
|
||||
options,
|
||||
)
|
||||
if string(decision.Action) != option.OptionID {
|
||||
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
|
||||
|
||||
@@ -39,11 +39,14 @@ func TestBuildPermissionOptionsDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecisionFromOutcome(t *testing.T) {
|
||||
offered := []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
offered := buildPermissionOptions(agent.PermissionRequest{
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
})
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,43 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The launcher denylist is only worth anything if it holds on the path a user
|
||||
// actually sees. A model naming its own prefix_rule is that path: before
|
||||
// launcher names were normalized, "python3.11" was offered as a one-token
|
||||
// grant, and hasStringPrefix then matched every later python3.11 invocation.
|
||||
func TestProposedCommandPrefixRejectsLauncherSpellingsInRequestedPrefix(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
command string
|
||||
rule []any
|
||||
}{
|
||||
{"python3.11 script.py", []any{"python3.11"}},
|
||||
{"python.exe script.py", []any{"python.exe"}},
|
||||
{"node.exe app.js", []any{"node.exe"}},
|
||||
{"cmd /c whoami", []any{"cmd"}},
|
||||
{"busybox sh -c id", []any{"busybox"}},
|
||||
{"uv run main.py", []any{"uv", "run"}},
|
||||
{"python3.11 -c import_os", []any{"python3.11", "-c"}},
|
||||
} {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": testCase.command,
|
||||
"prefix_rule": testCase.rule,
|
||||
})
|
||||
if got != nil {
|
||||
t.Errorf("prefix_rule %v for %q was offered as %#v, want rejected", testCase.rule, testCase.command, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixStillOffersOrdinaryCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "cargo build --release",
|
||||
"prefix_rule": []any{"cargo", "build"},
|
||||
})
|
||||
if len(got) != 2 || got[0] != "cargo" || got[1] != "build" {
|
||||
t.Fatalf("ordinary prefix rule = %#v, want [cargo build]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"cat < in > out",
|
||||
|
||||
+19
-7
@@ -40,7 +40,15 @@ const (
|
||||
toolResultControlSpecReview = "spec_review_required"
|
||||
)
|
||||
|
||||
var errPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
// ErrPermissionApprovalCanceled ends a run because the caller cancelled a
|
||||
// permission prompt, rather than because anything failed.
|
||||
//
|
||||
// EXPORTED FOR THE SURFACES. A cancel is a normal ending and each surface says
|
||||
// so its own way — the TUI unwinds, and ACP has a "cancelled" stop reason for
|
||||
// exactly this. While it was unexported ACP could not distinguish it from a
|
||||
// fault and reported it as an internal error, so declining a tool looked like
|
||||
// the agent crashing.
|
||||
var ErrPermissionApprovalCanceled = errors.New("permission approval cancelled")
|
||||
|
||||
// isImageRejectionError reports whether err is a provider 400 that rejects
|
||||
// image/multimodal content. This is checked BEFORE the compaction-retry path
|
||||
@@ -1364,7 +1372,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, decisionReason)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return canceledPermissionResult(call, decisionReason, requestEvent), fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, decisionReason)
|
||||
return deniedPermissionResult(call, decisionReason, requestEvent), nil
|
||||
@@ -1548,7 +1556,7 @@ func maybeRetryUnsandboxedAfterSandboxRestriction(ctx context.Context, registry
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return result, &canceled, true, decision.Action, reason, nil, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
@@ -1596,7 +1604,7 @@ func maybeRetryWithNetworkAfterSandboxDenial(ctx context.Context, registry *tool
|
||||
case PermissionDecisionCancel:
|
||||
emitCanceledPermission(options, call, requestEvent, reason)
|
||||
canceled := canceledPermissionResult(call, reason, requestEvent)
|
||||
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", errPermissionApprovalCanceled, call.Name)
|
||||
return result, &canceled, true, decision.Action, reason, fmt.Errorf("%w for %s", ErrPermissionApprovalCanceled, call.Name)
|
||||
default:
|
||||
emitDeniedPermission(options, call, requestEvent, reason)
|
||||
denied := deniedPermissionResult(call, reason, requestEvent)
|
||||
@@ -3087,13 +3095,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// registered, model-visible, not in DisabledTools, and advertised in the
|
||||
// current permission mode (e.g. not spec-draft, where tool_search is not
|
||||
// advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
tools.ModelVisible(loader) &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
@@ -3208,7 +3218,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
return tools.ModelVisible(tool) &&
|
||||
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
|
||||
ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
||||
|
||||
@@ -1853,7 +1853,7 @@ func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
if !errors.Is(err, errPermissionApprovalCanceled) {
|
||||
if !errors.Is(err, ErrPermissionApprovalCanceled) {
|
||||
t.Fatalf("expected permission approval cancel error, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "" {
|
||||
@@ -4340,3 +4340,95 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
|
||||
t.Fatalf("expected tool result to mention the beforeTool veto, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
// A cancelled permission on either sandbox-retry path must abort the run with a
|
||||
// recognisable cancellation, and must not run the escalated retry.
|
||||
//
|
||||
// BOTH PATHS, because both wrap ErrPermissionApprovalCanceled and each is
|
||||
// reached by a different kind of denial: one by an unrestricted platform denial
|
||||
// asking for approval, the other by an external-network denial. Surfaces map
|
||||
// this sentinel onto their own idea of a cancel — ACP returns stopReason
|
||||
// "cancelled" for it — so a path that produced a bare error instead would show
|
||||
// the user a crash where they had simply declined.
|
||||
func TestCancellingASandboxRetryAbortsWithoutRetrying(t *testing.T) {
|
||||
denial := func(capability execution.CapabilityKind, scope string) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: capability, Scope: scope},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "denied by the sandbox",
|
||||
Recoverable: true,
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
result tools.Result
|
||||
run func(context.Context, *tools.Registry, ToolCall, tools.Tool, map[string]any, tools.Result, Options) error
|
||||
}{
|
||||
{
|
||||
name: "unsandboxed retry",
|
||||
result: denial(execution.CapabilityUnrestricted, "host"),
|
||||
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
|
||||
_, _, _, _, _, _, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(
|
||||
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
|
||||
return abortErr
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "network retry",
|
||||
result: denial(execution.CapabilityExternalNetwork, ""),
|
||||
run: func(ctx context.Context, registry *tools.Registry, call ToolCall, tool tools.Tool, args map[string]any, result tools.Result, options Options) error {
|
||||
_, _, _, _, _, abortErr := maybeRetryWithNetworkAfterSandboxDenial(
|
||||
ctx, registry, call, tool, args, result, PermissionModeAsk, options, nil)
|
||||
return abortErr
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
retryTool := &sandboxNamespaceLimitedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
|
||||
asked := 0
|
||||
options := Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
asked++
|
||||
return PermissionDecision{Action: PermissionDecisionCancel, Reason: "client cancelled"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
call := ToolCall{ID: "call-1", Name: "bash", Arguments: `{"command":"curl example.com"}`}
|
||||
args := map[string]any{"command": "curl example.com"}
|
||||
|
||||
abortErr := testCase.run(context.Background(), registry, call, retryTool, args, testCase.result, options)
|
||||
|
||||
if asked != 1 {
|
||||
t.Fatalf("permission was requested %d times, want exactly 1", asked)
|
||||
}
|
||||
if !errors.Is(abortErr, ErrPermissionApprovalCanceled) {
|
||||
t.Fatalf("abort error = %v, want it to match ErrPermissionApprovalCanceled", abortErr)
|
||||
}
|
||||
// The escalated retry is the thing the permission was gating. A
|
||||
// cancelled prompt that still ran it would have run the command the
|
||||
// user just declined.
|
||||
if len(retryTool.calls) != 0 {
|
||||
t.Fatalf("the escalated retry ran %d times after a cancel: %+v", len(retryTool.calls), retryTool.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
|
||||
names := make(map[string]bool, len(exposed))
|
||||
for _, definition := range exposed {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for existing-file changes")
|
||||
}
|
||||
if names["edit_file"] {
|
||||
t.Fatal("agent must not receive the ambiguous string-replacement tool")
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ work.
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, edit_file, apply_patch - over shelling out to
|
||||
write_file, apply_patch - over shelling out to
|
||||
cat/sed/awk/python for file operations.
|
||||
They are safer, reviewable, and produce clean diffs.
|
||||
- Prefer read_minified_file when initially exploring source code; it preserves
|
||||
@@ -61,10 +61,11 @@ work.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
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.
|
||||
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
|
||||
and enough unchanged context to identify the intended location. 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
|
||||
@@ -104,7 +105,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
with exec_command, or use write_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
|
||||
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
|
||||
commands, and symbols.
|
||||
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
|
||||
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
write_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
- Persist until the task is fully handled this turn: gather context, implement,
|
||||
run the validators, and report — do not stop at a partial result.
|
||||
</model_guidance>`
|
||||
|
||||
const geminiPromptAddendum = `<model_guidance>
|
||||
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
|
||||
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"prefer edit_file or apply_patch",
|
||||
"for edits to existing files, prefer apply_patch",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
|
||||
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
||||
}
|
||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
var migrated bool
|
||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
||||
if !migrated {
|
||||
return Task{}, false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if outputFile != task.OutputFile {
|
||||
changed = true
|
||||
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
||||
// or XDG data root. It accepts only the canonical per-task output
|
||||
// name in the historic zero/background layout, and only when the corresponding
|
||||
// regular file already exists inside the active manager root.
|
||||
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if !filepath.IsAbs(requested) {
|
||||
return "", false
|
||||
}
|
||||
requested = filepath.Clean(requested)
|
||||
expectedName := taskID + ".ndjson"
|
||||
if filepath.Base(requested) != expectedName {
|
||||
return "", false
|
||||
}
|
||||
legacyRoot := filepath.Dir(requested)
|
||||
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
||||
return "", false
|
||||
}
|
||||
canonical := filepath.Join(manager.rootDir, expectedName)
|
||||
info, err := os.Lstat(canonical)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func validTaskID(taskID string) bool {
|
||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
canonicalOutput := filepath.Join(root, taskID+".ndjson")
|
||||
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: legacyOutput,
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write legacy metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
task, ok := manager.Get(taskID)
|
||||
if !ok {
|
||||
t.Fatal("manager did not load migrated task")
|
||||
}
|
||||
if task.OutputFile != canonicalOutput {
|
||||
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
|
||||
}
|
||||
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
|
||||
t.Fatalf("migration produced load warnings: %#v", warnings)
|
||||
}
|
||||
|
||||
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated metadata: %v", err)
|
||||
}
|
||||
var saved Task
|
||||
if err := json.Unmarshal(persisted, &saved); err != nil {
|
||||
t.Fatalf("decode migrated metadata: %v", err)
|
||||
}
|
||||
if saved.OutputFile != canonicalOutput {
|
||||
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
if _, ok := manager.Get(taskID); ok {
|
||||
t.Fatal("manager loaded task with an unrelated absolute output path")
|
||||
}
|
||||
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
|
||||
t.Fatalf("load warnings = %q, want containment rejection", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager, err := NewManager(root)
|
||||
|
||||
+1
-1
@@ -856,7 +856,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
||||
// explained rather than silently absent. A built-in default the user never
|
||||
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
|
||||
// configured (e.g. keyless Exa with no credentials) is the exception: it
|
||||
// was never asked for, so its failure is not worth a startup warning — only
|
||||
// servers the user actually configured warn on failure (issue #552).
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
|
||||
@@ -211,12 +211,12 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
||||
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
||||
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
||||
}}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
@@ -227,7 +227,7 @@ func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "firecrawl") {
|
||||
if strings.Contains(stderr.String(), "exa") {
|
||||
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
|
||||
// `zero mcp disable exa` must work even though exa is a built-in
|
||||
// default that is not written to the user's config file until overridden.
|
||||
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
func TestRunMCPDisableSeededExaDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
// A config with no firecrawl entry — the default lives in code, not the file.
|
||||
// A config with no Exa entry — the default lives in code, not the file.
|
||||
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
|
||||
code := runWithDeps([]string{"mcp", "disable", "exa", "--json"}, &out, &errBuf, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
@@ -31,8 +31,8 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
|
||||
if payload.ServerName != "exa" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want exa disabled+changed", payload)
|
||||
}
|
||||
|
||||
// End-to-end: resolving that config now turns the default off.
|
||||
@@ -40,7 +40,7 @@ func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("expected `mcp disable exa` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
const (
|
||||
providerCommandTestBudget = 90 * time.Second
|
||||
providerCommandReadyWaitAttempts = 45
|
||||
)
|
||||
|
||||
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
|
||||
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
|
||||
if providerCommandTestBudget < 2*readyWaitCeiling {
|
||||
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
|
||||
// runner while leaving the other half of providerCommandTestBudget for
|
||||
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
|
||||
@@ -5,22 +5,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
|
||||
// search and scraping work out of the box with no setup and no API key. They are
|
||||
// seeded before user/project config is merged (see ResolveMCP), so a user can
|
||||
// override any field — for example point firecrawl at a self-hosted instance, or
|
||||
// add an API-key header to lift the free-tier limit — or disable it entirely with
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so
|
||||
// web search and page fetching work out of the box with no setup and no API
|
||||
// key. They are seeded before user/project config is merged (see ResolveMCP),
|
||||
// so a user can override any field — for example add an API-key header to lift
|
||||
// Exa's anonymous rate limit — or disable it entirely with
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
|
||||
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
|
||||
// use. Zero only calls it over the network, so Firecrawl's license never reaches
|
||||
// into Zero's own code.
|
||||
// Exa's hosted MCP server works anonymously with rate limits. Users can add an
|
||||
// Exa API key for higher limits.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"firecrawl": {
|
||||
"exa": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
URL: "https://mcp.exa.ai/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -35,13 +33,13 @@ func IsDefaultMCPServer(name string) bool {
|
||||
|
||||
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
|
||||
// defaults that the user never wrote an entry for in their config — i.e. it is
|
||||
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
|
||||
// running with whatever Zero ships (e.g. keyless Exa, no credentials).
|
||||
//
|
||||
// Both conditions below must hold:
|
||||
// - !server.configured: the user's JSON never declared an object for this
|
||||
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
|
||||
// actually ran for this key). Any explicit action — including a
|
||||
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
|
||||
// disable/enable toggle like `zero mcp enable exa` that leaves the
|
||||
// resolved value unchanged — sets configured, so it always counts as
|
||||
// user-configured, even though the value comparison below could not tell
|
||||
// the difference on its own.
|
||||
@@ -58,3 +56,90 @@ func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
|
||||
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok && !server.configured && reflect.DeepEqual(def, server)
|
||||
}
|
||||
|
||||
// retiredDefaultMCPServer describes a built-in default Zero no longer ships.
|
||||
type retiredDefaultMCPServer struct {
|
||||
// successor is the default that replaced it.
|
||||
successor string
|
||||
// shipped is the value Zero used to seed for this name. A user entry
|
||||
// written while the default was still shipped may name only an override
|
||||
// (a header, say) and rely on these fields for the rest, so they have to
|
||||
// outlive the default itself.
|
||||
shipped MCPServerConfig
|
||||
}
|
||||
|
||||
// retiredDefaultMCPServers maps a retired built-in default to what replaced it
|
||||
// and to the value Zero used to ship for it.
|
||||
var retiredDefaultMCPServers = map[string]retiredDefaultMCPServer{
|
||||
"firecrawl": {
|
||||
successor: "exa",
|
||||
shipped: MCPServerConfig{
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// migrateRetiredDefaultMCPServers keeps an upgrade across a default-provider
|
||||
// swap from changing what the user's config means. It handles the two ways a
|
||||
// rename can break a config written against the old default:
|
||||
//
|
||||
// - A user who ran `zero mcp disable <old default>` made an explicit choice
|
||||
// not to open that outbound connection. Without carrying that decision to
|
||||
// the successor, the upgrade re-opens it under the new name — and because
|
||||
// the replacement looks like an untouched default, even the startup
|
||||
// warning stays quiet (see IsUnconfiguredDefault and issue #552).
|
||||
// - A user who customized the old default could name only the field they
|
||||
// were changing, because the seeded default supplied the transport. Once
|
||||
// the default stops being seeded that entry resolves to a server with no
|
||||
// type, url, or command, which fails NormalizeConfig and takes the whole
|
||||
// startup down rather than just that one server.
|
||||
//
|
||||
// It runs immediately after the user layer merges (see ResolveMCP), which
|
||||
// scopes it to user-level decisions — the only scope whose disable is sticky.
|
||||
// The carried-over disable applies only when the user never declared the
|
||||
// successor themselves: an explicit `exa` entry wins whether it enables or
|
||||
// disables. Because the disable is recorded as a user-level decision, the
|
||||
// lower-trust project layer cannot lift it, while `zero mcp enable exa` still
|
||||
// can (the CLI override scope merges with canReenable=true).
|
||||
func migrateRetiredDefaultMCPServers(cfg *MCPConfig) {
|
||||
for name, retired := range retiredDefaultMCPServers {
|
||||
entry, ok := cfg.Servers[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if inheritsRetiredTransport(entry) {
|
||||
// Re-seed what Zero used to ship underneath the user's fields, so a
|
||||
// partial entry still resolves to the complete server it named when
|
||||
// it was written. Their own values still win — this only fills the
|
||||
// gap the retired default used to cover.
|
||||
entry = mergeMCPServer(retired.shipped, entry, true)
|
||||
cfg.Servers[name] = entry
|
||||
}
|
||||
if !entry.disabledSet || !entry.Disabled {
|
||||
continue
|
||||
}
|
||||
replacement, ok := cfg.Servers[retired.successor]
|
||||
if !ok || replacement.configured {
|
||||
continue
|
||||
}
|
||||
replacement.Disabled = true
|
||||
replacement.disabledSet = true
|
||||
cfg.Servers[retired.successor] = replacement
|
||||
}
|
||||
}
|
||||
|
||||
// inheritsRetiredTransport reports whether entry named no transport of its own
|
||||
// — no type, url, command, or args — and so relied entirely on the retired
|
||||
// default to supply one.
|
||||
//
|
||||
// Anything else is a complete definition the user owns (a self-hosted url, a
|
||||
// stdio command) and must be left alone: grafting the retired default's http
|
||||
// type onto an entry that names a command produces a server that is rejected
|
||||
// outright, turning a working config into a startup failure.
|
||||
func inheritsRetiredTransport(entry MCPServerConfig) bool {
|
||||
return strings.TrimSpace(entry.Type) == "" &&
|
||||
strings.TrimSpace(entry.URL) == "" &&
|
||||
strings.TrimSpace(entry.Command) == "" &&
|
||||
len(entry.Args) == 0
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
)
|
||||
|
||||
func TestIsDefaultMCPServer(t *testing.T) {
|
||||
if !IsDefaultMCPServer("firecrawl") {
|
||||
t.Fatal("firecrawl should be a built-in default")
|
||||
if !IsDefaultMCPServer("exa") {
|
||||
t.Fatal("exa should be a built-in default")
|
||||
}
|
||||
if IsDefaultMCPServer(" firecrawl ") == false {
|
||||
if IsDefaultMCPServer(" exa ") == false {
|
||||
t.Fatal("IsDefaultMCPServer should trim whitespace")
|
||||
}
|
||||
if IsDefaultMCPServer("not-a-default") {
|
||||
@@ -18,45 +18,45 @@ func TestIsDefaultMCPServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
|
||||
func TestResolveMCPSeedsEnabledExaDefault(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl, ok := cfg.Servers["firecrawl"]
|
||||
exa, ok := cfg.Servers["exa"]
|
||||
if !ok {
|
||||
t.Fatal("expected the firecrawl default to be seeded with no user config")
|
||||
t.Fatal("expected the exa default to be seeded with no user config")
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
|
||||
if exa.Type != "http" || exa.URL != "https://mcp.exa.ai/mcp" {
|
||||
t.Fatalf("unexpected exa default: %#v", exa)
|
||||
}
|
||||
if firecrawl.Disabled {
|
||||
t.Fatal("the firecrawl default must be enabled out of the box")
|
||||
if exa.Disabled {
|
||||
t.Fatal("the exa default must be enabled out of the box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("a user must be able to disable the default by writing over it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnconfiguredDefault(t *testing.T) {
|
||||
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
|
||||
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
|
||||
if !IsUnconfiguredDefault("exa", DefaultMCPServers()["exa"]) {
|
||||
t.Fatal("an untouched exa default should be reported as unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
|
||||
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://example.com/mcp"}) {
|
||||
t.Fatal("a server overriding the default URL is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
|
||||
if IsUnconfiguredDefault("exa", MCPServerConfig{Type: "http", URL: "https://mcp.exa.ai/mcp", Auth: "bearer"}) {
|
||||
t.Fatal("a server with credentials added is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
|
||||
@@ -65,57 +65,173 @@ func TestIsUnconfiguredDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
|
||||
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
|
||||
// `zero mcp enable exa` after a prior disable writes {"disabled":false}
|
||||
// explicitly. The resolved value is identical to the untouched default (both
|
||||
// enabled, no credentials), but the user DID take an explicit action here, so
|
||||
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.Disabled {
|
||||
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
|
||||
exa := cfg.Servers["exa"]
|
||||
if exa.Disabled {
|
||||
t.Fatalf("explicit re-enable should leave the server enabled: %#v", exa)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
if IsUnconfiguredDefault("exa", exa) {
|
||||
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
|
||||
// A user who copies firecrawl's exact default type/url into their config
|
||||
// A user who copies Exa's exact default type/url into their config
|
||||
// (e.g. from an example file, planning to add credentials later) produces a
|
||||
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
|
||||
// resolved value byte-identical to DefaultMCPServers()["exa"] — the
|
||||
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
|
||||
// the disabled toggle. IsUnconfiguredDefault must still treat this as
|
||||
// user-configured because the user's JSON declared an entry for it, even
|
||||
// though a plain resolved-value comparison could not tell the difference.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"type":"http","url":"https://mcp.exa.ai/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
want := DefaultMCPServers()["firecrawl"]
|
||||
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
|
||||
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
|
||||
exa := cfg.Servers["exa"]
|
||||
want := DefaultMCPServers()["exa"]
|
||||
if exa.Type != want.Type || exa.URL != want.URL || exa.Disabled != want.Disabled {
|
||||
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", exa)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
if IsUnconfiguredDefault("exa", exa) {
|
||||
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// Point firecrawl at a self-hosted instance; the default's Type must survive.
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
|
||||
// Point Exa at a proxy; the default's Type must survive.
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"exa":{"url":"https://example.com/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
exa := cfg.Servers["exa"]
|
||||
if exa.URL != "https://example.com/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", exa)
|
||||
}
|
||||
if exa.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#v", exa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPCarriesLegacyDefaultDisableToSuccessor(t *testing.T) {
|
||||
// Upgrade path: the user disabled the firecrawl default Zero used to ship.
|
||||
// Swapping the default to exa must not re-open an outbound connection they
|
||||
// explicitly switched off.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatalf("a prior disable of the retired default must carry to its replacement: %#v", cfg.Servers["exa"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitSuccessorBeatsLegacyDefaultDisable(t *testing.T) {
|
||||
// The user disabled the old default but has since explicitly configured exa.
|
||||
// Their newer, explicit choice wins — the migration must not override it.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true},"exa":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
exa := cfg.Servers["exa"]
|
||||
if exa.Disabled {
|
||||
t.Fatalf("an explicit exa entry must survive the legacy-disable migration: %#v", exa)
|
||||
}
|
||||
if IsUnconfiguredDefault("exa", exa) {
|
||||
t.Fatal("an explicitly configured exa is not an untouched default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPLegacyDefaultLeftEnabledDoesNotDisableSuccessor(t *testing.T) {
|
||||
// A user who configured firecrawl without disabling it (e.g. added a header)
|
||||
// never opted out of the default search server, so exa stays enabled.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("only an explicit disable of the retired default should carry over")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPLegacyDefaultDisableIsLiftableByOverride(t *testing.T) {
|
||||
// The carried-over disable is a user-level decision, not a permanent one:
|
||||
// `zero mcp enable exa` merges through the CLI override scope, which is the
|
||||
// one layer allowed to re-enable.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled := MCPServerConfig{Disabled: false, disabledSet: true}
|
||||
cfg, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: path,
|
||||
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{"exa": enabled}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if cfg.Servers["exa"].Disabled {
|
||||
t.Fatalf("an explicit enable must lift the carried-over disable: %#v", cfg.Servers["exa"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPLegacyDisableSurvivesProjectReenable(t *testing.T) {
|
||||
// The project layer is lower-trust and must not lift the carried-over
|
||||
// user-level disable — the same guard a direct `zero mcp disable exa` gets.
|
||||
dir := t.TempDir()
|
||||
userPath := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectPath := filepath.Join(dir, "project.json")
|
||||
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"exa":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("project config must not re-enable a server the user disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPKeepsRetiredDefaultTransportForPartialEntry(t *testing.T) {
|
||||
// A user who added an API key to the firecrawl default wrote only the header
|
||||
// — the seeded default supplied the http type and URL. Retiring the default
|
||||
// must not strip those out from under them and leave an unusable server.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
@@ -123,10 +239,55 @@ func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.URL != "http://localhost:3002/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("a partial entry must keep the retired default's transport: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
|
||||
if firecrawl.Headers["Authorization"] != "Bearer k" {
|
||||
t.Fatalf("the user's own field must still win: %#v", firecrawl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPDoesNotGraftRetiredDefaultOntoOwnTransport(t *testing.T) {
|
||||
// A self-hosted firecrawl over stdio is a complete definition the user owns.
|
||||
// Re-seeding the retired http default under it would produce an http server
|
||||
// carrying a command, which NormalizeConfig rejects outright.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.Type != "" || firecrawl.URL != "" {
|
||||
t.Fatalf("an entry naming its own transport must be left alone: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Command != "firecrawl-mcp" {
|
||||
t.Fatalf("the user's command must survive: %#v", firecrawl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPDisabledRetiredEntryStaysReEnableable(t *testing.T) {
|
||||
// The disable carries to exa, and the retired entry itself keeps a usable
|
||||
// transport so a later `zero mcp enable firecrawl` does not resolve to a
|
||||
// server with no type, url, or command.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if !firecrawl.Disabled {
|
||||
t.Fatalf("the retired entry must stay disabled: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("a disabled retired entry should still carry a usable transport: %#v", firecrawl)
|
||||
}
|
||||
if !cfg.Servers["exa"].Disabled {
|
||||
t.Fatal("the disable must still carry to the successor")
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
|
||||
if _, ok := cfg.Servers["user-srv"]; !ok {
|
||||
t.Fatal("the user server must always be present")
|
||||
}
|
||||
if _, ok := cfg.Servers["firecrawl"]; !ok {
|
||||
if _, ok := cfg.Servers["exa"]; !ok {
|
||||
t.Fatal("the built-in default must always be present")
|
||||
}
|
||||
})
|
||||
@@ -54,7 +54,7 @@ func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
|
||||
if _, ok := cfg.Servers["user-srv"]; !ok {
|
||||
t.Fatal("the user server must survive when the project layer is dropped")
|
||||
}
|
||||
if _, ok := cfg.Servers["firecrawl"]; !ok {
|
||||
if _, ok := cfg.Servers["exa"]; !ok {
|
||||
t.Fatal("the built-in default must survive when the project layer is dropped")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,7 +180,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
}
|
||||
|
||||
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Exa for free,
|
||||
// no-setup web search/scrape) BEFORE merging user/project config, so the user
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
@@ -194,6 +194,10 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
// server the user disabled (a user-level disable is sticky, but the user
|
||||
// scope itself may lift it).
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP, true)
|
||||
// Reconcile config written against a default Zero has since retired,
|
||||
// here while the user layer is the only one merged — so a carried-over
|
||||
// disable counts as a user-level decision the project layer cannot lift.
|
||||
migrateRetiredDefaultMCPServers(&cfg.MCP)
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
|
||||
|
||||
@@ -151,7 +151,8 @@ type KeyBindingsConfig struct {
|
||||
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 is retained for configuration compatibility. Plan updates render
|
||||
// in the transcript and no longer have a persistent panel to toggle.
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
@@ -464,7 +465,7 @@ type MCPServerConfig struct {
|
||||
// fields it set or what values they hold. A built-in default seeded by
|
||||
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
|
||||
// any explicit entry in the user/project file — even one that happens to
|
||||
// repeat a default's exact field values (e.g. re-declaring firecrawl's
|
||||
// repeat a default's exact field values (e.g. re-declaring Exa's
|
||||
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
|
||||
// a resolved-value comparison, so redeclaring default values verbatim still
|
||||
// counts as user-configured.
|
||||
|
||||
@@ -9,7 +9,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const PolicyVersion = 1
|
||||
// PolicyVersion is the version of the approval policy that recorded grants were
|
||||
// evaluated under. Bump it whenever a change narrows what may be approved: the
|
||||
// grant store then backs the file up, keeps deny grants, invalidates prior
|
||||
// approvals, and tells the user once, instead of silently honoring approvals
|
||||
// that the current rules would have refused.
|
||||
//
|
||||
// v2 tightened command-prefix validation so a launcher spelled with a version
|
||||
// or an executable extension ("python3.11", "python.exe") can no longer be
|
||||
// approved as an ordinary command.
|
||||
const PolicyVersion = 2
|
||||
|
||||
type Origin string
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type Server struct {
|
||||
// credential lookup uses it to avoid reusing legacy user tokens by name.
|
||||
ProjectConfigured bool
|
||||
// UnconfiguredDefault is true when this server is one of Zero's built-in
|
||||
// defaults (e.g. keyless Firecrawl) that the user never touched in their
|
||||
// defaults (e.g. keyless Exa) that the user never touched in their
|
||||
// config — no credentials, no overrides. Callers use it to avoid warning
|
||||
// loudly when a server nobody configured fails to connect.
|
||||
UnconfiguredDefault bool
|
||||
|
||||
+118
-3
@@ -1,6 +1,8 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -90,7 +92,7 @@ func TestNormalizeConfigValidatesTransportBoundaries(t *testing.T) {
|
||||
|
||||
func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
|
||||
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
"web": {
|
||||
Type: "http",
|
||||
URL: "https://example.com/mcp",
|
||||
@@ -107,8 +109,8 @@ func TestNormalizeConfigFlagsUnconfiguredDefault(t *testing.T) {
|
||||
byName[server.Name] = server
|
||||
}
|
||||
|
||||
if !byName["firecrawl"].UnconfiguredDefault {
|
||||
t.Fatal("an untouched firecrawl default should be flagged UnconfiguredDefault")
|
||||
if !byName["exa"].UnconfiguredDefault {
|
||||
t.Fatal("an untouched exa default should be flagged UnconfiguredDefault")
|
||||
}
|
||||
if byName["web"].UnconfiguredDefault {
|
||||
t.Fatal("a server the user configured must not be flagged UnconfiguredDefault")
|
||||
@@ -240,3 +242,116 @@ func TestCopyStringMapTrimsKeysAndPreservesValues(t *testing.T) {
|
||||
t.Fatalf("copied[TOKEN] = %q, want value preserved verbatim", copied["TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
// Startup behavior for the firecrawl -> exa default migration: a user who
|
||||
// disabled the retired default must end up with no Exa server to connect to,
|
||||
// not merely a disabled entry in the resolved config.
|
||||
func TestNormalizeConfigSkipsExaAfterLegacyFirecrawlDisable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
servers, err := NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeConfig: %v", err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name == "exa" {
|
||||
t.Fatalf("exa must not be started after the user disabled the default it replaced: %#v", server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same path with no legacy disable still starts Exa, so the migration
|
||||
// cannot quietly switch off the default for everyone else.
|
||||
func TestNormalizeConfigStartsExaWithoutLegacyDisable(t *testing.T) {
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
servers, err := NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeConfig: %v", err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name == "exa" {
|
||||
if !server.UnconfiguredDefault {
|
||||
t.Fatalf("an untouched exa default should be flagged unconfigured: %#v", server)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("expected the exa default to be started out of the box")
|
||||
}
|
||||
|
||||
// The failure path CodeRabbit flagged: a firecrawl entry that named only a
|
||||
// header relied on the retired default for its transport. Resolving it must
|
||||
// still produce a complete server — an incomplete one fails NormalizeConfig,
|
||||
// which takes down every other server's startup with it, not just this one.
|
||||
func TestNormalizeConfigResolvesPartialRetiredDefaultEntry(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"headers":{"Authorization":"Bearer k"}}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
servers, err := NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("a header-only legacy entry must not break startup: %v", err)
|
||||
}
|
||||
var firecrawl *Server
|
||||
var sawExa bool
|
||||
for i := range servers {
|
||||
switch servers[i].Name {
|
||||
case "firecrawl":
|
||||
firecrawl = &servers[i]
|
||||
case "exa":
|
||||
sawExa = true
|
||||
}
|
||||
}
|
||||
if firecrawl == nil {
|
||||
t.Fatalf("expected the customized firecrawl server to survive: %#v", servers)
|
||||
}
|
||||
if firecrawl.Type != ServerTypeHTTP || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("firecrawl lost the transport its entry relied on: %#v", *firecrawl)
|
||||
}
|
||||
if firecrawl.Headers["Authorization"] != "Bearer k" {
|
||||
t.Fatalf("firecrawl lost the user's header: %#v", *firecrawl)
|
||||
}
|
||||
if !sawExa {
|
||||
t.Fatal("the new exa default should still start alongside it")
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror case: an entry that names its own stdio transport must not have
|
||||
// the retired http default grafted onto it, which would be rejected outright.
|
||||
func TestNormalizeConfigLeavesRetiredEntryWithOwnTransportAlone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"command":"firecrawl-mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
servers, err := NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("a self-hosted stdio entry must keep working: %v", err)
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name != "firecrawl" {
|
||||
continue
|
||||
}
|
||||
if server.Type != ServerTypeStdio || server.Command != "firecrawl-mcp" {
|
||||
t.Fatalf("the user's own stdio transport was corrupted: %#v", server)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("expected the self-hosted firecrawl server to survive: %#v", servers)
|
||||
}
|
||||
@@ -153,16 +153,16 @@ func TestRegisterToolsSkipsUnreachableServerAndKeepsOthers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
|
||||
// firecrawl is seeded by config.DefaultMCPServers with no credentials; when a
|
||||
// user never configures it, a connect failure (e.g. the real HTTP 401 from
|
||||
// issue #552) must be recorded as UnconfiguredDefault so callers can avoid
|
||||
// Exa is seeded by config.DefaultMCPServers with no credentials; when a
|
||||
// user never configures it, a connect failure must be recorded as
|
||||
// UnconfiguredDefault so callers can avoid
|
||||
// warning about a server the user never asked for. "custom" is configured by
|
||||
// the user and must NOT be flagged even though it also fails to connect.
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
"custom": {Type: "stdio", Command: "custom-mcp"},
|
||||
"exa": config.DefaultMCPServers()["exa"],
|
||||
"custom": {Type: "stdio", Command: "custom-mcp"},
|
||||
}}, RegisterOptions{
|
||||
ClientFactory: func(_ context.Context, server Server) (ToolClient, error) {
|
||||
return nil, errors.New(server.Name + " connect failed")
|
||||
@@ -177,12 +177,12 @@ func TestRegisterToolsFlagsUnconfiguredDefaultOnSkip(t *testing.T) {
|
||||
for _, skipped := range runtime.Skipped() {
|
||||
byName[skipped.Name] = skipped
|
||||
}
|
||||
firecrawl, ok := byName["firecrawl"]
|
||||
exa, ok := byName["exa"]
|
||||
if !ok {
|
||||
t.Fatalf("Skipped() = %#v, want an entry for firecrawl", runtime.Skipped())
|
||||
t.Fatalf("Skipped() = %#v, want an entry for exa", runtime.Skipped())
|
||||
}
|
||||
if !firecrawl.UnconfiguredDefault {
|
||||
t.Fatalf("Skipped()[firecrawl] = %#v, want UnconfiguredDefault", firecrawl)
|
||||
if !exa.UnconfiguredDefault {
|
||||
t.Fatalf("Skipped()[exa] = %#v, want UnconfiguredDefault", exa)
|
||||
}
|
||||
custom, ok := byName["custom"]
|
||||
if !ok {
|
||||
|
||||
@@ -231,6 +231,12 @@ func reasoningEffortsForModelName(name string) []ReasoningEffort {
|
||||
// the gateway's translation concern — unknown fields are ignored, so the
|
||||
// worst case is a silent no-op rather than a 400.
|
||||
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
case strings.Contains(n, "deepseek"):
|
||||
// DeepSeek V4 (deepseek-v4-flash / deepseek-v4-pro) exposes thinking-mode
|
||||
// effort control through the OpenAI-compatible reasoning_effort parameter.
|
||||
// The API accepts low/high/max and maps medium/xhigh onto high, so only
|
||||
// the three distinct tiers are advertised.
|
||||
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
|
||||
{"hy3", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"hunyuan-t1", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"openai/o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}}, // github-style vendor/ id
|
||||
{"deepseek-v4-flash", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
|
||||
{"deepseek-v4-pro", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
|
||||
{"deepseek-reasoner", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
|
||||
{"deepseek-chat", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortHigh, ReasoningEffortMax}},
|
||||
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
|
||||
{"gpt-4o-mini", nil},
|
||||
{"ollama/llama3.1", nil},
|
||||
|
||||
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
}
|
||||
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("Windows access token has no default owner")
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
|
||||
|
||||
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
switch strings.TrimSpace(provider.ID) {
|
||||
case "chatgpt":
|
||||
// ChatGPT authenticates against the Codex backend, while its public
|
||||
// model metadata is published under the OpenAI provider on models.dev.
|
||||
return "openai"
|
||||
case "dashscope":
|
||||
return "alibaba"
|
||||
case "github":
|
||||
|
||||
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
|
||||
|
||||
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"chatgpt": "openai",
|
||||
"github": "github-models",
|
||||
"moonshot": "moonshotai",
|
||||
"nvidia-nim": "nvidia",
|
||||
|
||||
@@ -539,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
|
||||
hasCatalog := len(byID) > 0
|
||||
// Aggregators publish the live list as the source of truth. Keep live-only
|
||||
// ids even when a remote catalog also loaded, instead of intersecting.
|
||||
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
preferLive := provider.Local ||
|
||||
providermodelcatalog.PublicLiveCatalog(provider.ID) ||
|
||||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
|
||||
result := make([]Model, 0, len(liveModels))
|
||||
for _, live := range liveModels {
|
||||
|
||||
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api.json":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"openai": {"models": {
|
||||
"gpt-5.6-sol": {
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"description": "Frontier coding model",
|
||||
"tool_call": true,
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["low", "high", "max"],
|
||||
"limit": {"context": 1050000},
|
||||
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
|
||||
}
|
||||
}}
|
||||
}`))
|
||||
case "/backend-api/codex/models":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, ok := providercatalog.Get("chatgpt")
|
||||
if !ok {
|
||||
t.Fatal("ChatGPT provider missing from catalog")
|
||||
}
|
||||
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
|
||||
Name: "chatgpt",
|
||||
CatalogID: "chatgpt",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/backend-api/codex",
|
||||
}, Options{
|
||||
HTTPClient: server.Client(),
|
||||
ModelsDevURL: server.URL + "/api.json",
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
return "Authorization", "Bearer test", true, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog returned error: %v", err)
|
||||
}
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("models = %#v, want one ChatGPT live model", models)
|
||||
}
|
||||
model := models[0]
|
||||
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
|
||||
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
|
||||
}
|
||||
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
|
||||
t.Fatalf("input modalities = %q, want text,image,pdf", got)
|
||||
}
|
||||
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
|
||||
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
@@ -164,6 +226,29 @@ func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLocalModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "lmstudio", Local: true},
|
||||
[]Model{
|
||||
{ID: "qwen3-coder-30b", ContextWindow: 32768},
|
||||
{ID: "text-embedding-local"},
|
||||
{ID: "qwen2.5-coder-32b-instruct", Description: "live description"},
|
||||
},
|
||||
[]Model{{ID: "qwen2.5-coder-32b-instruct", Description: "catalog description", ToolCall: true}},
|
||||
)
|
||||
|
||||
if got, want := strings.Join(modelIDs(models), ","), "qwen3-coder-30b,qwen2.5-coder-32b-instruct"; got != want {
|
||||
t.Fatalf("models = %q, want %q", got, want)
|
||||
}
|
||||
for _, model := range models {
|
||||
if model.ID == "qwen2.5-coder-32b-instruct" {
|
||||
if model.Description != "catalog description" || !model.ToolCall {
|
||||
t.Fatalf("merged catalog metadata = %#v", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
|
||||
// Catalog and live endpoints return distinct payloads so the merge must keep
|
||||
// live-only ids that are absent from the remote catalog response.
|
||||
|
||||
@@ -486,7 +486,7 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
Function: toolFunction{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
Parameters: normalizeToolParameters(tool.Parameters),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -494,6 +494,21 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
return mapped
|
||||
}
|
||||
|
||||
// normalizeToolParameters keeps schemas accepted by strict OpenAI-compatible
|
||||
// servers such as LM Studio. No-argument tools still need an object-valued
|
||||
// parameters.properties field instead of an omitted field.
|
||||
func normalizeToolParameters(parameters map[string]any) map[string]any {
|
||||
normalized := make(map[string]any, len(parameters)+1)
|
||||
for key, value := range parameters {
|
||||
normalized[key] = value
|
||||
}
|
||||
properties, ok := normalized["properties"].(map[string]any)
|
||||
if !ok || properties == nil {
|
||||
normalized["properties"] = map[string]any{}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -3,6 +3,7 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -105,6 +106,72 @@ func TestStreamCompletionPostsChatCompletionRequest(t *testing.T) {
|
||||
if tool["type"] != "function" {
|
||||
t.Fatalf("unexpected tool wrapper: %#v", tool)
|
||||
}
|
||||
functionDefinition := tool["function"].(map[string]any)
|
||||
parameters := functionDefinition["parameters"].(map[string]any)
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok || len(properties) != 0 {
|
||||
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject locks in the
|
||||
// normalizeToolParameters fix: a tool whose parameters carry a typed-nil
|
||||
// properties map must serialize "properties":{} on the wire, never
|
||||
// "properties":null, because strict OpenAI-compatible servers (LM Studio)
|
||||
// reject the null form.
|
||||
func TestStreamCompletionSerializesTypedNilPropertiesAsEmptyObject(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
var gotRaw []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
gotRaw = raw
|
||||
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"choices":[]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-secret",
|
||||
BaseURL: server.URL + "/",
|
||||
Model: "gpt-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: "hi"}},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "no_args",
|
||||
Description: "Takes no arguments",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any(nil)},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if !strings.Contains(string(gotRaw), `"properties":{}`) {
|
||||
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
|
||||
}
|
||||
if strings.Contains(string(gotRaw), `"properties":null`) {
|
||||
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
|
||||
}
|
||||
tools := gotBody["tools"].([]any)
|
||||
tool := tools[0].(map[string]any)
|
||||
functionDefinition := tool["function"].(map[string]any)
|
||||
parameters := functionDefinition["parameters"].(map[string]any)
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok || len(properties) != 0 {
|
||||
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresModelButNotAPIKey(t *testing.T) {
|
||||
@@ -1479,3 +1546,66 @@ func TestOpenAIRequestPreservesCacheablePrefixAcrossTurns(t *testing.T) {
|
||||
t.Fatalf("wire prompt cache key must remain stable: first=%#v second=%#v", first["prompt_cache_key"], second["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject locks in the
|
||||
// normalizeToolParameters type-assert branch: when a tool's parameters carry a
|
||||
// `properties` field whose value is a non-map (e.g., an empty slice), it must
|
||||
// be normalized to "properties":{} on the wire, never "properties":[] or
|
||||
// "properties":null.
|
||||
func TestStreamCompletionSerializesNonMapPropertiesAsEmptyObject(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
var gotRaw []byte
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read request body: %v", err)
|
||||
}
|
||||
gotRaw = raw
|
||||
if err := json.Unmarshal(raw, &gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"choices":[]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{
|
||||
APIKey: "sk-secret",
|
||||
BaseURL: server.URL + "/",
|
||||
Model: "gpt-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: "hi"}},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "no_args",
|
||||
Description: "Takes no arguments",
|
||||
Parameters: map[string]any{"type": "object", "properties": []any{}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
if !strings.Contains(string(gotRaw), `"properties":{}`) {
|
||||
t.Fatalf("request body must serialize properties as an empty object, got: %s", gotRaw)
|
||||
}
|
||||
if strings.Contains(string(gotRaw), `"properties":null`) {
|
||||
t.Fatalf("request body must not serialize properties as null, got: %s", gotRaw)
|
||||
}
|
||||
if strings.Contains(string(gotRaw), `"properties":[]`) {
|
||||
t.Fatalf("request body must not serialize properties as an empty array, got: %s", gotRaw)
|
||||
}
|
||||
tools := gotBody["tools"].([]any)
|
||||
tool := tools[0].(map[string]any)
|
||||
functionDefinition := tool["function"].(map[string]any)
|
||||
parameters := functionDefinition["parameters"].(map[string]any)
|
||||
properties, ok := parameters["properties"].(map[string]any)
|
||||
if !ok || len(properties) != 0 {
|
||||
t.Fatalf("parameters.properties = %#v, want empty object", parameters["properties"])
|
||||
}
|
||||
}
|
||||
@@ -148,8 +148,10 @@ func unsafeCommandPrefix(prefix []string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalized := append([]string(nil), prefix...)
|
||||
normalized[0] = normalizeLauncherName(normalized[0])
|
||||
for _, banned := range bannedCommandPrefixSuggestions {
|
||||
if sameStringSlice(prefix, banned) {
|
||||
if sameStringSlice(prefix, banned) || sameStringSlice(normalized, banned) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -165,25 +167,118 @@ func unsafeCommandPrefixPart(part string) bool {
|
||||
}
|
||||
|
||||
func unsafeCommandPrefixLauncher(program string) bool {
|
||||
program = strings.ToLower(strings.TrimSpace(program))
|
||||
if strings.ContainsAny(program, `/\`) {
|
||||
// A path is already refused. ":" and "~" are refused for the same reason:
|
||||
// on Windows they introduce names that resolve to a different file than the
|
||||
// one matched here, and no list can keep up with them. "PYTHON~1.EXE" is
|
||||
// python.exe under an 8.3 short name, and 8.3 truncates the stem, so
|
||||
// "POWERS~1.EXE" cannot be recognized by name at all; "python.exe::$DATA"
|
||||
// names the same executable through its default stream. Refusing the shape
|
||||
// costs a permission prompt on names no real command uses.
|
||||
if strings.ContainsAny(program, `/\:~`) {
|
||||
return true
|
||||
}
|
||||
switch program {
|
||||
case "bash", "sh", "zsh", "/bin/bash", "/bin/zsh",
|
||||
"pwsh", "powershell", "powershell.exe",
|
||||
"env", "sudo", "doas", "su", "run0", "osascript",
|
||||
name := normalizeLauncherName(program)
|
||||
return bannedLauncherName(name) || bannedLauncherName(strings.TrimRight(name, "0123456789"))
|
||||
}
|
||||
|
||||
func bannedLauncherName(name string) bool {
|
||||
switch name {
|
||||
case "bash", "sh", "zsh", "dash", "ash", "ksh", "csh", "tcsh", "fish", "busybox",
|
||||
"pwsh", "powershell", "cmd", "wsl",
|
||||
"env", "sudo", "sudoedit", "doas", "su", "run0", "osascript",
|
||||
"command", "eval", "exec", "time",
|
||||
"find", "xargs", "timeout", "nice", "nohup", "watch", "setsid", "stdbuf", "ionice",
|
||||
"ssh", "make", "npm", "npx",
|
||||
"python", "python3", "py", "pythonw", "pyw", "pypy", "pypy3",
|
||||
"node", "perl", "ruby", "php", "lua", "deno", "bun":
|
||||
"ssh", "make", "npm", "npx", "pnpm", "yarn", "bunx",
|
||||
"python", "py", "pythonw", "pyw", "pypy", "uv", "uvx",
|
||||
"node", "nodejs", "perl", "ruby", "php", "lua", "deno", "bun":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeLauncherName reduces a program name to the launcher it actually runs
|
||||
// so the list above cannot be stepped around by spelling the same interpreter
|
||||
// differently: "python3.11", "python.exe" and "PYTHON3" all reach it as a name
|
||||
// the list matches. internal/agent's commandName already normalizes case and
|
||||
// Windows executable extensions on the allow side; this side matched raw, so
|
||||
// every versioned or .exe-suffixed launcher validated as an ordinary command
|
||||
// and persisted as a prefix grant that auto-allows every later invocation.
|
||||
//
|
||||
// A name is only ever narrowed toward an existing entry, so the failure
|
||||
// direction is an extra permission prompt, never a silent grant.
|
||||
func normalizeLauncherName(program string) string {
|
||||
name := strings.ToLower(strings.TrimSpace(program))
|
||||
// Windows discards trailing dots and spaces when resolving a filename, so
|
||||
// "python." starts python.exe. Drop them before anything matches on the
|
||||
// result, on every platform: the deny side must not depend on which OS is
|
||||
// reading the grant, since the grants file travels with a synced home
|
||||
// directory.
|
||||
name = strings.TrimRight(name, ". ")
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com", ".ps1"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
name = strings.TrimSuffix(name, suffix)
|
||||
name = strings.TrimRight(name, ". ")
|
||||
break
|
||||
}
|
||||
}
|
||||
return trimLauncherVersion(trimLauncherABISuffix(trimLauncherBuildSuffix(name)))
|
||||
}
|
||||
|
||||
// trimLauncherBuildSuffix drops the build-channel suffixes distributions and
|
||||
// upstreams append to an otherwise unchanged interpreter, so "python3.11-dbg",
|
||||
// "bash-static" and "pwsh-preview" are the launchers they say they are. The set
|
||||
// is closed on purpose: a general "-word" strip would also swallow
|
||||
// "node-gyp", "python3-config" and "ruby-lsp", which are ordinary tools a user
|
||||
// should still be able to approve.
|
||||
func trimLauncherBuildSuffix(name string) string {
|
||||
for _, suffix := range []string{"-dbg", "-debug", "-static", "-nightly", "-preview", "-beta"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// trimLauncherABISuffix drops CPython's ABI flags from a versioned interpreter
|
||||
// name, so "python3.7m" and "python3.6dm" reduce to their version, and the
|
||||
// free-threaded builds "python3.13t" and "python3.13td" reduce to theirs. The
|
||||
// flags are only removed when a digit sits underneath them, which leaves
|
||||
// ordinary names that happen to end in those letters alone: "sha256sum" keeps
|
||||
// its "sum", "zstd" its "td", "cat" its "t".
|
||||
func trimLauncherABISuffix(name string) string {
|
||||
trimmed := strings.TrimRight(name, "dmut")
|
||||
if trimmed == name || trimmed == "" {
|
||||
return name
|
||||
}
|
||||
if last := trimmed[len(trimmed)-1]; last < '0' || last > '9' {
|
||||
return name
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// trimLauncherVersion drops a trailing "<separator><digits>" version from a
|
||||
// program name, so "python3.11" becomes "python3" and "python2.7" becomes
|
||||
// "python2". Digits without a separator are part of the name, which keeps
|
||||
// "base64", "7z" and "sha256sum" intact; the caller handles the "python3" shape
|
||||
// by also testing the digit-stripped form.
|
||||
func trimLauncherVersion(name string) string {
|
||||
for {
|
||||
trimmed := strings.TrimRight(name, "0123456789")
|
||||
if trimmed == name || trimmed == "" {
|
||||
return name
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, ".") && !strings.HasSuffix(trimmed, "-") {
|
||||
return name
|
||||
}
|
||||
next := trimmed[:len(trimmed)-1]
|
||||
if next == "" {
|
||||
return name
|
||||
}
|
||||
name = next
|
||||
}
|
||||
}
|
||||
|
||||
func sameStringSlice(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package sandbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnsafeCommandPrefixLauncherIgnoresVersionAndExtension(t *testing.T) {
|
||||
for _, program := range []string{
|
||||
"python3", "python", "node", "git", "sh", "bash", "sudo",
|
||||
"python3.11", "python3.12", "python2.7", "python3.13.1",
|
||||
"python.exe", "python3.exe", "node.exe", "git.exe", "bash.exe",
|
||||
"PYTHON3.11", "Node.EXE",
|
||||
"cmd", "cmd.exe", "wsl", "wsl.exe",
|
||||
// Windows resolves these to the same executable.
|
||||
"python.", "python3.", "python3.11.", "python.exe.", "PYTHON.",
|
||||
"python.cmd", "python.bat", "python.com", "python.ps1",
|
||||
"PYTHON~1.EXE", "POWERS~1.EXE", "python.exe::$DATA",
|
||||
// Versioned and ABI-suffixed interpreters.
|
||||
"python3.7m", "python3.6dm", "pythonw3.11", "python-3.11",
|
||||
// Free-threaded CPython (PEP 703) ships beside the GIL build.
|
||||
"python3.13t", "python3.13t.exe", "python3.14t", "python3.13td",
|
||||
// Distribution and build-channel spellings of the same interpreter.
|
||||
"nodejs", "nodejs.exe", "node-nightly", "python3-dbg", "python3.11-dbg",
|
||||
"python3.13t-dbg", "python3-debug", "python3.11-debug", "node-beta",
|
||||
"pwsh-preview", "bash-static", "sudoedit",
|
||||
// Twins of npm and npx, which the list already refuses.
|
||||
"pnpm", "yarn", "bunx", "pnpm.cmd", "yarn.exe",
|
||||
"perl5.36", "ruby3.2", "php8", "lua5.4", "node22.exe", "bash5",
|
||||
} {
|
||||
if !unsafeCommandPrefix([]string{program}) {
|
||||
t.Errorf("unsafeCommandPrefix([%q]) = false, want true", program)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafeCommandPrefixKeepsOrdinaryCommandsGrantable(t *testing.T) {
|
||||
for _, prefix := range [][]string{
|
||||
{"cargo", "build"},
|
||||
{"go", "test"},
|
||||
{"ls", "-la"},
|
||||
{"rg", "--json"},
|
||||
{"7z", "l"},
|
||||
{"base64", "-d"},
|
||||
{"kubectl", "get", "pods"},
|
||||
{"docker", "ps"},
|
||||
{"sha256sum", "file"},
|
||||
{"gcc-13", "-c"},
|
||||
{"node-gyp", "build"},
|
||||
{"python3-config", "--includes"},
|
||||
{"s3cmd", "ls"},
|
||||
{"cat", "file"},
|
||||
{"zstd", "-d"},
|
||||
{"sqlite3", "db"},
|
||||
{"yt-dlp", "url"},
|
||||
{"perl-doc", "-h"},
|
||||
{"php-fpm", "-t"},
|
||||
{"ruby-lsp", "--version"},
|
||||
{"lua-language-server", "--check"},
|
||||
{"nodemon", "app.js"},
|
||||
} {
|
||||
if unsafeCommandPrefix(prefix) {
|
||||
t.Errorf("unsafeCommandPrefix(%q) = true, want false", prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNormalizeLauncherName(t *testing.T) {
|
||||
for _, testCase := range []struct{ in, want string }{
|
||||
{"python3.11", "python3"},
|
||||
{"python2.7", "python2"},
|
||||
{"pypy3", "pypy3"},
|
||||
{"node.exe", "node"},
|
||||
{"POWERSHELL.EXE", "powershell"},
|
||||
{"7z", "7z"},
|
||||
{"base64", "base64"},
|
||||
{"sha256sum", "sha256sum"},
|
||||
{"python.", "python"},
|
||||
{"python3.11.exe.", "python3"},
|
||||
{"python3.7m", "python3"},
|
||||
{"python3.6dm", "python3"},
|
||||
{"python3.13t", "python3"},
|
||||
{"python3.13td", "python3"},
|
||||
{"python3.11-dbg", "python3"},
|
||||
{"node-nightly", "node"},
|
||||
{"node-gyp", "node-gyp"},
|
||||
{"python3-config", "python3-config"},
|
||||
{"zstd", "zstd"},
|
||||
{"cat", "cat"},
|
||||
{"cargo", "cargo"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := normalizeLauncherName(testCase.in); got != testCase.want {
|
||||
t.Errorf("normalizeLauncherName(%q) = %q, want %q", testCase.in, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session-scoped grants are the agent's other entry point (loop.go grants them
|
||||
// for a single turn without touching the store), so they must refuse the same
|
||||
// launcher spellings the persisted path does.
|
||||
func TestGrantCommandPrefixForSessionRefusesLauncherVariants(t *testing.T) {
|
||||
engine := &Engine{commandPrefixes: newCommandPrefixGrantSet()}
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"cargo", "build"})
|
||||
if _, ok := engine.LookupCommandPrefixForSession("bash", []string{"cargo", "build", "--release"}); !ok {
|
||||
t.Fatal("CONTROL BROKEN: an ordinary session prefix grant did not match")
|
||||
}
|
||||
for _, prefix := range [][]string{
|
||||
{"python3.11", "-c"},
|
||||
{"python.exe", "-c"},
|
||||
{"cmd", "/c"},
|
||||
} {
|
||||
engine.GrantCommandPrefixForSession("bash", prefix)
|
||||
command := append(append([]string(nil), prefix...), "whatever")
|
||||
if _, ok := engine.LookupCommandPrefixForSession("bash", command); ok {
|
||||
t.Errorf("session grant %q matched %q, want refused", prefix, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
|
||||
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
|
||||
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
for _, patch := range []string{
|
||||
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
|
||||
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
|
||||
} {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "apply_patch",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Args: map[string]any{"patch": patch},
|
||||
})
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
|
||||
|
||||
@@ -2,12 +2,15 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
func TestGrantStorePersistsListsRevokesAndClears(t *testing.T) {
|
||||
@@ -343,7 +346,8 @@ func TestGrantStoreMigratesExactV1GrantAndReportsOnce(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read rewritten grant file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), `"policyVersion": 1`) || !strings.Contains(string(raw), `"write_file": [`) {
|
||||
policyLine := fmt.Sprintf(`"policyVersion": %d`, execution.PolicyVersion)
|
||||
if !strings.Contains(string(raw), `"schemaVersion": 3`) || !strings.Contains(string(raw), policyLine) || !strings.Contains(string(raw), `"write_file": [`) {
|
||||
t.Fatalf("grant file was not rewritten as a versioned grant store:\n%s", raw)
|
||||
}
|
||||
backup, err := os.ReadFile(path + ".v1.backup")
|
||||
@@ -406,6 +410,62 @@ func TestGrantStorePolicyChangePreservesDeniesAndInvalidatesApprovals(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix grant recorded before launcher-name normalization existed (a
|
||||
// versioned or .exe-suffixed interpreter) no longer validates. It must reach
|
||||
// the user through the policy migration — backup, denies preserved, one notice
|
||||
// — and never make the whole grants file unreadable, which would silently drop
|
||||
// their persisted deny grants too.
|
||||
func TestGrantStoreMigratesPreNormalizationPrefixInsteadOfRejectingTheFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
original := `{"schemaVersion":3,"policyVersion":1,"grants":{"bash":[{"toolName":"bash","decision":"deny","approvedAt":"2026-06-05T14:30:00Z"}]},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["cargo","build"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
|
||||
if err := writeText(path, original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, err := store.List()
|
||||
if err != nil || len(grants) != 1 || grants[0].Decision != GrantDeny {
|
||||
t.Fatalf("grants after migration = %#v err=%v, want the deny preserved", grants, err)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil || len(prefixes) != 0 {
|
||||
t.Fatalf("prefixes after migration = %#v err=%v, want all re-approved", prefixes, err)
|
||||
}
|
||||
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "invalidated 2") {
|
||||
t.Fatalf("notice = %q err=%v", notice, err)
|
||||
}
|
||||
if backup, err := os.ReadFile(path + ".policy-v1.backup"); err != nil || string(backup) != original {
|
||||
t.Fatalf("backup = %q err=%v", backup, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The legacy-schema path is a separate migration from the policy bump above and
|
||||
// prunes per grant rather than wholesale, so a pre-normalization prefix must be
|
||||
// dropped there while the valid prefix beside it survives.
|
||||
func TestGrantStoreLegacyMigrationDropsOnlyThePreNormalizationPrefix(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sandbox-grants.json")
|
||||
original := `{"schemaVersion":2,"grants":{},"commandPrefixes":{"bash":[{"toolName":"bash","prefix":["python3.11","-c"],"approvedAt":"2026-06-05T14:30:00Z"},{"toolName":"bash","prefix":["git","status"],"approvedAt":"2026-06-05T14:30:00Z"}]}}`
|
||||
if err := writeText(path, original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := NewGrantStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
t.Fatalf("ListCommandPrefixes after legacy migration returned error: %v", err)
|
||||
}
|
||||
if len(prefixes) != 1 || !sameStringSlice(prefixes[0].Prefix, []string{"git", "status"}) {
|
||||
t.Fatalf("prefixes = %#v, want only [git status]", prefixes)
|
||||
}
|
||||
if notice, err := store.ConsumeMigrationNotice(); err != nil || !strings.Contains(notice, "migrated 1, invalidated 1") {
|
||||
t.Fatalf("notice = %q err=%v", notice, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantStorePersistsCommandPrefixes(t *testing.T) {
|
||||
store, err := NewGrantStore(StoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json"),
|
||||
@@ -481,6 +541,11 @@ func TestGrantStoreRejectsUnsafeCommandPrefixes(t *testing.T) {
|
||||
{"python", "script.py"},
|
||||
{"./script.sh"},
|
||||
{"git"},
|
||||
{"python3.11", "-c"},
|
||||
{"python.exe", "-c"},
|
||||
{"node.exe", "-e"},
|
||||
{"git.exe"},
|
||||
{"cmd", "/c"},
|
||||
} {
|
||||
if _, err := store.GrantCommandPrefix(CommandPrefixInput{ToolName: "bash", Prefix: prefix}); err == nil {
|
||||
t.Fatalf("GrantCommandPrefix(%#v) succeeded, want validation error", prefix)
|
||||
|
||||
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
|
||||
}
|
||||
cwd := firstArgString(args, "cwd")
|
||||
var paths []string
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
@@ -296,6 +296,34 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPatchPaths(patch string) []string {
|
||||
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
|
||||
return structuredPatchHeaderPaths(patch)
|
||||
}
|
||||
return patchHeaderPaths(patch)
|
||||
}
|
||||
|
||||
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
|
||||
// the sandbox boundary. The executor validates the complete hunk grammar before
|
||||
// writing; the sandbox only needs every possible target path so it can reject an
|
||||
// unsafe request before the tool runs.
|
||||
func structuredPatchHeaderPaths(patch string) []string {
|
||||
var paths []string
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
|
||||
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
|
||||
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
|
||||
if path != "" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func patchHeaderPaths(patch string) []string {
|
||||
var paths []string
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
|
||||
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return applyPatchTool{
|
||||
baseTool: baseTool{
|
||||
name: "apply_patch",
|
||||
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
|
||||
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"patch": {Type: "string", Description: "Unified diff patch to apply."},
|
||||
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
|
||||
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
|
||||
},
|
||||
Required: []string{"patch"},
|
||||
@@ -56,6 +56,9 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if isStructuredPatch(patch) {
|
||||
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
|
||||
}
|
||||
if err := validatePatchPaths(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
|
||||
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
|
||||
want := map[string]bool{
|
||||
ResourceKeyDirectory + "pkg": true,
|
||||
ResourceKeyFile + "pkg/old.go": true,
|
||||
ResourceKeyFile + "pkg/new.go": true,
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !want[key] {
|
||||
t.Fatalf("unexpected key %q in %v", key, keys)
|
||||
}
|
||||
delete(want, key)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing keys %v from %v", want, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
|
||||
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
|
||||
// must still see the raw declaration so miswired constructors fail the gate.
|
||||
|
||||
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: f.txt",
|
||||
"@@",
|
||||
"-beta",
|
||||
"+updated",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
trackedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracker.SeenWhole(trackedPath) {
|
||||
t.Fatal("a partial structured patch credited the unread file as fully seen")
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
|
||||
}, opts)
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
|
||||
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
|
||||
}
|
||||
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
|
||||
t.Fatalf("file = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("read_file failed: %s", result.Output)
|
||||
}
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: f.txt",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+updated",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
trackedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(trackedPath) {
|
||||
t.Fatal("structured patch discarded an existing whole-file observation")
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
|
||||
}, opts)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Add File: created.txt",
|
||||
"+supplied",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(path) {
|
||||
t.Fatal("a fully supplied structured add was not credited as fully seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package tools
|
||||
|
||||
// ModelVisible reports whether a registered tool is advertised to an agent.
|
||||
// edit_file remains implemented for compatibility with existing integrations,
|
||||
// but its text-only replacement contract cannot safely disambiguate repeated
|
||||
// source fragments. Agents use apply_patch for contextual existing-file edits.
|
||||
func ModelVisible(tool Tool) bool {
|
||||
return tool != nil && tool.Name() != "edit_file"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
|
||||
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
search := NewToolSearchTool(registry).(toolSearchTool)
|
||||
|
||||
eager := search.visibleEagerToolNames(nil, nil, "ask")
|
||||
if eager["edit_file"] {
|
||||
t.Fatal("tool_search must not describe edit_file as available to the model")
|
||||
}
|
||||
if !eager["apply_patch"] {
|
||||
t.Fatal("tool_search must retain apply_patch as the available edit tool")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package tools
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// MutationTargets returns the workspace-relative paths a tool call will write to,
|
||||
// so the session layer can snapshot their before-state for safe rewind. It is a
|
||||
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
|
||||
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if isStructuredPatch(patch) {
|
||||
operations, err := parseStructuredPatch(patch)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
paths := structuredPatchOperationPaths(operations)
|
||||
for _, path := range paths {
|
||||
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return prefixPatchPaths(relativeRoot, paths)
|
||||
}
|
||||
// Enforce the same workspace confinement apply_patch applies (against the
|
||||
// resolved apply dir), so a patch with a traversal path (../x) never yields
|
||||
// an out-of-workspace target.
|
||||
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func prefixPatchPaths(relativeRoot string, paths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, path := range paths {
|
||||
workspacePath := path
|
||||
if relativeRoot != "" && relativeRoot != "." {
|
||||
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
|
||||
}
|
||||
if !seen[workspacePath] {
|
||||
seen[workspacePath] = true
|
||||
out = append(out, workspacePath)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
|
||||
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
|
||||
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
|
||||
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
|
||||
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
|
||||
{"bash", map[string]any{"command": "echo hi"}, nil},
|
||||
{"read_file", map[string]any{"path": "e.txt"}, nil},
|
||||
{"grep", map[string]any{"pattern": "x"}, nil},
|
||||
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
|
||||
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
|
||||
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
|
||||
}
|
||||
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
|
||||
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
|
||||
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
|
||||
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
|
||||
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("structured cwd: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// cwd "." (the default) must keep paths unprefixed.
|
||||
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
|
||||
if !reflect.DeepEqual(got, []string{"d.txt"}) {
|
||||
|
||||
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
|
||||
keys = append(keys, multiFileResourceKeys(args)...)
|
||||
cwd := firstStringArg(args, "cwd", "workdir")
|
||||
patch := firstStringArg(args, "patch", "diff")
|
||||
for _, p := range pathsFromUnifiedDiff(patch) {
|
||||
for _, p := range pathsFromApplyPatch(patch) {
|
||||
joined := joinUnderResourceCwd(cwd, p)
|
||||
if n := NormalizeResourcePath(joined); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func pathsFromApplyPatch(patch string) []string {
|
||||
if !isStructuredPatch(patch) {
|
||||
return pathsFromUnifiedDiff(patch)
|
||||
}
|
||||
operations, err := parseStructuredPatch(patch)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return structuredPatchOperationPaths(operations)
|
||||
}
|
||||
|
||||
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
|
||||
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
|
||||
// Never touches the filesystem.
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/pathjail"
|
||||
)
|
||||
|
||||
const (
|
||||
structuredPatchBegin = "*** Begin Patch"
|
||||
structuredPatchEnd = "*** End Patch"
|
||||
structuredAddFile = "*** Add File: "
|
||||
structuredDeleteFile = "*** Delete File: "
|
||||
structuredUpdateFile = "*** Update File: "
|
||||
structuredMoveTo = "*** Move to: "
|
||||
structuredEndOfFile = "*** End of File"
|
||||
structuredHunkContext = "@@ "
|
||||
)
|
||||
|
||||
type structuredPatchKind uint8
|
||||
|
||||
const (
|
||||
structuredPatchAdd structuredPatchKind = iota
|
||||
structuredPatchDelete
|
||||
structuredPatchUpdate
|
||||
)
|
||||
|
||||
type structuredPatchOperation struct {
|
||||
kind structuredPatchKind
|
||||
path string
|
||||
movePath string
|
||||
contents string
|
||||
chunks []structuredPatchChunk
|
||||
line int
|
||||
}
|
||||
|
||||
type structuredPatchChunk struct {
|
||||
context string
|
||||
hasContext bool
|
||||
old []string
|
||||
new []string
|
||||
newSourceOffsets []int
|
||||
endOfFile bool
|
||||
}
|
||||
|
||||
type structuredPatchTarget struct {
|
||||
requested string
|
||||
absolute string
|
||||
relative string
|
||||
}
|
||||
|
||||
type structuredPatchChange struct {
|
||||
kind structuredPatchKind
|
||||
from structuredPatchTarget
|
||||
to structuredPatchTarget
|
||||
before string
|
||||
after string
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func isStructuredPatch(patch string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
|
||||
}
|
||||
|
||||
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
|
||||
operations, err := parseStructuredPatch(patch)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
workspace, err := os.OpenRoot(applyRoot)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
defer workspace.Close()
|
||||
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
wholeBefore := make(map[string]bool, len(changes))
|
||||
if options.FileTracker != nil {
|
||||
for _, change := range changes {
|
||||
if change.kind == structuredPatchUpdate {
|
||||
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
for _, change := range changes {
|
||||
switch change.kind {
|
||||
case structuredPatchDelete:
|
||||
options.FileTracker.Forget(change.from.absolute)
|
||||
case structuredPatchAdd:
|
||||
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
|
||||
case structuredPatchUpdate:
|
||||
wasWhole := wholeBefore[change.from.absolute]
|
||||
options.FileTracker.Forget(change.from.absolute)
|
||||
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
|
||||
}
|
||||
}
|
||||
|
||||
summary := "Patch applied successfully."
|
||||
if relativeRoot != "." {
|
||||
summary = "Patch applied successfully in " + relativeRoot + "."
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
|
||||
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
|
||||
if normalized == "" {
|
||||
return nil, fmt.Errorf("structured patch is empty")
|
||||
}
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
|
||||
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
|
||||
}
|
||||
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
|
||||
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
|
||||
}
|
||||
|
||||
var operations []structuredPatchOperation
|
||||
for index := 1; index < len(lines)-1; {
|
||||
header := strings.TrimSpace(lines[index])
|
||||
lineNumber := index + 1
|
||||
if header == "" {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(header, structuredAddFile):
|
||||
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
|
||||
index++
|
||||
var content []string
|
||||
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
|
||||
if !strings.HasPrefix(lines[index], "+") {
|
||||
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
|
||||
}
|
||||
content = append(content, strings.TrimPrefix(lines[index], "+"))
|
||||
index++
|
||||
}
|
||||
if len(content) > 0 {
|
||||
op.contents = strings.Join(content, "\n") + "\n"
|
||||
}
|
||||
operations = append(operations, op)
|
||||
case strings.HasPrefix(header, structuredDeleteFile):
|
||||
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
|
||||
index++
|
||||
case strings.HasPrefix(header, structuredUpdateFile):
|
||||
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
|
||||
index++
|
||||
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
|
||||
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index++
|
||||
}
|
||||
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
|
||||
line := lines[index]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == structuredEndOfFile {
|
||||
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
|
||||
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
|
||||
}
|
||||
op.chunks[len(op.chunks)-1].endOfFile = true
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
|
||||
chunk := structuredPatchChunk{}
|
||||
if trimmed != "@@" {
|
||||
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
|
||||
chunk.hasContext = true
|
||||
}
|
||||
op.chunks = append(op.chunks, chunk)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if len(op.chunks) == 0 {
|
||||
op.chunks = append(op.chunks, structuredPatchChunk{})
|
||||
}
|
||||
chunk := &op.chunks[len(op.chunks)-1]
|
||||
switch {
|
||||
case line == "":
|
||||
sourceOffset := len(chunk.old)
|
||||
chunk.old = append(chunk.old, "")
|
||||
chunk.new = append(chunk.new, "")
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
|
||||
case strings.HasPrefix(line, " "):
|
||||
content := strings.TrimPrefix(line, " ")
|
||||
sourceOffset := len(chunk.old)
|
||||
chunk.old = append(chunk.old, content)
|
||||
chunk.new = append(chunk.new, content)
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
|
||||
case strings.HasPrefix(line, "+"):
|
||||
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
|
||||
case strings.HasPrefix(line, "-"):
|
||||
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
|
||||
default:
|
||||
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
|
||||
}
|
||||
index++
|
||||
}
|
||||
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
|
||||
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
|
||||
}
|
||||
operations = append(operations, op)
|
||||
default:
|
||||
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
|
||||
}
|
||||
}
|
||||
if len(operations) == 0 {
|
||||
return nil, fmt.Errorf("structured patch contains no file operations")
|
||||
}
|
||||
return operations, nil
|
||||
}
|
||||
|
||||
func structuredPatchPath(header, prefix string, line int) (string, error) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
if path == "" {
|
||||
return "", structuredPatchLineError(line, "file path is required")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isStructuredPatchHeader(line string) bool {
|
||||
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
|
||||
}
|
||||
|
||||
func structuredPatchLineError(line int, message string) error {
|
||||
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
|
||||
}
|
||||
|
||||
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
|
||||
return len(chunk.old) > 0 || len(chunk.new) > 0
|
||||
}
|
||||
|
||||
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
|
||||
for _, chunk := range chunks {
|
||||
if !hasStructuredPatchContent(chunk) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
for _, operation := range operations {
|
||||
for _, path := range []string{operation.path, operation.movePath} {
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
|
||||
seen := make(map[string]struct{})
|
||||
var changes []structuredPatchChange
|
||||
for _, operation := range operations {
|
||||
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := from
|
||||
if operation.movePath != "" {
|
||||
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
targets := []structuredPatchTarget{from}
|
||||
if to.absolute != from.absolute {
|
||||
targets = append(targets, to)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if _, exists := seen[target.absolute]; exists {
|
||||
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
|
||||
}
|
||||
seen[target.absolute] = struct{}{}
|
||||
}
|
||||
|
||||
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
|
||||
switch operation.kind {
|
||||
case structuredPatchAdd:
|
||||
if _, err := root.Lstat(to.relative); err == nil {
|
||||
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
change.after = operation.contents
|
||||
case structuredPatchDelete, structuredPatchUpdate:
|
||||
info, err := root.Stat(from.relative)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
|
||||
}
|
||||
change.mode = info.Mode()
|
||||
content, err := root.ReadFile(from.relative)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
|
||||
}
|
||||
if err := tracker.CheckConflict(from.absolute, content); err != nil {
|
||||
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
|
||||
}
|
||||
change.before = string(content)
|
||||
if operation.kind == structuredPatchUpdate {
|
||||
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change.after = updated
|
||||
if from.absolute != to.absolute {
|
||||
if _, err := root.Lstat(to.relative); err == nil {
|
||||
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
|
||||
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
|
||||
if err != nil {
|
||||
return structuredPatchTarget{}, err
|
||||
}
|
||||
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
|
||||
}
|
||||
|
||||
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
|
||||
lineEnding := structuredPatchLineEnding(content)
|
||||
if lineEnding == "\r\n" {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
replacements := make([]structuredReplacement, 0, len(chunks))
|
||||
lineIndex := 0
|
||||
for _, chunk := range chunks {
|
||||
if chunk.hasContext {
|
||||
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
|
||||
if ambiguous {
|
||||
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
|
||||
}
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
|
||||
}
|
||||
lineIndex = index + 1
|
||||
}
|
||||
if len(chunk.old) == 0 {
|
||||
// A context anchor identifies the line immediately before a pure
|
||||
// insertion. Only an explicit end-of-file hunk (or an unanchored
|
||||
// insertion) belongs at the file end.
|
||||
start := lineIndex
|
||||
if chunk.endOfFile || !chunk.hasContext {
|
||||
start = len(lines)
|
||||
}
|
||||
if start > len(lines) {
|
||||
start = len(lines)
|
||||
}
|
||||
replacement, err := structuredPatchReplacement(chunk, lines, start)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("building replacement for %s: %w", path, err)
|
||||
}
|
||||
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
|
||||
lineIndex = start
|
||||
continue
|
||||
}
|
||||
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
|
||||
if ambiguous {
|
||||
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
|
||||
}
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
|
||||
}
|
||||
replacement, err := structuredPatchReplacement(chunk, lines, index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("building replacement for %s: %w", path, err)
|
||||
}
|
||||
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
|
||||
lineIndex = index + len(chunk.old)
|
||||
}
|
||||
for index := 1; index < len(replacements); index++ {
|
||||
previous := replacements[index-1]
|
||||
current := replacements[index]
|
||||
if current.start < previous.start+previous.length {
|
||||
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
|
||||
}
|
||||
}
|
||||
for index := len(replacements) - 1; index >= 0; index-- {
|
||||
replacement := replacements[index]
|
||||
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
updated := strings.Join(lines, lineEnding)
|
||||
if trailingNewline {
|
||||
updated += lineEnding
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
|
||||
if len(chunk.newSourceOffsets) != len(chunk.new) {
|
||||
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
|
||||
}
|
||||
replacement := append([]string(nil), chunk.new...)
|
||||
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
|
||||
if sourceOffset < 0 {
|
||||
continue
|
||||
}
|
||||
sourceIndex := start + sourceOffset
|
||||
if sourceIndex < 0 || sourceIndex >= len(source) {
|
||||
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
|
||||
}
|
||||
replacement[outputIndex] = source[sourceIndex]
|
||||
}
|
||||
return replacement, nil
|
||||
}
|
||||
|
||||
func structuredPatchLineEnding(content string) string {
|
||||
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
|
||||
return "\r\n"
|
||||
}
|
||||
return "\n"
|
||||
}
|
||||
|
||||
type structuredReplacement struct {
|
||||
start int
|
||||
length int
|
||||
replacement []string
|
||||
}
|
||||
|
||||
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
|
||||
if len(wanted) == 0 {
|
||||
return start, false
|
||||
}
|
||||
if len(wanted) > len(lines) {
|
||||
return -1, false
|
||||
}
|
||||
searchStart := start
|
||||
if endOfFile {
|
||||
searchStart = len(lines) - len(wanted)
|
||||
if searchStart < start {
|
||||
searchStart = start
|
||||
}
|
||||
}
|
||||
for _, equal := range []func(string, string) bool{
|
||||
func(left, right string) bool { return left == right },
|
||||
func(left, right string) bool {
|
||||
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
|
||||
},
|
||||
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
|
||||
} {
|
||||
match := -1
|
||||
for index := searchStart; index+len(wanted) <= len(lines); index++ {
|
||||
matched := true
|
||||
for offset, line := range wanted {
|
||||
if !equal(lines[index+offset], line) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
if match >= 0 {
|
||||
return -1, true
|
||||
}
|
||||
match = index
|
||||
}
|
||||
}
|
||||
if match >= 0 {
|
||||
return match, false
|
||||
}
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
|
||||
mutated := false
|
||||
for _, change := range changes {
|
||||
committed, err := applyStructuredPatchChange(root, change)
|
||||
mutated = mutated || committed
|
||||
if err != nil {
|
||||
forgetStructuredPatchFiles(tracker, changes)
|
||||
if mutated {
|
||||
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
for _, change := range changes {
|
||||
tracker.Forget(change.from.absolute)
|
||||
if change.to.absolute != change.from.absolute {
|
||||
tracker.Forget(change.to.absolute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
|
||||
switch change.kind {
|
||||
case structuredPatchDelete:
|
||||
if err := root.Remove(change.from.relative); err != nil {
|
||||
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
|
||||
}
|
||||
return true, nil
|
||||
case structuredPatchAdd:
|
||||
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
|
||||
case structuredPatchUpdate:
|
||||
moving := change.from.absolute != change.to.absolute
|
||||
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
|
||||
if err != nil {
|
||||
return committed, err
|
||||
}
|
||||
if moving {
|
||||
if err := root.Remove(change.from.relative); err != nil {
|
||||
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("unsupported structured patch operation")
|
||||
}
|
||||
|
||||
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
|
||||
parent := filepath.Dir(target.relative)
|
||||
if err := root.MkdirAll(parent, 0o755); err != nil {
|
||||
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
|
||||
}
|
||||
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
|
||||
if _, err := temp.WriteString(content); err != nil {
|
||||
_ = temp.Close()
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
if err := temp.Chmod(mode.Perm()); err != nil {
|
||||
_ = temp.Close()
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
if createOnly {
|
||||
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
|
||||
}
|
||||
if err := root.Rename(tempName, target.relative); err != nil {
|
||||
return false, fmt.Errorf("writing %s: %w", target.relative, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
|
||||
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
|
||||
}
|
||||
|
||||
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
|
||||
if linkErr := link(source, target); linkErr == nil {
|
||||
cleanupErr := removeStructuredPatchTemp(root, source)
|
||||
chmodErr := root.Chmod(target, mode.Perm())
|
||||
if cleanupErr != nil || chmodErr != nil {
|
||||
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
|
||||
}
|
||||
return true, nil
|
||||
} else if errors.Is(linkErr, os.ErrExist) {
|
||||
return false, fmt.Errorf("creating %s: %w", target, linkErr)
|
||||
} else {
|
||||
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
|
||||
if copyErr != nil {
|
||||
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
}
|
||||
|
||||
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
|
||||
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
|
||||
}
|
||||
|
||||
func copyStructuredPatchNoReplaceWith(
|
||||
root *os.Root,
|
||||
source, target string,
|
||||
mode os.FileMode,
|
||||
copyFile func(io.Writer, io.Reader) (int64, error),
|
||||
cleanup func(*os.Root, string) error,
|
||||
) (bool, error) {
|
||||
sourceFile, err := root.Open(source)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sourceClosed := false
|
||||
defer func() {
|
||||
if !sourceClosed {
|
||||
_ = sourceFile.Close()
|
||||
}
|
||||
}()
|
||||
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
committed := true
|
||||
cleanupTarget := func(cause error) (bool, error) {
|
||||
_ = targetFile.Close()
|
||||
if cleanupErr := cleanup(root, target); cleanupErr == nil {
|
||||
return false, cause
|
||||
} else {
|
||||
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
|
||||
}
|
||||
}
|
||||
if _, err := copyFile(targetFile, sourceFile); err != nil {
|
||||
return cleanupTarget(err)
|
||||
}
|
||||
if err := sourceFile.Close(); err != nil {
|
||||
sourceClosed = true
|
||||
return cleanupTarget(err)
|
||||
}
|
||||
sourceClosed = true
|
||||
if err := targetFile.Chmod(mode.Perm()); err != nil {
|
||||
return cleanupTarget(err)
|
||||
}
|
||||
if err := targetFile.Close(); err != nil {
|
||||
if cleanupErr := cleanup(root, target); cleanupErr == nil {
|
||||
return false, err
|
||||
} else {
|
||||
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
|
||||
}
|
||||
}
|
||||
if err := cleanup(root, source); err != nil {
|
||||
return true, fmt.Errorf("removing published source: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func removeStructuredPatchTemp(root *os.Root, name string) error {
|
||||
chmodErr := root.Chmod(name, 0o600)
|
||||
if os.IsNotExist(chmodErr) {
|
||||
return nil
|
||||
}
|
||||
removeErr := root.Remove(name)
|
||||
if os.IsNotExist(removeErr) {
|
||||
removeErr = nil
|
||||
}
|
||||
if removeErr == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(chmodErr, removeErr)
|
||||
}
|
||||
|
||||
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
content, err := os.ReadFile(absolute)
|
||||
if err != nil {
|
||||
tracker.Forget(absolute)
|
||||
return
|
||||
}
|
||||
info, _ := os.Stat(absolute)
|
||||
tracker.Record(absolute, content, info)
|
||||
if seenWhole {
|
||||
lines := lineCount(string(content))
|
||||
tracker.RecordSeenRange(absolute, 1, lines, lines)
|
||||
}
|
||||
if created {
|
||||
tracker.RecordCreated(absolute)
|
||||
}
|
||||
}
|
||||
|
||||
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
for _, change := range changes {
|
||||
targets := []structuredPatchTarget{change.to}
|
||||
if change.from.absolute != change.to.absolute {
|
||||
targets = append([]structuredPatchTarget{change.from}, targets...)
|
||||
}
|
||||
for _, target := range targets {
|
||||
path := target.relative
|
||||
if relativeRoot != "" && relativeRoot != "." {
|
||||
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
|
||||
}
|
||||
if !seen[path] {
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func structuredPatchPreview(changes []structuredPatchChange) string {
|
||||
var preview strings.Builder
|
||||
for _, change := range changes {
|
||||
path := change.to.relative
|
||||
if change.kind == structuredPatchDelete {
|
||||
path = change.from.relative
|
||||
}
|
||||
before, after := change.before, change.after
|
||||
if change.kind == structuredPatchDelete {
|
||||
after = ""
|
||||
}
|
||||
diff := boundedUnifiedDiff(path, before, after)
|
||||
if diff == "" {
|
||||
continue
|
||||
}
|
||||
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
|
||||
break
|
||||
}
|
||||
if preview.Len() > 0 {
|
||||
preview.WriteByte('\n')
|
||||
}
|
||||
preview.WriteString(diff)
|
||||
}
|
||||
return capPreviewDiff(preview.String())
|
||||
}
|
||||
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
|
||||
var deferred []Tool
|
||||
if tool.registry != nil {
|
||||
for _, candidate := range tool.registry.All() {
|
||||
if !ModelVisible(candidate) {
|
||||
continue
|
||||
}
|
||||
if !IsDeferred(candidate) {
|
||||
continue
|
||||
}
|
||||
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
|
||||
return names
|
||||
}
|
||||
for _, candidate := range tool.registry.All() {
|
||||
if !ModelVisible(candidate) {
|
||||
continue
|
||||
}
|
||||
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustReadTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
|
||||
toolset := CoreToolsScoped(t.TempDir(), nil)
|
||||
byName := make(map[string]Tool, len(toolset))
|
||||
@@ -513,6 +524,532 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
|
||||
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: hello.txt",
|
||||
"@@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
|
||||
t.Fatalf("structured patch content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Add File: nested/new.txt",
|
||||
"+created",
|
||||
"*** Update File: old.txt",
|
||||
"*** Move to: moved.txt",
|
||||
"@@",
|
||||
"-old",
|
||||
"+moved",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
|
||||
t.Fatalf("added file = %q, err = %v", got, err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
|
||||
t.Fatalf("moved file = %q, err = %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old source should be removed, stat err = %v", err)
|
||||
}
|
||||
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
|
||||
t.Fatalf("ChangedFiles = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: hello.txt",
|
||||
"@@",
|
||||
"-hello",
|
||||
"+goodbye",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(content); got != "goodbye\n" {
|
||||
t.Fatalf("structured patch content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.go")
|
||||
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
|
||||
writeTestFile(t, path, original)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.go",
|
||||
"@@",
|
||||
" if cond {",
|
||||
" doWork()",
|
||||
"-\t\tlogIt()",
|
||||
"+\t\tlogIt(ctx)",
|
||||
" }",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
|
||||
if got := mustReadTestFile(t, path); got != want {
|
||||
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.txt")
|
||||
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
|
||||
writeTestFile(t, path, original)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.txt",
|
||||
"@@",
|
||||
" alpha",
|
||||
" context",
|
||||
"-\told",
|
||||
"+\tnew",
|
||||
" omega",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
|
||||
if got := mustReadTestFile(t, path); got != want {
|
||||
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.txt",
|
||||
"@@ anchor",
|
||||
"+inserted",
|
||||
"@@ middle",
|
||||
"-remove",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured insertion failed: %s", result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
|
||||
t.Fatalf("structured insertion content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.go")
|
||||
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
|
||||
writeTestFile(t, path, original)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.go",
|
||||
"@@",
|
||||
"-\treturn nil",
|
||||
"+\treturn errNotFound",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
|
||||
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got := mustReadTestFile(t, path); got != original {
|
||||
t.Fatalf("ambiguous structured patch changed the file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.txt")
|
||||
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.txt",
|
||||
"@@",
|
||||
"-bravo",
|
||||
"+BRAVO",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
|
||||
t.Fatalf("structured patch content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.txt")
|
||||
writeTestFile(t, path, "alpha\nbravo")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.txt",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+ALPHA",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
|
||||
t.Fatalf("structured patch content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "empty.txt")
|
||||
writeTestFile(t, path, "")
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: empty.txt",
|
||||
"@@",
|
||||
"+content",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
if got, want := mustReadTestFile(t, path), "content"; got != want {
|
||||
t.Fatalf("structured patch content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "example.txt")
|
||||
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
|
||||
writeTestFile(t, path, original)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: example.txt",
|
||||
"@@",
|
||||
"-delta",
|
||||
"+DELTA",
|
||||
"@@",
|
||||
"-delta",
|
||||
"-echo",
|
||||
"+tail",
|
||||
"*** End of File",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if got := mustReadTestFile(t, path); got != original {
|
||||
t.Fatalf("out-of-order structured patch changed the file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetPath := filepath.Join(root, "created.txt")
|
||||
writeTestFile(t, targetPath, "other writer\n")
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
change := structuredPatchChange{
|
||||
kind: structuredPatchAdd,
|
||||
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
|
||||
after: "patch content\n", mode: 0o644,
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
if err == nil || !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "exclusive-copy fallback") {
|
||||
t.Fatalf("existing destination should be reported directly: %v", err)
|
||||
}
|
||||
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
|
||||
t.Fatalf("raced add overwrote another writer's file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
missingPath := filepath.Join(root, "missing.txt")
|
||||
change := structuredPatchChange{
|
||||
kind: structuredPatchDelete,
|
||||
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
|
||||
before: "removed by another writer\n", mode: 0o644,
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("delete of an already removed file should fail")
|
||||
}
|
||||
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed delete recreated the missing file: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
sourcePath := filepath.Join(root, "source.txt")
|
||||
destinationPath := filepath.Join(root, "destination.txt")
|
||||
change := structuredPatchChange{
|
||||
kind: structuredPatchUpdate,
|
||||
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
|
||||
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
|
||||
before: "removed source\n", after: "moved content\n", mode: 0o644,
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("move with a missing source = %v, want partial-application error", err)
|
||||
}
|
||||
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed move recreated a missing source: %v", statErr)
|
||||
}
|
||||
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
|
||||
t.Fatalf("published move destination = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
sourcePath := filepath.Join(root, "source.tmp")
|
||||
writeTestFile(t, sourcePath, "complete content\n")
|
||||
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
committed, err := publishStructuredPatchNoReplaceWith(
|
||||
workspace,
|
||||
"source.tmp",
|
||||
"target.txt",
|
||||
0o444,
|
||||
func(string, string) error { return errors.New("hard links unsupported") },
|
||||
)
|
||||
if err != nil || !committed {
|
||||
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
|
||||
}
|
||||
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
|
||||
t.Fatalf("fallback target content = %q", got)
|
||||
}
|
||||
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("read-only source was not cleaned up: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
sourcePath := filepath.Join(root, "source.tmp")
|
||||
writeTestFile(t, sourcePath, "complete content\n")
|
||||
copyErr := errors.New("injected copy failure")
|
||||
cleanupErr := errors.New("injected target cleanup failure")
|
||||
committed, err := copyStructuredPatchNoReplaceWith(
|
||||
workspace,
|
||||
"source.tmp",
|
||||
"target.txt",
|
||||
0o644,
|
||||
func(destination io.Writer, _ io.Reader) (int64, error) {
|
||||
written, writeErr := io.WriteString(destination, "partial")
|
||||
if writeErr != nil {
|
||||
return int64(written), writeErr
|
||||
}
|
||||
return int64(written), copyErr
|
||||
},
|
||||
func(root *os.Root, name string) error {
|
||||
if name == "target.txt" {
|
||||
return cleanupErr
|
||||
}
|
||||
return removeStructuredPatchTemp(root, name)
|
||||
},
|
||||
)
|
||||
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("copy failure = committed %v, error %v", committed, err)
|
||||
}
|
||||
targetPath := filepath.Join(root, "target.txt")
|
||||
if got := mustReadTestFile(t, targetPath); got != "partial" {
|
||||
t.Fatalf("partial target content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
trackedPath := filepath.Join(root, "tracked.txt")
|
||||
writeTestFile(t, trackedPath, "original\n")
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
|
||||
trackedPath, err = filepath.EvalSymlinks(trackedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(trackedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker := NewFileTracker()
|
||||
tracker.Record(trackedPath, []byte("original\n"), info)
|
||||
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
|
||||
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
|
||||
blockedPath := filepath.Join(root, "blocked")
|
||||
if err := os.Mkdir(blockedPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
|
||||
changes := []structuredPatchChange{
|
||||
{
|
||||
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
|
||||
before: "original\n", after: "updated\n", mode: 0o644,
|
||||
},
|
||||
{
|
||||
kind: structuredPatchAdd,
|
||||
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
|
||||
after: "cannot replace a non-empty directory\n", mode: 0o644,
|
||||
},
|
||||
}
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, changes, tracker)
|
||||
if err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("second change = %v, want partial-application error", err)
|
||||
}
|
||||
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
|
||||
t.Fatalf("completed change was unexpectedly altered: %q", got)
|
||||
}
|
||||
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
|
||||
t.Fatal("partial failure retained stale file-tracker state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tool := NewScopedApplyPatchTool(root, nil)
|
||||
for name, patch := range map[string]string{
|
||||
"empty update": strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: target.txt",
|
||||
"*** End Patch",
|
||||
}, "\n"),
|
||||
"escape": strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Add File: ../outside.txt",
|
||||
"+nope",
|
||||
"*** End Patch",
|
||||
}, "\n"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result := tool.Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "No valid patches") {
|
||||
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
|
||||
}
|
||||
if name == "empty update" {
|
||||
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty update must not write target file, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A hunk-body line that removes content beginning with "-- " appears in the diff
|
||||
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
|
||||
// apply_patch reject a valid patch as targeting an outside path).
|
||||
|
||||
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("add-dir: " + err.Error())
|
||||
}
|
||||
return m.appendSystemNotice("write access added: " + root + " (this session only)")
|
||||
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
|
||||
}
|
||||
|
||||
// appendSystemNotice appends a system transcript line and returns the updated
|
||||
|
||||
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
|
||||
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
|
||||
t.Fatalf("expected one additional write root after grant, got %#v", roots)
|
||||
}
|
||||
notice := lastTranscriptText(next)
|
||||
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
|
||||
notice := next.transientNotice.text
|
||||
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
|
||||
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
markdownTableRuleBodyRows = 4
|
||||
markdownBoldStart = "\x1b[1m"
|
||||
markdownBoldEnd = "\x1b[22m"
|
||||
markdownCodeStart = "\x1b[7m"
|
||||
markdownCodeEnd = "\x1b[27m"
|
||||
)
|
||||
|
||||
type markdownDisplayStyle int
|
||||
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
|
||||
const (
|
||||
markdownDisplayNormal markdownDisplayStyle = iota
|
||||
markdownDisplayBold
|
||||
markdownDisplayCode
|
||||
markdownDisplayRule
|
||||
)
|
||||
|
||||
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
case markdownDisplayBold:
|
||||
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
|
||||
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
|
||||
case markdownDisplayCode:
|
||||
builder.WriteString(inlineCodeStyle().Render(text))
|
||||
case markdownDisplayRule:
|
||||
builder.WriteString(zeroTheme.lineStrong.Render(text))
|
||||
default:
|
||||
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
style = markdownDisplayNormal
|
||||
index += len(markdownBoldEnd)
|
||||
continue
|
||||
case strings.HasPrefix(line[index:], markdownCodeStart):
|
||||
flush()
|
||||
style = markdownDisplayCode
|
||||
index += len(markdownCodeStart)
|
||||
continue
|
||||
case strings.HasPrefix(line[index:], markdownCodeEnd):
|
||||
flush()
|
||||
style = markdownDisplayNormal
|
||||
index += len(markdownCodeEnd)
|
||||
continue
|
||||
case line[index] == '\x1b':
|
||||
// Already-styled input (highlighted code, headings, tables) carries real
|
||||
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
|
||||
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func hasMarkdownDisplayControls(line string) bool {
|
||||
return strings.Contains(line, markdownBoldStart) ||
|
||||
strings.Contains(line, markdownBoldEnd) ||
|
||||
strings.Contains(line, markdownCodeStart) ||
|
||||
strings.Contains(line, markdownCodeEnd)
|
||||
}
|
||||
|
||||
func hasExternalANSIStyle(line string) bool {
|
||||
for index := 0; index < len(line); {
|
||||
if line[index] != '\x1b' {
|
||||
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
|
||||
continue
|
||||
}
|
||||
seq := line[index:end]
|
||||
if seq != markdownBoldStart && seq != markdownBoldEnd {
|
||||
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
|
||||
return true
|
||||
}
|
||||
index = end
|
||||
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
|
||||
}
|
||||
|
||||
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
|
||||
if current == markdownDisplayCode {
|
||||
return current
|
||||
}
|
||||
switch r {
|
||||
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
|
||||
return markdownDisplayRule
|
||||
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
|
||||
type markdownInlineSegment struct {
|
||||
text string
|
||||
bold bool
|
||||
code bool
|
||||
}
|
||||
|
||||
func wrapMarkdownInline(text string, measure int) []string {
|
||||
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
|
||||
flush()
|
||||
}
|
||||
head, tail := splitAtWidth(word.text, available)
|
||||
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
|
||||
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
|
||||
word.text = tail
|
||||
prefix = continuationPrefix
|
||||
available = maxInt(1, measure-lipgloss.Width(prefix))
|
||||
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
|
||||
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
|
||||
words := []markdownInlineSegment{}
|
||||
for _, segment := range segments {
|
||||
if segment.code {
|
||||
if segment.text != "" {
|
||||
words = append(words, segment)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, word := range strings.Fields(segment.text) {
|
||||
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
|
||||
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
|
||||
}
|
||||
}
|
||||
return words
|
||||
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
|
||||
if segment.text == "" {
|
||||
return ""
|
||||
}
|
||||
if segment.code {
|
||||
return markdownCodeStart + segment.text + markdownCodeEnd
|
||||
}
|
||||
if segment.bold {
|
||||
return renderMarkdownBoldText(segment.text)
|
||||
}
|
||||
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
|
||||
if builder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
|
||||
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
|
||||
builder.Reset()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
const (
|
||||
attachmentImageID = uint32(0xC0E0)
|
||||
attachmentPreviewCols = 10
|
||||
attachmentPreviewRows = 4
|
||||
attachmentPreviewHeightPixels = 64
|
||||
attachmentPreviewMaxImages = 4
|
||||
)
|
||||
|
||||
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
|
||||
// image is an enhancement to the existing attachment chips, not a reason to
|
||||
// crowd a narrow or modal surface.
|
||||
func (m model) attachmentThumbnailVisible(width int) bool {
|
||||
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
|
||||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
|
||||
return false
|
||||
}
|
||||
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
|
||||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
|
||||
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
|
||||
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailSlots(width int) int {
|
||||
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
|
||||
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
|
||||
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailLines(width int) []string {
|
||||
left := strings.Repeat(" ", attachmentPreviewCols+1)
|
||||
lines := make([]string, attachmentPreviewRows)
|
||||
for index := range lines {
|
||||
lines[index] = left
|
||||
}
|
||||
for index, line := range lines {
|
||||
lines[index] = fitStyledLine(line, width)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailSupplementalChips() string {
|
||||
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
|
||||
}
|
||||
|
||||
func (m model) attachmentComposerPrefixRows(width int) int {
|
||||
if m.attachmentThumbnailVisible(width) {
|
||||
rows := attachmentPreviewRows
|
||||
if m.attachmentThumbnailSupplementalChips() != "" {
|
||||
rows++
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
|
||||
// of the composer. The position comes from the same footer layout used for mouse
|
||||
// input, so it follows sidebar changes and terminal resizes instead of relying on
|
||||
// a brittle absolute bottom-right calculation.
|
||||
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
|
||||
width := m.chatColumnWidth()
|
||||
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
|
||||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
|
||||
return nil
|
||||
}
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
if frame.composerRect.height < attachmentPreviewRows+2 {
|
||||
return nil
|
||||
}
|
||||
return &terminalpet.ImageDraw{
|
||||
ID: attachmentImageID + uint32(index),
|
||||
Animation: m.pendingImageThumbnails[index],
|
||||
State: terminalpet.Idle,
|
||||
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
|
||||
Y: frame.composerRect.y + 1,
|
||||
Columns: attachmentPreviewCols,
|
||||
Rows: attachmentPreviewRows,
|
||||
HeightPixels: attachmentPreviewHeightPixels,
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentPreviewImageIDs() []uint32 {
|
||||
ids := make([]uint32, attachmentPreviewMaxImages)
|
||||
for index := range ids {
|
||||
ids[index] = attachmentImageID + uint32(index)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
|
||||
t.Helper()
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
|
||||
for y := 0; y < 6; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
|
||||
}
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
|
||||
t.Fatal("expected staged image thumbnail")
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
plain := plainRender(t, view.Content)
|
||||
if strings.Contains(plain, "[Image #1]") {
|
||||
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "Image #1 attached") {
|
||||
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
|
||||
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{
|
||||
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"first.png", "second.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
|
||||
plain := plainRender(t, m.composerBox(m.width))
|
||||
if !strings.Contains(plain, "[Image #2]") {
|
||||
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
|
||||
}
|
||||
_ = m.View()
|
||||
for index, renderer := range m.attachmentRenderers {
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
|
||||
if !strings.Contains(output.String(), id) {
|
||||
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
|
||||
plain := plainRender(t, m.composerBox(m.width))
|
||||
if !strings.Contains(plain, "[Image #1]") {
|
||||
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "Image #1 attached") {
|
||||
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
_ = m.View()
|
||||
var first bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
m.pendingImageThumbnails = nil
|
||||
_ = m.View()
|
||||
var cleared bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
|
||||
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
|
||||
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sink.Close() })
|
||||
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(sink.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
|
||||
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
|
||||
}
|
||||
for _, id := range []string{"i=49374", "i=49376"} {
|
||||
if !strings.Contains(got, id) {
|
||||
t.Fatalf("missing image placement %s: %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
if question == "" {
|
||||
side.pendingImages = nil
|
||||
side.pendingImageLabels = nil
|
||||
side.pendingImageThumbnails = nil
|
||||
side.pendingDocuments = nil
|
||||
}
|
||||
side.loops = nil
|
||||
|
||||
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
|
||||
}, true
|
||||
}
|
||||
if provider, ok := m.activeProviderDescriptor(); ok {
|
||||
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
|
||||
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
|
||||
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
|
||||
if strings.EqualFold(model.ID, candidate) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, model := range providermodelcatalog.Models(provider) {
|
||||
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
for _, model := range providermodelcatalog.Models(provider) {
|
||||
if strings.EqualFold(model.ID, candidate) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
|
||||
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
|
||||
return modelSwitchTarget{}, false
|
||||
}
|
||||
|
||||
// providerModelSwitchCandidates preserves provider-owned model IDs while
|
||||
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
|
||||
// The ChatGPT backend itself publishes bare model IDs (for example,
|
||||
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
|
||||
// as part of their model ID, so the alias is deliberately provider-specific.
|
||||
func providerModelSwitchCandidates(providerID string, input string) []string {
|
||||
requested := strings.TrimSpace(input)
|
||||
candidates := []string{requested}
|
||||
const openAIPrefix = "openai/"
|
||||
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
|
||||
return candidates
|
||||
}
|
||||
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
|
||||
candidates = append(candidates, bare)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
|
||||
if modelSwitchCompactionGuard == nil {
|
||||
return m, "", false
|
||||
|
||||
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
if width < 8 {
|
||||
return m.composerPositionAtVisualCell(localX, localY, width)
|
||||
}
|
||||
contentY := localY - 1
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
contentY--
|
||||
}
|
||||
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
|
||||
if contentY < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -201,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
|
||||
}
|
||||
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
|
||||
if m.dictation.voiceModeEnabled {
|
||||
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
|
||||
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
|
||||
}
|
||||
// Turning voice off is the "done dictating" signal, so release the warm sherpa
|
||||
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
|
||||
// its port) until the app exits. It respawns lazily on the next streaming
|
||||
// recording. Skip while a recording is still in flight so we don't kill it.
|
||||
next := m.appendSystemNotice("Voice mode off.")
|
||||
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
|
||||
if next.dictation.active() {
|
||||
return next, nil
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
|
||||
}
|
||||
m = m.discardDictationRegion()
|
||||
m.dictation.reset()
|
||||
return m.appendSystemNotice("Dictation cancelled."), nil
|
||||
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
|
||||
}
|
||||
|
||||
// wantStreaming decides whether this recording uses the streaming pipeline:
|
||||
@@ -438,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
|
||||
if streaming {
|
||||
return m, nil // streaming already rendered live; nothing to add
|
||||
}
|
||||
return m.appendSystemNotice("No speech detected."), nil
|
||||
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
|
||||
}
|
||||
if !streaming {
|
||||
m = m.insertDictatedText(msg.text)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
|
||||
// variant, reporting progress live, then persists and applies the resolved paths.
|
||||
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
|
||||
if m.dictation.downloading {
|
||||
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
|
||||
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
|
||||
}
|
||||
m.dictation.downloading = true
|
||||
sink := m.runtimeMessageSink
|
||||
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
|
||||
base = context.Background()
|
||||
}
|
||||
m.dictation.downloadStatus = "Starting download…"
|
||||
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
|
||||
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
|
||||
return m, func() tea.Msg {
|
||||
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
|
||||
DestRoot: root,
|
||||
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
|
||||
}
|
||||
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
|
||||
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
|
||||
}
|
||||
|
||||
// applyEngineComponents persists and applies resolved engine/model paths to the
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
|
||||
if got.dictation.cfg.StreamingEnabled() {
|
||||
t.Error("a batch variant should set streaming off")
|
||||
}
|
||||
if !transcriptHasText(got, "ready") {
|
||||
if !strings.Contains(got.transientNotice.text, "ready") {
|
||||
t.Error("expected a ready notice")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
|
||||
if cmd == nil {
|
||||
t.Error("expected a download command")
|
||||
}
|
||||
if !transcriptHasText(next, variant.Label) {
|
||||
if !strings.Contains(next.transientNotice.text, variant.Label) {
|
||||
t.Error("expected the download-starting notice to name the variant")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
|
||||
if got.composer.text != "" {
|
||||
t.Errorf("composer should stay empty, got %q", got.composer.text)
|
||||
}
|
||||
if !transcriptHasText(got, "No speech detected") {
|
||||
t.Error("expected a 'no speech' notice")
|
||||
if got.transientNotice.text != "No speech detected." {
|
||||
t.Errorf("no-speech notice = %q", got.transientNotice.text)
|
||||
}
|
||||
if transcriptHasText(got, "No speech detected") {
|
||||
t.Error("no-speech confirmation should not be persisted in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
|
||||
if !next.dictation.voiceModeEnabled {
|
||||
t.Fatal("first /voice should enable voice mode")
|
||||
}
|
||||
if !transcriptHasText(next, "Voice mode on") {
|
||||
t.Error("expected voice-mode-on notice")
|
||||
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
|
||||
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
|
||||
}
|
||||
if transcriptHasText(next, "Voice mode on") {
|
||||
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
|
||||
}
|
||||
off, _ := next.toggleVoiceMode()
|
||||
if off.dictation.voiceModeEnabled {
|
||||
|
||||
+35
-11
@@ -2,12 +2,16 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func renderMarkdownInline(text string) string {
|
||||
@@ -48,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// runningPlanModel supplies plan state for tests that exercise contextual
|
||||
// details without reviving the former persistent plan presentation.
|
||||
func runningPlanModel(t *testing.T, steps int) model {
|
||||
t.Helper()
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
m.width = 100
|
||||
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return base.Add(15 * time.Second) }
|
||||
items := make([]tools.PlanItem, steps)
|
||||
for i := range items {
|
||||
status := "pending"
|
||||
if i == 0 {
|
||||
status = "in_progress"
|
||||
}
|
||||
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
|
||||
}
|
||||
m.plan.updateFromItems(items, base)
|
||||
return m
|
||||
}
|
||||
|
||||
func listCommandNames() []string {
|
||||
names := make([]string, 0, len(commandDefinitions))
|
||||
for _, command := range commandDefinitions {
|
||||
@@ -88,18 +112,18 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
|
||||
return m.overlayMouseRect(overlayHeight, width).y
|
||||
}
|
||||
|
||||
// height returns the number of terminal lines renderPlanPanel will occupy at
|
||||
// the given width (0 when the panel is not visible). The step list is shown
|
||||
// when the panel is expanded or still running; a collapsed, finished plan is
|
||||
// just the header and progress bar.
|
||||
func (s planPanelState) height(width int, now time.Time) int {
|
||||
if !s.visible(now) {
|
||||
return 0
|
||||
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if s.expanded || !s.isComplete() {
|
||||
return 2 + len(s.steps)
|
||||
}
|
||||
return 2
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
|
||||
defer cancel()
|
||||
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
|
||||
}
|
||||
|
||||
func WatchPRState(service *PrService, onChange func(PrState)) func() {
|
||||
return WatchPRStateContext(context.Background(), service, onChange)
|
||||
}
|
||||
|
||||
func (c *staticRenderCache) retainedCharacters() int {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
|
||||
// is open closes the file view first (the subchat owns the single-column view).
|
||||
func TestSubchatEntryClosesFileView(t *testing.T) {
|
||||
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
|
||||
// coordinates cannot unexpectedly replace the full-width conversation view.
|
||||
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
|
||||
}
|
||||
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
|
||||
updated, _, handled := m.handleTranscriptSelectionMouse(click)
|
||||
if !handled {
|
||||
t.Fatal("agent row click should be handled")
|
||||
if handled {
|
||||
t.Fatal("invisible rail coordinates must not handle clicks")
|
||||
}
|
||||
if updated.fileView.active {
|
||||
t.Fatal("entering the subchat should close the file view")
|
||||
if !updated.fileView.active {
|
||||
t.Fatal("an ignored rail click must leave the active file view alone")
|
||||
}
|
||||
if !updated.subchat.active {
|
||||
t.Fatal("subchat should be active")
|
||||
if updated.subchat.active {
|
||||
t.Fatal("an ignored rail click must not enter a subchat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
|
||||
// user saw while the run was live. The provider-facing output is intentionally
|
||||
// a short confirmation, so it cannot substitute for the card-only preview.
|
||||
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
|
||||
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
|
||||
events := []sessions.Event{{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
|
||||
}}
|
||||
|
||||
rows := transcriptRowsFromSessionEvents(events)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one restored row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].detail != preview {
|
||||
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
|
||||
}
|
||||
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
|
||||
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
|
||||
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
|
||||
// being viewed must not clobber the user's mode or scroll — the old
|
||||
// unconditional openFileView bounced full mode back to diff and reset scroll.
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
// transcript's tool-result rows (their changedFiles), so it survives resume for
|
||||
// free and can never drift from what the chat shows.
|
||||
//
|
||||
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
|
||||
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
|
||||
// to the most recent one; a second click (or a click while the drill-in is
|
||||
// already open) opens the file view (file_view.go). Esc clears the selection.
|
||||
// The same compact rows feed the run-details overlay; selecting a file from a
|
||||
// visible transcript card opens the file drill-in (file_view.go).
|
||||
package tui
|
||||
|
||||
import (
|
||||
@@ -17,8 +15,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
|
||||
return hits
|
||||
}
|
||||
|
||||
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
|
||||
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
|
||||
// sidebar's height budget (or colliding with the token floor line) never match.
|
||||
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
|
||||
if !m.sidebarActive() {
|
||||
return "", false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return "", false
|
||||
}
|
||||
sidebarW := sidebarWidth(m.width)
|
||||
if sidebarW <= 0 {
|
||||
return "", false
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
|
||||
x, y := mouseX(msg), mouseY(msg)
|
||||
if x < x0 || x >= x0+sidebarW {
|
||||
return "", false
|
||||
}
|
||||
for _, hit := range m.sidebarFileSelectables(sidebarW) {
|
||||
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
|
||||
return hit.path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
|
||||
// the currently selected file, so its card renders with the selection tint.
|
||||
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
|
||||
|
||||
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
|
||||
// it (tint + scroll state, no view swap); the second click on the same file
|
||||
// opens the drill-in; clicking another file while the view is open switches it.
|
||||
func TestFileRowClickSelectsThenOpens(t *testing.T) {
|
||||
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
|
||||
// permanent column beside the conversation.
|
||||
func TestRunDetailsListsTouchedFiles(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
width := sidebarWidth(m.width)
|
||||
hits := m.sidebarFileSelectables(width)
|
||||
if len(hits) < 2 {
|
||||
t.Fatal("expected two clickable FILES rows")
|
||||
}
|
||||
x := m.chatColumnWidth() + 3
|
||||
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
|
||||
|
||||
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !handled {
|
||||
t.Fatal("FILES row click should be handled")
|
||||
}
|
||||
if m1.selectedFile != hits[0].path || m1.fileView.active {
|
||||
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
|
||||
}
|
||||
|
||||
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !m2.fileView.active || m2.fileView.path != hits[0].path {
|
||||
t.Fatalf("second click should open the file view: %+v", m2.fileView)
|
||||
}
|
||||
|
||||
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
|
||||
if !m3.fileView.active || m3.fileView.path != hits[1].path {
|
||||
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
|
||||
}
|
||||
if m3.selectedFile != hits[1].path {
|
||||
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
|
||||
m.runDetailsOpen = true
|
||||
plain := plainRender(t, m.runDetailsOverlay(m.width))
|
||||
for _, file := range m.touchedFiles() {
|
||||
if !strings.Contains(plain, file.path) {
|
||||
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
selectedView := viewString(m.View())
|
||||
_ = viewString(m.View())
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("clearing a selected file should rebuild the invalidated cache")
|
||||
}
|
||||
if clearedView := viewString(m.View()); clearedView == selectedView {
|
||||
t.Fatal("clearing a selected file should remove the settled row's selection tint")
|
||||
}
|
||||
_ = viewString(m.View())
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
|
||||
|
||||
+3
-14
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
|
||||
return mouseEvent(msg).Button == tea.MouseNone
|
||||
}
|
||||
|
||||
// updateHoverTarget resolves what's under the cursor for a hover motion, using
|
||||
// the SAME hit-testers and priority order as the click handlers in
|
||||
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
|
||||
// step) takes priority since it's outside the chat column, then a clickable
|
||||
// transcript line. Clears the hover when nothing clickable is under the cursor.
|
||||
// updateHoverTarget resolves visible transcript targets for a hover motion.
|
||||
// Context data lives in the run-details overlay; the former sidebar rail is not
|
||||
// rendered and therefore cannot own a hover target.
|
||||
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
|
||||
if hit, ok := m.sidebarLineAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
|
||||
}
|
||||
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
|
||||
}
|
||||
if path, ok := m.fileRowAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
|
||||
}
|
||||
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
||||
// A permission option reuses its OWN existing keyboard-cursor highlight
|
||||
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
|
||||
|
||||
+49
-70
@@ -1,12 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestMouseHoverPredicate(t *testing.T) {
|
||||
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
|
||||
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
|
||||
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
|
||||
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
|
||||
// that: real conversation + a mapped swarm member session.
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
|
||||
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "diff",
|
||||
tool: "write_file",
|
||||
status: tools.StatusOK,
|
||||
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
|
||||
})
|
||||
rowIndex := len(m.transcript) - 1
|
||||
width := m.chatColumnWidth()
|
||||
body, selectable := m.transcriptBody(width, "")
|
||||
start, _, top := m.transcriptViewportStart(body, width)
|
||||
var target transcriptSelectableLine
|
||||
for _, line := range selectable {
|
||||
if line.rowIndex == rowIndex {
|
||||
target = line
|
||||
break
|
||||
}
|
||||
}
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
if target.text == "" {
|
||||
t.Fatalf("expected a selectable diff header, got %#v", selectable)
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
|
||||
}
|
||||
if m.hover.sessionID != hits[0].sessionID {
|
||||
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
|
||||
}
|
||||
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
|
||||
// not a raw line offset — resolve it back to a line offset the same way the
|
||||
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
|
||||
// SAME row that was actually hovered, not some other row at that raw index.
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
|
||||
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
if m.transcript[rowIndex].expanded {
|
||||
t.Fatal("clicking an always-visible diff header must not toggle it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
|
||||
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
|
||||
// leaving the transcript at its default (welcome-only, i.e. "empty").
|
||||
m.transcript = appendRow(m.transcript, rowUser, "do something")
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
|
||||
}
|
||||
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable plan step row")
|
||||
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverPlanStep {
|
||||
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
|
||||
}
|
||||
if m.hover.stepIndex != hits[0].stepIndex {
|
||||
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
|
||||
}
|
||||
// The bug this guards: the render path must resolve stepIndex back to the
|
||||
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
|
||||
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
|
||||
// into the rendered sidebar (which would land on the AGENTS header/body).
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
width := m.chatColumnWidth()
|
||||
height := m.height
|
||||
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
}
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
|
||||
}
|
||||
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
if without == with {
|
||||
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverClearsOnWheelScroll(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
for i := 0; i < 80; i++ {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -9,9 +14,13 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const attachmentThumbnailMaxPixels = 16 << 20
|
||||
|
||||
// droppableImageExts are the image extensions a dragged-and-dropped file may
|
||||
// carry (matched case-insensitively); PDFs are recognized separately.
|
||||
var droppableImageExts = map[string]bool{
|
||||
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
|
||||
for _, models := range m.modelPickerLiveByProvider {
|
||||
for _, dm := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
|
||||
// A provider's authenticated listing may only establish which models
|
||||
// are available, without repeating modalities. Treat an empty list as
|
||||
// unknown rather than as an explicit image-input denial, so the
|
||||
// curated registry/name capability fallback remains available while
|
||||
// models.dev metadata is temporarily unavailable.
|
||||
if len(dm.InputModalities) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, modality := range dm.InputModalities {
|
||||
if strings.EqualFold(strings.TrimSpace(modality), "image") {
|
||||
return true
|
||||
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
|
||||
Data: data,
|
||||
})
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
|
||||
m.refreshPendingImageThumbnail()
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
|
||||
case strings.EqualFold(trimmed, "clear"):
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
m.pendingImageThumbnails = nil
|
||||
m.pendingDocuments = nil
|
||||
return m.appendImageNotice("Cleared pending attachments.")
|
||||
}
|
||||
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
|
||||
|
||||
m.pendingImages = append(m.pendingImages, block)
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
|
||||
m.refreshPendingImageThumbnail()
|
||||
// No "attached" system message: the composer attachment chip ([Image #N]) is
|
||||
// the confirmation, matching the compact attach UX.
|
||||
return m
|
||||
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
|
||||
m.pendingImages = append(m.pendingImages, block)
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, label)
|
||||
}
|
||||
m.refreshPendingImageThumbnail()
|
||||
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
|
||||
// "attached" system message.
|
||||
return m
|
||||
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
|
||||
if len(m.pendingImages) > 0 {
|
||||
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
|
||||
}
|
||||
m.refreshPendingImageThumbnail()
|
||||
return m, true
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// refreshPendingImageThumbnail prepares only the first pending image for the
|
||||
// optional inline terminal preview. Decode failures deliberately leave the
|
||||
// attachment intact: the text chip remains the portable fallback and the model
|
||||
// still receives the original bytes.
|
||||
func (m *model) refreshPendingImageThumbnail() {
|
||||
m.pendingImageThumbnails = nil
|
||||
if len(m.pendingImages) == 0 {
|
||||
return
|
||||
}
|
||||
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
|
||||
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
|
||||
for index := 0; index < count; index++ {
|
||||
preview, err := attachmentThumbnail(m.pendingImages[index])
|
||||
if err == nil {
|
||||
m.pendingImageThumbnails[index] = preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
|
||||
return nil, fmt.Errorf("image is too large for an inline thumbnail")
|
||||
}
|
||||
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return terminalpet.ThumbnailAnimation(decoded)
|
||||
}
|
||||
|
||||
// renderAttachmentChips builds the pending-attachment row from both staged images
|
||||
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
|
||||
// nothing is staged. Numbered (not named) so a long screenshot path never shows
|
||||
|
||||
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
|
||||
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
|
||||
}
|
||||
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
|
||||
t.Fatalf("submit should append a user row, got %#v", next.transcript)
|
||||
}
|
||||
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
|
||||
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
|
||||
}
|
||||
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("submit should persist the user event")
|
||||
}
|
||||
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
|
||||
t.Fatalf("persisted attachment summary = %#v, want one image", got)
|
||||
}
|
||||
|
||||
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
|
||||
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
|
||||
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
|
||||
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
|
||||
// by reading the source. A single-key `?` overlay (opened on an empty composer)
|
||||
// lists them grouped, so the keymap is discoverable the way the reference TUIs
|
||||
@@ -7,11 +7,15 @@
|
||||
// model.go's Update switch; keep them in sync when a binding changes.
|
||||
//
|
||||
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
|
||||
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// who remaps them in config.json sees the actual chords, not the defaults.
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// keybinding is one row in the help overlay: the key chord and what it does.
|
||||
type keybinding struct {
|
||||
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
|
||||
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
|
||||
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
},
|
||||
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
|
||||
func (m model) renderKeybindingHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
|
||||
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
|
||||
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.altScreen = true
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
initialSidebar := m.sidebarHidden
|
||||
initialDetails := m.runDetailsOpen
|
||||
updated, _ := m.Update(testKeyCtrl('b'))
|
||||
next := updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
|
||||
if next.runDetailsOpen == initialDetails {
|
||||
t.Fatal("Ctrl+B on empty composer should toggle run details")
|
||||
}
|
||||
updated, _ = next.Update(testKeyCtrl('b'))
|
||||
next = updated.(model)
|
||||
|
||||
initialMouse := next.mouseReleased
|
||||
updated, _ = next.Update(testKeyCtrl('e'))
|
||||
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
|
||||
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
|
||||
}
|
||||
|
||||
initialSidebar2 := m2.sidebarHidden
|
||||
initialDetails2 := m2.runDetailsOpen
|
||||
updated, _ = m2.Update(testKeyCtrl('b'))
|
||||
next2 := updated.(model)
|
||||
if next2.sidebarHidden != initialSidebar2 {
|
||||
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
|
||||
if next2.runDetailsOpen != initialDetails2 {
|
||||
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
|
||||
}
|
||||
|
||||
initialMouse2 := next2.mouseReleased
|
||||
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
|
||||
// reaches configurable global bindings.
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
m = typeRunes(t, m, "hello")
|
||||
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
initialDetails := next.runDetailsOpen
|
||||
updated, _ = next.Update(testKeyCtrl('y'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
|
||||
if next.runDetailsOpen == initialDetails {
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
|
||||
}
|
||||
if next.composerValue() != "hello" {
|
||||
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
|
||||
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
m = typeRunes(t, m, "hello")
|
||||
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
|
||||
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
initialDetails := next.runDetailsOpen
|
||||
updated, _ = next.Update(testKeyCtrl('b'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden != initialSidebar {
|
||||
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
|
||||
if next.runDetailsOpen != initialDetails {
|
||||
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
|
||||
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
|
||||
}
|
||||
|
||||
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
|
||||
// general ? keyboard-shortcut overlay: same width helper, border style
|
||||
// (zeroTheme.line), panel fill, and centered block.
|
||||
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
|
||||
// terminal's own canvas remains visible without jagged panel-fill padding.
|
||||
func (m model) renderLeaderHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := renderLeaderHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
|
||||
// Both overlays must use the same frame builder (titled box + panel fill).
|
||||
// Both overlays must use the same transparent, titled frame builder.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
help := m.renderKeybindingHelpOverlay(100)
|
||||
@@ -267,6 +267,22 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
panelFill := zeroTheme.panel.Render(" ")
|
||||
for _, overlay := range []struct {
|
||||
name string
|
||||
view string
|
||||
}{
|
||||
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
|
||||
{"leader help", m.renderLeaderHelpOverlay(100)},
|
||||
} {
|
||||
if strings.Contains(overlay.view, panelFill) {
|
||||
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.leaderHelpOverlay = true
|
||||
|
||||
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
}
|
||||
if m.plan.isEmpty() {
|
||||
t.Fatal("setup: plan should be non-empty")
|
||||
}
|
||||
initial := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if next.plan.expanded == initial {
|
||||
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
|
||||
func TestCtrlPMovesPickerSelection(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ModelName: "gpt-4o",
|
||||
ProviderName: "openai",
|
||||
Provider: &fakeProvider{},
|
||||
})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: true,
|
||||
}
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.handleSubmit()
|
||||
m = updated.(model)
|
||||
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
|
||||
}
|
||||
updated, _ = m.Update(testKeyCtrl('p'))
|
||||
next := updated.(model)
|
||||
if !next.plan.expanded {
|
||||
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
|
||||
}
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should remain open")
|
||||
}
|
||||
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
|
||||
|
||||
func TestCtrlNNoOpWithoutModal(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.plan = planPanelState{
|
||||
steps: []planStep{{content: "step one", status: "pending"}},
|
||||
expanded: false,
|
||||
}
|
||||
before := m.plan.expanded
|
||||
updated, _ := m.Update(testKeyCtrl('n'))
|
||||
next := updated.(model)
|
||||
if next.picker != nil || next.suggestionsActive() {
|
||||
t.Fatal("Ctrl+N idle must not open a modal")
|
||||
}
|
||||
if next.plan.expanded != before {
|
||||
t.Fatal("Ctrl+N idle must not toggle the plan panel")
|
||||
}
|
||||
if next.composerValue() != "" {
|
||||
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
|
||||
}
|
||||
|
||||
+342
-333
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,23 @@ func TestModelSupportsVisionTUIChecksDiscoveredBeforeHeuristic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSupportsVisionTUIFallsBackWhenLiveMetadataOmitsModalities(t *testing.T) {
|
||||
registry := mustTestModelRegistry(t, testModelEntry("custom-known", 12345, []modelregistry.ModelCapability{
|
||||
modelregistry.ModelCapabilityChat,
|
||||
}))
|
||||
m := model{
|
||||
modelName: "gpt-5.6-sol",
|
||||
modelCatalog: registry,
|
||||
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-5.6-sol"}},
|
||||
},
|
||||
}
|
||||
|
||||
if !m.modelSupportsVisionTUI() {
|
||||
t.Fatal("live discovery without modality metadata must fall back to the model capability heuristic")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkModelContextWindowLookup(b *testing.B) {
|
||||
cachedRegistry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
|
||||
+119
-28
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -558,13 +560,79 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
|
||||
if rebuilt.Model != "gpt-4.1-mini" {
|
||||
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
|
||||
}
|
||||
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
|
||||
for _, want := range []string{"Model:", "gpt-4.1-mini · openai"} {
|
||||
if !strings.Contains(next.transientNotice.text, want) {
|
||||
t.Fatalf("expected model notice to contain %q, got %q", want, next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
|
||||
var rebuilt config.ProviderProfile
|
||||
nextProvider := &fakeProvider{}
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "chatgpt",
|
||||
ModelName: "gpt-5.6-terra",
|
||||
ProviderProfile: config.ProviderProfile{
|
||||
Name: "chatgpt",
|
||||
CatalogID: "chatgpt",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://chatgpt.com/backend-api/codex",
|
||||
Model: "gpt-5.6-terra",
|
||||
},
|
||||
Provider: &fakeProvider{},
|
||||
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
rebuilt = profile
|
||||
return nextProvider, nil
|
||||
},
|
||||
})
|
||||
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
|
||||
"chatgpt": {{ID: "gpt-5.6-sol"}},
|
||||
}
|
||||
m.input.SetValue("/model openai/gpt-5.6-sol")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /model to be handled without starting an agent run")
|
||||
}
|
||||
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
|
||||
t.Fatalf("expected ChatGPT model switch to use bare catalog ID, got model=%q provider=%#v", next.modelName, next.provider)
|
||||
}
|
||||
if rebuilt.Model != "gpt-5.6-sol" {
|
||||
t.Fatalf("expected provider rebuild with bare ChatGPT model ID, got %#v", rebuilt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelSwitchCandidatesPreserveGatewayModelIDs(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
provider string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "ChatGPT accepts the models.dev OpenAI namespace",
|
||||
provider: "chatgpt",
|
||||
input: "openai/gpt-5.6-sol",
|
||||
want: []string{"openai/gpt-5.6-sol", "gpt-5.6-sol"},
|
||||
},
|
||||
{
|
||||
name: "gateway keeps qualified model ID",
|
||||
provider: "openrouter",
|
||||
input: "openai/gpt-5.6-sol",
|
||||
want: []string{"openai/gpt-5.6-sol"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := providerModelSwitchCandidates(test.provider, test.input); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("providerModelSwitchCandidates(%q, %q) = %#v, want %#v", test.provider, test.input, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero.json")
|
||||
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
|
||||
@@ -611,8 +679,8 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
|
||||
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
|
||||
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "· saved") {
|
||||
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
|
||||
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
|
||||
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,6 +1536,28 @@ func TestToolResultDetailPrefersPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultSessionPayloadKeepsPreviewForResume(t *testing.T) {
|
||||
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
|
||||
payload := toolResultSessionPayload(agent.ToolResult{
|
||||
ToolCallID: "edit-1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "Successfully edited calculator.go (replaced 1 occurrence).",
|
||||
Display: tools.Display{Preview: preview},
|
||||
})
|
||||
if got, want := payload["displayPreview"], preview; got != want {
|
||||
t.Fatalf("persisted display preview = %#v, want %q", got, want)
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal session payload: %v", err)
|
||||
}
|
||||
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: encoded}})
|
||||
if len(rows) != 1 || rows[0].detail != preview {
|
||||
t.Fatalf("resumed row lost the display preview: %#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
|
||||
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
|
||||
func TestReasoningRefreshesActivityClock(t *testing.T) {
|
||||
@@ -1502,16 +1592,15 @@ func TestStaleExplanationDropped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
|
||||
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
|
||||
// and the explanation generation advances.
|
||||
func TestBeginRunResetsSidebarHidden(t *testing.T) {
|
||||
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
|
||||
// conversation surface and advances the explanation generation.
|
||||
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.sidebarHidden = true
|
||||
m.runDetailsOpen = true
|
||||
gen := m.planDetailGen
|
||||
m = m.beginRun(nil)
|
||||
if m.sidebarHidden {
|
||||
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
|
||||
if m.runDetailsOpen {
|
||||
t.Error("beginRun should close run details for the new turn")
|
||||
}
|
||||
if m.planDetailGen <= gen {
|
||||
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
|
||||
@@ -2561,14 +2650,10 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
|
||||
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
|
||||
}
|
||||
|
||||
withSidebar := idle
|
||||
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
|
||||
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
|
||||
t.Fatalf("available sidebar should be advertised, got %q", hint)
|
||||
}
|
||||
withSidebar.sidebarHidden = true
|
||||
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
|
||||
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
|
||||
withDetails := idle
|
||||
withDetails.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
|
||||
if hint := plainRender(t, withDetails.composerIdleHint()); !strings.Contains(hint, "Ctrl+B details") {
|
||||
t.Fatalf("available run details should be advertised, got %q", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2946,21 +3031,27 @@ func TestScrimViewportLine(t *testing.T) {
|
||||
if got := scrimViewportLine(" ", 10); got != " " {
|
||||
t.Fatalf("blank line should be untouched, got %q", got)
|
||||
}
|
||||
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
|
||||
// stays readable behind the overlay.
|
||||
// Non-blank plain lines keep their text and become faint, so the backdrop stays
|
||||
// readable behind the overlay without competing with its focus surface.
|
||||
got := scrimViewportLine("transcript content", 40)
|
||||
if ansi.Strip(got) != "transcript content" {
|
||||
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
|
||||
}
|
||||
// A pre-styled line must have its OWN styling stripped (so the dim wins), while
|
||||
// the text content survives. This fails if scrim stops re-styling the backdrop.
|
||||
styled := "\x1b[31mred backdrop\x1b[0m text"
|
||||
// Styled transcript content must retain its semantic foreground and background
|
||||
// colors. The scrim adds faintness around resets rather than flattening a diff
|
||||
// or syntax-highlighted line to a single grey style.
|
||||
styled := "\x1b[38;2;235;80;110mremoved\x1b[0m \x1b[48;2;35;80;58madded\x1b[0m"
|
||||
scrimmed := scrimViewportLine(styled, 40)
|
||||
if ansi.Strip(scrimmed) != "red backdrop text" {
|
||||
if ansi.Strip(scrimmed) != "removed added" {
|
||||
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
|
||||
}
|
||||
if strings.Contains(scrimmed, "\x1b[31m") {
|
||||
t.Fatalf("scrim must strip the line's original styling, got %q", scrimmed)
|
||||
for _, sequence := range []string{"\x1b[38;2;235;80;110m", "\x1b[48;2;35;80;58m"} {
|
||||
if !strings.Contains(scrimmed, sequence) {
|
||||
t.Fatalf("scrim must preserve semantic ANSI sequence %q, got %q", sequence, scrimmed)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(scrimmed, "\x1b[2m") {
|
||||
t.Fatalf("scrim must apply faint styling around semantic colors, got %q", scrimmed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-35
@@ -287,38 +287,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// sidebarLineAtMouse maps a left-click in the AGENTS sidebar column to the swarm
|
||||
// member whose row was clicked, when that member's session is known. Geometry:
|
||||
// twoColumnTranscriptView is the full screen (sidebarActive ⇒ alt-screen), and
|
||||
// joinColumns lays out [chat(chatColumnWidth)][" │ " 3-cell divider][sidebar],
|
||||
// zipping row-by-row — so a sidebar line's screen Y equals its sidebar index and
|
||||
// the sidebar starts at screen X = chatColumnWidth + 3. Recomputed on demand
|
||||
// (View can't persist a registry on the value-receiver model), like
|
||||
// transcriptLineAtMouse.
|
||||
func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) {
|
||||
if !m.sidebarActive() {
|
||||
return sidebarAgentHit{}, false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return sidebarAgentHit{}, false
|
||||
}
|
||||
sidebarW := sidebarWidth(m.width)
|
||||
if sidebarW <= 0 {
|
||||
return sidebarAgentHit{}, false
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
|
||||
x, y := mouseX(msg), mouseY(msg)
|
||||
if x < x0 || x >= x0+sidebarW {
|
||||
return sidebarAgentHit{}, false
|
||||
}
|
||||
for _, hit := range m.sidebarAgentSelectables(sidebarW) {
|
||||
if hit.lineOffset == y && hit.sessionID != "" {
|
||||
return hit, true
|
||||
}
|
||||
}
|
||||
return sidebarAgentHit{}, false
|
||||
}
|
||||
|
||||
func (m model) repeatMouseSelection(target mouseSelectionTarget) bool {
|
||||
return target.Scope != "" && m.lastMouseSelection == target
|
||||
}
|
||||
@@ -531,6 +499,20 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
if !ok {
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
// The theme picker has a right-hand visual preview on wider terminals. It is
|
||||
// deliberately non-interactive: clicking a color sample must not select a
|
||||
// list item merely because it shares that row's y-coordinate.
|
||||
if m.picker.kind == pickerTheme {
|
||||
overlayWidth := minInt(width, pickerOverlayMaxWidth)
|
||||
if overlayWidth < pickerOverlayMinWidth {
|
||||
overlayWidth = width
|
||||
}
|
||||
listWidth, _, showPreview := themePickerColumnWidths(maxInt(1, overlayWidth-4))
|
||||
// styledBlockFillTitle contributes a left border and one-cell inset.
|
||||
if showPreview && hit.x >= listWidth+2 {
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
}
|
||||
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
|
||||
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
|
||||
start := selectableListStart(len(m.picker.items), maxVisible, selected)
|
||||
@@ -549,9 +531,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
if hit.y == line {
|
||||
index := start + offset
|
||||
m.picker.selected = index
|
||||
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
|
||||
// so the theme picker repaints before the confirming second click.
|
||||
m.previewSelectedTheme()
|
||||
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
|
||||
}
|
||||
line++
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
|
||||
@@ -251,6 +253,27 @@ func TestComposerMouseClickMovesCursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerMouseClickMovesCursorBelowImageThumbnail(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.refreshPendingImageThumbnail()
|
||||
m.input.SetValue("hello world")
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + 5
|
||||
y := frame.composerRect.y + 1 + attachmentPreviewRows
|
||||
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, x, y))
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("composer click should not return a command")
|
||||
}
|
||||
if got := next.currentComposerState().cursor; got != 5 {
|
||||
t.Fatalf("composer cursor below thumbnail = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposerMouseDragSelectsCopiesAndClears(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.input.SetValue("hello world")
|
||||
@@ -985,10 +1008,7 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
|
||||
if frame.composerRect.height <= 0 {
|
||||
t.Fatalf("expected visible composer rect, frame=%#v", frame)
|
||||
}
|
||||
contentY := 1
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
contentY++
|
||||
}
|
||||
contentY := 1 + m.attachmentComposerPrefixRows(width)
|
||||
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
|
||||
y := frame.composerRect.y + contentY
|
||||
return x, y
|
||||
|
||||
+55
-22
@@ -22,13 +22,19 @@ type terminalOutputFile interface {
|
||||
|
||||
type petImageOutput struct {
|
||||
output terminalOutputFile
|
||||
renderer *terminalpet.ImageRenderer
|
||||
renderDisabled bool
|
||||
renderers []*terminalpet.ImageRenderer
|
||||
renderDisabled []bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
|
||||
return &petImageOutput{output: output, renderer: renderer}
|
||||
func newPetImageOutput(output terminalOutputFile, renderers ...*terminalpet.ImageRenderer) *petImageOutput {
|
||||
active := make([]*terminalpet.ImageRenderer, 0, len(renderers))
|
||||
for _, renderer := range renderers {
|
||||
if renderer != nil {
|
||||
active = append(active, renderer)
|
||||
}
|
||||
}
|
||||
return &petImageOutput{output: output, renderers: active, renderDisabled: make([]bool, len(active))}
|
||||
}
|
||||
|
||||
func (o *petImageOutput) Read(value []byte) (int, error) {
|
||||
@@ -39,11 +45,13 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
|
||||
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
|
||||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
|
||||
if leavingAltScreen && !kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return 0, err
|
||||
if leavingAltScreen {
|
||||
for _, renderer := range o.renderers {
|
||||
if !rendererUsesKitty(renderer) {
|
||||
if err := o.writeImageUpdate(renderer.Clear); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if leavingAltScreen {
|
||||
@@ -51,26 +59,36 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return written, err
|
||||
for _, renderer := range o.renderers {
|
||||
if rendererUsesKitty(renderer) {
|
||||
if err := o.writeImageUpdate(renderer.Clear); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
|
||||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
|
||||
o.renderer.Invalidate()
|
||||
for _, renderer := range o.renderers {
|
||||
renderer.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
var imageUpdate bytes.Buffer
|
||||
if !o.renderDisabled {
|
||||
if err := o.renderer.Render(&imageUpdate); err != nil {
|
||||
// Companions are decorative. Disable them for this output session after
|
||||
// a renderer failure instead of terminating the interactive shell.
|
||||
o.renderDisabled = true
|
||||
imageUpdate.Reset()
|
||||
for index, renderer := range o.renderers {
|
||||
if o.renderDisabled[index] {
|
||||
continue
|
||||
}
|
||||
var rendererUpdate bytes.Buffer
|
||||
if err := renderer.Render(&rendererUpdate); err != nil {
|
||||
// Terminal images are decorative. Disable only the failing renderer for
|
||||
// this output session so an attachment issue cannot hide a companion
|
||||
// (or vice versa) or terminate the interactive shell.
|
||||
o.renderDisabled[index] = true
|
||||
continue
|
||||
}
|
||||
imageUpdate.Write(rendererUpdate.Bytes())
|
||||
}
|
||||
if imageUpdate.Len() == 0 {
|
||||
return writeChecked(o.output, value)
|
||||
@@ -120,6 +138,15 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
return written, endErr
|
||||
}
|
||||
|
||||
func rendererUsesKitty(renderer *terminalpet.ImageRenderer) bool {
|
||||
switch renderer.Support().Protocol {
|
||||
case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writeChecked(writer io.Writer, value []byte) (int, error) {
|
||||
written, err := writer.Write(value)
|
||||
if err == nil && written != len(value) {
|
||||
@@ -180,9 +207,15 @@ func (o *petImageOutput) clearImage() error {
|
||||
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := o.renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
for _, renderer := range o.renderers {
|
||||
if err := renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
|
||||
if len(o.renderers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
|
||||
return o.renderers[0].DeleteImages(writer, ids...)
|
||||
})
|
||||
}
|
||||
+4
-10
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
|
||||
m.petPreview = nil
|
||||
m.picker = nil
|
||||
m.petTickSeq++
|
||||
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
|
||||
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
|
||||
}
|
||||
m.petRequestedSlug = argument
|
||||
localEntries, _ := m.petClient.InstalledEntries()
|
||||
@@ -242,13 +242,13 @@ func (m model) installPet(slug string) (tea.Model, tea.Cmd) {
|
||||
m.petName = ""
|
||||
m.petAnimation = nil
|
||||
m.petTickSeq++
|
||||
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
|
||||
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
|
||||
}
|
||||
entry, ok := m.petEntries[slug]
|
||||
if !ok {
|
||||
return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil
|
||||
}
|
||||
m = m.appendSystemNotice("Installing " + entry.Label() + "…")
|
||||
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
|
||||
return m, func() tea.Msg {
|
||||
animation, err := m.petClient.Install(m.ctx, entry)
|
||||
return petInstalledMsg{entry: entry, animation: animation, err: err}
|
||||
@@ -270,7 +270,7 @@ func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) {
|
||||
m.petPlaybackState = terminalpet.Idle
|
||||
m.petClickAnimationIndex = 0
|
||||
m.petOutcome = terminalpet.Idle
|
||||
m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
|
||||
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
|
||||
if m.reducedMotion {
|
||||
return m, nil
|
||||
}
|
||||
@@ -396,9 +396,6 @@ func (m model) petLayoutActive() bool {
|
||||
return false
|
||||
}
|
||||
layoutWidth := m.width
|
||||
if m.sidebarActive() {
|
||||
layoutWidth = m.chatColumnWidth()
|
||||
}
|
||||
if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
@@ -615,9 +612,6 @@ func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int {
|
||||
}
|
||||
|
||||
func (m model) petHomePosition(width, height int) (int, int) {
|
||||
if m.sidebarActive() {
|
||||
width = m.chatColumnWidth()
|
||||
}
|
||||
maxX := maxInt(0, width-petImageColumns)
|
||||
maxY := maxInt(0, height-petImageRows)
|
||||
return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY)
|
||||
|
||||
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
|
||||
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
m.width, m.height = 120, 34
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 12, 12))
|
||||
@@ -639,12 +639,12 @@ func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
|
||||
}
|
||||
runes := []rune(line)
|
||||
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
|
||||
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
|
||||
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
|
||||
if len(runes) < m.width || runes[dockEdge] != '╮' {
|
||||
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("sidebar composer top not found")
|
||||
t.Fatal("composer top not found")
|
||||
}
|
||||
|
||||
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
|
||||
|
||||
+51
-23
@@ -522,9 +522,43 @@ func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
|
||||
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
|
||||
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
|
||||
pairs = append(pairs, m.recentModels...)
|
||||
for index := range pairs {
|
||||
pairs[index] = m.canonicalRecentModelPair(pairs[index])
|
||||
}
|
||||
return config.NormalizeRecentModels(pairs)
|
||||
}
|
||||
|
||||
// canonicalRecentModelPair collapses legacy OpenAI-prefixed model IDs for a
|
||||
// ChatGPT profile. ChatGPT's Codex endpoint uses bare model IDs, while older
|
||||
// hand-entered configuration could retain the equivalent openai/<id> spelling.
|
||||
// Keep this picker-only so OpenAI-compatible gateway IDs remain untouched.
|
||||
func (m model) canonicalRecentModelPair(pair config.RecentModelEntry) config.RecentModelEntry {
|
||||
pair.Provider = strings.TrimSpace(pair.Provider)
|
||||
pair.Model = strings.TrimSpace(pair.Model)
|
||||
if !m.recentPairUsesChatGPT(pair.Provider) {
|
||||
return pair
|
||||
}
|
||||
if len(pair.Model) >= len("openai/") && strings.EqualFold(pair.Model[:len("openai/")], "openai/") {
|
||||
pair.Model = strings.TrimSpace(pair.Model[len("openai/"):])
|
||||
}
|
||||
return pair
|
||||
}
|
||||
|
||||
func (m model) recentPairUsesChatGPT(providerName string) bool {
|
||||
if profile, ok := m.savedProviderByName(providerName); ok {
|
||||
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
|
||||
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
|
||||
}
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(providerName), strings.TrimSpace(m.providerName)) {
|
||||
return false
|
||||
}
|
||||
if descriptor, ok := m.activeProviderDescriptor(); ok {
|
||||
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
|
||||
}
|
||||
return providercatalog.NormalizeID(m.providerProfile.CatalogID) == "chatgpt"
|
||||
}
|
||||
|
||||
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
|
||||
// tagging it with OwnerProvider so choosing it can switch providers (like any
|
||||
// other cross-provider picker row) even when it isn't the active provider.
|
||||
@@ -1008,45 +1042,39 @@ func (m model) newEffortPicker() *commandPicker {
|
||||
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
|
||||
}
|
||||
|
||||
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
|
||||
// Dark/Light sections (the registry is ordered dark-then-light so the group header
|
||||
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
|
||||
// same overlay /model and /effort use. Moving the cursor live-previews each palette
|
||||
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
|
||||
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
|
||||
// themeModes and in the same order, so the popup and /theme state list agree.
|
||||
// newThemePicker lists the terminal-native system theme followed by named palettes
|
||||
// in one flat list. Old dark/light preferences migrate to System rather than
|
||||
// appearing as choices: they would imply a terminal-canvas change that Zero
|
||||
// deliberately does not make.
|
||||
func (m model) newThemePicker() *commandPicker {
|
||||
items := make([]pickerItem, 0, len(themeModes))
|
||||
selected := 0
|
||||
// `auto` sits at the top with an empty Group, so it renders header-less above
|
||||
// the Dark/Light sections.
|
||||
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
|
||||
for _, entry := range themeRegistry {
|
||||
group := "Light"
|
||||
if entry.IsDark {
|
||||
group = "Dark"
|
||||
for _, name := range themeModes {
|
||||
item := pickerItem{Label: name, Value: name}
|
||||
if name == string(themeSystem) {
|
||||
item.Label = "System"
|
||||
} else if entry, ok := lookupTheme(name); ok {
|
||||
item.Label = entry.Label
|
||||
}
|
||||
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
|
||||
if entry.Name == string(m.themeMode) {
|
||||
items = append(items, item)
|
||||
if name == string(m.themeMode) {
|
||||
selected = len(items) - 1
|
||||
}
|
||||
}
|
||||
// allItems lets the query filter restore rows on Backspace (one-way narrowing
|
||||
// otherwise, since applyQuery falls back to the current items without it).
|
||||
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
|
||||
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
|
||||
}
|
||||
|
||||
// pickerMoved advances the open picker's cursor by delta and live-previews the new
|
||||
// selection where the picker supports it — stepping through the /theme popup
|
||||
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
|
||||
// mutate through m.picker (a pointer) and the global theme, so the value receiver
|
||||
// is fine.
|
||||
// pickerMoved advances the open picker's cursor by delta. Theme candidates render
|
||||
// only in the picker preview; their active palette is applied only after Enter.
|
||||
// Safe to call with no picker open. Callers mutate through m.picker (a pointer),
|
||||
// so the value receiver is fine.
|
||||
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
|
||||
if m.picker == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.move(delta)
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
|
||||
@@ -532,8 +532,8 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
|
||||
if next.modelName != "openai/gpt-4.1" {
|
||||
t.Fatalf("active model = %q, want raw OpenRouter model ID", next.modelName)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
|
||||
t.Fatalf("expected model switch status, got %#v", next.transcript)
|
||||
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
|
||||
t.Fatalf("model switch notice = %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,30 @@ func TestRecentModelPairsForPickerPinsActiveDedupesAndCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentModelPairsForPickerCanonicalizesChatGPTOpenAIModelAliases(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "chatgpt",
|
||||
ModelName: "gpt-5.6-sol",
|
||||
ProviderProfile: config.ProviderProfile{
|
||||
Name: "chatgpt",
|
||||
CatalogID: "chatgpt",
|
||||
},
|
||||
RecentModels: []config.RecentModelEntry{
|
||||
{Provider: "chatgpt", Model: "openai/gpt-5.6-sol"},
|
||||
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
|
||||
},
|
||||
})
|
||||
|
||||
got := m.recentModelPairsForPicker()
|
||||
want := []config.RecentModelEntry{
|
||||
{Provider: "chatgpt", Model: "gpt-5.6-sol"},
|
||||
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("recentModelPairsForPicker() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The picker's cross-group de-dup must key on provider+model, not model id
|
||||
// alone: the same model id can be offered by two different providers, and
|
||||
// those are distinct, independently selectable rows (issue: recents should
|
||||
@@ -930,8 +954,8 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) {
|
||||
if m.modelName != "claude-haiku-4.5" {
|
||||
t.Fatalf("expected model switched to claude-haiku-4.5 via handler, got %q", m.modelName)
|
||||
}
|
||||
if !transcriptContains(m.transcript, "Model") {
|
||||
t.Fatal("choosing should append the model handler's status text")
|
||||
if !strings.Contains(m.transientNotice.text, "Model:") {
|
||||
t.Fatalf("model picker notice = %q", m.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,7 +987,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestThemeCommandOpensPicker(t *testing.T) {
|
||||
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
|
||||
// Bare /theme opens the theme popup (contained preview on move, apply on Enter),
|
||||
// like /model and /effort. Full preview/commit/cancel behavior is covered in
|
||||
// theme_picker_test.go; here we just pin that the no-arg command opens it.
|
||||
defer applyTheme(themeDark, true)
|
||||
|
||||
+7
-208
@@ -1,15 +1,10 @@
|
||||
// plan_panel.go renders the sticky plan panel for the Zero TUI. The panel
|
||||
// surfaces the in-progress task plan produced by the update_plan tool: a
|
||||
// one-line header with a live spinner and progress count, a text progress
|
||||
// bar, and (while running or expanded) the per-step list with status icons
|
||||
// and timings. planPanelState tracks per-step start/completion timestamps
|
||||
// across the tool's full-replacement updates so durations stay stable as
|
||||
// steps transition between pending, in_progress, completed, and failed.
|
||||
// plan_panel.go maintains the current task-plan state for run details and
|
||||
// step drill-in. The update_plan result itself renders as a transcript
|
||||
// checklist, while planPanelState preserves per-step timestamps across the
|
||||
// tool's full-replacement updates for the contextual detail view.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -27,11 +22,10 @@ type planStep struct {
|
||||
completedAt time.Time
|
||||
}
|
||||
|
||||
// planPanelState holds the sticky plan panel's view state. It lives on the
|
||||
// model and is synced from the update_plan tool's CurrentPlan() output.
|
||||
// planPanelState holds the latest plan state. It lives on the model and is
|
||||
// synced from the update_plan tool's CurrentPlan() output.
|
||||
type planPanelState struct {
|
||||
steps []planStep
|
||||
expanded bool
|
||||
completedAt time.Time // set once all steps reach a terminal status
|
||||
startedAt time.Time // set on the first non-empty update
|
||||
// frozenAt freezes the live plan clock while the agent is idle (between
|
||||
@@ -41,10 +35,6 @@ type planPanelState struct {
|
||||
frozenAt time.Time
|
||||
}
|
||||
|
||||
// completedHideAfter is how long a finished plan stays pinned before the
|
||||
// collapsed panel hides itself to reclaim screen space.
|
||||
const completedHideAfter = 30 * time.Second
|
||||
|
||||
// updateFromItems syncs the planStep slice from the update_plan tool. The
|
||||
// tool replaces the entire plan each call, so steps are matched by content
|
||||
// to preserve start/completion timestamps. Timestamps are filled in for
|
||||
@@ -128,10 +118,9 @@ func (s *planPanelState) updateFromItems(items []tools.PlanItem, now time.Time)
|
||||
}
|
||||
}
|
||||
|
||||
// clear resets all plan panel state (steps, expansion, timestamps).
|
||||
// clear resets all plan state and timestamps.
|
||||
func (s *planPanelState) clear() {
|
||||
s.steps = nil
|
||||
s.expanded = false
|
||||
s.completedAt = time.Time{}
|
||||
s.startedAt = time.Time{}
|
||||
s.frozenAt = time.Time{}
|
||||
@@ -194,18 +183,6 @@ func (s *planPanelState) completeRemaining(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// visible reports whether renderPlanPanel should emit anything. A finished
|
||||
// plan hides itself once completedHideAfter has elapsed, unless expanded.
|
||||
func (s planPanelState) visible(now time.Time) bool {
|
||||
if s.isEmpty() {
|
||||
return false
|
||||
}
|
||||
if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// planNow returns the clock used for live plan durations. While the agent is
|
||||
// idle (no run in flight, activeRunID == 0) it freezes at the moment the last
|
||||
// run ended, so an in_progress step left mid-plan when the agent yields (e.g.
|
||||
@@ -218,128 +195,6 @@ func (m model) planNow() time.Time {
|
||||
return m.now()
|
||||
}
|
||||
|
||||
// renderPlanPanel renders the full sticky plan panel. It returns an empty
|
||||
// string when the plan is empty or when a finished plan has been collapsed
|
||||
// past completedHideAfter without being expanded.
|
||||
func (m model) renderPlanPanel(width int) string {
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
|
||||
state := m.plan
|
||||
now := m.planNow()
|
||||
if !state.visible(now) {
|
||||
return ""
|
||||
}
|
||||
|
||||
total := len(state.steps)
|
||||
done := 0
|
||||
for _, step := range state.steps {
|
||||
if step.status == "completed" || step.status == "failed" {
|
||||
done++
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Duration(0)
|
||||
if !state.startedAt.IsZero() {
|
||||
elapsed = now.Sub(state.startedAt)
|
||||
}
|
||||
|
||||
// The header already carries done/total and the per-step ✓/•/✗ icons convey
|
||||
// progress; a separate filled █/░ bar restates it and reads heavier than the
|
||||
// reference agents, so it's dropped.
|
||||
header := renderPlanHeader(state, m.spinnerGlyph(), done, total, elapsed)
|
||||
|
||||
lines := []string{header}
|
||||
|
||||
showSteps := state.expanded || !state.isComplete()
|
||||
if showSteps {
|
||||
maxContent := width - 15
|
||||
if maxContent < 4 {
|
||||
maxContent = 4
|
||||
}
|
||||
for _, step := range state.steps {
|
||||
lines = append(lines, renderPlanStepLine(step, now, maxContent))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderPinnedPlanPanel renders the plan for the pinned slot above the
|
||||
// composer. It returns the full panel when it fits within maxHeight, otherwise
|
||||
// a one-line summary (so a long plan can't crowd out the transcript or the
|
||||
// input). maxHeight <= 0 means "no budget" and always uses the summary line.
|
||||
// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the
|
||||
// full list via m.plan.expanded, but the height budget always wins to keep the
|
||||
// composer on screen.
|
||||
func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {
|
||||
// The pinned panel is only for terminals where the context sidebar CANNOT
|
||||
// host the plan (too narrow / inline mode). Whenever the two-column layout is
|
||||
// available — whether the sidebar is shown OR collapsed with Ctrl+B — the
|
||||
// plan's home is the sidebar, so suppress the pinned copy: a Ctrl+B hide
|
||||
// should hide the plan entirely, not resurrect it above the composer. Gating
|
||||
// on sidebarAvailable (not sidebarActive) covers the hidden case too. The
|
||||
// hidePinnedPlan flag additionally suppresses it in the two-column chat-column
|
||||
// copy. Both the view and the mouse-geometry frame call footerView, so this
|
||||
// stays consistent.
|
||||
if m.hidePinnedPlan || m.sidebarAvailable() {
|
||||
return ""
|
||||
}
|
||||
if !m.plan.visible(m.now()) {
|
||||
return ""
|
||||
}
|
||||
full := m.renderPlanPanel(width)
|
||||
if full == "" {
|
||||
return ""
|
||||
}
|
||||
if maxHeight > 0 && strings.Count(full, "\n")+1 <= maxHeight {
|
||||
return full
|
||||
}
|
||||
return m.renderPlanSummaryLine(width)
|
||||
}
|
||||
|
||||
// renderPlanSummaryLine renders the collapsed one-line plan summary used when
|
||||
// the full panel won't fit the pinned slot: a spinner/check, the done/total
|
||||
// count, and the current (or first incomplete) step, truncated to width.
|
||||
func (m model) renderPlanSummaryLine(width int) string {
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
state := m.plan
|
||||
total := len(state.steps)
|
||||
done := 0
|
||||
current := ""
|
||||
for _, step := range state.steps {
|
||||
switch step.status {
|
||||
case "completed", "failed":
|
||||
done++
|
||||
case "in_progress":
|
||||
if current == "" {
|
||||
current = step.content
|
||||
}
|
||||
}
|
||||
}
|
||||
if current == "" {
|
||||
// No in-progress step: show the first not-yet-done step, else the first.
|
||||
for _, step := range state.steps {
|
||||
if step.status != "completed" && step.status != "failed" {
|
||||
current = step.content
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.isComplete() {
|
||||
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN · %d/%d complete", done, total))
|
||||
}
|
||||
label := fmt.Sprintf("%s PLAN · %d/%d · ", m.spinnerGlyph(), done, total)
|
||||
room := width - len([]rune(label)) - 1
|
||||
if room < 4 {
|
||||
room = 4
|
||||
}
|
||||
return zeroTheme.accent.Render(label + truncateStep(current, room))
|
||||
}
|
||||
|
||||
// currentStepContent returns the content of the step the plan is "on": the
|
||||
// in_progress step, else the first step not yet in a terminal status, else the
|
||||
// first step. Used by the header so the title names the step actually being
|
||||
@@ -361,62 +216,6 @@ func currentStepContent(steps []planStep) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderPlanHeader builds the single header line. While running it shows the
|
||||
// live spinner, the truncated first step, the done/total count, and the
|
||||
// elapsed time in the accent color; once complete it shows a green check and
|
||||
// "PLAN COMPLETE".
|
||||
func renderPlanHeader(state planPanelState, spinnerView string, done, total int, elapsed time.Duration) string {
|
||||
// Show the step the plan is currently ON (in_progress, else first incomplete),
|
||||
// not always step 1 — otherwise the header text never advances and a running
|
||||
// plan reads as stuck even while the done/total count climbs.
|
||||
current := ""
|
||||
if total > 0 {
|
||||
current = truncateStep(currentStepContent(state.steps), 40)
|
||||
}
|
||||
if state.isComplete() {
|
||||
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN COMPLETE · %d/%d · %s", done, total, formatElapsedSeconds(elapsed)))
|
||||
}
|
||||
return zeroTheme.accent.Render(fmt.Sprintf("%s PLAN · %s · %d/%d · %s", spinnerView, current, done, total, formatElapsedSeconds(elapsed)))
|
||||
}
|
||||
|
||||
// renderPlanStepLine renders one step row: an indent, a status icon, the
|
||||
// (truncated) content styled by status, and a duration where applicable.
|
||||
// Completed/failed steps show the startedAt→completedAt span; an in_progress
|
||||
// step shows the elapsed time since it started; pending steps show no time.
|
||||
func renderPlanStepLine(step planStep, now time.Time, maxContent int) string {
|
||||
content := truncateStep(step.content, maxContent)
|
||||
var icon, body, timeStr string
|
||||
switch step.status {
|
||||
case "completed":
|
||||
icon = zeroTheme.green.Render("✓")
|
||||
// Quiet the completed body (muted, not saturated green) so the single
|
||||
// in-progress accent step is the obvious focus: past=quiet, now=bright,
|
||||
// future=faint. The green ✓ icon still marks success.
|
||||
body = zeroTheme.muted.Render(content)
|
||||
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
|
||||
case "in_progress":
|
||||
icon = zeroTheme.accent.Render("•")
|
||||
body = zeroTheme.accent.Render(content)
|
||||
started := step.startedAt
|
||||
if started.IsZero() {
|
||||
started = now
|
||||
}
|
||||
timeStr = formatElapsedSeconds(now.Sub(started))
|
||||
case "failed":
|
||||
icon = zeroTheme.red.Render("✗")
|
||||
body = zeroTheme.red.Render(content)
|
||||
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
|
||||
default: // pending
|
||||
icon = zeroTheme.faint.Render("○")
|
||||
body = zeroTheme.faint.Render(content)
|
||||
}
|
||||
line := " " + icon + " " + body
|
||||
if timeStr != "" {
|
||||
line += " " + zeroTheme.faint.Render(timeStr)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// truncateStep caps s to max runes, appending an ellipsis when truncated.
|
||||
func truncateStep(s string, max int) string {
|
||||
if max <= 0 {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -112,92 +110,6 @@ func TestUpdateFromItemsDuplicateContentKeepsDistinctTimestamps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelHeight(t *testing.T) {
|
||||
var s planPanelState
|
||||
now := time.Now()
|
||||
|
||||
// Empty plan: height 0
|
||||
if h := s.height(80, now); h != 0 {
|
||||
t.Errorf("empty plan height = %d, want 0", h)
|
||||
}
|
||||
|
||||
// Running plan with 3 steps: 2 (header+bar) + 3 (steps) = 5
|
||||
s.updateFromItems([]tools.PlanItem{
|
||||
{Content: "A", Status: "completed"},
|
||||
{Content: "B", Status: "in_progress"},
|
||||
{Content: "C", Status: "pending"},
|
||||
}, now)
|
||||
if h := s.height(80, now); h != 5 {
|
||||
t.Errorf("running plan height = %d, want 5", h)
|
||||
}
|
||||
|
||||
// Completed plan collapsed: 2 (header+bar only)
|
||||
s.updateFromItems([]tools.PlanItem{
|
||||
{Content: "A", Status: "completed"},
|
||||
{Content: "B", Status: "completed"},
|
||||
{Content: "C", Status: "completed"},
|
||||
}, now)
|
||||
if h := s.height(80, now); h != 2 {
|
||||
t.Errorf("completed collapsed plan height = %d, want 2", h)
|
||||
}
|
||||
|
||||
// Expanded: 2 + 3 = 5
|
||||
s.expanded = true
|
||||
if h := s.height(80, now); h != 5 {
|
||||
t.Errorf("expanded plan height = %d, want 5", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelVisibleHidesAfterTimeout(t *testing.T) {
|
||||
var s planPanelState
|
||||
t0 := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
s.updateFromItems([]tools.PlanItem{
|
||||
{Content: "A", Status: "completed"},
|
||||
}, t0)
|
||||
|
||||
// Visible right after completion
|
||||
if !s.visible(t0.Add(10 * time.Second)) {
|
||||
t.Error("plan should be visible within 30s of completion")
|
||||
}
|
||||
|
||||
// Hidden after 30s
|
||||
if s.visible(t0.Add(31 * time.Second)) {
|
||||
t.Error("plan should hide after 30s of completion when not expanded")
|
||||
}
|
||||
|
||||
// Expanded keeps it visible
|
||||
s.expanded = true
|
||||
if !s.visible(t0.Add(31 * time.Second)) {
|
||||
t.Error("expanded plan should stay visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelRenderEmpty(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
if got := m.renderPlanPanel(80); got != "" {
|
||||
t.Errorf("empty plan should render empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPanelRenderRunning(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
m.width = 100
|
||||
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return base.Add(15 * time.Second) }
|
||||
|
||||
m.plan.updateFromItems([]tools.PlanItem{
|
||||
{Content: "Read encrypt.go", Status: "completed"},
|
||||
{Content: "Fix retry loop", Status: "in_progress"},
|
||||
{Content: "Run tests", Status: "pending"},
|
||||
}, base)
|
||||
|
||||
got := m.renderPlanPanel(80)
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty plan panel render")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanNowFreezeAndResume covers the idle-freeze fix: while the agent is idle
|
||||
// (activeRunID == 0) and a freeze time is stamped, the plan clock is frozen so an
|
||||
// in_progress step left mid-plan stops ticking; during a run it tracks live time.
|
||||
@@ -228,101 +140,3 @@ func TestPlanPanelClearResetsFrozenAt(t *testing.T) {
|
||||
t.Fatal("clear() must reset frozenAt so the next run starts with a live clock")
|
||||
}
|
||||
}
|
||||
|
||||
// runningPlanModel builds a model with an n-step running plan for the pinned
|
||||
// panel tests.
|
||||
func runningPlanModel(t *testing.T, steps int) model {
|
||||
t.Helper()
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
m.width = 100
|
||||
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return base.Add(15 * time.Second) }
|
||||
items := make([]tools.PlanItem, steps)
|
||||
for i := range items {
|
||||
status := "pending"
|
||||
if i == 0 {
|
||||
status = "in_progress"
|
||||
}
|
||||
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
|
||||
}
|
||||
m.plan.updateFromItems(items, base)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestPinnedPlanFullWhenItFits(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
// 3 steps => header + 3 step lines = 4; budget 10 fits.
|
||||
got := m.renderPinnedPlanPanel(80, 10)
|
||||
if strings.Count(got, "\n")+1 < 4 {
|
||||
t.Fatalf("expected full multi-line panel within budget, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Step number 1") || !strings.Contains(got, "Step number 3") {
|
||||
t.Fatalf("full panel should list every step, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedPlanCollapsesToSummaryWhenTooTall(t *testing.T) {
|
||||
m := runningPlanModel(t, 12)
|
||||
// 12 steps would be 14 lines; budget 5 forces the one-line summary.
|
||||
got := m.renderPinnedPlanPanel(80, 5)
|
||||
if strings.Contains(got, "\n") {
|
||||
t.Fatalf("over-budget plan should collapse to one line, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "PLAN") {
|
||||
t.Fatalf("summary line should still show PLAN, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedPlanHiddenWhenEmpty(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
m.width = 100
|
||||
if got := m.renderPinnedPlanPanel(80, 10); got != "" {
|
||||
t.Fatalf("no plan => empty pinned render, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinnedPlanHiddenWhenSidebarToggledOff: Ctrl+B (sidebarHidden) on a wide
|
||||
// alt-screen terminal hides the plan entirely — it must NOT fall back to the
|
||||
// pinned panel above the composer, since the sidebar is the plan's home there.
|
||||
func TestPinnedPlanHiddenWhenSidebarToggledOff(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
m.headerPrinted = true
|
||||
// Real conversation so the sidebar is available (not gated by the home screen).
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
|
||||
}
|
||||
// Sidebar shown: the plan lives in the sidebar, no pinned panel.
|
||||
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
|
||||
t.Fatalf("sidebar shown: pinned plan should be suppressed, got:\n%s", got)
|
||||
}
|
||||
|
||||
// Ctrl+B collapses the sidebar -> the plan must be hidden entirely, not pinned.
|
||||
m.sidebarHidden = true
|
||||
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
|
||||
t.Fatalf("Ctrl+B should hide the plan entirely, but the pinned panel showed:\n%s", got)
|
||||
}
|
||||
|
||||
// Sidebar unavailable (no alt-screen): the pinned panel is the plan's only
|
||||
// home and must still show.
|
||||
if got := runningPlanModel(t, 3).renderPinnedPlanPanel(80, 10); got == "" {
|
||||
t.Fatal("sidebar unavailable: the pinned plan must still show")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterIncludesPinnedPlanAboveComposer(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.height = 40
|
||||
footer := plainRender(t, m.footerView(m.chatColumnWidth()))
|
||||
planIdx := strings.Index(footer, "PLAN")
|
||||
composerIdx := strings.Index(footer, "describe a task")
|
||||
if planIdx < 0 {
|
||||
t.Fatalf("footer should contain the pinned plan, got:\n%s", footer)
|
||||
}
|
||||
if composerIdx >= 0 && planIdx > composerIdx {
|
||||
t.Fatalf("pinned plan should render ABOVE the composer, got:\n%s", footer)
|
||||
}
|
||||
}
|
||||
@@ -120,32 +120,6 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit {
|
||||
return hits
|
||||
}
|
||||
|
||||
// planStepAtMouse maps a left-click in the context sidebar to a plan step index,
|
||||
// mirroring sidebarLineAtMouse's column/x gate.
|
||||
func (m model) planStepAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
if !m.sidebarActive() {
|
||||
return 0, false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return 0, false
|
||||
}
|
||||
sidebarW := sidebarWidth(m.width)
|
||||
if sidebarW <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
|
||||
x, y := mouseX(msg), mouseY(msg)
|
||||
if x < x0 || x >= x0+sidebarW {
|
||||
return 0, false
|
||||
}
|
||||
for _, hit := range m.sidebarPlanSelectables(sidebarW) {
|
||||
if hit.lineOffset == y {
|
||||
return hit.stepIndex, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// planStepDetailRowID is the stable transcript id for the single plan-step
|
||||
// detail card, so re-clicking toggles it instead of stacking duplicates.
|
||||
const planStepDetailRowID = "plan/step-detail"
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user