mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb3350ea5 | ||
|
|
865bd5c22b | ||
|
|
2749447cdc | ||
|
|
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",
|
||||
|
||||
+172
-11
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -390,6 +392,27 @@ type compactionState struct {
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
|
||||
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
|
||||
// (Options.Summarizer) for the current main model. summarizerProvider
|
||||
// memoizes the built provider and summarizerResolved records that the
|
||||
// factory ran (it may legitimately return no provider: the main model is
|
||||
// already the cheap one). summarizerBroken is the sticky fallback: once a
|
||||
// build or call fails, the run stays on the main provider until the next
|
||||
// model switch — a misconfigured cheap model must never break (or
|
||||
// repeatedly delay) compaction.
|
||||
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
summarizerProvider Provider
|
||||
summarizerResolved bool
|
||||
summarizerBroken bool
|
||||
// window is the context window the threshold was derived from. The
|
||||
// reactive free-prune stage compares against it (the limit the provider
|
||||
// enforces), not against the 70% proactive threshold — a pruned history
|
||||
// between the two would still succeed on retry.
|
||||
window int
|
||||
// mainModel is the model the run currently streams on; the summarizer
|
||||
// choice is made against it and remade after a mid-run switch.
|
||||
mainModel string
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
@@ -424,17 +447,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
window: options.ContextWindow,
|
||||
mainModel: options.Model,
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
|
||||
trace: options.Trace,
|
||||
summarizerFactory: options.Summarizer,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// updateWindow re-derives the compaction threshold after a mid-run model
|
||||
// switch. The low-water mark is reset: it was measured against the old
|
||||
// threshold, and keeping it could suppress a compaction the smaller window
|
||||
// now requires. <= 0 (unknown model) leaves everything unchanged.
|
||||
func (state *compactionState) updateWindow(window int) {
|
||||
if window <= 0 {
|
||||
return
|
||||
}
|
||||
state.enabled = true
|
||||
state.window = window
|
||||
state.threshold = compactionThreshold(window)
|
||||
state.lowWaterMark = 0
|
||||
if state.planner != nil {
|
||||
state.planner.config.contextWindow = window
|
||||
}
|
||||
}
|
||||
|
||||
// switchModel records a mid-run model switch: the window is re-derived
|
||||
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
|
||||
// the next compaction re-resolves it against the new main model. A run that
|
||||
// started on the cheap model (no dedicated summarizer) and escalated to an
|
||||
// expensive one therefore regains cheap summaries — the exact workflow a
|
||||
// dedicated summarizer exists for.
|
||||
func (state *compactionState) switchModel(modelID string, window int) {
|
||||
state.mainModel = modelID
|
||||
state.updateWindow(window)
|
||||
state.summarizerProvider = nil
|
||||
state.summarizerResolved = false
|
||||
state.summarizerBroken = false
|
||||
}
|
||||
|
||||
// summarizeProvider returns the provider summarization calls should use and
|
||||
// whether it is the dedicated summarizer: that one when it applies to the
|
||||
// current main model and is healthy, else the main provider. The factory runs
|
||||
// at most once per main model; a build error marks the summarizer broken for
|
||||
// that model. Callers branch on the boolean rather than comparing Provider
|
||||
// values — interface comparison panics on a non-comparable dynamic type.
|
||||
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
|
||||
if state.summarizerFactory == nil || state.summarizerBroken {
|
||||
return main, false
|
||||
}
|
||||
if !state.summarizerResolved {
|
||||
state.summarizerResolved = true
|
||||
built, err := state.summarizerFactory(ctx, state.mainModel)
|
||||
if err != nil {
|
||||
state.summarizerBroken = true
|
||||
return main, false
|
||||
}
|
||||
state.summarizerProvider = built
|
||||
}
|
||||
if state.summarizerProvider == nil {
|
||||
return main, false
|
||||
}
|
||||
return state.summarizerProvider, true
|
||||
}
|
||||
|
||||
// fitsWindow reports whether a calibrated size is safely under the limit the
|
||||
// provider enforces. The proactive threshold (70% of the window) is a trigger
|
||||
// for the paid summarizer, not the hard limit; the reactive path compares
|
||||
// against the window itself, with a small margin for estimate error, so a
|
||||
// history pruned to, say, 85% of the window takes the free retry.
|
||||
func (state *compactionState) fitsWindow(size int) bool {
|
||||
if state.window <= 0 {
|
||||
return size <= state.threshold
|
||||
}
|
||||
return size <= state.window-state.window/20
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
|
||||
// when the history is under threshold, or when the history has not grown past
|
||||
@@ -528,10 +622,34 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
|
||||
// often be fixed for free by dropping superseded reads and pruning old
|
||||
// tool-result bodies. It runs regardless of the one-shot reactive budget
|
||||
// (it costs nothing and cannot loop: a second pass finds nothing left to
|
||||
// prune). The free retry is taken only when the pruned history fits the
|
||||
// window the provider enforces — otherwise a retry would fail the same
|
||||
// way, so fall through to the paid summarizer in this same call.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
prunedAny := false
|
||||
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages, prunedAny = pruned, true
|
||||
}
|
||||
if prunedAny {
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if state.fitsWindow(size) {
|
||||
state.lowWaterMark = size
|
||||
return messages, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if state.reactiveAttempted {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
@@ -571,11 +689,21 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
// The call goes to the dedicated (cheap) summarizer when one applies; if that
|
||||
// call fails the summarizer is marked broken for the current model and the
|
||||
// same slice is retried once on the main provider, so the caller sees at most
|
||||
// one (main-provider-quality) error.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
chosen, dedicated := state.summarizeProvider(ctx, provider)
|
||||
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
if err == nil || !dedicated {
|
||||
return summary, err
|
||||
}
|
||||
state.summarizerBroken = true
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
}
|
||||
}
|
||||
@@ -659,8 +787,41 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
|
||||
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
|
||||
// search results), yet the summary instructions only need their gist — the
|
||||
// model that ACTED on the full output already turned it into decisions the
|
||||
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
|
||||
// head+tail of a result while cutting the summarizer input by 5-10x on
|
||||
// tool-heavy sessions; the summarizer's own cost is the price being cut.
|
||||
const summaryToolResultClamp = 2000
|
||||
|
||||
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
|
||||
// a write_file call carries the whole file body in its arguments.
|
||||
const summaryToolArgsClamp = 500
|
||||
|
||||
// clampForSummary head+tail-truncates text for the summarizer transcript,
|
||||
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
|
||||
func clampForSummary(text string, max int) string {
|
||||
if max <= 0 || len(text) <= max {
|
||||
return text
|
||||
}
|
||||
head := max * 3 / 4
|
||||
tail := max - head
|
||||
for head > 0 && !utf8.RuneStart(text[head]) {
|
||||
head--
|
||||
}
|
||||
start := len(text) - tail
|
||||
for start < len(text) && !utf8.RuneStart(text[start]) {
|
||||
start++
|
||||
}
|
||||
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a plain-text transcript for the
|
||||
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
|
||||
// Tool-result bodies and tool-call arguments are clamped (see the constants
|
||||
// above): the summary needs what was done and decided, not the raw payloads.
|
||||
func renderTranscript(messages []zeroruntime.Message) string {
|
||||
lines := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
@@ -670,13 +831,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
|
||||
if len(message.ToolCalls) > 0 {
|
||||
calls := make([]string, 0, len(message.ToolCalls))
|
||||
for _, call := range message.ToolCalls {
|
||||
calls = append(calls, call.Name+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
// the proactive threshold for a 1000-token window.
|
||||
func compactableHistory() []zeroruntime.Message {
|
||||
return []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
|
||||
}
|
||||
}
|
||||
|
||||
// Summarization must run on the dedicated (cheap) summarizer when one is
|
||||
// configured — the main provider sees no summarize traffic at all.
|
||||
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
|
||||
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
main := &mockProvider{}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
|
||||
|
||||
if len(cheap.requests) == 0 {
|
||||
t.Fatal("dedicated summarizer never received the summarize call")
|
||||
}
|
||||
if len(main.requests) != 0 {
|
||||
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
|
||||
t.Fatal("compaction did not shrink the history")
|
||||
}
|
||||
found := false
|
||||
for _, message := range compacted {
|
||||
if strings.Contains(message.Content, "CHEAP SUMMARY") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cheap summarizer's summary missing from the compacted history")
|
||||
}
|
||||
}
|
||||
|
||||
// A failing summarizer falls back to the main provider for the SAME slice and
|
||||
// stays broken for the rest of the run — no repeated failed cheap calls.
|
||||
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
|
||||
|
||||
summary, err := summarize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback must succeed via the main provider: %v", err)
|
||||
}
|
||||
if summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, want the main provider's", summary)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
|
||||
}
|
||||
if !state.summarizerBroken {
|
||||
t.Fatal("a failed summarizer must be marked broken for the run")
|
||||
}
|
||||
|
||||
// Second summarization: straight to main, no cheap retry.
|
||||
if _, err := summarize(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(broken.requests) != 1 {
|
||||
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
|
||||
}
|
||||
if len(main.requests) != 2 {
|
||||
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A factory that errors (misconfigured model) is equivalent to no summarizer:
|
||||
// main provider used, factory not retried.
|
||||
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
|
||||
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
factoryCalls := 0
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) {
|
||||
factoryCalls++
|
||||
return nil, errors.New("unknown model")
|
||||
},
|
||||
}, nil)
|
||||
summarize := state.summarizeClosure(context.Background(), main)
|
||||
|
||||
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("after a factory failure the run must stay on the main provider")
|
||||
}
|
||||
if factoryCalls != 1 {
|
||||
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tool payloads are clamped in the summarizer transcript: results to
|
||||
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
|
||||
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
|
||||
bigResult := strings.Repeat("r", 50_000)
|
||||
bigArgs := strings.Repeat("w", 20_000)
|
||||
transcript := renderTranscript([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "c1", Name: "write_file", Arguments: bigArgs},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
|
||||
})
|
||||
|
||||
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
|
||||
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
|
||||
}
|
||||
if !strings.Contains(transcript, "chars omitted for summarization") {
|
||||
t.Fatal("clamped transcript must carry the omission marker")
|
||||
}
|
||||
if !strings.Contains(transcript, "keep going") {
|
||||
t.Fatal("non-tool content must survive unclamped")
|
||||
}
|
||||
// Head AND tail of the clamped payloads survive.
|
||||
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
|
||||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
|
||||
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// bigToolHistory returns a history with n large tool results (~40k estimated
|
||||
// tokens each): the trailing protection window keeps the newest verbatim,
|
||||
// the older ones are prunable.
|
||||
func bigToolHistory(n int) []zeroruntime.Message {
|
||||
bigBody := strings.Repeat("x", 160_000)
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
|
||||
for i := 0; i < n; i++ {
|
||||
id := string(rune('a' + i))
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
|
||||
)
|
||||
}
|
||||
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
|
||||
}
|
||||
|
||||
func summaryProvider(text string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
// A context-limit error is first answered with the free prune stage; when the
|
||||
// pruned history fits the window the retry is free (no summarizer call) and
|
||||
// the one-shot reactive budget is untouched. The paid summarizer only runs
|
||||
// when a later error finds nothing left to prune.
|
||||
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// The protected trailing tool output (~40k tokens) must fit the window.
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("prunable history that fits must produce a free retry")
|
||||
}
|
||||
if len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
t.Fatal("a free prune retry must not consume the reactive budget")
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
|
||||
// Second context-limit error on the pruned history: nothing meaningfully
|
||||
// prunable remains, so the paid summarizer fires and consumes the budget.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("second recover must compact and retry")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("second recover must reach the summarizer")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("a paid recover must consume the reactive budget")
|
||||
}
|
||||
}
|
||||
|
||||
// When pruning reclaims something but the pruned history still exceeds the
|
||||
// window, recover falls through to the paid summarizer in the SAME call
|
||||
// instead of spending a retry that would fail the same way.
|
||||
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
// A 1,000-token window: even the protected trailing output overflows it.
|
||||
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried {
|
||||
t.Fatal("insufficient prune must still end in a retry via summarization")
|
||||
}
|
||||
if len(provider.requests) == 0 {
|
||||
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
|
||||
}
|
||||
if !state.reactiveAttempted {
|
||||
t.Fatal("the paid summarizer consumes the reactive budget")
|
||||
}
|
||||
if estimateTokens(compacted) >= estimateTokens(messages) {
|
||||
t.Fatal("recover did not shrink the history")
|
||||
}
|
||||
}
|
||||
|
||||
// The free prune stage is not gated behind the one-shot budget: after a paid
|
||||
// reactive compaction, a later context-limit error still gets a free retry
|
||||
// when pruning makes the history fit.
|
||||
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
|
||||
state.reactiveAttempted = true
|
||||
messages := bigToolHistory(5)
|
||||
|
||||
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
if estimateTokens(pruned) >= estimateTokens(messages) {
|
||||
t.Fatal("prune did not shrink the history")
|
||||
}
|
||||
// With nothing left to prune and the budget spent, the caller keeps its
|
||||
// original error — recover neither loops nor summarizes.
|
||||
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
|
||||
if err != nil || retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
|
||||
}
|
||||
}
|
||||
|
||||
// The free-retry gate compares against the window the provider enforces, not
|
||||
// the 70% proactive threshold: a history pruned to ~80% of the window still
|
||||
// takes the free retry.
|
||||
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
|
||||
provider := summaryProvider("SUMMARY")
|
||||
messages := bigToolHistory(5)
|
||||
pruned, _ := pruneStaleToolOutput(messages, 2)
|
||||
prunedTokens := estimateTokens(pruned)
|
||||
// Choose a window so the pruned size sits between 70% and 95% of it.
|
||||
window := prunedTokens * 100 / 80
|
||||
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
|
||||
if prunedTokens <= state.threshold {
|
||||
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
|
||||
}
|
||||
|
||||
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !retried || len(provider.requests) != 0 {
|
||||
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// After a mid-run model switch the summarizer is re-resolved against the new
|
||||
// main model: a run that started on the cheap model (no dedicated summarizer)
|
||||
// and escalated gets a cheap summarizer, and a previously broken one is
|
||||
// retried for the new model.
|
||||
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
|
||||
cheap := summaryProvider("CHEAP SUMMARY")
|
||||
main := summaryProvider("MAIN SUMMARY")
|
||||
var factoryModels []string
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Model: "cheap-model",
|
||||
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
|
||||
factoryModels = append(factoryModels, mainModelID)
|
||||
if mainModelID == "cheap-model" {
|
||||
return nil, nil // main already is the cheap model
|
||||
}
|
||||
return cheap, nil
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("on the cheap model the main provider must summarize")
|
||||
}
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
|
||||
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
state.switchModel("big-model", 400_000)
|
||||
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
|
||||
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
|
||||
}
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
|
||||
}
|
||||
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
|
||||
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
|
||||
}
|
||||
|
||||
// A broken summarizer is retried after the next switch.
|
||||
state.summarizerBroken = true
|
||||
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
|
||||
t.Fatal("a broken summarizer must fall back to main")
|
||||
}
|
||||
state.switchModel("other-model", 0)
|
||||
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
|
||||
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionUpdateWindow(t *testing.T) {
|
||||
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
|
||||
state.lowWaterMark = 50_000
|
||||
before := state.threshold
|
||||
|
||||
state.updateWindow(0) // unknown model: unchanged
|
||||
if state.threshold != before || state.lowWaterMark != 50_000 {
|
||||
t.Fatal("updateWindow(0) must be a no-op")
|
||||
}
|
||||
|
||||
state.updateWindow(10_000)
|
||||
if state.threshold >= before {
|
||||
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
|
||||
}
|
||||
if state.lowWaterMark != 0 {
|
||||
t.Fatal("low-water mark must reset on a window change")
|
||||
}
|
||||
if !state.enabled {
|
||||
t.Fatal("a positive window must keep compaction enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: after escalate_model switches providers, the loop re-derives the
|
||||
// context window via ContextWindowFor, visible in the OnContext report.
|
||||
func TestRunEscalationRederivesContextWindow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "big-model"})
|
||||
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
var windows []int
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
ContextWindow: 100_000,
|
||||
Model: "small-model",
|
||||
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
if modelID == "big-model" {
|
||||
return 400_000
|
||||
}
|
||||
return 0
|
||||
},
|
||||
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(windows) < 2 {
|
||||
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
|
||||
}
|
||||
if windows[0] != 100_000 {
|
||||
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
|
||||
}
|
||||
if windows[len(windows)-1] != 400_000 {
|
||||
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// nonComparableProvider has a slice field, so comparing two Provider interface
|
||||
// values holding it would panic; the summarizer path must never compare them.
|
||||
type nonComparableProvider struct {
|
||||
inner *mockProvider
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return p.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
|
||||
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
|
||||
}}}
|
||||
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
|
||||
state := newCompactionState(Options{
|
||||
ContextWindow: 1000,
|
||||
CompactionPreserveLast: 2,
|
||||
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
|
||||
}, nil)
|
||||
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
|
||||
if err != nil || summary != "MAIN SUMMARY" {
|
||||
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
|
||||
}
|
||||
// And with no dedicated summarizer at all the closure still works.
|
||||
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+34
-12
@@ -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
|
||||
@@ -836,11 +844,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// KNOWN LIMITATION (deferred): the compactor's context-window budget
|
||||
// is fixed at run start from options.ContextWindow and is NOT updated
|
||||
// here, so a switch to a model with a different window keeps compacting
|
||||
// against the original budget. Fixing it needs the switcher to also
|
||||
// report the new window — out of scope for this change.
|
||||
// Re-derive the context window for the new model so compaction and
|
||||
// the OnContext budget report track the model actually in force —
|
||||
// without this a switch to a smaller-window model can overflow the
|
||||
// target, and a switch to a larger one over-compacts. An unknown
|
||||
// model (<= 0) keeps the original window. The compactor also
|
||||
// re-resolves its dedicated summarizer against the new model.
|
||||
window := 0
|
||||
if options.ContextWindowFor != nil {
|
||||
window = options.ContextWindowFor(turnRequestedModel)
|
||||
}
|
||||
if window > 0 {
|
||||
options.ContextWindow = window
|
||||
planner.config.contextWindow = window
|
||||
}
|
||||
compactor.switchModel(turnRequestedModel, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1364,7 +1382,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 +1566,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 +1614,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 +3105,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 +3228,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 TestAgentAdvertisesBothEditTools(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 multi-hunk changes")
|
||||
}
|
||||
if !names["edit_file"] {
|
||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
edit_file, apply_patch, write_file - 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,18 @@ 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, use edit_file for a targeted change (old_string
|
||||
must match the file exactly and be unique, so include a few surrounding
|
||||
lines) and apply_patch when one coherent change spans several hunks or
|
||||
files. Use write_file only to create a file or when most of it changes; do
|
||||
not rewrite a whole file to change a few lines. 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.
|
||||
- A successful edit result already confirms the change; do not re-read a file
|
||||
just to verify an edit that succeeded. If an edit fails, read the error, fix
|
||||
the old_string or hunk, and retry the same tool rather than switching to a
|
||||
full rewrite.
|
||||
- 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 +112,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 edit_file/apply_patch/write_file 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,8 +60,12 @@ 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
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python
|
||||
for file work. read_file, edit_file and write_file take one file per call;
|
||||
a single apply_patch may span several files when they form one coherent
|
||||
change. Do not batch file writes into a script. Independent tool calls
|
||||
(several reads, several edit_file calls to different files) belong in the
|
||||
same turn.
|
||||
- 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>`
|
||||
|
||||
@@ -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, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
|
||||
+20
-4
@@ -348,10 +348,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// Summarizer, when set, lazily builds the provider used for compaction
|
||||
// summarization calls — typically a cheap/fast model, since summaries at
|
||||
// main-model prices are the single most expensive recurring event in a
|
||||
// long run. It receives the main model currently in force and may return
|
||||
// (nil, nil) when no dedicated summarizer applies to it (the main model is
|
||||
// already the cheap one). Built on the first paid compaction and again
|
||||
// after a mid-run model switch. Any failure (build or call) falls back to
|
||||
// the run's main provider until the next switch, so a misconfigured
|
||||
// summarizer can never break compaction. nil keeps today's behavior.
|
||||
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
|
||||
// ContextWindowFor, when set, resolves a model ID to its context window so
|
||||
// the compactor can re-derive its threshold after a mid-run model switch
|
||||
// (escalate_model). Without it a switch keeps compacting against the
|
||||
// original model's window — overflowing a smaller target or over-compacting
|
||||
// a larger one. Return <= 0 when the model is unknown (window unchanged).
|
||||
ContextWindowFor func(modelID string) int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// FileTracker records per-session file read/write versions so the write tools
|
||||
// can detect a file changed on disk outside Zero since it was last read. nil
|
||||
// disables the check. Created once per session and threaded into every tool run.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-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() {
|
||||
@@ -948,6 +948,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
CompactionModel: resolved.Preferences.CompactionModel,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+14
-10
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
Summarizer: summarizerFactory(resolved, deps.newProvider),
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// summarizerFactory adapts the resolved profile and the authenticated provider
|
||||
// builder into agent.Options.Summarizer; the selection rules live in
|
||||
// providers.CompactionSummarizerFactory, shared with the TUI.
|
||||
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
|
||||
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -119,6 +119,12 @@ type PreferencesConfig struct {
|
||||
// the user turned idle recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
// CompactionModel routes compaction summarization calls to this model
|
||||
// instead of the session's main model (summaries at main-model prices are
|
||||
// the most expensive recurring event in long runs). Empty = automatic: a
|
||||
// curated cheap model on official endpoints, the main model elsewhere.
|
||||
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
|
||||
CompactionModel string `json:"compactionModel,omitempty"`
|
||||
}
|
||||
|
||||
// RecentModelEntry is one provider-qualified model selection recorded in
|
||||
@@ -151,7 +157,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 +471,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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Compaction summarization model selection.
|
||||
//
|
||||
// Compaction summaries used to run on the session's main model — near a 200k
|
||||
// window one summarization is a ~100k-token input call at full price, the
|
||||
// single most expensive recurring event in a long run. A cheap model handles
|
||||
// the "dense factual summary" task fine, so summarization is routed to one
|
||||
// whenever the target is knowable.
|
||||
//
|
||||
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
|
||||
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
|
||||
// main model). The literal value "main" at any level forces the main model.
|
||||
// A wrong choice is never fatal: the agent falls back to the main provider on
|
||||
// the first summarizer failure for the rest of the run.
|
||||
|
||||
// CompactionModelEnv overrides the compaction summarization model.
|
||||
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
|
||||
|
||||
// compactionMainSentinel disables the dedicated summarizer explicitly.
|
||||
const compactionMainSentinel = "main"
|
||||
|
||||
// Curated cheap summarizer defaults, applied only for provider kinds whose
|
||||
// model catalog is guaranteed (official endpoints). Custom / *-compatible
|
||||
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
|
||||
// failed call per run is worse than main-model prices.
|
||||
const (
|
||||
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
|
||||
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
|
||||
defaultOpenAICompactionModel = "gpt-4.1-mini"
|
||||
)
|
||||
|
||||
// CompactionModelID returns the model that compaction summarization calls
|
||||
// should use for the given profile, or "" to use the session's main model.
|
||||
// preference is the resolved config value (preferences.compactionModel).
|
||||
func CompactionModelID(profile config.ProviderProfile, preference string) string {
|
||||
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(preference)
|
||||
}
|
||||
if value != "" {
|
||||
if strings.EqualFold(value, compactionMainSentinel) {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
return defaultCompactionModel(profile)
|
||||
}
|
||||
|
||||
func defaultCompactionModel(profile config.ProviderProfile) string {
|
||||
metadata, err := ResolveRuntimeMetadata(profile, Options{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var candidate string
|
||||
switch metadata.ProviderKind {
|
||||
case config.ProviderKindAnthropic:
|
||||
candidate = defaultAnthropicCompactionModel
|
||||
case config.ProviderKindGoogle:
|
||||
candidate = defaultGoogleCompactionModel
|
||||
case config.ProviderKindOpenAI:
|
||||
candidate = defaultOpenAICompactionModel
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
// Session already runs on the cheap model: a dedicated summarizer buys
|
||||
// nothing and would only add a second provider instance.
|
||||
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
|
||||
return ""
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// CompactionSummarizerFactory returns the lazy constructor the agent loop
|
||||
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
|
||||
// The compactor passes the main model in force at that moment — on the first
|
||||
// paid compaction and again after a mid-run model switch — so the choice
|
||||
// tracks escalations: a run that starts on the cheap model gets no dedicated
|
||||
// summarizer, and gains one once it escalates to a pricier main model.
|
||||
// Returning (nil, nil) means "summarize on the main provider". The factory
|
||||
// itself is nil only when no provider builder exists. Shared by the exec and
|
||||
// TUI front ends so the selection rules cannot drift between them.
|
||||
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
|
||||
if newProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
|
||||
profile := base
|
||||
if strings.TrimSpace(mainModelID) != "" {
|
||||
profile.Model = mainModelID
|
||||
}
|
||||
modelID := CompactionModelID(profile, preference)
|
||||
if modelID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile.Model = modelID
|
||||
return newProvider(profile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestCompactionModelIDResolutionOrder(t *testing.T) {
|
||||
anthropic := config.ProviderProfile{
|
||||
Name: "anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropic,
|
||||
Model: "claude-sonnet-4.5",
|
||||
}
|
||||
|
||||
t.Setenv(CompactionModelEnv, "my-env-model")
|
||||
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
|
||||
t.Fatalf("env must win, got %q", got)
|
||||
}
|
||||
|
||||
t.Setenv(CompactionModelEnv, "main")
|
||||
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
|
||||
t.Fatalf("env 'main' must force the main model, got %q", got)
|
||||
}
|
||||
|
||||
t.Setenv(CompactionModelEnv, "")
|
||||
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
|
||||
t.Fatalf("config preference must apply when env unset, got %q", got)
|
||||
}
|
||||
if got := CompactionModelID(anthropic, "main"); got != "" {
|
||||
t.Fatalf("config 'main' must force the main model, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
|
||||
t.Setenv(CompactionModelEnv, "")
|
||||
|
||||
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
|
||||
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
|
||||
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
|
||||
}
|
||||
|
||||
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
|
||||
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
|
||||
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
|
||||
}
|
||||
|
||||
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
|
||||
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
|
||||
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
|
||||
}
|
||||
|
||||
// Custom/compatible endpoints: catalog unknowable, no default.
|
||||
compatible := config.ProviderProfile{
|
||||
Name: "opengateway",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opengateway.gitlawb.com/v1",
|
||||
Model: "some-model",
|
||||
}
|
||||
if got := CompactionModelID(compatible, ""); got != "" {
|
||||
t.Fatalf("openai-compatible must have no default, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
|
||||
t.Setenv(CompactionModelEnv, "")
|
||||
haiku := config.ProviderProfile{
|
||||
Name: "anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropic,
|
||||
Model: defaultAnthropicCompactionModel,
|
||||
}
|
||||
if got := CompactionModelID(haiku, ""); got != "" {
|
||||
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
|
||||
t.Setenv(CompactionModelEnv, "")
|
||||
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
|
||||
var built []string
|
||||
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
built = append(built, profile.Model)
|
||||
return nil, errors.New("not needed")
|
||||
}
|
||||
factory := CompactionSummarizerFactory(base, "", newProvider)
|
||||
if factory == nil {
|
||||
t.Fatal("factory must exist when a provider builder exists")
|
||||
}
|
||||
// Main model is the cheap default: no dedicated summarizer, nothing built.
|
||||
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
|
||||
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
|
||||
}
|
||||
// After escalating to an expensive model the cheap default is built.
|
||||
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
|
||||
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
|
||||
}
|
||||
// An empty main model falls back to the base profile's model — the cheap
|
||||
// default here, so again nothing is built.
|
||||
built = nil
|
||||
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
|
||||
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
|
||||
}
|
||||
// The explicit "main" preference disables the dedicated summarizer.
|
||||
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
|
||||
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
|
||||
}
|
||||
if CompactionSummarizerFactory(base, "", nil) != nil {
|
||||
t.Fatal("no provider builder means no factory")
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(path string) string {
|
||||
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"absolute inside workspace": structured(inside),
|
||||
"relative": structured("main.js"),
|
||||
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
|
||||
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
|
||||
} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
if block := applyPatchPathBlock(request); block != nil {
|
||||
t.Fatalf("%s: unexpected block %+v", name, block)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"../escape.js", ".."} {
|
||||
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
block := applyPatchPathBlock(request)
|
||||
if block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every marker spelling the tool applies must be classified as structured at
|
||||
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
|
||||
// diff, extracts no targets, and validates nothing (fail-open).
|
||||
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
|
||||
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
|
||||
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
|
||||
if !IsStructuredPatch(patch) {
|
||||
t.Fatalf("%q must classify as a structured patch", header)
|
||||
}
|
||||
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
|
||||
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
|
||||
if IsStructuredPatch(header + "\n-a\n+b") {
|
||||
t.Fatalf("%q must not classify as a structured patch", header)
|
||||
}
|
||||
}
|
||||
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
|
||||
t.Fatal("decorated end markers must classify as end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
|
||||
// legitimately in scope; pick a path under the filesystem root instead.
|
||||
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope, err := NewScope(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(header, footer, path string) string {
|
||||
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
|
||||
}
|
||||
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
|
||||
// Failure path: the exact path the boundary parsed must be denied by the
|
||||
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
|
||||
// parsed form is compared in slash form and then validated as-is.
|
||||
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
|
||||
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
// Success path: the parsed inside path must be accepted by the scope.
|
||||
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
|
||||
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block != nil {
|
||||
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,11 +281,15 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
// Only relative traversal is rejected up front. Absolute paths flow through
|
||||
// the regular workspace-scope validation below (requestPaths), which accepts
|
||||
// one inside the workspace and denies one outside — a model that echoes the
|
||||
// absolute path read_file showed it must not be blocked for that alone.
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
if path == ".." || strings.HasPrefix(path, "../") {
|
||||
return &pathBlock{
|
||||
Code: BlockOutsideWorkspace,
|
||||
Path: path,
|
||||
@@ -296,6 +300,61 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
// structuredPatchMarkerPattern is the single classifier for structured-patch
|
||||
// markers, shared by the sandbox boundary and the apply_patch tool (which
|
||||
// imports this package). It accepts the canonical "*** Begin Patch" /
|
||||
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
|
||||
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
|
||||
// tool would apply but the sandbox did not recognise would make the sandbox
|
||||
// scan the patch as a unified diff, extract no targets, and validate nothing.
|
||||
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
|
||||
|
||||
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
|
||||
// structured patch, or "" when it is neither.
|
||||
func StructuredPatchMarker(line string) string {
|
||||
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if match == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(match[1])
|
||||
}
|
||||
|
||||
// IsStructuredPatch reports whether patch opens with a structured begin marker.
|
||||
// The tool applies exactly the patches this returns true for, so the sandbox
|
||||
// extracts structured header paths for exactly the same set.
|
||||
func IsStructuredPatch(patch string) bool {
|
||||
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
|
||||
return StructuredPatchMarker(first) == "begin"
|
||||
}
|
||||
|
||||
func applyPatchPaths(patch string) []string {
|
||||
if IsStructuredPatch(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
|
||||
|
||||
+34
-186
@@ -3,11 +3,11 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
type applyPatchTool struct {
|
||||
@@ -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 multi-hunk or multi-file patch inside the workspace (or a granted extra write root). Structured format:\n*** Begin Patch\n*** Update File: src/app.js\n@@\n unchanged context line\n-removed line\n+added line\n*** End Patch\nUse \"*** Add File: path\" with \"+\" lines to create a file and \"*** Delete File: path\" to remove one; several sections may follow each other. A unified diff (---/+++ headers with a/ b/ prefixes, @@ hunks) is also accepted and applied in-process. Paths are workspace-relative; absolute paths inside the workspace are fine. For a single targeted change, edit_file is simpler.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"patch": {Type: "string", Description: "Unified diff patch to apply."},
|
||||
"patch": {Type: "string", Description: "The structured (*** Begin Patch) or unified-diff patch text."},
|
||||
"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,177 +56,17 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if err := validatePatchPaths(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
if isStructuredPatch(patch) {
|
||||
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
|
||||
// Unified diffs are translated into the same operations and applied by
|
||||
// the same os.Root engine, so neither format opens a target by pathname
|
||||
// after validation (no check-to-use window) and git is not needed.
|
||||
operations, err := parseUnifiedPatch(patch)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
patchPath := tempFile.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(patchPath)
|
||||
}()
|
||||
if _, err := tempFile.WriteString(patch); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
var createdTargets []string
|
||||
var fullySuppliedTargets []string
|
||||
wholeBefore := map[string]bool{}
|
||||
if options.FileTracker != nil {
|
||||
createdTargets = missingPatchTargets(applyRoot, patch)
|
||||
fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch)
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if absolute, _, rerr := resolveWorkspaceTargetPath(applyRoot, path); rerr == nil {
|
||||
wholeBefore[absolute] = options.FileTracker.SeenWhole(absolute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
|
||||
command.Dir = applyRoot
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return errorResult("Error applying patch: " + message)
|
||||
}
|
||||
|
||||
summary := "Patch applied successfully."
|
||||
if relativeRoot != "." {
|
||||
summary = "Patch applied successfully in " + relativeRoot + "."
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch)
|
||||
result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)}
|
||||
fullySupplied := make(map[string]bool, len(fullySuppliedTargets))
|
||||
for _, absolute := range fullySuppliedTargets {
|
||||
fullySupplied[absolute] = true
|
||||
}
|
||||
// Re-baseline files changed by this tool. When the model had already seen the
|
||||
// whole input (or supplied a complete new file), the exact patch plus that
|
||||
// input determines the whole output, so a follow-up edit needs no wasted read.
|
||||
// Partial observations remain conservative and are cleared by Record.
|
||||
for _, changed := range result.ChangedFiles {
|
||||
if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil {
|
||||
content, readErr := os.ReadFile(absolute)
|
||||
if readErr != nil {
|
||||
options.FileTracker.Forget(absolute)
|
||||
continue
|
||||
}
|
||||
info, _ := os.Stat(absolute)
|
||||
wasWhole := wholeBefore[absolute] || fullySupplied[absolute]
|
||||
options.FileTracker.Record(absolute, content, info)
|
||||
if wasWhole {
|
||||
lines := lineCount(string(content))
|
||||
options.FileTracker.RecordSeenRange(absolute, 1, lines, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
recordCreatedPatchTargets(options.FileTracker, createdTargets)
|
||||
return result
|
||||
}
|
||||
|
||||
func missingPatchTargets(root string, patch string) []string {
|
||||
seen := map[string]bool{}
|
||||
var missing []string
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
absolute, _, err := resolveWorkspaceTargetPath(root, path)
|
||||
if err != nil || seen[absolute] {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = true
|
||||
if _, err := os.Stat(absolute); os.IsNotExist(err) {
|
||||
missing = append(missing, absolute)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// completeCreatedPatchTargets returns only files whose full contents are
|
||||
// supplied by a /dev/null creation patch. A missing rename/copy destination is
|
||||
// created by git too, but its bytes come from an unread source and must not gain
|
||||
// whole-file observation credit.
|
||||
func completeCreatedPatchTargets(root string, patch string) []string {
|
||||
seen := map[string]bool{}
|
||||
var created []string
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
inHunk := false
|
||||
fromDevNull := false
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-"):
|
||||
oldRemaining--
|
||||
case strings.HasPrefix(line, "+"):
|
||||
newRemaining--
|
||||
case strings.HasPrefix(line, "\\"):
|
||||
default:
|
||||
oldRemaining--
|
||||
newRemaining--
|
||||
}
|
||||
continue
|
||||
}
|
||||
inHunk = false
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
fromDevNull = false
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
oldRemaining, newRemaining = parseHunkCounts(line)
|
||||
inHunk = oldRemaining > 0 || newRemaining > 0
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
fromDevNull = patchFileHeaderPath(line) == "/dev/null"
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
path := patchFileHeaderPath(line)
|
||||
if !fromDevNull || path == "" || path == "/dev/null" {
|
||||
fromDevNull = false
|
||||
continue
|
||||
}
|
||||
fromDevNull = false
|
||||
absolute, _, err := resolveWorkspaceTargetPath(root, stripPatchPrefix(path))
|
||||
if err != nil || seen[absolute] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(absolute); !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = true
|
||||
created = append(created, absolute)
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
for _, absolute := range missingBefore {
|
||||
if _, err := os.Stat(absolute); err != nil {
|
||||
continue
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = resolved
|
||||
}
|
||||
tracker.RecordCreated(absolute)
|
||||
}
|
||||
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
|
||||
}
|
||||
|
||||
// changedFilesFromPatch extracts the unique, WORKSPACE-relative paths a patch
|
||||
@@ -257,27 +97,35 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// normalizePatchPathForRoot resolves platform-level symlinks (macOS /var ->
|
||||
// /private/var) in the prefix of an absolute patch path that lies outside the
|
||||
// apply root, so a path the model copied from read_file compares equal to the
|
||||
// symlink-resolved root. Relative paths are returned unchanged.
|
||||
func normalizePatchPathForRoot(root string, path string) string {
|
||||
if !filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
resolvedRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
|
||||
resolvedRoot = evaluated
|
||||
}
|
||||
return sandbox.NormalizePrefixForRoot(path, resolvedRoot)
|
||||
}
|
||||
|
||||
func validatePatchPaths(root string, patch string) error {
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
// Relative traversal is rejected here; an absolute path is checked by
|
||||
// resolveWorkspaceTargetPath, which only accepts one inside the root.
|
||||
if path == ".." || strings.HasPrefix(path, "../") {
|
||||
return fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckPatchWriteTargets(root string, patch string) error {
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
|
||||
if _, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -348,7 +196,7 @@ func patchFileHeaderPath(line string) string {
|
||||
// tokens. Scanning the whole line would let a crafted heading like
|
||||
// "@@ -1,1 +1,1 @@ +1,999999" overwrite the real count, keep the parser stuck in
|
||||
// hunk mode, and swallow later "--- "/"+++ " file headers so they escape
|
||||
// validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
|
||||
// validatePatchPaths — a workspace-confinement bypass.
|
||||
func parseHunkCounts(line string) (int, int) {
|
||||
_, rest, ok := strings.Cut(line, "@@")
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Models routinely decorate the structured markers ("*** Begin Patch ***") and
|
||||
// write unified-diff ranges after "@@"; both used to fail on the first line and
|
||||
// pushed the model into whole-file rewrites.
|
||||
func TestApplyPatchToleratesDecoratedMarkersAndRangeHeaders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\nbye\n")
|
||||
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch ***",
|
||||
"*** Update File: hello.txt",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch ***",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
if !isStructuredPatch(patch) {
|
||||
t.Fatal("decorated begin marker must still be recognised as a structured patch")
|
||||
}
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("decorated 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\nbye\n" {
|
||||
t.Fatalf("patched content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredHunkAnchorKeepsHeadingAndPlainContext(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"-12,4 +12,6": "",
|
||||
"-12 +12": "",
|
||||
"-12,4 +12,6 @@": "",
|
||||
"-12,4 +12,6 @@ func main() {": "func main() {",
|
||||
"func main() {": "func main() {",
|
||||
"": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := structuredHunkAnchor(input); got != want {
|
||||
t.Fatalf("structuredHunkAnchor(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchMarkerSpellings(t *testing.T) {
|
||||
begin := []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "*** Begin Patch **"}
|
||||
for _, line := range begin {
|
||||
if structuredPatchMarker(line) != "begin" {
|
||||
t.Fatalf("%q must read as the begin marker", line)
|
||||
}
|
||||
}
|
||||
if structuredPatchMarker("*** End Patch ***") != "end" {
|
||||
t.Fatal("decorated end marker must read as the end marker")
|
||||
}
|
||||
for _, line := range []string{"*** Update File: x", "Begin Patch", "*** Begin Patchwork"} {
|
||||
if structuredPatchMarker(line) != "" {
|
||||
t.Fatalf("%q must not read as a marker", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchHeaderErrorCarriesFormatHint(t *testing.T) {
|
||||
_, err := parseStructuredPatch("--- a/x\n+++ b/x\n")
|
||||
if err == nil || !strings.Contains(err.Error(), "*** Update File: path") {
|
||||
t.Fatalf("header error should teach the format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchAcceptsAbsoluteInWorkspacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
|
||||
absolute := filepath.Join(root, "hello.txt")
|
||||
|
||||
structured := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + absolute,
|
||||
"@@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+structured",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": structured})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("absolute in-workspace structured path should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ := os.ReadFile(absolute)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nstructured\n" {
|
||||
t.Fatalf("structured content = %q", got)
|
||||
}
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "hello.txt" {
|
||||
t.Fatalf("changed files should be workspace-relative, got %v", result.ChangedFiles)
|
||||
}
|
||||
|
||||
unified := strings.Join([]string{
|
||||
"--- " + absolute,
|
||||
"+++ " + absolute,
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-structured",
|
||||
"+unified",
|
||||
"",
|
||||
}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": unified})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("absolute in-workspace unified path should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ = os.ReadFile(absolute)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nunified\n" {
|
||||
t.Fatalf("unified content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchStillRejectsAbsolutePathsOutsideWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
writeTestFile(t, outside, "old\n")
|
||||
|
||||
for name, patch := range map[string]string{
|
||||
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: " + outside, "@@", "-old", "+new", "*** End Patch", ""}, "\n"),
|
||||
"unified": strings.Join([]string{"--- " + outside, "+++ " + outside, "@@ -1 +1 @@", "-old", "+new", ""}, "\n"),
|
||||
} {
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("%s patch outside the workspace must be rejected", name)
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "old\n" {
|
||||
t.Fatalf("%s patch must not touch a file outside the workspace", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified diffs are applied in-process through the same os.Root engine as
|
||||
// structured patches: git C-quoted paths with whitespace resolve to one file,
|
||||
// and the same header outside the workspace is refused before anything is
|
||||
// written.
|
||||
func TestUnifiedPatchQuotedWhitespacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "work tree")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "file.txt")
|
||||
writeTestFile(t, target, "hello\nold\n")
|
||||
|
||||
quoted := func(path string) string { return strconv.Quote(path) }
|
||||
inside := strings.Join([]string{
|
||||
"diff --git " + quoted("a/"+filepath.ToSlash(target)) + " " + quoted("b/"+filepath.ToSlash(target)),
|
||||
"--- " + quoted(target),
|
||||
"+++ " + quoted(target),
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": inside})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("quoted whitespace path inside the workspace should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ := os.ReadFile(target)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "work tree/file.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
|
||||
outsideDir := filepath.Join(t.TempDir(), "other tree")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(outsideDir, "file.txt")
|
||||
writeTestFile(t, outside, "hello\nold\n")
|
||||
escape := strings.Join([]string{"--- " + quoted(outside), "+++ " + quoted(outside), "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": escape})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatal("quoted whitespace path outside the workspace must be rejected")
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
|
||||
t.Fatal("outside file must be untouched")
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace path that is swapped for a symlink escaping the root — the
|
||||
// classic check-to-use attack — is refused by the os.Root engine for both
|
||||
// formats, and the outside file is never modified.
|
||||
func TestApplyPatchRefusesSymlinkEscapingWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
writeTestFile(t, outside, "hello\nold\n")
|
||||
link := filepath.Join(root, "target.txt")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"unified": strings.Join([]string{"--- a/target.txt", "+++ b/target.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n"),
|
||||
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: target.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("%s patch through an escaping symlink must be refused", name)
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
|
||||
t.Fatalf("%s patch must not modify the file outside the workspace", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchCreatesDeletesAndHonoursNoNewlineMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "bye\n")
|
||||
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\nb\n")
|
||||
patch := strings.Join([]string{
|
||||
"diff --git a/new.txt b/new.txt",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/new.txt",
|
||||
"@@ -0,0 +1,2 @@",
|
||||
"+first",
|
||||
"+second",
|
||||
"\\ No newline at end of file",
|
||||
"diff --git a/gone.txt b/gone.txt",
|
||||
"deleted file mode 100644",
|
||||
"--- a/gone.txt",
|
||||
"+++ /dev/null",
|
||||
"@@ -1 +0,0 @@",
|
||||
"-bye",
|
||||
"--- a/keep.txt",
|
||||
"+++ b/keep.txt",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" a",
|
||||
"-b",
|
||||
"+c",
|
||||
"\\ No newline at end of file",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-file unified patch should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "new.txt")); string(content) != "first\nsecond" {
|
||||
t.Fatalf("created file = %q", string(content))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("deleted file must be gone")
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "a\nc" {
|
||||
t.Fatalf("updated file = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 3 {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchUsesRangeHintThenFallsBackToContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Two identical blocks: the hunk's range picks the second one, which
|
||||
// content search alone would call ambiguous.
|
||||
writeTestFile(t, filepath.Join(root, "dup.txt"), "x\ny\nx\ny\n")
|
||||
patch := strings.Join([]string{"--- a/dup.txt", "+++ b/dup.txt", "@@ -3,2 +3,2 @@", " x", "-y", "+z", ""}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("range-hinted hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "dup.txt")); string(content) != "x\ny\nx\nz\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
// A stale range (file grew above the hunk) still applies by unique context.
|
||||
writeTestFile(t, filepath.Join(root, "moved.txt"), "header\nhello\nold\n")
|
||||
patch = strings.Join([]string{"--- a/moved.txt", "+++ b/moved.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("offset hunk should apply by context, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "moved.txt")); string(content) != "header\nhello\nnew\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchRenameWithHunkAndRejectsBinary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "x.txt"), "hello\nold\n")
|
||||
rename := strings.Join([]string{
|
||||
"diff --git a/x.txt b/y.txt",
|
||||
"similarity index 90%",
|
||||
"rename from x.txt",
|
||||
"rename to y.txt",
|
||||
"--- a/x.txt",
|
||||
"+++ b/y.txt",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": rename})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("rename with hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "x.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("renamed source must be gone")
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "y.txt")); string(content) != "hello\nnew\n" {
|
||||
t.Fatalf("renamed content = %q", string(content))
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"binary": "diff --git a/x b/x\nBinary files a/x and b/x differ\n",
|
||||
"empty": "diff --git a/x b/x\n",
|
||||
} {
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status == StatusOK {
|
||||
t.Fatalf("%s patch must be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchCopyOperation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "src.txt"), "hello\nold\n")
|
||||
copyPatch := func(from, to string, hunk bool) string {
|
||||
lines := []string{"diff --git a/" + from + " b/" + to, "similarity index 90%", "copy from " + from, "copy to " + to}
|
||||
if hunk {
|
||||
lines = append(lines, "--- a/"+from, "+++ b/"+to, "@@ -1,2 +1,2 @@", " hello", "-old", "+new")
|
||||
}
|
||||
return strings.Join(append(lines, ""), "\n")
|
||||
}
|
||||
|
||||
// Success: source kept, destination created with the hunk applied, and the
|
||||
// destination inherits the source's tracker state (read whole -> whole).
|
||||
tracker := NewFileTracker()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedReadFileTool(root, nil))
|
||||
registry.Register(NewScopedApplyPatchTool(root, nil))
|
||||
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "src.txt"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("read: %s", r.Output)
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": copyPatch("src.txt", "dst.txt", true)}, opts)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("copy with hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "src.txt")); string(content) != "hello\nold\n" {
|
||||
t.Fatalf("copy must keep the source, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
|
||||
t.Fatalf("copy destination = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(destination) {
|
||||
t.Fatal("destination copied from a fully read source must be tracked as seen whole")
|
||||
}
|
||||
unreadSource, _ := filepath.EvalSymlinks(filepath.Join(root, "src.txt"))
|
||||
if _, tracked := tracker.Version(unreadSource); !tracked {
|
||||
t.Fatal("source must stay tracked after a copy")
|
||||
}
|
||||
|
||||
// Failure: destination already exists — nothing is written.
|
||||
writeTestFile(t, filepath.Join(root, "taken.txt"), "keep\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "taken.txt", false)})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "already exists") {
|
||||
t.Fatalf("copy onto an existing destination must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "taken.txt")); string(content) != "keep\n" {
|
||||
t.Fatal("existing destination must be untouched")
|
||||
}
|
||||
|
||||
// Failure: copy onto itself.
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "src.txt", false)})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "onto itself") {
|
||||
t.Fatalf("copy onto itself must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchRejectsOverDeclaredHunkCounts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.txt"), "hello\nold\n")
|
||||
writeTestFile(t, filepath.Join(root, "b.txt"), "x\n")
|
||||
// "-1,3 +1,3" over-declares a two-line hunk, so without a guard the next
|
||||
// file's ---/+++ headers would be swallowed as "-"/"+" content.
|
||||
patch := strings.Join([]string{
|
||||
"--- a/a.txt", "+++ b/a.txt", "@@ -1,3 +1,3 @@", " hello", "-old", "+new",
|
||||
"--- a/b.txt", "+++ b/b.txt", "@@ -1 +1 @@", "-x", "+y", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
|
||||
t.Fatalf("over-declared hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for name, want := range map[string]string{"a.txt": "hello\nold\n", "b.txt": "x\n"} {
|
||||
if content, _ := os.ReadFile(filepath.Join(root, name)); string(content) != want {
|
||||
t.Fatalf("%s must be untouched after a malformed patch, got %q", name, string(content))
|
||||
}
|
||||
}
|
||||
// Truncated at end of input is reported the same way.
|
||||
truncated := "--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n hello\n-old\n"
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": truncated}); result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
|
||||
t.Fatalf("truncated hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchNoNewlineMarkerOnlyAfterFinalHunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "two.txt"), "a\nb\nc\nd\ne\nf\n")
|
||||
// A marker after the first of two hunks describes nothing real and must
|
||||
// not leak into the second hunk's result: the patch is rejected untouched.
|
||||
early := strings.Join([]string{
|
||||
"--- a/two.txt", "+++ b/two.txt",
|
||||
"@@ -1,2 +1,2 @@", " a", "-b", "+B", "\\ No newline at end of file",
|
||||
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": early})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "must follow the last hunk") {
|
||||
t.Fatalf("marker before a later hunk must be rejected, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nb\nc\nd\ne\nf\n" {
|
||||
t.Fatal("rejected patch must not modify the file")
|
||||
}
|
||||
// The same marker after the final hunk applies and strips the newline.
|
||||
final := strings.Join([]string{
|
||||
"--- a/two.txt", "+++ b/two.txt",
|
||||
"@@ -1,2 +1,2 @@", " a", "-b", "+B",
|
||||
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "\\ No newline at end of file", "",
|
||||
}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": final})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("two-hunk patch with a final marker should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nB\nc\nd\ne\nF" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// A removed "-- …" line directly followed by an added "++ …" line looks like a
|
||||
// ---/+++ header pair; the boundary check must not mistake it for one.
|
||||
func TestUnifiedPatchKeepsAdjacentDashPlusContentLines(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.sql"), "select 1;\n-- old comment\nselect 2;\n")
|
||||
patch := strings.Join([]string{
|
||||
"--- a/notes.sql", "+++ b/notes.sql",
|
||||
"@@ -1,3 +1,3 @@", " select 1;", "--- old comment", "+++ new comment", " select 2;", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("adjacent -- / ++ content lines must apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "notes.sql")); string(content) != "select 1;\n++ new comment\nselect 2;\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// A unified deletion states the content it removes; it must be verified
|
||||
// against the current file before anything is deleted.
|
||||
func TestUnifiedPatchDeletionVerifiesExpectedContent(t *testing.T) {
|
||||
deletion := func(path string, hunks ...string) string {
|
||||
lines := []string{"diff --git a/" + path + " b/" + path, "deleted file mode 100644", "--- a/" + path, "+++ /dev/null"}
|
||||
return strings.Join(append(append(lines, hunks...), ""), "\n")
|
||||
}
|
||||
t.Run("matching deletion succeeds", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-current")})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("matching deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("stale deletion is refused untouched", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-stale")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
|
||||
t.Fatalf("stale deletion must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "current\n" {
|
||||
t.Fatalf("file must be byte-for-byte unchanged, got %q", string(content))
|
||||
}
|
||||
})
|
||||
t.Run("partial deletion is refused", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "one\ntwo\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-one")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
|
||||
t.Fatalf("deletion that does not cover the file must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "one\ntwo\n" {
|
||||
t.Fatal("file must be unchanged")
|
||||
}
|
||||
})
|
||||
t.Run("header-only deletion is refused", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "must include the hunk") {
|
||||
t.Fatalf("header-only deletion must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); err != nil {
|
||||
t.Fatal("file must still exist")
|
||||
}
|
||||
})
|
||||
t.Run("multi-hunk deletion verifies everything before removing", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\nb\nc\nd\n")
|
||||
good := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-d")
|
||||
bad := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-stale")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": bad})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("deletion with one stale hunk must be refused: %s", result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "a\nb\nc\nd\n" {
|
||||
t.Fatal("no hunk may be applied when any hunk is stale")
|
||||
}
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": good})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-hunk deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("structured delete stays unconditional", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "anything\n")
|
||||
patch := "*** Begin Patch\n*** Delete File: gone.txt\n*** End Patch\n"
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
|
||||
t.Fatalf("structured delete should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The source is re-read through the root immediately before commit, so a
|
||||
// change made after planning is refused instead of overwritten or removed.
|
||||
func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "file.txt")
|
||||
writeTestFile(t, path, "planned\n")
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
target := structuredPatchTarget{requested: "file.txt", absolute: path, relative: "file.txt"}
|
||||
destination := structuredPatchTarget{requested: "copy.txt", absolute: filepath.Join(root, "copy.txt"), relative: "copy.txt"}
|
||||
for name, change := range map[string]structuredPatchChange{
|
||||
"update": {kind: structuredPatchUpdate, from: target, to: target, before: "planned\n", after: "new\n", mode: 0o644},
|
||||
"delete": {kind: structuredPatchDelete, from: target, to: target, before: "planned\n", mode: 0o644},
|
||||
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
|
||||
} {
|
||||
writeTestFile(t, path, "changed after planning\n")
|
||||
committed, err := applyStructuredPatchChange(workspace, change)
|
||||
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
|
||||
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
|
||||
}
|
||||
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
|
||||
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "copy.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s: refused change must not create copy.txt", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchDeletionIsByteExact(t *testing.T) {
|
||||
deletion := func(hunks ...string) string {
|
||||
return strings.Join(append(append([]string{"--- a/gone.txt", "+++ /dev/null"}, hunks...), ""), "\n")
|
||||
}
|
||||
for name, tc := range map[string]struct{ file, patch string }{
|
||||
"trailing whitespace differs": {"current \n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"indentation differs": {" current\n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"blank line not covered": {"current\n\n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"space-only line not covered": {"current\n \n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"missing final newline": {"current", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), tc.file)
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": tc.patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("deletion must be byte-exact; %q was deleted by a non-matching hunk", tc.file)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != tc.file {
|
||||
t.Fatalf("file must be unchanged, got %q", string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no-newline marker on the removed side matches exactly", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current")
|
||||
patch := deletion("@@ -1 +0,0 @@", "-current", "\\ No newline at end of file")
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
|
||||
t.Fatalf("deletion with a matching no-newline marker should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("CRLF file matches an LF patch of the same content", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\r\nb\r\n")
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("@@ -1,2 +0,0 @@", "-a", "-b")}); result.Status != StatusOK {
|
||||
t.Fatalf("CRLF deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// git describes an empty file's creation or deletion with header lines only.
|
||||
func TestUnifiedPatchHeaderOnlyEmptyFileForms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "empty.txt"), "")
|
||||
writeTestFile(t, filepath.Join(root, "full.txt"), "content\n")
|
||||
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\n")
|
||||
deleteHeader := func(path string) string {
|
||||
return "diff --git a/" + path + " b/" + path + "\ndeleted file mode 100644\nindex e69de29..0000000\n"
|
||||
}
|
||||
// A non-empty file is not deleted by the header-only form.
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deleteHeader("full.txt")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "expects an empty file") {
|
||||
t.Fatalf("header-only deletion of a non-empty file must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "full.txt")); err != nil {
|
||||
t.Fatal("non-empty file must still exist")
|
||||
}
|
||||
// In a multi-file patch the empty-file deletion and creation are applied
|
||||
// alongside a normal hunk.
|
||||
patch := deleteHeader("empty.txt") +
|
||||
"diff --git a/blank.txt b/blank.txt\nnew file mode 100644\nindex 0000000..e69de29\n" +
|
||||
"diff --git a/keep.txt b/keep.txt\n--- a/keep.txt\n+++ b/keep.txt\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-file patch with header-only forms should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "empty.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("empty file must be deleted")
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "blank.txt")); err != nil || len(content) != 0 {
|
||||
t.Fatalf("empty file must be created, got err=%v content=%q", err, string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "b\n" {
|
||||
t.Fatalf("hunk file = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 3 {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// When a later change fails, the error names exactly which files were already
|
||||
// committed so the caller knows what changed and what did not.
|
||||
func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "first.txt"), "one\n")
|
||||
writeTestFile(t, filepath.Join(root, "second.txt"), "two\n")
|
||||
writeTestFile(t, filepath.Join(root, "third.txt"), "three\n")
|
||||
// Make second.txt change after planning so its commit is refused.
|
||||
structuredPatchBeforeCommit = func(change structuredPatchChange) {
|
||||
if change.to.relative == "second.txt" {
|
||||
writeTestFile(t, filepath.Join(root, "second.txt"), "changed\n")
|
||||
}
|
||||
}
|
||||
defer func() { structuredPatchBeforeCommit = nil }()
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.txt", "@@", "-one", "+ONE",
|
||||
"*** Update File: second.txt", "@@", "-two", "+TWO",
|
||||
"*** Update File: third.txt", "@@", "-three", "+THREE",
|
||||
"*** End Patch", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatal("patch must fail when a later change is refused")
|
||||
}
|
||||
for _, want := range []string{"already committed: first.txt", "remaining files are unchanged"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("error must report the committed prefix, got: %s", result.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.Output, "committed: first.txt, second.txt") || strings.Contains(result.Output, "third.txt") {
|
||||
t.Fatalf("error must not list uncommitted files as committed: %s", result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "first.txt")); string(content) != "ONE\n" {
|
||||
t.Fatalf("first.txt must hold the committed change, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "second.txt")); string(content) != "changed\n" {
|
||||
t.Fatalf("second.txt must be untouched by the patch, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
|
||||
t.Fatalf("third.txt must be untouched, got %q", string(content))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -168,7 +168,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
|
||||
if updated == modelKnownContent {
|
||||
if previouslySeenWhole {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated))
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
|
||||
} else {
|
||||
for _, span := range editedSpans {
|
||||
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -26,8 +26,8 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"File: notes.txt (lines 2-3 of 4)",
|
||||
"2 | beta",
|
||||
"3 | gamma",
|
||||
"2→beta",
|
||||
"3→gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
@@ -54,7 +54,7 @@ func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
|
||||
if result.Truncated {
|
||||
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
|
||||
@@ -94,7 +94,7 @@ func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
|
||||
if result.Status != StatusOK || result.Truncated {
|
||||
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
|
||||
@@ -955,7 +955,7 @@ func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"end_line 2 was before start_line 3", // the note
|
||||
"only line 3 was read",
|
||||
"3 | gamma",
|
||||
"3→gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in output, got %q", want, result.Output)
|
||||
@@ -984,7 +984,7 @@ func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
|
||||
// Byte-for-byte: header, blank separator, the exact selected lines, and no
|
||||
// recovery note or trailing content. An altered header/separator or a stray
|
||||
// out-of-range line would fail this where a substring check would not.
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2→beta\n3→gamma\n4→delta"
|
||||
if result.Output != want {
|
||||
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package tools
|
||||
|
||||
// ModelVisible reports whether a registered tool is advertised to an agent.
|
||||
// Every registered tool is visible. edit_file (exact search/replace with a
|
||||
// uniqueness guard, CRLF and fuzzy fallbacks) was hidden for a while in favour
|
||||
// of apply_patch; head-to-head benchmarking showed that when a model misses the
|
||||
// patch grammar it degrades to whole-file rewrites, so both edit tools are
|
||||
// advertised and the prompt steers between them.
|
||||
func ModelVisible(tool Tool) bool {
|
||||
return tool != nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestToolSearchSurfacesBothEditTools(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 describe edit_file as available to the model")
|
||||
}
|
||||
if !eager["apply_patch"] {
|
||||
t.Fatal("tool_search must retain apply_patch as an available edit tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelVisibleAdvertisesEveryRegisteredTool(t *testing.T) {
|
||||
if ModelVisible(nil) {
|
||||
t.Fatal("a nil tool must not be visible")
|
||||
}
|
||||
for _, tool := range []Tool{NewScopedEditFileTool(t.TempDir(), nil), NewScopedApplyPatchTool(t.TempDir(), nil), NewScopedWriteFileTool(t.TempDir(), nil)} {
|
||||
if !ModelVisible(tool) {
|
||||
t.Fatalf("%s must be visible to the model", tool.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const readFileByteChunkMax = 64 * 1024
|
||||
|
||||
// readFileLinePrefixSeparator follows the line number on every read_file line.
|
||||
const readFileLinePrefixSeparator = "→"
|
||||
|
||||
type readFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
@@ -31,7 +33,7 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return readFileTool{
|
||||
baseTool: baseTool{
|
||||
name: "read_file",
|
||||
description: "Read exact file text with line numbers. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
|
||||
description: "Read exact file text, each line prefixed with its line number and →. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
@@ -221,7 +223,6 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
}
|
||||
|
||||
lastLine := startLine + selectedLines - 1
|
||||
width := len(strconv.Itoa(lastLine))
|
||||
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
|
||||
if startLine != 1 || endLine != total || maxLines > 0 {
|
||||
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
|
||||
@@ -244,7 +245,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
budgetedOutput.WriteString("\n")
|
||||
}
|
||||
budgetedOutput.WriteString("\n")
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines); err != nil {
|
||||
return errorResult("Error reading file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if truncated {
|
||||
@@ -357,7 +358,7 @@ func renderReadFileBytes(path, relativePath string, total, requestedStart, limit
|
||||
return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end
|
||||
}
|
||||
|
||||
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int, width int) error {
|
||||
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -380,10 +381,11 @@ func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int
|
||||
if emitted > 0 {
|
||||
output.WriteString("\n")
|
||||
}
|
||||
number := strconv.Itoa(lineNumber)
|
||||
output.WriteString(strings.Repeat(" ", width-len(number)))
|
||||
output.WriteString(number)
|
||||
output.WriteString(" | ")
|
||||
// Compact "N→" prefix: the padded " N | " form cost ~19% of every
|
||||
// read (about 1.5k tokens per 800-line file) and was re-sent on each
|
||||
// later call; no consumer parses it, and models read "N→" natively.
|
||||
output.WriteString(strconv.Itoa(lineNumber))
|
||||
output.WriteString(readFileLinePrefixSeparator)
|
||||
output.WriteString(string(trimLineBreak(raw, ended)))
|
||||
emitted++
|
||||
}
|
||||
|
||||
@@ -171,14 +171,9 @@ func selectSourceLines(content []byte, offset, limit int) sourceSelection {
|
||||
}
|
||||
|
||||
func sourceLineCount(content []byte) int {
|
||||
if len(content) == 0 {
|
||||
return 1
|
||||
}
|
||||
lines := strings.Count(string(content), "\n")
|
||||
if content[len(content)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
// One implementation for reader and writer: the tracker contract lives on
|
||||
// trackedLineTotal, and this must never drift from it.
|
||||
return trackedLineTotal(string(content))
|
||||
}
|
||||
|
||||
// lineCount reports the number of newline-separated lines in s (an empty string
|
||||
@@ -189,3 +184,20 @@ func lineCount(s string) int {
|
||||
}
|
||||
return strings.Count(s, "\n") + 1
|
||||
}
|
||||
|
||||
// trackedLineTotal reports a file's line count the way read_file reports it
|
||||
// to the FileTracker (newline-terminated lines, plus an unterminated last
|
||||
// line; an empty file is one line). Writers must record this same number:
|
||||
// RecordSeenRange resets every observation when the total changes, so a
|
||||
// writer that recorded lineCount (one higher for a trailing newline) made the
|
||||
// next partial read_file wipe whole-file knowledge and refuse the next edit.
|
||||
func trackedLineTotal(s string) int {
|
||||
if s == "" {
|
||||
return 1
|
||||
}
|
||||
total := strings.Count(s, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
total++
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -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,959 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/pathjail"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
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
|
||||
// structuredPatchCopy creates movePath from path's content (plus any
|
||||
// hunks) and keeps path; produced by a unified diff's "copy from/to".
|
||||
structuredPatchCopy
|
||||
)
|
||||
|
||||
type structuredPatchOperation struct {
|
||||
kind structuredPatchKind
|
||||
path string
|
||||
movePath string
|
||||
contents string
|
||||
chunks []structuredPatchChunk
|
||||
line int
|
||||
// eofNewline lets a unified diff's "\ No newline at end of file" marker
|
||||
// force the trailing-newline state of the result; structured patches keep
|
||||
// the file's existing state.
|
||||
eofNewline eofNewlineMode
|
||||
// verifyDelete marks a deletion that carries the expected old content
|
||||
// (a unified diff's "+++ /dev/null" hunks, or git's header-only form for
|
||||
// an empty file): the removed lines must equal the current file byte for
|
||||
// byte, otherwise the deletion is stale and refused. A structured
|
||||
// "*** Delete File" means "delete this path" and has no chunks.
|
||||
verifyDelete bool
|
||||
// oldNoNewline records a unified diff's "\ No newline at end of file"
|
||||
// on the removed side, i.e. the old content did not end with a newline.
|
||||
oldNoNewline bool
|
||||
}
|
||||
|
||||
type eofNewlineMode uint8
|
||||
|
||||
const (
|
||||
eofNewlineKeep eofNewlineMode = iota
|
||||
eofNewlinePresent
|
||||
eofNewlineAbsent
|
||||
)
|
||||
|
||||
type structuredPatchChunk struct {
|
||||
context string
|
||||
hasContext bool
|
||||
old []string
|
||||
new []string
|
||||
newSourceOffsets []int
|
||||
endOfFile bool
|
||||
// hint is the 0-based line the hunk is expected at (from a unified diff's
|
||||
// "@@ -a,b" range). When the expected lines match there it is used as-is;
|
||||
// otherwise the hunk is located by content like a structured hunk.
|
||||
hint int
|
||||
hasHint 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
|
||||
}
|
||||
|
||||
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
|
||||
// optionally followed by "@@ heading") written inside a structured hunk marker.
|
||||
// The line numbers carry no information for the context-anchored grammar, so
|
||||
// the range is dropped and only an explicit heading is kept as the anchor.
|
||||
var unifiedHunkRangePattern = regexp.MustCompile(`^-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*(?:@@\s*(.*))?$`)
|
||||
|
||||
const structuredPatchFormatHint = ` (format: "*** Begin Patch", then "*** Update File: path" / "*** Add File: path" / "*** Delete File: path" sections whose hunks start with "@@" and use " " context, "-" removed and "+" added lines, then "*** End Patch")`
|
||||
|
||||
// structuredPatchMarker classifies a line as the "begin" or "end" marker of a
|
||||
// structured patch, or "" when it is neither. The classifier is owned by the
|
||||
// sandbox package so the boundary check and the tool accept exactly the same
|
||||
// spellings; a strict byte-equal match here once made every patch from some
|
||||
// models fail on line 1 and pushed them into whole-file rewrites.
|
||||
func structuredPatchMarker(line string) string {
|
||||
return sandbox.StructuredPatchMarker(line)
|
||||
}
|
||||
|
||||
// structuredHunkAnchor normalises the text after a hunk's "@@ " marker. A
|
||||
// unified-diff range is dropped (its optional heading survives as the anchor);
|
||||
// anything else is used verbatim.
|
||||
func structuredHunkAnchor(context string) string {
|
||||
context = strings.TrimSpace(context)
|
||||
if match := unifiedHunkRangePattern.FindStringSubmatch(context); match != nil {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func isStructuredPatch(patch string) bool {
|
||||
return sandbox.IsStructuredPatch(patch)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
|
||||
}
|
||||
|
||||
// applyPatchOperations applies parsed operations (from either patch format)
|
||||
// through an opened workspace root: every stat, read, create and write is
|
||||
// descriptor-relative and refuses to follow a link out of the root, so there
|
||||
// is no pathname check-to-use window between validation and write.
|
||||
func applyPatchOperations(applyRoot, relativeRoot string, operations []structuredPatchOperation, options RunOptions) Result {
|
||||
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 || change.kind == structuredPatchCopy {
|
||||
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)
|
||||
case structuredPatchCopy:
|
||||
// The source is untouched; the destination inherits only what the
|
||||
// model had actually seen of the source.
|
||||
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, wholeBefore[change.from.absolute])
|
||||
}
|
||||
}
|
||||
|
||||
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 structuredPatchMarker(lines[0]) != "begin" {
|
||||
return nil, fmt.Errorf("the first line of a structured patch must be %q%s", structuredPatchBegin, structuredPatchFormatHint)
|
||||
}
|
||||
if structuredPatchMarker(lines[len(lines)-1]) != "end" {
|
||||
return nil, fmt.Errorf("the last line of a structured patch must be %q%s", structuredPatchEnd, structuredPatchFormatHint)
|
||||
}
|
||||
|
||||
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{}
|
||||
rest := ""
|
||||
if trimmed != "@@" {
|
||||
rest = strings.TrimPrefix(trimmed, structuredHunkContext)
|
||||
}
|
||||
if anchor := structuredHunkAnchor(rest); anchor != "" {
|
||||
chunk.context = anchor
|
||||
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 structuredPatchMarker(line) == "end" || 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, structuredPatchCopy:
|
||||
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 == structuredPatchDelete && operation.verifyDelete {
|
||||
if err := verifyUnifiedDeletion(change.before, from.relative, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if operation.kind == structuredPatchUpdate || operation.kind == structuredPatchCopy {
|
||||
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change.after = applyEOFNewline(updated, operation.eofNewline)
|
||||
if operation.kind == structuredPatchCopy && from.absolute == to.absolute {
|
||||
return nil, fmt.Errorf("cannot copy %s onto itself", from.relative)
|
||||
}
|
||||
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) {
|
||||
// Relative traversal is rejected outright. An absolute path is allowed when
|
||||
// it resolves inside the apply root (models routinely echo the absolute path
|
||||
// they were shown by read_file); resolveWorkspaceTargetPath rejects anything
|
||||
// that lands outside.
|
||||
if path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
|
||||
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
absolute, relative, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(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. A unified diff names the
|
||||
// insertion point in its range instead; a zero-context hunk (as
|
||||
// from `git diff -U0`) has nothing to verify the position against,
|
||||
// so it is position-trusting by design — the same semantics git
|
||||
// apply uses without fuzz. Hunks that carry context are verified.
|
||||
start := lineIndex
|
||||
if chunk.hasHint {
|
||||
start = max(chunk.hint, lineIndex)
|
||||
} else 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
|
||||
}
|
||||
var index int
|
||||
ambiguous := false
|
||||
if chunk.hasHint && chunk.hint >= lineIndex && sequenceMatchesAt(lines, chunk.old, chunk.hint) {
|
||||
index = chunk.hint
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// verifyUnifiedDeletion checks, byte for byte, that the lines a unified
|
||||
// deletion removes are exactly the file's current content. A deletion is not
|
||||
// recoverable, so none of the whitespace tolerance used to locate update
|
||||
// hunks applies here; only the file's own line-ending style is normalised.
|
||||
// With no hunks (git's header-only form) the file must be empty.
|
||||
func verifyUnifiedDeletion(current, path string, operation structuredPatchOperation) error {
|
||||
var removed []string
|
||||
for _, chunk := range operation.chunks {
|
||||
if len(chunk.new) > 0 {
|
||||
return fmt.Errorf("deletion of %s must not add lines", path)
|
||||
}
|
||||
removed = append(removed, chunk.old...)
|
||||
}
|
||||
expected := strings.Join(removed, "\n")
|
||||
if len(removed) > 0 && !operation.oldNoNewline {
|
||||
expected += "\n"
|
||||
}
|
||||
actual := current
|
||||
if structuredPatchLineEnding(current) == "\r\n" {
|
||||
actual = strings.ReplaceAll(current, "\r\n", "\n")
|
||||
}
|
||||
if actual != expected {
|
||||
if len(removed) == 0 {
|
||||
return fmt.Errorf("deletion of %s expects an empty file but it has content; include the removed lines in the patch", path)
|
||||
}
|
||||
return fmt.Errorf("deletion of %s does not match its current content; the removed lines must equal the whole file", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sequenceMatchesAt reports whether wanted appears verbatim at lines[start:].
|
||||
func sequenceMatchesAt(lines, wanted []string, start int) bool {
|
||||
if start < 0 || start+len(wanted) > len(lines) {
|
||||
return false
|
||||
}
|
||||
for offset, line := range wanted {
|
||||
if lines[start+offset] != line {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyEOFNewline forces or strips a single trailing line ending when a
|
||||
// unified diff's end-of-file marker asked for it.
|
||||
func applyEOFNewline(content string, mode eofNewlineMode) string {
|
||||
switch mode {
|
||||
case eofNewlinePresent:
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
return content + structuredPatchLineEnding(content)
|
||||
}
|
||||
case eofNewlineAbsent:
|
||||
content = strings.TrimSuffix(content, "\n")
|
||||
content = strings.TrimSuffix(content, "\r")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
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 {
|
||||
// committed lists, in order, the paths whose change reached disk before a
|
||||
// later change failed, so the caller (and the model) knows exactly which
|
||||
// files now hold the patched content and which were never touched.
|
||||
var committed []string
|
||||
for _, change := range changes {
|
||||
done, err := applyStructuredPatchChange(root, change)
|
||||
if done {
|
||||
committed = append(committed, structuredPatchChangePaths(change)...)
|
||||
}
|
||||
if err != nil {
|
||||
forgetStructuredPatchFiles(tracker, changes)
|
||||
if len(committed) > 0 {
|
||||
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// structuredPatchChangePaths names the workspace-relative paths a committed
|
||||
// change touched: the destination, plus the source of a move or copy.
|
||||
func structuredPatchChangePaths(change structuredPatchChange) []string {
|
||||
if change.kind == structuredPatchDelete {
|
||||
return []string{change.from.relative}
|
||||
}
|
||||
paths := []string{change.to.relative}
|
||||
if change.from.absolute != change.to.absolute && change.from.relative != "" {
|
||||
paths = append([]string{change.from.relative}, paths...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// structuredPatchBeforeCommit, when set, runs before each change's pre-commit
|
||||
// recheck. Tests use it to alter the filesystem between planning and commit
|
||||
// deterministically; it is nil in production.
|
||||
var structuredPatchBeforeCommit func(change structuredPatchChange)
|
||||
|
||||
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
|
||||
if structuredPatchBeforeCommit != nil {
|
||||
structuredPatchBeforeCommit(change)
|
||||
}
|
||||
// The planner validated the source against the bytes it read; re-read
|
||||
// through the same root immediately before committing so a change made
|
||||
// by another process in between is refused rather than overwritten or
|
||||
// removed.
|
||||
if change.kind != structuredPatchAdd {
|
||||
current, err := root.ReadFile(change.from.relative)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
|
||||
}
|
||||
if string(current) != change.before {
|
||||
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
|
||||
}
|
||||
}
|
||||
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, structuredPatchCopy:
|
||||
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 := trackedLineTotal(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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrackedLineTotalMatchesReadFileStats(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{"trailing": "a\nb\nc\n", "unterminated": "a\nb\nc", "single": "x\n", "empty": "", "crlf": "a\r\nb\r\n"} {
|
||||
path := filepath.Join(root, name+".txt")
|
||||
writeTestFile(t, path, content)
|
||||
stats, err := scanReadFileStats(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := trackedLineTotal(content); got != stats.lines {
|
||||
t.Fatalf("%s: trackedLineTotal = %d, read_file reports %d", name, got, stats.lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A whole-file read, an edit, then a partial re-read must keep the file "seen
|
||||
// whole": the writer and reader now agree on the line total, so the partial
|
||||
// read accumulates instead of resetting and a later edit far from the re-read
|
||||
// window is not refused.
|
||||
func TestEditThenPartialReadKeepsWholeFileObservation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "notes.txt")
|
||||
content := ""
|
||||
for i := 1; i <= 40; i++ {
|
||||
content += fmt.Sprintf("line %02d\n", i)
|
||||
}
|
||||
writeTestFile(t, path, content)
|
||||
tracker := NewFileTracker()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedReadFileTool(root, nil))
|
||||
registry.Register(NewScopedEditFileTool(root, nil))
|
||||
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
|
||||
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("read: %s", r.Output)
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 01\nline 02\n", "new_string": "line 01\nline two\n"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("first edit: %s", r.Output)
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt", "limit": 5}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("partial read: %s", r.Output)
|
||||
}
|
||||
absolute, _ := filepath.EvalSymlinks(path)
|
||||
if !tracker.SeenWhole(absolute) {
|
||||
t.Fatal("partial re-read of an unchanged file must not discard whole-file knowledge")
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 39\nline 40\n", "new_string": "line 39\nline forty\n"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("edit far from the re-read window must succeed: %s", r.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseUnifiedPatch converts a unified diff into the same operations a
|
||||
// structured patch produces, so both formats are applied by Zero itself
|
||||
// through an opened workspace root (descriptor-relative, no-follow writes)
|
||||
// instead of handing pathnames to git apply after validation.
|
||||
//
|
||||
// Supported: file modifications, creations (--- /dev/null) and deletions
|
||||
// (+++ /dev/null, verified against the content the hunks expect to remove),
|
||||
// multiple hunks per file, "\ No newline at end of file"
|
||||
// markers, a/ and b/ prefixes, git C-quoted paths, CRLF patches, and git's
|
||||
// "rename from/to" and "copy from/to" headers (with or without hunks). Headers
|
||||
// git emits around a hunk (diff --git, index, mode lines) are skipped; binary
|
||||
// patches are rejected.
|
||||
func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
normalized := strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
// The element after a final "\n" is the line terminator, not an empty
|
||||
// context line; counting it would let a truncated hunk look complete.
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
var operations []structuredPatchOperation
|
||||
var current *structuredPatchOperation
|
||||
var chunk *structuredPatchChunk
|
||||
var added []string // collected "+" lines for a file creation
|
||||
oldPath, newPath := "", ""
|
||||
pendingFrom, pendingKind := "", structuredPatchUpdate
|
||||
// git's header-only forms: "deleted file mode" / "new file mode" after a
|
||||
// "diff --git" line with no ---/+++ pair describe an empty file.
|
||||
diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new)
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
inHunk := false
|
||||
lastSide := byte(0) // '+' or '-' for the most recent content line
|
||||
|
||||
finish := func() error {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
switch current.kind {
|
||||
case structuredPatchAdd:
|
||||
if len(added) > 0 {
|
||||
current.contents = strings.Join(added, "\n")
|
||||
if current.eofNewline != eofNewlineAbsent {
|
||||
current.contents += "\n"
|
||||
}
|
||||
}
|
||||
case structuredPatchUpdate, structuredPatchCopy:
|
||||
if !allStructuredPatchChunksHaveContent(current.chunks) {
|
||||
return fmt.Errorf("unified diff for %s has an empty hunk", current.path)
|
||||
}
|
||||
if len(current.chunks) == 0 && current.movePath == "" {
|
||||
return fmt.Errorf("unified diff for %s has no hunk lines", current.path)
|
||||
}
|
||||
case structuredPatchDelete:
|
||||
if len(current.chunks) == 0 || !allStructuredPatchChunksHaveContent(current.chunks) {
|
||||
return fmt.Errorf("unified deletion of %s must include the hunk with the content being removed", current.path)
|
||||
}
|
||||
}
|
||||
operations = append(operations, *current)
|
||||
current, chunk, added = nil, nil, nil
|
||||
return nil
|
||||
}
|
||||
flushHeaderOnly := func(line int) error {
|
||||
if headerOnly == 0 {
|
||||
return nil
|
||||
}
|
||||
if diffPath == "" {
|
||||
return fmt.Errorf("invalid unified diff at line %d: file mode header without a diff --git path", line)
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch headerOnly {
|
||||
case 'd':
|
||||
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: diffPath, line: line, verifyDelete: true})
|
||||
case 'n':
|
||||
operations = append(operations, structuredPatchOperation{kind: structuredPatchAdd, path: diffPath, line: line})
|
||||
}
|
||||
headerOnly, diffPath = 0, ""
|
||||
return nil
|
||||
}
|
||||
startFile := func(line int) error {
|
||||
// A ---/+++ pair means the file has hunks; it is not header-only.
|
||||
headerOnly, diffPath = 0, ""
|
||||
if oldPath == "" || newPath == "" {
|
||||
return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line)
|
||||
}
|
||||
// A ---/+++ pair after a rename/copy header names the same files; keep
|
||||
// accumulating that operation's hunks instead of starting a new one.
|
||||
if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) {
|
||||
return nil
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
op := structuredPatchOperation{line: line}
|
||||
switch {
|
||||
case oldPath == "/dev/null" && newPath == "/dev/null":
|
||||
return fmt.Errorf("invalid unified diff at line %d: both sides are /dev/null", line)
|
||||
case oldPath == "/dev/null":
|
||||
op.kind, op.path = structuredPatchAdd, newPath
|
||||
case newPath == "/dev/null":
|
||||
// A unified deletion states the content it expects to remove; keep
|
||||
// its hunks so the planner verifies them before deleting.
|
||||
op.kind, op.path, op.verifyDelete = structuredPatchDelete, oldPath, true
|
||||
default:
|
||||
op.kind, op.path = structuredPatchUpdate, oldPath
|
||||
if newPath != oldPath {
|
||||
op.movePath = newPath
|
||||
}
|
||||
}
|
||||
current = &op
|
||||
return nil
|
||||
}
|
||||
|
||||
for index, raw := range lines {
|
||||
lineNumber := index + 1
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
// A "--- " line followed by "+++ " and then a "@@" hunk header is
|
||||
// the next file's header pair, not a removed "-- x" line followed by
|
||||
// an added "++ y" line: an over-declared count would otherwise
|
||||
// swallow both and the hunk would later fail against the wrong
|
||||
// file. The "@@" requirement keeps a genuine adjacent pair intact.
|
||||
if strings.HasPrefix(raw, "--- ") && index+2 < len(lines) && strings.HasPrefix(lines[index+1], "+++ ") && strings.HasPrefix(lines[index+2], "@@") {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "\\"):
|
||||
// "\ No newline at end of file" qualifies the previous line's side.
|
||||
if current != nil && lastSide == '+' {
|
||||
current.eofNewline = eofNewlineAbsent
|
||||
} else if current != nil && lastSide == '-' {
|
||||
current.oldNoNewline = true
|
||||
if current.eofNewline == eofNewlineKeep {
|
||||
current.eofNewline = eofNewlinePresent
|
||||
}
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(raw, "-"):
|
||||
oldRemaining--
|
||||
lastSide = '-'
|
||||
if chunk != nil {
|
||||
chunk.old = append(chunk.old, raw[1:])
|
||||
}
|
||||
case strings.HasPrefix(raw, "+"):
|
||||
newRemaining--
|
||||
lastSide = '+'
|
||||
if current != nil && current.kind == structuredPatchAdd {
|
||||
added = append(added, raw[1:])
|
||||
} else if chunk != nil {
|
||||
chunk.new = append(chunk.new, raw[1:])
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
|
||||
}
|
||||
default:
|
||||
content := raw
|
||||
if strings.HasPrefix(raw, " ") {
|
||||
content = raw[1:]
|
||||
} else if raw != "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk lines must start with ' ', '+', '-' or '\\'", lineNumber)
|
||||
}
|
||||
oldRemaining--
|
||||
newRemaining--
|
||||
lastSide = ' '
|
||||
if chunk != nil {
|
||||
offset := len(chunk.old)
|
||||
chunk.old = append(chunk.old, content)
|
||||
chunk.new = append(chunk.new, content)
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, offset)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
inHunk = false
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if oldRemaining > 0 || newRemaining > 0 {
|
||||
// The hunk ended before its declared counts were consumed. Report
|
||||
// it here rather than absorbing the next file's ---/+++ headers as
|
||||
// "-"/"+" content and failing later against the wrong file.
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
||||
}
|
||||
switch {
|
||||
case trimmed == "":
|
||||
continue
|
||||
case strings.HasPrefix(raw, "\\"):
|
||||
// "\ No newline at end of file" directly after a hunk's last line.
|
||||
if current != nil && lastSide == '+' {
|
||||
current.eofNewline = eofNewlineAbsent
|
||||
} else if current != nil && lastSide == '-' {
|
||||
current.oldNoNewline = true
|
||||
if current.eofNewline == eofNewlineKeep {
|
||||
current.eofNewline = eofNewlinePresent
|
||||
}
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(raw, "diff --git "):
|
||||
if err := flushHeaderOnly(lineNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diffPath = diffGitNewPath(raw)
|
||||
continue
|
||||
case strings.HasPrefix(raw, "deleted file mode "):
|
||||
headerOnly = 'd'
|
||||
continue
|
||||
case strings.HasPrefix(raw, "new file mode "):
|
||||
headerOnly = 'n'
|
||||
continue
|
||||
case strings.HasPrefix(raw, "index "), strings.HasPrefix(raw, "old mode "), strings.HasPrefix(raw, "new mode "):
|
||||
continue
|
||||
case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "):
|
||||
continue
|
||||
case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "):
|
||||
pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")))
|
||||
pendingKind = structuredPatchUpdate
|
||||
if strings.HasPrefix(raw, "copy from ") {
|
||||
pendingKind = structuredPatchCopy
|
||||
}
|
||||
if pendingFrom == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber)
|
||||
}
|
||||
case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "):
|
||||
to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")))
|
||||
if pendingFrom == "" || to == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber)
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber}
|
||||
oldPath, newPath, pendingFrom = pendingFrom, to, ""
|
||||
case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"):
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber)
|
||||
case strings.HasPrefix(raw, "--- "):
|
||||
oldPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
||||
newPath = ""
|
||||
if oldPath == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber)
|
||||
}
|
||||
case strings.HasPrefix(raw, "+++ "):
|
||||
newPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
||||
if newPath == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber)
|
||||
}
|
||||
if err := startFile(lineNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case strings.HasPrefix(raw, "@@"):
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", lineNumber)
|
||||
}
|
||||
oldStart, oldCount, newCount, ok := parseHunkRange(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: malformed hunk header", lineNumber)
|
||||
}
|
||||
// "\ No newline at end of file" describes the end of the file, so it
|
||||
// may only follow a file's final hunk; a marker before another hunk
|
||||
// would otherwise leak into that later hunk's result.
|
||||
if current.eofNewline != eofNewlineKeep {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: \"\\ No newline at end of file\" must follow the last hunk of a file", lineNumber)
|
||||
}
|
||||
oldRemaining, newRemaining = oldCount, newCount
|
||||
inHunk = oldRemaining > 0 || newRemaining > 0
|
||||
lastSide = 0
|
||||
if current.kind != structuredPatchAdd {
|
||||
next := structuredPatchChunk{hasHint: true, hint: oldStart - 1}
|
||||
if oldCount == 0 {
|
||||
// A pure insertion's range names the line after which the
|
||||
// new lines go, so the insertion index is oldStart itself.
|
||||
next.hint = oldStart
|
||||
}
|
||||
if next.hint < 0 {
|
||||
next.hint = 0
|
||||
}
|
||||
current.chunks = append(current.chunks, next)
|
||||
chunk = ¤t.chunks[len(current.chunks)-1]
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: unexpected %q", lineNumber, trimmed)
|
||||
}
|
||||
}
|
||||
// Strict at end of input on purpose: a hunk cut short (typically a model
|
||||
// response that stopped mid-patch) would otherwise apply as a partial
|
||||
// change — removals without their replacements — which is worse than a
|
||||
// clear error and a retry. Miscounted ranges with complete content are
|
||||
// still tolerated at every other boundary.
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", len(lines))
|
||||
}
|
||||
if err := flushHeaderOnly(len(lines)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(operations) == 0 {
|
||||
return nil, fmt.Errorf("unified diff contains no file changes")
|
||||
}
|
||||
return operations, nil
|
||||
}
|
||||
|
||||
// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y"
|
||||
// line, handling git's C-quoted form; "" when it cannot be determined.
|
||||
func diffGitNewPath(line string) string {
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
// Two quoted tokens: skip the first, unquote the second.
|
||||
end := strings.Index(rest[1:], "\"")
|
||||
for end > 0 && rest[end] == '\\' {
|
||||
next := strings.Index(rest[end+2:], "\"")
|
||||
if next < 0 {
|
||||
return ""
|
||||
}
|
||||
end += next + 2
|
||||
}
|
||||
if end < 0 || end+2 > len(rest) {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[end+2:])
|
||||
return stripPatchPrefix(unquoteGitPath(rest))
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 2 {
|
||||
return ""
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
if strings.HasPrefix(last, "\"") {
|
||||
return stripPatchPrefix(unquoteGitPath(last))
|
||||
}
|
||||
return stripPatchPrefix(last)
|
||||
}
|
||||
|
||||
// parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing
|
||||
// count means 1 per unified-diff convention.
|
||||
func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) {
|
||||
_, rest, found := strings.Cut(line, "@@")
|
||||
if !found {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
rangeSection := rest
|
||||
if before, _, found := strings.Cut(rest, "@@"); found {
|
||||
rangeSection = before
|
||||
}
|
||||
fields := strings.Fields(rangeSection)
|
||||
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
parse := func(spec string) (int, int, bool) {
|
||||
startText, countText, hasCount := strings.Cut(spec, ",")
|
||||
start, err := parseNonNegativeInt(startText)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
count := 1
|
||||
if hasCount {
|
||||
if count, err = parseNonNegativeInt(countText); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
return start, count, true
|
||||
}
|
||||
oldStart, oldCount, ok = parse(fields[0][1:])
|
||||
if !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
_, newCount, ok = parse(fields[1][1:])
|
||||
if !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return oldStart, oldCount, newCount, true
|
||||
}
|
||||
|
||||
func parseNonNegativeInt(text string) (int, error) {
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("empty number")
|
||||
}
|
||||
value := 0
|
||||
for _, r := range text {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, fmt.Errorf("not a number: %q", text)
|
||||
}
|
||||
value = value*10 + int(r-'0')
|
||||
if value > 1<<30 {
|
||||
return 0, fmt.Errorf("number too large: %q", text)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
if content == modelKnownContent {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
|
||||
}
|
||||
if !existed {
|
||||
options.FileTracker.RecordCreated(absolutePath)
|
||||
|
||||
@@ -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,547 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// A move whose source vanishes after planning is refused by the pre-commit
|
||||
// recheck before the destination is published, so nothing is left half-done.
|
||||
func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(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")
|
||||
writeTestFile(t, sourcePath, "removed source\n")
|
||||
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,
|
||||
}
|
||||
// The source exists at planning time and disappears just before commit.
|
||||
removed := false
|
||||
structuredPatchBeforeCommit = func(structuredPatchChange) {
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed = true
|
||||
}
|
||||
defer func() { structuredPatchBeforeCommit = nil }()
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
if !removed {
|
||||
t.Fatal("pre-commit hook did not run")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "before commit") || strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("move with a missing source = %v, want a pre-commit refusal with nothing applied", err)
|
||||
}
|
||||
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed move recreated a missing source: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(destinationPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("refused move must not publish the destination: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user