mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c427052f9b | ||
|
|
fce9569362 | ||
|
|
3259ea3f6a | ||
|
|
f6e298ad20 | ||
|
|
15ddc79e75 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd |
No files matched your search
@@ -37,8 +37,13 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-skills
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Termux/Android build artifacts
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
@@ -47,6 +47,12 @@ Tips:
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
### Personal guidelines, across every project
|
||||
|
||||
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||
|
||||
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
@@ -307,6 +313,7 @@ A team that wants every contributor's Zero to behave the same way commits:
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
@@ -173,6 +173,61 @@ Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
### Termux (Android)
|
||||
|
||||
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
|
||||
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
|
||||
seccomp filter on Android:
|
||||
|
||||
```bash
|
||||
# Install Go in Termux
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||
|
||||
# Move into PATH
|
||||
mv zero ~/.local/bin/
|
||||
```
|
||||
|
||||
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
|
||||
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
|
||||
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
|
||||
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
|
||||
|
||||
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
|
||||
needs one. Use `proot` to bind-mount Termux's resolver config:
|
||||
|
||||
```bash
|
||||
pkg install proot
|
||||
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
|
||||
```
|
||||
|
||||
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
|
||||
|
||||
```bash
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
|
||||
```
|
||||
|
||||
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
|
||||
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
|
||||
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
|
||||
to avoid PRoot's ptrace interference with the 1003 escape sequence.
|
||||
|
||||
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
|
||||
example, to use OpenCode Zen's free tier:
|
||||
|
||||
```bash
|
||||
zero providers add opencode \
|
||||
--name opencode \
|
||||
--model deepseek-v4-flash-free \
|
||||
--base-url https://opencode.ai/zen/v1 \
|
||||
--set-active
|
||||
```
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
@@ -41,6 +41,7 @@ events when present.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
@@ -51,6 +52,10 @@ events when present.
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||
stream them separately from answer text. They are liveness/progress events only:
|
||||
they are not folded into `text` or the final answer.
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
// slow or wedged language server can never hang a tool call; on timeout the
|
||||
// edit simply reports without a diagnostics block.
|
||||
const fileDiagnosticsTimeout = 10 * time.Second
|
||||
|
||||
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||
// checks it against the file's language server, and formats error-severity
|
||||
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||
// style on every edit is noise, while a type error the edit just introduced is
|
||||
// exactly what the model should see before its next step. Diagnostics are
|
||||
// rendered with workspace-relative paths: the absolute path would put the
|
||||
// local username/home directory into the model prompt and session transcript
|
||||
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
|
||||
// entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, absPath string) string {
|
||||
text, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||
defer cancel()
|
||||
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticsDisplayPath renders absPath relative to the workspace root for
|
||||
// model-facing output, falling back to the file's base name when the path is
|
||||
// outside the workspace (a bare name still identifies the file without
|
||||
// exposing the directory layout).
|
||||
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
|
||||
if workspaceRoot != "" {
|
||||
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return filepath.Base(absPath)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Diagnostics are model-facing: absolute paths would leak the local username
|
||||
// and directory layout into the prompt and session transcript on every edit.
|
||||
func TestDiagnosticsDisplayPath(t *testing.T) {
|
||||
root := filepath.Join("/Users", "someone", "project")
|
||||
cases := []struct {
|
||||
root, abs, want string
|
||||
}{
|
||||
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
|
||||
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
|
||||
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
|
||||
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,8 +429,9 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||
hasToolCalls := len(collected.ToolCalls) > 0
|
||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||
|
||||
if hasToolCalls || hasVisibleText {
|
||||
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
|
||||
@@ -22,6 +22,14 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTurn produces live reasoning without visible assistant text.
|
||||
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -101,6 +109,31 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
|
||||
+32
-1
@@ -173,6 +173,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
@@ -208,6 +209,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
@@ -268,6 +270,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
@@ -326,6 +329,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
@@ -495,11 +499,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// between tool_results breaks strict provider replay) — same after-batch
|
||||
// rationale as turnRequestedModel above.
|
||||
var changedFilesThisBatch []string
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
precomputedStart, precomputedEnd := 0, 0
|
||||
for index, call := range collected.ToolCalls {
|
||||
// When this call starts a consecutive run of >= 2 auto-allowed read-only
|
||||
// calls, execute the whole run concurrently now (see parallel_tools.go).
|
||||
// The scan happens lazily at the run's first index — never ahead of a
|
||||
// pending mutating call — so read-after-write ordering is preserved.
|
||||
if index >= precomputedEnd {
|
||||
runEnd := index
|
||||
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||||
runEnd++
|
||||
}
|
||||
if runEnd-index >= 2 {
|
||||
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||||
precomputedStart, precomputedEnd = index, runEnd
|
||||
}
|
||||
}
|
||||
if options.OnToolCall != nil {
|
||||
options.OnToolCall(call)
|
||||
}
|
||||
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
var toolResult ToolResult
|
||||
var abortErr error
|
||||
if index >= precomputedStart && index < precomputedEnd {
|
||||
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
|
||||
} else {
|
||||
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
}
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -681,6 +709,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
@@ -1071,6 +1100,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
// Inline post-edit diagnostics for mutating tools (nil = disabled).
|
||||
Diagnostics: options.FileDiagnostics,
|
||||
// Forward the run's operator tool filters so a filter-aware tool
|
||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||
EnabledTools: options.EnabledTools,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||
// them one after another serializes pure I/O waits. A consecutive run of
|
||||
// auto-allowed read-only calls is executed concurrently instead; results are
|
||||
// then consumed in the original call order, so guard counters, message
|
||||
// ordering, abort semantics, and the surface's call/result event pairing are
|
||||
// byte-identical to sequential execution. Runs never span a mutating call: a
|
||||
// read that follows a write must observe the write, so eligibility is decided
|
||||
// per consecutive run, not per batch.
|
||||
|
||||
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
||||
const maxParallelReadTools = 8
|
||||
|
||||
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
||||
// its batch index by the caller.
|
||||
type precomputedToolResult struct {
|
||||
result ToolResult
|
||||
abortErr error
|
||||
}
|
||||
|
||||
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
||||
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
||||
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
||||
// and tool_search (mutates the deferred-tool set) stay sequential.
|
||||
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
||||
switch call.Name {
|
||||
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
||||
return false
|
||||
}
|
||||
tool, found := registry.Get(call.Name)
|
||||
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
||||
return false
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return effectivePermission(tool, args) == tools.PermissionAllow
|
||||
}
|
||||
|
||||
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
||||
// maxParallelReadTools) and returns results indexed relative to start. All
|
||||
// execution-side callbacks that can fire inside executeToolCall are serialized
|
||||
// behind one mutex: a permission prompt (a sandbox preflight can demand one
|
||||
// even for an auto-allowed read) must never appear twice at once on an
|
||||
// interactive front-end, and OnPermission event handlers append to shared
|
||||
// session-recording state without their own locking — two batched reads under
|
||||
// a granted extra root would otherwise race (the pre-batch serial loop never
|
||||
// had two callbacks in flight at once).
|
||||
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
||||
batchOptions := options
|
||||
var callbackMutex sync.Mutex
|
||||
if options.OnPermissionRequest != nil {
|
||||
inner := options.OnPermissionRequest
|
||||
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
return inner(ctx, request)
|
||||
}
|
||||
}
|
||||
if options.OnPermission != nil {
|
||||
inner := options.OnPermission
|
||||
batchOptions.OnPermission = func(event PermissionEvent) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
inner(event)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]precomputedToolResult, end-start)
|
||||
semaphore := make(chan struct{}, maxParallelReadTools)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := start; index < end; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
||||
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
||||
}(index)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// probeTool records execution overlap and ordering so tests can assert what
|
||||
// actually ran concurrently.
|
||||
type probeTool struct {
|
||||
name string
|
||||
sideEffect tools.SideEffect
|
||||
delay time.Duration
|
||||
// shared, when set, receives this probe's start/end entries too, so a test
|
||||
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
|
||||
shared *probeLog
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
log []string
|
||||
}
|
||||
|
||||
// probeLog is a mutex-guarded event log shared between probes.
|
||||
type probeLog struct {
|
||||
mu sync.Mutex
|
||||
entries []string
|
||||
}
|
||||
|
||||
func (log *probeLog) append(entry string) {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
log.entries = append(log.entries, entry)
|
||||
}
|
||||
|
||||
func (log *probeLog) snapshot() []string {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
return append([]string(nil), log.entries...)
|
||||
}
|
||||
|
||||
func (tool *probeTool) Name() string { return tool.name }
|
||||
func (tool *probeTool) Description() string { return "test probe tool" }
|
||||
func (tool *probeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *probeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
id, _ := args["id"].(string)
|
||||
tool.mu.Lock()
|
||||
tool.active++
|
||||
if tool.active > tool.maxActive {
|
||||
tool.maxActive = tool.active
|
||||
}
|
||||
tool.log = append(tool.log, "start:"+id)
|
||||
tool.mu.Unlock()
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("start:" + id)
|
||||
}
|
||||
time.Sleep(tool.delay)
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("end:" + id)
|
||||
}
|
||||
tool.mu.Lock()
|
||||
tool.active--
|
||||
tool.log = append(tool.log, "end:"+id)
|
||||
tool.mu.Unlock()
|
||||
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
||||
}
|
||||
|
||||
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSafeToolCall(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||
|
||||
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
||||
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("auto-allowed read tool must be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("mutating tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
||||
t.Fatal("unknown tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
||||
t.Fatal("undecodable arguments must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||
t.Fatal("loop-intercepted tools must stay sequential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(probe)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
var results []ToolResult
|
||||
_, err := Run(context.Background(), "probe", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe.maxActive < 2 {
|
||||
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
||||
}
|
||||
// Results must still be recorded in original call order.
|
||||
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
||||
t.Fatalf("tool results out of order: %#v", results)
|
||||
}
|
||||
messages := provider.requests[1].Messages
|
||||
var toolOrder []string
|
||||
for _, message := range messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolOrder = append(toolOrder, message.ToolCallID)
|
||||
}
|
||||
}
|
||||
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
||||
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||
shared := &probeLog{}
|
||||
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
|
||||
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(read)
|
||||
registry.Register(write)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cross-probe ordering on the SHARED log: the write must start only after
|
||||
// both first-batch reads finished, and both second-batch reads must start
|
||||
// only after the write finished — batches never cross a mutating call.
|
||||
log := shared.snapshot()
|
||||
index := func(entry string) int {
|
||||
for i, e := range log {
|
||||
if e == entry {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("entry %q missing from shared log: %v", entry, log)
|
||||
return -1
|
||||
}
|
||||
writeStart, writeEnd := index("start:w"), index("end:w")
|
||||
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
|
||||
t.Fatalf("write started before the first read batch finished: %v", log)
|
||||
}
|
||||
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
||||
t.Fatalf("second read batch started before the write finished: %v", log)
|
||||
}
|
||||
if read.maxActive < 2 {
|
||||
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,18 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
|
||||
// the 200-rune truncation) total well past the 4096-byte list budget, so the
|
||||
// overflow summary must kick in while the block stays bounded.
|
||||
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
|
||||
skills := make([]SkillInfo, 0, 60)
|
||||
for i := 0; i < 60; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
if len(got) > skillsContextListBudget+1000 {
|
||||
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
|
||||
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
|
||||
// budget, under which skills past ~#6 were invisible to the model and therefore
|
||||
// never triggered.
|
||||
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
|
||||
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
|
||||
skills := make([]SkillInfo, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
|
||||
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
|
||||
+126
-26
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
@@ -38,6 +39,14 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// userContextFile is the per-user instruction file, resolved under
|
||||
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
|
||||
// (config.json, commands, specialists) so users can keep personal guidance out
|
||||
// of individual repositories.
|
||||
const userContextFile = "ZERO.md"
|
||||
|
||||
var userConfigDirForPrompt = config.UserConfigDir
|
||||
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
@@ -81,6 +90,13 @@ func buildSystemPrompt(options Options) string {
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
// User guidelines are injected before workspace/project guidelines so the
|
||||
// project's AGENTS.md/ZERO.md is the later, more specific instruction
|
||||
// block. See userGuidelines for the explicit precedence note carried in
|
||||
// the section text itself.
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
@@ -175,10 +191,19 @@ func specialistDelegationContext(options Options) string {
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
// workspace with an extreme number of them can't bloat every turn's prompt.
|
||||
// Skills past the budget are summarized as a count rather than dropped — the
|
||||
// model can still load any of them by name (an unknown name returns the full
|
||||
// list from the skill tool).
|
||||
//
|
||||
// The budget is deliberately generous — roughly 18 skills with maximally long
|
||||
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
|
||||
// descriptions consume it faster since the budget counts bytes. This list is
|
||||
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
|
||||
// never triggers. The previous 640-byte cap silently broke invocation for
|
||||
// anyone with more than ~6 skills — the model cannot match a request against a
|
||||
// skill it cannot see.
|
||||
const skillsContextListBudget = 4096
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -186,7 +211,7 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -216,9 +241,11 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
// verbose description can't dominate the skills-list budget. The cap is roomy on
|
||||
// purpose: the description carries the skill's trigger conditions ("use when…"),
|
||||
// and truncating those is what stops the model from ever invoking the skill.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
const maxDescRunes = 200
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -312,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -326,6 +347,92 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// userGuidelines returns the per-user ZERO.md instructions block, if present.
|
||||
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
|
||||
// per-user config; the basename match is case-insensitive so a file saved as
|
||||
// zero.md still resolves on case-sensitive filesystems, mirroring the project
|
||||
// guideline loader. The section carries an explicit precedence note because
|
||||
// this is a global, personal preferences file: it is injected earlier in the
|
||||
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
|
||||
// the note keeps that precedence unambiguous even if a model weighs later
|
||||
// context more heavily than section order alone implies.
|
||||
func userGuidelines() string {
|
||||
configDir, err := userConfigDirForPrompt()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
configDir = strings.TrimSpace(configDir)
|
||||
if configDir == "" {
|
||||
return ""
|
||||
}
|
||||
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
|
||||
if match == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
content = truncateGuidelineContent(content, maxProjectContextBytes)
|
||||
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
|
||||
"These are the operator's personal preferences, not project policy. " +
|
||||
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
|
||||
content
|
||||
}
|
||||
|
||||
// truncationMarker is appended to guideline content that was cut short.
|
||||
const truncationMarker = "\n… (truncated)"
|
||||
|
||||
// truncateGuidelineContent caps content at limit bytes without splitting a
|
||||
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
|
||||
// the marker is reserved before choosing the cut point. When limit is too
|
||||
// small to fit the marker itself, the marker is dropped instead, so the
|
||||
// returned string never exceeds limit for any non-negative limit.
|
||||
func truncateGuidelineContent(content string, limit int) string {
|
||||
if len(content) <= limit {
|
||||
return content
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
cut := limit - len(truncationMarker)
|
||||
if cut < 0 {
|
||||
cut = 0
|
||||
}
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
|
||||
return truncated
|
||||
}
|
||||
// limit is smaller than the marker itself: fall back to a hard,
|
||||
// rune-safe cut at limit bytes with no marker rather than exceeding it.
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(content[end]) {
|
||||
end--
|
||||
}
|
||||
return content[:end]
|
||||
}
|
||||
|
||||
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
|
||||
// whose name matches basename case-insensitively, or "" when absent.
|
||||
func findCaseInsensitiveFile(dir, basename string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
|
||||
return filepath.Join(dir, e.Name())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
@@ -378,23 +485,16 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
// matching, then find a regular file with the same basename. This works
|
||||
// on both case-sensitive and case-insensitive filesystems and always
|
||||
// returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -63,6 +63,10 @@ work.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -9,6 +9,25 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||
// directory for every test in this package. Without this, buildSystemPrompt
|
||||
// falls back to the real config.UserConfigDir, so any developer with a
|
||||
// personal ~/.config/zero/ZERO.md would get its content folded into
|
||||
// otherwise-unrelated prompt assertions, making test runs non-deterministic
|
||||
// across contributor machines. Tests that specifically exercise user
|
||||
// guidelines stub userConfigDirForPrompt themselves via
|
||||
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -128,6 +147,88 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
if strings.Contains(prompt, " Prefer concise summaries. ") {
|
||||
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
|
||||
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
|
||||
// User guidelines are global personal preferences; project guidelines
|
||||
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
|
||||
// and the user section must say so explicitly so the precedence holds
|
||||
// even if a model otherwise weighs later context more heavily.
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
|
||||
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
|
||||
if userIdx < 0 || projectIdx < 0 {
|
||||
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
|
||||
}
|
||||
if userIdx > projectIdx {
|
||||
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
|
||||
}
|
||||
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
|
||||
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
|
||||
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if strings.Contains(prompt, "## User guidelines") {
|
||||
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
|
||||
t.Helper()
|
||||
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
|
||||
t.Helper()
|
||||
old := userConfigDirForPrompt
|
||||
userConfigDirForPrompt = fn
|
||||
return func() {
|
||||
userConfigDirForPrompt = old
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
@@ -257,6 +358,30 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
|
||||
limit := 64
|
||||
content := strings.Repeat("x", limit+1)
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
|
||||
}
|
||||
if !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("expected truncation marker, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
|
||||
// Limits at or below the truncation marker's own length can't fit the
|
||||
// marker at all; the helper must still never exceed the requested limit.
|
||||
content := strings.Repeat("x", 100)
|
||||
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
|
||||
@@ -285,6 +285,12 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
|
||||
// write_file append error-severity language diagnostics for the file they
|
||||
// just wrote directly to their tool output — the model sees an error it
|
||||
// introduced in the same turn instead of a later verification pass. Build
|
||||
// one with NewFileDiagnostics. nil disables inline diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
@@ -77,6 +79,8 @@ type appDeps struct {
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
|
||||
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
@@ -178,6 +182,8 @@ func defaultAppDeps() appDeps {
|
||||
runAgentEval: defaultRunAgentEval,
|
||||
inspectChanges: zerogit.Inspect,
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
@@ -229,6 +235,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
||||
deps = fillAppDeps(deps)
|
||||
|
||||
// CLI runs opt into the models.dev overlay (cached live context limits and
|
||||
// pricing on top of the curated catalog). Explicitly enabled here — and only
|
||||
// here — so library consumers and hermetic tests are never perturbed by a
|
||||
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
|
||||
modelregistry.EnableModelsDevOverlay()
|
||||
|
||||
addDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -510,6 +522,12 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.commitChanges == nil {
|
||||
deps.commitChanges = defaults.commitChanges
|
||||
}
|
||||
if deps.pushChanges == nil {
|
||||
deps.pushChanges = defaults.pushChanges
|
||||
}
|
||||
if deps.createPR == nil {
|
||||
deps.createPR = defaults.createPR
|
||||
}
|
||||
if deps.runTUI == nil {
|
||||
deps.runTUI = defaults.runTUI
|
||||
}
|
||||
@@ -544,6 +562,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
@@ -760,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||
// It resolves against the same merged set (default dir + plugin skill
|
||||
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||
// newly installed skills work without a restart.
|
||||
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
|
||||
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||
return merged
|
||||
}),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
@@ -1255,3 +1286,26 @@ Flags:
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
|
||||
// calls the loader from autocomplete on every "/x" keystroke; without a cache
|
||||
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
|
||||
// that a newly installed skill still shows up "immediately" while typing.
|
||||
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
cached []skills.Skill
|
||||
)
|
||||
const ttl = 2 * time.Second
|
||||
return func() []skills.Skill {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cached != nil && time.Since(at) < ttl {
|
||||
return cached
|
||||
}
|
||||
cached = load()
|
||||
at = time.Now()
|
||||
return cached
|
||||
}
|
||||
}
|
||||
+26
-13
@@ -136,6 +136,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
// mode-injected tool filter is validated and reflected in --list-tools, and a
|
||||
@@ -500,9 +505,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||
// changed files; the autonomy gate inside the corrector still decides whether
|
||||
// failures auto-fix or just report. lspShutdown tears down any language-server
|
||||
// sessions the LSP half spawned (no-op when self-correct is off).
|
||||
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
|
||||
// edit_file/write_file inline error diagnostics for the file they just wrote.
|
||||
// lspShutdown tears down any language-server sessions either half spawned.
|
||||
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
@@ -526,6 +532,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
// 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
|
||||
@@ -537,6 +544,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnReasoning: writer.reasoning,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
@@ -701,23 +709,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// unless a changed file's language actually has one installed on PATH. The
|
||||
// returned cleanup shuts that manager down (terminating any spawned server
|
||||
// sessions); it is a no-op when self-correct is off.
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
|
||||
if !enabled {
|
||||
return nil, func() {}
|
||||
}
|
||||
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
|
||||
// The manager is created regardless of --self-correct: it also backs the
|
||||
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
|
||||
// stays lazy — no language server is spawned unless an edited file's
|
||||
// language actually has one installed on PATH.
|
||||
manager := lsp.NewManager(workspaceRoot)
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
|
||||
if !enabled {
|
||||
return nil, fileDiagnostics, cleanup
|
||||
}
|
||||
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
||||
Enabled: true,
|
||||
IncludeTests: true,
|
||||
IncludeLSP: true,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
return corrector, cleanup
|
||||
return corrector, fileDiagnostics, cleanup
|
||||
}
|
||||
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
|
||||
@@ -529,6 +529,45 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return reasoningExecProvider{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
|
||||
if reasoning["delta"] != "Thinking. " {
|
||||
t.Fatalf("unexpected reasoning event: %#v", reasoning)
|
||||
}
|
||||
textIdx, text := findJSONEventIndex(t, events, "text")
|
||||
if text["delta"] != "done" {
|
||||
t.Fatalf("unexpected text event: %#v", text)
|
||||
}
|
||||
if reasoningIdx >= textIdx {
|
||||
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
|
||||
}
|
||||
final := findJSONEvent(t, events, "final")
|
||||
if final["text"] != "done" {
|
||||
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
writer := execEventWriter{
|
||||
@@ -751,6 +790,17 @@ type toolCallingExecProvider struct {
|
||||
answer string
|
||||
}
|
||||
|
||||
type reasoningExecProvider struct{}
|
||||
|
||||
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
@@ -777,13 +827,19 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
||||
|
||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||
t.Helper()
|
||||
for _, event := range events {
|
||||
_, event := findJSONEventIndex(t, events, eventType)
|
||||
return event
|
||||
}
|
||||
|
||||
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
for idx, event := range events {
|
||||
if event["type"] == eventType {
|
||||
return event
|
||||
return idx, event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||
return nil
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||
|
||||
@@ -80,6 +80,16 @@ func (writer *execEventWriter) text(delta string) {
|
||||
writer.writeStdout(delta)
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) reasoning(delta string) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
|
||||
}
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
|
||||
if writer.format == execOutputJSON {
|
||||
writer.writeJSON(map[string]any{
|
||||
|
||||
@@ -88,6 +88,15 @@ func formatRepoInfo(info repoinfo.Info) string {
|
||||
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
|
||||
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
|
||||
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
|
||||
if info.CommitCount != nil {
|
||||
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
|
||||
}
|
||||
if info.BranchCount != nil {
|
||||
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
|
||||
}
|
||||
if info.TagCount != nil {
|
||||
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
|
||||
}
|
||||
if info.PrimaryLanguage != "" {
|
||||
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
|
||||
contrib := 3
|
||||
info := repoInfoTestData(age, contrib)
|
||||
out := formatRepoInfo(info)
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
|
||||
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("text output missing %q:\n%s", want, out)
|
||||
}
|
||||
@@ -113,12 +113,16 @@ func initTempGitRepo(t *testing.T) string {
|
||||
}
|
||||
|
||||
func repoInfoTestData(age, contrib int) repoinfo.Info {
|
||||
commits := 100
|
||||
branches := 5
|
||||
tags := 10
|
||||
return repoinfo.Info{
|
||||
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
|
||||
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
|
||||
PrimaryLanguage: "TypeScript", LanguageCount: 1,
|
||||
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
|
||||
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
|
||||
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+195
-2
@@ -41,6 +41,13 @@ type changesCommandOptions struct {
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
remote string
|
||||
force bool
|
||||
title string
|
||||
body string
|
||||
fill bool
|
||||
draft bool
|
||||
yes bool
|
||||
auto bool
|
||||
}
|
||||
|
||||
@@ -266,6 +273,10 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
case "push":
|
||||
return runChangesPush(args, stdout, stderr, deps)
|
||||
case "pr", "pull-request":
|
||||
return runChangesPR(args, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
|
||||
}
|
||||
@@ -465,6 +476,41 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "--remote":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.remote = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--remote="):
|
||||
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
|
||||
case arg == "--force":
|
||||
options.force = true
|
||||
case arg == "--title":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.title = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--title="):
|
||||
options.title = strings.TrimPrefix(arg, "--title=")
|
||||
case arg == "--body":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.body = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--body="):
|
||||
options.body = strings.TrimPrefix(arg, "--body=")
|
||||
case arg == "--fill":
|
||||
options.fill = true
|
||||
case arg == "--draft":
|
||||
options.draft = true
|
||||
case arg == "--yes":
|
||||
options.yes = true
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
@@ -473,15 +519,30 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.message != "" {
|
||||
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
|
||||
}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
if command != "commit" && command != "push" && options.dryRun {
|
||||
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
|
||||
}
|
||||
if command != "inspect" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
|
||||
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
|
||||
}
|
||||
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
|
||||
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
|
||||
}
|
||||
if command != "push" && command != "pr" && options.yes {
|
||||
return options, false, execUsageError{"--yes is only valid with push or pr"}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
@@ -759,14 +820,24 @@ func writeChangesHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero changes inspect [flags]
|
||||
zero changes commit [flags]
|
||||
zero changes push [flags]
|
||||
zero changes pr [flags]
|
||||
|
||||
Inspects local git changes and optionally creates a commit.
|
||||
Inspects, commits, pushes, and creates pull requests for local git changes.
|
||||
|
||||
Flags:
|
||||
-C, --cwd <path> Workspace directory
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
--dry-run Preview commit metadata / push without mutating git state
|
||||
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
|
||||
--force Use force-with-lease when pushing
|
||||
--title <text> PR title
|
||||
--body <text> PR body
|
||||
--fill Automatically populate PR title and body from commits
|
||||
--draft Create PR as a draft
|
||||
--yes Confirm pushing to a default/protected branch
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
@@ -775,6 +846,128 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "push")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
dryRunStr := ""
|
||||
if options.dryRun {
|
||||
dryRunStr = " (dry run)"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Output != "" {
|
||||
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseChangesArgs(args, "pr")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeChangesHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.fill && options.title == "" {
|
||||
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Remote: options.remote,
|
||||
Force: options.force,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
|
||||
}
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
|
||||
Cwd: workspaceRoot,
|
||||
Fill: options.fill,
|
||||
Draft: options.draft,
|
||||
Title: options.title,
|
||||
Body: options.body,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
if options.json {
|
||||
type prJSONResult struct {
|
||||
Branch string `json:"branch"`
|
||||
Remote string `json:"remote"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
res := prJSONResult{
|
||||
Branch: pushResult.Branch,
|
||||
Remote: pushResult.Remote,
|
||||
Output: strings.TrimSpace(prResult.Output),
|
||||
}
|
||||
if err := writePrettyJSON(stdout, res); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
|
||||
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
|
||||
// lspServersCheck reports which language servers ZERO would use are present on
|
||||
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
|
||||
// those languages — so the worst status is WARN, and each missing server gets an
|
||||
// actionable install command keyed by its binary name.
|
||||
// actionable install command keyed by its binary name. Only the tier-1 servers
|
||||
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
|
||||
// for breadth are listed informationally under missingOptional, because warning
|
||||
// about a missing zls on a machine with no Zig code would be permanent noise.
|
||||
func lspServersCheck(lookup func(string) (string, error)) Check {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
}
|
||||
core := map[string]bool{}
|
||||
for _, binary := range lsp.CoreServerBinaries() {
|
||||
core[binary] = true
|
||||
}
|
||||
present := map[string]any{}
|
||||
missing := map[string]any{}
|
||||
missingOptional := map[string]any{}
|
||||
for _, binary := range lsp.ServerBinaries() {
|
||||
if _, err := lookup(binary); err == nil {
|
||||
present[binary] = "on PATH"
|
||||
continue
|
||||
}
|
||||
missing[binary] = lspRemedy(binary)
|
||||
if core[binary] {
|
||||
missing[binary] = lspRemedy(binary)
|
||||
} else {
|
||||
missingOptional[binary] = lspRemedy(binary)
|
||||
}
|
||||
}
|
||||
details := map[string]any{"present": present}
|
||||
if len(missingOptional) > 0 {
|
||||
details["missingOptional"] = missingOptional
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
|
||||
"present": present,
|
||||
})
|
||||
message := "All core language servers are available on PATH."
|
||||
if len(missingOptional) > 0 {
|
||||
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusPass, message, details)
|
||||
}
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
|
||||
"present": present,
|
||||
"missing": missing,
|
||||
})
|
||||
details["missing"] = missing
|
||||
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
|
||||
}
|
||||
|
||||
// lspRemedy returns an actionable install command for a missing language-server
|
||||
|
||||
+123
-15
@@ -9,26 +9,134 @@ import (
|
||||
|
||||
// serverCommands maps a file extension to the language-server command (argv) ZERO
|
||||
// will spawn. The first element is the binary looked up on PATH; missing binaries
|
||||
// are not an error, the agent just degrades to text-only for that file.
|
||||
// are not an error, the agent just degrades to text-only for that file. Every
|
||||
// command is the language's community-standard server invoked in stdio mode, so
|
||||
// a wrong guess can never spawn something surprising — only fail the PATH check.
|
||||
var serverCommands = map[string][]string{
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".go": {"gopls", "serve"},
|
||||
".ts": {"typescript-language-server", "--stdio"},
|
||||
".tsx": {"typescript-language-server", "--stdio"},
|
||||
".mts": {"typescript-language-server", "--stdio"},
|
||||
".cts": {"typescript-language-server", "--stdio"},
|
||||
".js": {"typescript-language-server", "--stdio"},
|
||||
".jsx": {"typescript-language-server", "--stdio"},
|
||||
".mjs": {"typescript-language-server", "--stdio"},
|
||||
".cjs": {"typescript-language-server", "--stdio"},
|
||||
".py": {"pyright-langserver", "--stdio"},
|
||||
".pyi": {"pyright-langserver", "--stdio"},
|
||||
".rs": {"rust-analyzer"},
|
||||
".c": {"clangd"},
|
||||
".h": {"clangd"},
|
||||
".cpp": {"clangd"},
|
||||
".cc": {"clangd"},
|
||||
".cxx": {"clangd"},
|
||||
".hpp": {"clangd"},
|
||||
".java": {"jdtls"},
|
||||
".kt": {"kotlin-language-server"},
|
||||
".kts": {"kotlin-language-server"},
|
||||
".rb": {"ruby-lsp"},
|
||||
".php": {"intelephense", "--stdio"},
|
||||
".zig": {"zls"},
|
||||
".lua": {"lua-language-server"},
|
||||
".ex": {"elixir-ls"},
|
||||
".exs": {"elixir-ls"},
|
||||
".hs": {"haskell-language-server-wrapper", "--lsp"},
|
||||
".swift": {"sourcekit-lsp"},
|
||||
".ml": {"ocamllsp"},
|
||||
".mli": {"ocamllsp"},
|
||||
".scala": {"metals"},
|
||||
".clj": {"clojure-lsp"},
|
||||
".cljs": {"clojure-lsp"},
|
||||
".cljc": {"clojure-lsp"},
|
||||
".dart": {"dart", "language-server"},
|
||||
".gleam": {"gleam", "lsp"},
|
||||
".nix": {"nixd"},
|
||||
".tf": {"terraform-ls", "serve"},
|
||||
".sh": {"bash-language-server", "start"},
|
||||
".bash": {"bash-language-server", "start"},
|
||||
".yaml": {"yaml-language-server", "--stdio"},
|
||||
".yml": {"yaml-language-server", "--stdio"},
|
||||
".json": {"vscode-json-language-server", "--stdio"},
|
||||
".css": {"vscode-css-language-server", "--stdio"},
|
||||
".scss": {"vscode-css-language-server", "--stdio"},
|
||||
".less": {"vscode-css-language-server", "--stdio"},
|
||||
".html": {"vscode-html-language-server", "--stdio"},
|
||||
".svelte": {"svelteserver", "--stdio"},
|
||||
".vue": {"vue-language-server", "--stdio"},
|
||||
".astro": {"astro-ls", "--stdio"},
|
||||
}
|
||||
|
||||
// languageIDs maps a file extension to the LSP languageId used in didOpen.
|
||||
var languageIDs = map[string]string{
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".mjs": "javascript",
|
||||
".cjs": "javascript",
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".rs": "rust",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".cpp": "cpp",
|
||||
".cc": "cpp",
|
||||
".cxx": "cpp",
|
||||
".hpp": "cpp",
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".zig": "zig",
|
||||
".lua": "lua",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".hs": "haskell",
|
||||
".swift": "swift",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".scala": "scala",
|
||||
".clj": "clojure",
|
||||
".cljs": "clojure",
|
||||
".cljc": "clojure",
|
||||
".dart": "dart",
|
||||
".gleam": "gleam",
|
||||
".nix": "nix",
|
||||
".tf": "terraform",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".less": "less",
|
||||
".html": "html",
|
||||
".svelte": "svelte",
|
||||
".vue": "vue",
|
||||
".astro": "astro",
|
||||
}
|
||||
|
||||
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
|
||||
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
|
||||
// configured above for breadth is reported informationally only — warning about
|
||||
// a missing zls on a machine with no Zig code would be permanent noise.
|
||||
var coreServerBinaries = []string{
|
||||
"gopls",
|
||||
"typescript-language-server",
|
||||
"pyright-langserver",
|
||||
"rust-analyzer",
|
||||
}
|
||||
|
||||
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
|
||||
func CoreServerBinaries() []string {
|
||||
binaries := append([]string(nil), coreServerBinaries...)
|
||||
sort.Strings(binaries)
|
||||
return binaries
|
||||
}
|
||||
|
||||
// ServerBinaries returns the unique set of language-server binaries ZERO may
|
||||
|
||||
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
|
||||
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
|
||||
}
|
||||
decorateModelDepth(entries)
|
||||
// Overlay volatile facts (context limits, base pricing) from a cached
|
||||
// models.dev snapshot when one is present and fresh — see modelsdev.go.
|
||||
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
|
||||
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
|
||||
return cloneModelEntries(entries)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Live models.dev overlay for the curated catalog. The hand-maintained
|
||||
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
|
||||
// match patterns, deprecations, escalation targets — but its VOLATILE facts
|
||||
// (context window, max output tokens, per-million pricing) go stale between
|
||||
// releases. When a cached snapshot of https://models.dev/api.json is present,
|
||||
// those fields are refreshed from it at registry construction; everything else
|
||||
// stays curated. The overlay never adds models (an auto-added entry would lack
|
||||
// aliases, match patterns, and provider wiring) and never touches the network
|
||||
// on the registry hot path — fetching happens only in the explicit background
|
||||
// refresh, cached to disk with a TTL.
|
||||
|
||||
const (
|
||||
modelsDevDefaultURL = "https://models.dev/api.json"
|
||||
// modelsDevRefreshAfter is how old the cache may get before a background
|
||||
// refresh re-fetches it.
|
||||
modelsDevRefreshAfter = 24 * time.Hour
|
||||
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
|
||||
// this the curated catalog (updated with the binary) is likely fresher than
|
||||
// the snapshot, so a stale file is ignored rather than trusted.
|
||||
modelsDevMaxAge = 7 * 24 * time.Hour
|
||||
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
|
||||
modelsDevFetchWindow = 15 * time.Second
|
||||
)
|
||||
|
||||
// modelsDevModel is the subset of a models.dev model record the overlay uses.
|
||||
type modelsDevModel struct {
|
||||
Limit struct {
|
||||
Context int `json:"context"`
|
||||
Output int `json:"output"`
|
||||
} `json:"limit"`
|
||||
Cost struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
CacheRead float64 `json:"cache_read"`
|
||||
CacheWrite float64 `json:"cache_write"`
|
||||
} `json:"cost"`
|
||||
}
|
||||
|
||||
// modelsDevProvider matches one provider object in api.json.
|
||||
type modelsDevProvider struct {
|
||||
Models map[string]modelsDevModel `json:"models"`
|
||||
}
|
||||
|
||||
// parseModelsDev decodes an api.json document into provider-slug -> api-model
|
||||
// -> record. api.json is a top-level object keyed by provider id.
|
||||
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
|
||||
var doc map[string]modelsDevProvider
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
|
||||
}
|
||||
providers := make(map[string]map[string]modelsDevModel, len(doc))
|
||||
for slug, provider := range doc {
|
||||
if len(provider.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
providers[slug] = provider.Models
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
|
||||
// worth checking. Only first-party slugs are used: router entries on models.dev
|
||||
// carry generic or stale numbers.
|
||||
func modelsDevSlugs(kind ProviderKind) []string {
|
||||
switch kind {
|
||||
case ProviderAnthropic:
|
||||
return []string{"anthropic"}
|
||||
case ProviderOpenAI:
|
||||
return []string{"openai"}
|
||||
case ProviderGoogle:
|
||||
return []string{"google", "google-vertex"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyModelsDevOverrides refreshes each curated entry's context limits and
|
||||
// base pricing from the snapshot, when the snapshot knows the model. Tiered
|
||||
// pricing is never touched: models.dev has no tier data, and mixing a live
|
||||
// base rate with curated tiers would misprice the tier boundaries.
|
||||
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
|
||||
if len(providers) == 0 {
|
||||
return entries
|
||||
}
|
||||
for i := range entries {
|
||||
entry := &entries[i]
|
||||
var record modelsDevModel
|
||||
found := false
|
||||
for _, slug := range modelsDevSlugs(entry.Provider) {
|
||||
if models, ok := providers[slug]; ok {
|
||||
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
|
||||
record = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if record.Limit.Context > 0 {
|
||||
entry.ContextLimits.ContextWindow = record.Limit.Context
|
||||
}
|
||||
if record.Limit.Output > 0 {
|
||||
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
|
||||
}
|
||||
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
|
||||
entry.Cost.InputPerMillion = record.Cost.Input
|
||||
entry.Cost.OutputPerMillion = record.Cost.Output
|
||||
if record.Cost.CacheRead > 0 {
|
||||
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
|
||||
}
|
||||
if record.Cost.CacheWrite > 0 {
|
||||
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
|
||||
}
|
||||
entry.Cost.Source = "models.dev/api.json (cached)"
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
|
||||
// overrides it (used by tests and unusual setups).
|
||||
func modelsDevCachePath() (string, error) {
|
||||
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
|
||||
return override, nil
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(base, "zero", "modelsdev.json"), nil
|
||||
}
|
||||
|
||||
var (
|
||||
modelsDevOnce sync.Once
|
||||
modelsDevCached map[string]map[string]modelsDevModel
|
||||
modelsDevEnabled atomic.Bool
|
||||
)
|
||||
|
||||
// EnableModelsDevOverlay opts the process into applying the cached models.dev
|
||||
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
|
||||
// consumers and tests that never do get the curated catalog byte-identical to
|
||||
// before, so hermetic tests can't be perturbed by a cache file on the machine.
|
||||
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
|
||||
func EnableModelsDevOverlay() {
|
||||
modelsDevEnabled.Store(true)
|
||||
}
|
||||
|
||||
// cachedModelsDevProviders loads the cached snapshot once per process. Not
|
||||
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
|
||||
// the curated catalog is used untouched. Read once deliberately:
|
||||
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
|
||||
// re-stat the file every time; a background refresh benefits the NEXT process.
|
||||
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
modelsDevOnce.Do(func() {
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
providers, err := parseModelsDev(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
modelsDevCached = providers
|
||||
})
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// resetModelsDevCacheForTest clears the process-level cache memoization and
|
||||
// disables the overlay.
|
||||
func resetModelsDevCacheForTest() {
|
||||
modelsDevOnce = sync.Once{}
|
||||
modelsDevCached = nil
|
||||
modelsDevEnabled.Store(false)
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
// current process's registry (see cachedModelsDevProviders). Disabled entirely
|
||||
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
|
||||
func RefreshModelsDevCache(ctx context.Context) error {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return nil
|
||||
}
|
||||
path, err := modelsDevCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
|
||||
if url == "" {
|
||||
url = modelsDevDefaultURL
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate before persisting: a bad body must never clobber a good cache.
|
||||
if _, err := parseModelsDev(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
_ = os.Remove(temp.Name())
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package modelregistry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleModelsDev = `{
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"models": {
|
||||
"claude-sonnet-4-5-20250929": {
|
||||
"limit": {"context": 1000000, "output": 64000},
|
||||
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
|
||||
}
|
||||
}
|
||||
},
|
||||
"google": {
|
||||
"id": "google",
|
||||
"models": {
|
||||
"gemini-2.5-pro": {
|
||||
"limit": {"context": 2097152, "output": 65536},
|
||||
"cost": {"input": 9.99, "output": 9.99}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
func TestParseModelsDev(t *testing.T) {
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic sonnet record")
|
||||
}
|
||||
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
|
||||
t.Fatalf("unexpected record: %+v", record)
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
|
||||
t.Fatal("empty document must be rejected")
|
||||
}
|
||||
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
|
||||
t.Fatal("malformed document must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyModelsDevOverrides(t *testing.T) {
|
||||
// Point the cache at a non-existent file so DefaultModelEntries returns the
|
||||
// pure curated catalog, then apply the sample snapshot explicitly.
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
providers, err := parseModelsDev([]byte(sampleModelsDev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
|
||||
|
||||
var sonnet, geminiPro, opus ModelEntry
|
||||
for _, entry := range entries {
|
||||
switch entry.ID {
|
||||
case "claude-sonnet-4.5":
|
||||
sonnet = entry
|
||||
case "gemini-2.5-pro":
|
||||
geminiPro = entry
|
||||
case "claude-opus-4.1":
|
||||
opus = entry
|
||||
}
|
||||
}
|
||||
|
||||
// Known model: limits and base pricing refreshed from the snapshot.
|
||||
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
|
||||
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
|
||||
}
|
||||
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
|
||||
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
|
||||
}
|
||||
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
|
||||
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
|
||||
}
|
||||
|
||||
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
|
||||
// has curated tiers and the snapshot's flat 9.99 would misprice them).
|
||||
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
|
||||
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
|
||||
}
|
||||
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
|
||||
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
|
||||
}
|
||||
|
||||
// Model absent from the snapshot: untouched.
|
||||
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
|
||||
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
|
||||
hits := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_, _ = w.Write([]byte(sampleModelsDev))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("expected 1 fetch, got %d", hits)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); err != nil {
|
||||
t.Fatalf("cache file missing: %v", err)
|
||||
}
|
||||
// Fresh cache: second call must not re-fetch.
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
|
||||
if err := RefreshModelsDevCache(t.Context()); err == nil {
|
||||
t.Fatal("bad body must return an error")
|
||||
}
|
||||
content, err := os.ReadFile(cachePath)
|
||||
if err != nil || string(content) != sampleModelsDev {
|
||||
t.Fatal("bad fetch must not clobber the existing cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("fetch must not happen when disabled")
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
|
||||
t.Setenv("ZERO_MODELS_URL", server.URL)
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
if err := RefreshModelsDevCache(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
|
||||
if err := os.Chtimes(cachePath, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("stale cache must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
|
||||
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
|
||||
// library consumers and hermetic tests get the pure curated catalog.
|
||||
if providers := cachedModelsDevProviders(); providers != nil {
|
||||
t.Fatal("overlay must be opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
|
||||
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
|
||||
resetModelsDevCacheForTest()
|
||||
t.Cleanup(resetModelsDevCacheForTest)
|
||||
EnableModelsDevOverlay()
|
||||
|
||||
for _, entry := range DefaultModelEntries() {
|
||||
if entry.ID == "claude-sonnet-4.5" {
|
||||
if entry.ContextLimits.ContextWindow != 1_000_000 {
|
||||
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("claude-sonnet-4.5 not found")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// The final two messages must carry a cache_control breakpoint on their last
|
||||
// cacheable block so the conversation transcript — not just system prompt and
|
||||
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
|
||||
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
|
||||
// these two).
|
||||
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "first"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "calling a tool",
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
messages := gotBody["messages"].([]any)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
|
||||
}
|
||||
lastBlockHasCache := func(message any) bool {
|
||||
blocks, ok := message.(map[string]any)["content"].([]any)
|
||||
if !ok || len(blocks) == 0 {
|
||||
return false
|
||||
}
|
||||
last := blocks[len(blocks)-1].(map[string]any)
|
||||
control, ok := last["cache_control"].(map[string]any)
|
||||
return ok && control["type"] == "ephemeral"
|
||||
}
|
||||
if lastBlockHasCache(messages[0]) {
|
||||
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
|
||||
}
|
||||
if !lastBlockHasCache(messages[1]) {
|
||||
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
|
||||
}
|
||||
if !lastBlockHasCache(messages[2]) {
|
||||
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
// A string-content message must be converted to a block array so it can carry
|
||||
// the breakpoint, and thinking blocks must never carry cache_control (the API
|
||||
// rejects them) — the marker goes on the last cacheable block instead.
|
||||
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
|
||||
messages := []anthropicMessage{
|
||||
{Role: "user", Content: "plain string"},
|
||||
{Role: "assistant", Content: []map[string]any{
|
||||
{"type": "text", "text": "answer"},
|
||||
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
|
||||
}},
|
||||
}
|
||||
applyMessageCacheBreakpoints(messages)
|
||||
|
||||
userBlocks := contentBlocks(messages[0].Content)
|
||||
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
|
||||
}
|
||||
assistantBlocks := contentBlocks(messages[1].Content)
|
||||
if assistantBlocks[1]["cache_control"] != nil {
|
||||
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
|
||||
}
|
||||
if assistantBlocks[0]["cache_control"] == nil {
|
||||
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
|
||||
}
|
||||
}
|
||||
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
|
||||
}
|
||||
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
|
||||
}
|
||||
applyMessageCacheBreakpoints(mapped.Messages)
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// applyMessageCacheBreakpoints marks the last content block of the final two
|
||||
// messages with cache_control so the conversation transcript is cached
|
||||
// turn-over-turn, not just the system prompt and tool definitions. Two message
|
||||
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
|
||||
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
|
||||
// request: system + tools use two above, these use the remaining two. Thinking
|
||||
// blocks cannot carry cache_control, so the marker goes on the last block that
|
||||
// can.
|
||||
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
|
||||
marked := 0
|
||||
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
|
||||
blocks := contentBlocks(messages[i].Content)
|
||||
for j := len(blocks) - 1; j >= 0; j-- {
|
||||
blockType, _ := blocks[j]["type"].(string)
|
||||
if blockType == "thinking" || blockType == "redacted_thinking" {
|
||||
continue
|
||||
}
|
||||
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
|
||||
messages[i].Content = blocks
|
||||
marked++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
|
||||
systemParts := []string{}
|
||||
mapped := []anthropicMessage{}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -283,10 +284,10 @@ func (provider *Provider) emitChunk(
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: choice.Delta.ReasoningContent,
|
||||
Content: reasoning,
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
@@ -317,6 +318,13 @@ func (provider *Provider) emitChunk(
|
||||
}
|
||||
}
|
||||
|
||||
func (delta streamDelta) reasoningText() string {
|
||||
if delta.ReasoningContent != "" {
|
||||
return delta.ReasoningContent
|
||||
}
|
||||
return delta.Reasoning
|
||||
}
|
||||
|
||||
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
|
||||
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
|
||||
func mapFinishReason(reason string) string {
|
||||
@@ -414,6 +422,12 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
mapped.ReasoningEffort = effort
|
||||
}
|
||||
// prompt_cache_key is a documented OpenAI parameter; compatible servers
|
||||
// ignore unknown fields, but a strict endpoint that rejects it can be
|
||||
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
|
||||
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
|
||||
mapped.PromptCacheKey = key
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
|
||||
for _, tool := range request.Tools {
|
||||
@@ -430,6 +444,15 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
|
||||
return mapped
|
||||
}
|
||||
|
||||
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
|
||||
// kill switch is set to a truthy value. "0" and "false" (any case) are
|
||||
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
|
||||
// explicitly-disabled toggle never flips the behavior it names.
|
||||
func promptCacheKeyDisabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv("ZERO_DISABLE_PROMPT_CACHE_KEY"))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
|
||||
// completions API accepts, or "" to omit the field. "none" (and anything else)
|
||||
// is dropped rather than risking a 400 on an unrecognized enum.
|
||||
|
||||
@@ -227,6 +227,39 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 2 {
|
||||
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
|
||||
}
|
||||
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
|
||||
t.Fatalf("unexpected reasoning events: %#v", reasoning)
|
||||
}
|
||||
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
|
||||
t.Fatalf("reasoning must not emit text events, got %#v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
|
||||
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
||||
@@ -1226,3 +1259,61 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
|
||||
// session-carrying request serializes the key so the backend can route to a
|
||||
// replica holding the cached prefix, a keyless request omits the field
|
||||
// entirely (strict servers see byte-identical requests to before), and
|
||||
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
|
||||
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "sess_123" {
|
||||
t.Fatalf("PromptCacheKey = %q, want sess_123", req.PromptCacheKey)
|
||||
}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"prompt_cache_key":"sess_123"`) {
|
||||
t.Fatalf("prompt_cache_key not serialized: %s", data)
|
||||
}
|
||||
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{Messages: messages})
|
||||
if data, err = json.Marshal(req); err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "prompt_cache_key") {
|
||||
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "" {
|
||||
t.Fatalf("kill switch ignored; PromptCacheKey = %q", req.PromptCacheKey)
|
||||
}
|
||||
|
||||
// Explicitly-falsy kill switch values must NOT disable forwarding — only
|
||||
// truthy values flip the toggle (same parsing as ZERO_FORMAT_ON_WRITE).
|
||||
for _, value := range []string{"0", "false", "FALSE"} {
|
||||
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", value)
|
||||
req = provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: messages,
|
||||
PromptCacheKey: "sess_123",
|
||||
})
|
||||
if req.PromptCacheKey != "sess_123" {
|
||||
t.Fatalf("ZERO_DISABLE_PROMPT_CACHE_KEY=%q must be a no-op; PromptCacheKey = %q", value, req.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ type chatCompletionRequest struct {
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Stream bool `json:"stream"`
|
||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||
// PromptCacheKey asks the backend to route the request to a replica that
|
||||
// already holds this conversation's prefix in its prompt cache (the OpenAI
|
||||
// `prompt_cache_key` parameter). Omitted when the caller carries no session
|
||||
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
}
|
||||
|
||||
// streamOptions requests the final usage chunk on a streaming response. Without
|
||||
@@ -78,6 +83,7 @@ type streamChoice struct {
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,18 @@ import (
|
||||
// (billable) work. Only the INITIAL request is ever in scope; once the response
|
||||
// body starts streaming it is never re-issued.
|
||||
|
||||
const defaultMaxRetryAttempts = 3
|
||||
const defaultMaxRetryAttempts = 6
|
||||
|
||||
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
|
||||
// stall the agent for minutes.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
// retryBackoffBase is the first wait when the server supplied no Retry-After.
|
||||
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
|
||||
// after 400ms almost always burns the attempt while still limited, so the
|
||||
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
|
||||
var retryBackoffBase = 2 * time.Second
|
||||
|
||||
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
|
||||
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
|
||||
// between tries and honoring a server Retry-After header and context
|
||||
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
|
||||
}
|
||||
|
||||
// Backoff waits before retry attempt N (1-based), returning false if the context
|
||||
// is cancelled during the wait. The wait is attempt*400ms unless the server
|
||||
// supplied a (positive) Retry-After, and is capped at maxBackoff.
|
||||
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
|
||||
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
|
||||
// wait is capped at maxBackoff.
|
||||
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
wait := time.Duration(attempt) * 400 * time.Millisecond
|
||||
if retryAfter > 0 {
|
||||
wait = retryAfter
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
timer := time.NewTimer(backoffWait(attempt, retryAfter))
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
|
||||
// when supplied, else exponential from retryBackoffBase, both capped at
|
||||
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
|
||||
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
|
||||
wait := retryAfter
|
||||
if wait <= 0 {
|
||||
exponent := attempt - 1
|
||||
if exponent > 5 {
|
||||
exponent = 5
|
||||
}
|
||||
if exponent < 0 {
|
||||
exponent = 0
|
||||
}
|
||||
wait = retryBackoffBase * time.Duration(1<<exponent)
|
||||
}
|
||||
if wait > maxBackoff {
|
||||
wait = maxBackoff
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
|
||||
// date) into a positive duration, or 0 when absent/unparseable. The result is
|
||||
// capped at maxBackoff by Backoff.
|
||||
|
||||
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// shrinkBackoff makes retry waits negligible for the duration of a test.
|
||||
func shrinkBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := retryBackoffBase
|
||||
retryBackoffBase = time.Millisecond
|
||||
t.Cleanup(func() { retryBackoffBase = saved })
|
||||
}
|
||||
|
||||
func TestBackoffWaitSchedule(t *testing.T) {
|
||||
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
|
||||
// a supplied Retry-After wins but is capped too.
|
||||
cases := []struct {
|
||||
attempt int
|
||||
retryAfter time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{1, 0, 2 * time.Second},
|
||||
{2, 0, 4 * time.Second},
|
||||
{3, 0, 8 * time.Second},
|
||||
{4, 0, 16 * time.Second},
|
||||
{5, 0, 30 * time.Second}, // 32s capped
|
||||
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
|
||||
{1, 7 * time.Second, 7 * time.Second},
|
||||
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
|
||||
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) == 1 {
|
||||
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
|
||||
shrinkBackoff(t)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
|
||||
@@ -54,6 +54,9 @@ type Info struct {
|
||||
AgeDays *int `json:"ageDays,omitempty"`
|
||||
Contributors90d *int `json:"contributors90d,omitempty"`
|
||||
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
|
||||
BranchCount *int `json:"branchCount,omitempty"`
|
||||
CommitCount *int `json:"commitCount,omitempty"`
|
||||
TagCount *int `json:"tagCount,omitempty"`
|
||||
}
|
||||
|
||||
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
|
||||
@@ -229,6 +232,43 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
|
||||
info.CommitVelocity30d = &n
|
||||
}
|
||||
}
|
||||
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
|
||||
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
|
||||
info.CommitCount = &n
|
||||
}
|
||||
}
|
||||
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
|
||||
set := map[string]bool{}
|
||||
for _, line := range strings.Split(branches, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.Contains(line, "->") {
|
||||
continue
|
||||
}
|
||||
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
|
||||
line = strings.TrimPrefix(line, "* ")
|
||||
line = strings.TrimPrefix(line, "+ ")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "(") {
|
||||
continue
|
||||
}
|
||||
// If it's a remote branch, extract the branch name.
|
||||
// E.g., remotes/origin/main -> main
|
||||
// E.g., remotes/origin/feature/xyz -> feature/xyz
|
||||
if strings.HasPrefix(line, "remotes/") {
|
||||
line = strings.TrimPrefix(line, "remotes/")
|
||||
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
|
||||
line = line[slashIdx+1:]
|
||||
}
|
||||
}
|
||||
set[line] = true
|
||||
}
|
||||
n := len(set)
|
||||
info.BranchCount = &n
|
||||
}
|
||||
if tags, err := run(ctx, dir, "tag"); err == nil {
|
||||
n := countNonEmptyLines(tags)
|
||||
info.TagCount = &n
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -328,3 +368,13 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func countNonEmptyLines(s string) int {
|
||||
count := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -43,6 +43,8 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
"remote": "https://github.com/x/y.git\n",
|
||||
"log": "0\n",
|
||||
"rev-list": "12\n",
|
||||
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
|
||||
"tag": "v0.1.0\nv0.2.0\n",
|
||||
}, nil),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -85,6 +87,15 @@ func TestCollectCoreMetrics(t *testing.T) {
|
||||
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
|
||||
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
|
||||
}
|
||||
if info.CommitCount == nil || *info.CommitCount != 12 {
|
||||
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
|
||||
}
|
||||
if info.BranchCount == nil || *info.BranchCount != 2 {
|
||||
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
|
||||
}
|
||||
if info.TagCount == nil || *info.TagCount != 2 {
|
||||
t.Fatalf("TagCount=%v want 2", info.TagCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectContributorsUnique(t *testing.T) {
|
||||
@@ -252,12 +263,13 @@ func TestCollectNotGitRepo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
|
||||
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
|
||||
var used []string
|
||||
_, err := Collect(context.Background(), Options{
|
||||
Now: time.Unix(0, 0),
|
||||
RunGit: fakeGit(t, map[string]string{
|
||||
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
|
||||
"branch": "main\n", "tag": "v0.1.0\n",
|
||||
}, &used),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -3,8 +3,9 @@ package sandbox
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
||||
@@ -121,9 +122,55 @@ func (manager SandboxManager) Backend() Backend {
|
||||
return manager.backend
|
||||
}
|
||||
|
||||
// isExecutable checks whether a file is executable. On Unix, this checks the
|
||||
// execute permission bits. On Windows, Go's os.FileMode does not set execute
|
||||
// bits, so we check for common executable extensions instead.
|
||||
func isExecutable(fi os.FileInfo) bool {
|
||||
if !fi.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
name := strings.ToLower(fi.Name())
|
||||
return strings.HasSuffix(name, ".exe") ||
|
||||
strings.HasSuffix(name, ".com") ||
|
||||
strings.HasSuffix(name, ".bat") ||
|
||||
strings.HasSuffix(name, ".cmd")
|
||||
}
|
||||
return fi.Mode()&0o111 != 0
|
||||
}
|
||||
|
||||
// lookupExecutable checks whether a named binary exists and is executable,
|
||||
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
|
||||
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
|
||||
func lookupExecutable(name string) (string, error) {
|
||||
if !strings.Contains(name, string(os.PathSeparator)) {
|
||||
path := os.Getenv("PATH")
|
||||
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
|
||||
if dir == "" {
|
||||
continue // skip empty entries (e.g. trailing colon)
|
||||
}
|
||||
candidate := filepath.Join(dir, name)
|
||||
if fi, err := os.Stat(candidate); err == nil {
|
||||
if isExecutable(fi) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", errors.New("executable file not found in $PATH")
|
||||
}
|
||||
fi, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isExecutable(fi) {
|
||||
return name, nil
|
||||
}
|
||||
return "", errors.New("executable file not found")
|
||||
}
|
||||
|
||||
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
|
||||
if lookup == nil {
|
||||
lookup = exec.LookPath
|
||||
lookup = lookupExecutable
|
||||
}
|
||||
switch goos {
|
||||
case "linux":
|
||||
|
||||
@@ -190,6 +190,67 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsRestrictedTokenNestedPipeCapture pins the fix in
|
||||
// broadenWindowsRestrictedTokenDefaultDacl (windows_token_windows.go): a
|
||||
// nested subprocess spawned FROM WITHIN the sandboxed process, with its
|
||||
// output captured via a pipe, must succeed. Before the fix, the sandboxed
|
||||
// process's own attempt to create a pipe for such a subprocess failed with
|
||||
// ERROR_ACCESS_DENIED (Win32 error 5) — the WRITE_RESTRICTED token's extra
|
||||
// access check has no restricted-SID match against a freshly created pipe's
|
||||
// default security descriptor. This is exactly the pattern any tool that
|
||||
// shells out internally and captures output hits (`gh` invoking `git`, for
|
||||
// one; cmd.exe's own FOR /F does the identical CreatePipe+CreateProcess
|
||||
// dance internally, so it reproduces the bug with no external dependency
|
||||
// beyond cmd.exe itself).
|
||||
func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
marker := filepath.Join(root, "nested-pipe-marker.txt")
|
||||
script := filepath.Join(root, "nested-pipe.cmd")
|
||||
// FOR /F drives cmd.exe's own internal CreatePipe+CreateProcess capture
|
||||
// of the quoted command's output — the exact mechanism this fix targets.
|
||||
// The full path to whoami.exe sidesteps PATH resolution inside the
|
||||
// sandboxed process's minimal environment.
|
||||
scriptBody := "for /F %%i in ('C:\\Windows\\System32\\whoami.exe') do echo %%i> " + marker + "\r\n"
|
||||
if err := os.WriteFile(script, []byte(scriptBody), 0o644); err != nil {
|
||||
t.Fatalf("write nested-pipe script: %v", err)
|
||||
}
|
||||
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", script,
|
||||
}, 0)
|
||||
captured, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("read nested-pipe marker: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(captured)) == "" {
|
||||
t.Fatalf("nested-pipe marker is empty; FOR /F failed to capture the subprocess's output")
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -21,6 +21,11 @@ const (
|
||||
|
||||
var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken")
|
||||
|
||||
// procSetEntriesInAclW merges new ACEs into an existing ACL. Not wrapped by
|
||||
// x/sys/windows (its internal setEntriesInAcl is unexported), so it is
|
||||
// declared directly the same way procCreateRestrictedToken is.
|
||||
var procSetEntriesInAclW = windows.NewLazySystemDLL("advapi32.dll").NewProc("SetEntriesInAclW")
|
||||
|
||||
type windowsLocalSID struct {
|
||||
sid *windows.SID
|
||||
}
|
||||
@@ -122,9 +127,139 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := broadenWindowsRestrictedTokenDefaultDacl(restricted, sidFromBytes(logonSID)); err != nil {
|
||||
_ = restricted.Close()
|
||||
return 0, err
|
||||
}
|
||||
runtime.KeepAlive(logonSID)
|
||||
return restricted, nil
|
||||
}
|
||||
|
||||
// broadenWindowsRestrictedTokenDefaultDacl adds the token's own logon SID to
|
||||
// its default DACL (the DACL applied to any kernel object the token's process
|
||||
// creates WITHOUT an explicit security descriptor, e.g. anonymous pipes,
|
||||
// events, mutexes).
|
||||
//
|
||||
// A WRITE_RESTRICTED token requires a WRITE-type access check to match BOTH
|
||||
// the normal enabled-SID grant AND a SEPARATE grant to one of the token's
|
||||
// restricted SIDs. CreateRestrictedToken does not touch the default DACL it
|
||||
// inherits from the base token, so newly created objects only carry ACEs for
|
||||
// the base token's normal identity (owner, SYSTEM, Administrators) — none of
|
||||
// which are in the restricted-SID list — so the second check fails. This
|
||||
// breaks anything that creates a pipe/handle for itself with default
|
||||
// security, which is exactly what any tool using a language runtime's
|
||||
// "spawn a subprocess and capture its output" primitive does (Go's os/exec,
|
||||
// and by extension `gh`, and many other CLI tools that shell out
|
||||
// internally): CreatePipe() succeeds, but the creating process's own token
|
||||
// then fails to actually read/write the handle it just made.
|
||||
//
|
||||
// The logon SID is already unconditionally present in this token's
|
||||
// restricted-SID list (see createWindowsRestrictedTokenFromBase), so adding
|
||||
// it here — and ONLY it — to the default DACL is the minimal change that
|
||||
// satisfies the WRITE_RESTRICTED extra check for self-created objects. It
|
||||
// does not touch file-system access: NTFS write-jailing is enforced by the
|
||||
// explicit ACL grants applied to workspace paths (windows_acl.go), which are
|
||||
// created WITH an explicit security descriptor and never consult the token's
|
||||
// default DACL. The exposure is bounded to the same logon session (other
|
||||
// processes running as the same signed-in user could, in principle, open a
|
||||
// NAMED kernel object the sandboxed process creates with default security;
|
||||
// anonymous pipes — the actual object type this fixes — have no name and are
|
||||
// only reachable via an inherited handle, so this is a no-op for them).
|
||||
func broadenWindowsRestrictedTokenDefaultDacl(token windows.Token, logonSID *windows.SID) error {
|
||||
oldDacl, oldDaclBuf, err := windowsTokenDefaultDacl(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read token default DACL: %w", err)
|
||||
}
|
||||
// The second (WRITE-type) access check only needs a read/write match; a
|
||||
// full GENERIC_ALL grant (which also implies DELETE, WRITE_DAC, and
|
||||
// WRITE_OWNER) is broader than the pipe/event/mutex use case this exists
|
||||
// for actually requires.
|
||||
access := []windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_READ | windows.GENERIC_WRITE,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: windows.TRUSTEE{
|
||||
MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE,
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(logonSID),
|
||||
},
|
||||
}}
|
||||
newDacl, err := setEntriesInACL(access, oldDacl)
|
||||
// oldDacl points INTO oldDaclBuf (see windowsTokenDefaultDacl) and is
|
||||
// dereferenced by native code inside setEntriesInACL, above. Nothing in Go
|
||||
// references oldDaclBuf once windowsTokenDefaultDacl returned, so without
|
||||
// this KeepAlive it is eligible for GC the moment an allocation in this
|
||||
// function (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) hits a
|
||||
// safepoint, before the native call ever reads it - a real, if narrow,
|
||||
// use-after-free window in security-boundary code.
|
||||
runtime.KeepAlive(oldDaclBuf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("merge token default DACL: %w", err)
|
||||
}
|
||||
defer func() { _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(newDacl))) }()
|
||||
if err := windowsSetTokenDefaultDacl(token, newDacl); err != nil {
|
||||
return fmt.Errorf("set token default DACL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type windowsTokenDefaultDaclInfo struct {
|
||||
DefaultDacl *windows.ACL
|
||||
}
|
||||
|
||||
// windowsTokenDefaultDacl returns the token's current default DACL along with
|
||||
// the buffer backing it. TOKEN_DEFAULT_DACL embeds the ACL data inside the
|
||||
// same allocation GetTokenInformation fills, so the returned *windows.ACL is
|
||||
// only valid for as long as the returned buffer stays reachable: the caller
|
||||
// must runtime.KeepAlive(buf) until every native call that dereferences the
|
||||
// ACL has completed. A KeepAlive inside this function, ending at its own
|
||||
// return, would not protect any use by the caller after that.
|
||||
func windowsTokenDefaultDacl(token windows.Token) (*windows.ACL, []byte, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &size)
|
||||
if err == nil || err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, nil, fmt.Errorf("size TokenDefaultDacl: %w", err)
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buf[0], size, &size); err != nil {
|
||||
return nil, nil, fmt.Errorf("get TokenDefaultDacl: %w", err)
|
||||
}
|
||||
info := (*windowsTokenDefaultDaclInfo)(unsafe.Pointer(&buf[0]))
|
||||
return info.DefaultDacl, buf, nil
|
||||
}
|
||||
|
||||
func windowsSetTokenDefaultDacl(token windows.Token, dacl *windows.ACL) error {
|
||||
info := windowsTokenDefaultDaclInfo{DefaultDacl: dacl}
|
||||
err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
|
||||
runtime.KeepAlive(info)
|
||||
return err
|
||||
}
|
||||
|
||||
// setEntriesInACL merges entries into oldACL via Win32 SetEntriesInAclW,
|
||||
// returning a newly allocated ACL the caller must free with windows.LocalFree.
|
||||
func setEntriesInACL(entries []windows.EXPLICIT_ACCESS, oldACL *windows.ACL) (*windows.ACL, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, errors.New("setEntriesInACL requires at least one entry")
|
||||
}
|
||||
var newACL *windows.ACL
|
||||
// SetEntriesInAclW reports failure directly in its return value (ERROR_SUCCESS
|
||||
// on success, a Win32 error code otherwise); unlike many Win32 APIs it does not
|
||||
// set the thread's last-error, so the syscall trio's second/third return
|
||||
// values (which surface GetLastError) are not meaningful here.
|
||||
ret, _, _ := procSetEntriesInAclW.Call(
|
||||
uintptr(len(entries)),
|
||||
uintptr(unsafe.Pointer(&entries[0])),
|
||||
uintptr(unsafe.Pointer(oldACL)),
|
||||
uintptr(unsafe.Pointer(&newACL)),
|
||||
)
|
||||
runtime.KeepAlive(entries)
|
||||
if ret != 0 {
|
||||
return nil, fmt.Errorf("SetEntriesInAclW: %w", syscall.Errno(ret))
|
||||
}
|
||||
return newACL, nil
|
||||
}
|
||||
|
||||
func copyWindowsLogonSID(token windows.Token) ([]byte, error) {
|
||||
groups, err := token.GetTokenGroups()
|
||||
if err == nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ type EventType string
|
||||
const (
|
||||
EventRunStart EventType = "run_start"
|
||||
EventText EventType = "text"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventPermission EventType = "permission"
|
||||
EventPermissionRequest EventType = "permission_request"
|
||||
|
||||
@@ -2,6 +2,7 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -44,7 +45,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
||||
@@ -98,6 +99,35 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
|
||||
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
|
||||
// whitespace, indentation drift, escape normalization) to locate the span the
|
||||
// model intended. Only a span that occurs literally in the file is accepted,
|
||||
// so the replacement applied below is still exact.
|
||||
if occurrences == 0 {
|
||||
findOld, findNew := oldString, newString
|
||||
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
|
||||
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
|
||||
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
|
||||
}
|
||||
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
|
||||
switch {
|
||||
case ferr == nil:
|
||||
oldString = search
|
||||
// The model's new_string was written at old_string's (mismatched)
|
||||
// indentation; re-shape it to the span actually being replaced so a
|
||||
// tolerant match never strips indentation or a trailing CR.
|
||||
newString = adaptReplacementToSpan(search, findOld, findNew)
|
||||
occurrences = strings.Count(content, search)
|
||||
case errors.Is(ferr, errEditFuzzyAmbiguous):
|
||||
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
|
||||
case errors.Is(ferr, errEditFuzzyNotFound):
|
||||
// Fall through to the exact-match error below.
|
||||
default:
|
||||
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
@@ -120,6 +150,10 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker re-baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
|
||||
// Re-baseline to the content we just wrote so subsequent edits in this session
|
||||
// compare against the current on-disk state, not the pre-edit version.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -130,6 +164,7 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
suffix = "s"
|
||||
}
|
||||
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
package tools
|
||||
|
||||
// Fuzzy fallback matching for edit_file. When the model's old_string fails to
|
||||
// match byte-for-byte (drifted indentation, collapsed whitespace, escaped
|
||||
// characters, a slightly-misremembered middle line), these replacers propose
|
||||
// candidate spans that plausibly correspond to what the model intended. Only a
|
||||
// candidate that occurs literally in the file is accepted, so the replacement
|
||||
// itself is always exact even when the match was tolerant.
|
||||
//
|
||||
// Strategy cascade ported from opencode's edit tool, whose replacers were in
|
||||
// turn distilled from Cline's diff-apply evals and gemini-cli's edit corrector.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// editReplacer proposes candidate spans for find inside content. Candidates
|
||||
// are re-validated by fuzzyEditMatch before use.
|
||||
type editReplacer func(content, find string) []string
|
||||
|
||||
var (
|
||||
errEditFuzzyNotFound = errors.New("no fuzzy match for old_string")
|
||||
errEditFuzzyAmbiguous = errors.New("fuzzy match for old_string is ambiguous")
|
||||
)
|
||||
|
||||
// Minimum average middle-line similarity for block-anchor matches.
|
||||
const editAnchorSimilarityThreshold = 0.65
|
||||
|
||||
// fuzzyEditMatch runs the replacer cascade and returns the exact span of
|
||||
// content to replace. When replaceAll is false the span must be unique in
|
||||
// content; an ambiguous candidate is skipped in favor of later candidates and
|
||||
// only reported if nothing unique is found. A span wildly larger than
|
||||
// old_string is refused outright rather than risking a destructive edit.
|
||||
func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
|
||||
replacers := []editReplacer{
|
||||
lineTrimmedReplacer,
|
||||
blockAnchorReplacer,
|
||||
whitespaceNormalizedReplacer,
|
||||
indentationFlexibleReplacer,
|
||||
escapeNormalizedReplacer,
|
||||
trimmedBoundaryReplacer,
|
||||
contextAwareReplacer,
|
||||
}
|
||||
found := false
|
||||
for _, replacer := range replacers {
|
||||
// Collect the replacer's DISTINCT candidate spans that literally occur in
|
||||
// content. Two or more distinct spans from one strategy (e.g. duplicate
|
||||
// blocks at different indentation, each occurring once) mean the model's
|
||||
// intent is genuinely ambiguous — silently editing the first would be a
|
||||
// wrong-span write, so it is rejected instead.
|
||||
var candidates []string
|
||||
seen := map[string]bool{}
|
||||
for _, search := range replacer(content, find) {
|
||||
if search == "" || seen[search] {
|
||||
continue
|
||||
}
|
||||
seen[search] = true
|
||||
if strings.Index(content, search) < 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, search)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !replaceAll && len(candidates) > 1 {
|
||||
return "", errEditFuzzyAmbiguous
|
||||
}
|
||||
search := candidates[0]
|
||||
if isDisproportionateEditMatch(search, find) {
|
||||
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
|
||||
}
|
||||
if replaceAll {
|
||||
return search, nil
|
||||
}
|
||||
if strings.Index(content, search) == strings.LastIndex(content, search) {
|
||||
return search, nil
|
||||
}
|
||||
// The single candidate occurs at multiple positions; a later, stricter
|
||||
// strategy may still resolve a unique span, so keep cascading.
|
||||
}
|
||||
if !found {
|
||||
return "", errEditFuzzyNotFound
|
||||
}
|
||||
return "", errEditFuzzyAmbiguous
|
||||
}
|
||||
|
||||
// isDisproportionateEditMatch guards against anchor-style replacers matching a
|
||||
// span far larger than the text the model asked to replace (e.g. first/last
|
||||
// line anchors bridging hundreds of unrelated lines).
|
||||
func isDisproportionateEditMatch(search, find string) bool {
|
||||
findLines := strings.Count(find, "\n") + 1
|
||||
searchLines := strings.Count(search, "\n") + 1
|
||||
limit := findLines + 3
|
||||
if findLines*2 > limit {
|
||||
limit = findLines * 2
|
||||
}
|
||||
if searchLines >= limit {
|
||||
return true
|
||||
}
|
||||
if findLines == 1 {
|
||||
return false
|
||||
}
|
||||
searchTrimmed := len(strings.TrimSpace(search))
|
||||
findTrimmed := len(strings.TrimSpace(find))
|
||||
byteLimit := findTrimmed + 500
|
||||
if findTrimmed*4 > byteLimit {
|
||||
byteLimit = findTrimmed * 4
|
||||
}
|
||||
return searchTrimmed > byteLimit
|
||||
}
|
||||
|
||||
// splitFindLines splits find into lines, dropping the trailing empty line a
|
||||
// trailing newline produces so windows align with real content lines.
|
||||
func splitFindLines(find string) []string {
|
||||
lines := strings.Split(find, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// lineTrimmedReplacer matches when every line equals the corresponding
|
||||
// old_string line after trimming surrounding whitespace.
|
||||
func lineTrimmedReplacer(content, find string) []string {
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) == 0 {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
matches := true
|
||||
for j := range findLines {
|
||||
if strings.TrimSpace(contentLines[i+j]) != strings.TrimSpace(findLines[j]) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:i+len(findLines)], "\n"))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// blockAnchorReplacer anchors on the first and last lines (trimmed) and
|
||||
// accepts the block when the middle lines average >= the similarity threshold
|
||||
// (Levenshtein-based), tolerating a slightly misremembered interior.
|
||||
func blockAnchorReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
searchBlockSize := len(findLines)
|
||||
maxLineDelta := searchBlockSize / 4
|
||||
if maxLineDelta < 1 {
|
||||
maxLineDelta = 1
|
||||
}
|
||||
|
||||
type span struct{ start, end int }
|
||||
var candidates []span
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
// Only the first occurrence of the last anchor after this start counts,
|
||||
// mirroring the reference implementation: a farther-away closing line is
|
||||
// assumed to close a different block.
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
actualBlockSize := j - i + 1
|
||||
delta := actualBlockSize - searchBlockSize
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta <= maxLineDelta {
|
||||
candidates = append(candidates, span{start: i, end: j})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
middleSimilarity := func(candidate span) float64 {
|
||||
actualBlockSize := candidate.end - candidate.start + 1
|
||||
linesToCheck := searchBlockSize - 2
|
||||
if actualBlockSize-2 < linesToCheck {
|
||||
linesToCheck = actualBlockSize - 2
|
||||
}
|
||||
if linesToCheck <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
similarity := 0.0
|
||||
for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ {
|
||||
contentLine := strings.TrimSpace(contentLines[candidate.start+j])
|
||||
findLine := strings.TrimSpace(findLines[j])
|
||||
maxLen := len(contentLine)
|
||||
if len(findLine) > maxLen {
|
||||
maxLen = len(findLine)
|
||||
}
|
||||
if maxLen == 0 {
|
||||
continue
|
||||
}
|
||||
distance := levenshtein(contentLine, findLine)
|
||||
similarity += 1 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
return similarity / float64(linesToCheck)
|
||||
}
|
||||
|
||||
// Return EVERY candidate that clears the similarity threshold, best first.
|
||||
// Picking only the best would hide competing blocks from fuzzyEditMatch's
|
||||
// distinct-candidate ambiguity check and could silently edit the wrong
|
||||
// block; with all qualifying spans surfaced, one clear winner still
|
||||
// resolves (single candidate) while two plausible blocks are rejected as
|
||||
// ambiguous. replaceAll consumers take the first (most similar) span.
|
||||
type scoredSpan struct {
|
||||
span span
|
||||
similarity float64
|
||||
}
|
||||
var qualifying []scoredSpan
|
||||
for _, candidate := range candidates {
|
||||
if s := middleSimilarity(candidate); s >= editAnchorSimilarityThreshold {
|
||||
qualifying = append(qualifying, scoredSpan{span: candidate, similarity: s})
|
||||
}
|
||||
}
|
||||
if len(qualifying) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(qualifying, func(i, j int) bool {
|
||||
return qualifying[i].similarity > qualifying[j].similarity
|
||||
})
|
||||
spans := make([]string, 0, len(qualifying))
|
||||
for _, scored := range qualifying {
|
||||
spans = append(spans, strings.Join(contentLines[scored.span.start:scored.span.end+1], "\n"))
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
var editWhitespaceRun = regexp.MustCompile(`\s+`)
|
||||
|
||||
func normalizeEditWhitespace(text string) string {
|
||||
return strings.TrimSpace(editWhitespaceRun.ReplaceAllString(text, " "))
|
||||
}
|
||||
|
||||
// whitespaceNormalizedReplacer matches after collapsing all whitespace runs to
|
||||
// a single space: full lines, sub-line spans (via a word-boundary regex), and
|
||||
// multi-line windows.
|
||||
func whitespaceNormalizedReplacer(content, find string) []string {
|
||||
normalizedFind := normalizeEditWhitespace(find)
|
||||
if normalizedFind == "" {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
var candidates []string
|
||||
var subLinePattern *regexp.Regexp
|
||||
for _, line := range contentLines {
|
||||
normalizedLine := normalizeEditWhitespace(line)
|
||||
if normalizedLine == normalizedFind {
|
||||
candidates = append(candidates, line)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(normalizedLine, normalizedFind) {
|
||||
continue
|
||||
}
|
||||
if subLinePattern == nil {
|
||||
words := strings.Fields(find)
|
||||
quoted := make([]string, len(words))
|
||||
for i, word := range words {
|
||||
quoted[i] = regexp.QuoteMeta(word)
|
||||
}
|
||||
pattern, err := regexp.Compile(strings.Join(quoted, `\s+`))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subLinePattern = pattern
|
||||
}
|
||||
if match := subLinePattern.FindString(line); match != "" {
|
||||
candidates = append(candidates, match)
|
||||
}
|
||||
}
|
||||
|
||||
findLines := strings.Split(find, "\n")
|
||||
if len(findLines) > 1 {
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if normalizeEditWhitespace(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// stripCommonIndentation removes the minimum leading-whitespace width shared
|
||||
// by all non-empty lines, so blocks match regardless of their nesting depth.
|
||||
func stripCommonIndentation(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
minIndent := -1
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
indent := len(line) - len(strings.TrimLeft(line, " \t"))
|
||||
if minIndent < 0 || indent < minIndent {
|
||||
minIndent = indent
|
||||
}
|
||||
}
|
||||
if minIndent <= 0 {
|
||||
return text
|
||||
}
|
||||
stripped := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
stripped[i] = line
|
||||
continue
|
||||
}
|
||||
stripped[i] = line[minIndent:]
|
||||
}
|
||||
return strings.Join(stripped, "\n")
|
||||
}
|
||||
|
||||
func indentationFlexibleReplacer(content, find string) []string {
|
||||
normalizedFind := stripCommonIndentation(find)
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
var candidates []string
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if stripCommonIndentation(block) == normalizedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var editEscapeSequence = regexp.MustCompile("\\\\(n|t|r|'|\"|`|\\\\|\\n|\\$)")
|
||||
|
||||
// unescapeEditString undoes one level of string escaping (\n, \t, \", \\, a
|
||||
// backslash-newline continuation, \$) — the model sometimes reproduces file
|
||||
// content as it appeared inside a quoted string literal.
|
||||
func unescapeEditString(text string) string {
|
||||
return editEscapeSequence.ReplaceAllStringFunc(text, func(match string) string {
|
||||
switch match[1:] {
|
||||
case "n":
|
||||
return "\n"
|
||||
case "t":
|
||||
return "\t"
|
||||
case "r":
|
||||
return "\r"
|
||||
case "\n":
|
||||
return "\n"
|
||||
default:
|
||||
// ', ", `, \, $ all unescape to themselves.
|
||||
return match[1:]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func escapeNormalizedReplacer(content, find string) []string {
|
||||
unescapedFind := unescapeEditString(find)
|
||||
var candidates []string
|
||||
if strings.Contains(content, unescapedFind) {
|
||||
candidates = append(candidates, unescapedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(unescapedFind, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if unescapeEditString(block) == unescapedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// trimmedBoundaryReplacer tolerates stray leading/trailing whitespace (often
|
||||
// blank lines) around an otherwise-exact old_string.
|
||||
func trimmedBoundaryReplacer(content, find string) []string {
|
||||
trimmedFind := strings.TrimSpace(find)
|
||||
if trimmedFind == find || trimmedFind == "" {
|
||||
return nil
|
||||
}
|
||||
var candidates []string
|
||||
if strings.Contains(content, trimmedFind) {
|
||||
candidates = append(candidates, trimmedFind)
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
findLines := strings.Split(find, "\n")
|
||||
for i := 0; i+len(findLines) <= len(contentLines); i++ {
|
||||
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
|
||||
if strings.TrimSpace(block) == trimmedFind {
|
||||
candidates = append(candidates, block)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// contextAwareReplacer anchors on the first and last lines and accepts an
|
||||
// equal-length block when at least half of its non-empty middle lines match
|
||||
// after trimming — a cheaper, stricter cousin of blockAnchorReplacer.
|
||||
func contextAwareReplacer(content, find string) []string {
|
||||
findLines := splitFindLines(find)
|
||||
if len(findLines) < 3 {
|
||||
return nil
|
||||
}
|
||||
contentLines := strings.Split(content, "\n")
|
||||
firstAnchor := strings.TrimSpace(findLines[0])
|
||||
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
|
||||
var candidates []string
|
||||
for i := 0; i < len(contentLines); i++ {
|
||||
if strings.TrimSpace(contentLines[i]) != firstAnchor {
|
||||
continue
|
||||
}
|
||||
for j := i + 2; j < len(contentLines); j++ {
|
||||
if strings.TrimSpace(contentLines[j]) != lastAnchor {
|
||||
continue
|
||||
}
|
||||
if j-i+1 == len(findLines) {
|
||||
matching, nonEmpty := 0, 0
|
||||
for k := 1; k < len(findLines)-1; k++ {
|
||||
blockLine := strings.TrimSpace(contentLines[i+k])
|
||||
findLine := strings.TrimSpace(findLines[k])
|
||||
if blockLine == "" && findLine == "" {
|
||||
continue
|
||||
}
|
||||
nonEmpty++
|
||||
if blockLine == findLine {
|
||||
matching++
|
||||
}
|
||||
}
|
||||
if nonEmpty == 0 || float64(matching)/float64(nonEmpty) >= 0.5 {
|
||||
candidates = append(candidates, strings.Join(contentLines[i:j+1], "\n"))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// adaptReplacementToSpan re-shapes the model's replacement to the span a
|
||||
// tolerant matcher resolved. When old_string only matched after normalization,
|
||||
// new_string was written at old_string's (wrong) shape, so applying it raw
|
||||
// would strip the file's indentation or drop a trailing CR:
|
||||
//
|
||||
// 1. Uniform re-indent: when every span line equals delta + the corresponding
|
||||
// find line's indentation (the line-trimmed / indentation-flexible shapes),
|
||||
// the same delta is prepended to every non-blank replacement line. Any
|
||||
// line that breaks the uniform-delta relationship disables the shift —
|
||||
// block-anchor matches with a drifted interior are left untouched.
|
||||
// 2. Trailing CR: a span from a CRLF file ends mid-line at "\r" (candidates
|
||||
// are built by joining lines split on "\n"); the replacement gets the same
|
||||
// trailing "\r" so the file's CRLF pairs stay intact.
|
||||
func adaptReplacementToSpan(span, find, replacement string) string {
|
||||
if delta, ok := uniformIndentDelta(span, find); ok && delta != "" {
|
||||
lines := strings.Split(replacement, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
lines[i] = delta + line
|
||||
}
|
||||
replacement = strings.Join(lines, "\n")
|
||||
}
|
||||
if strings.HasSuffix(span, "\r") && !strings.HasSuffix(replacement, "\r") {
|
||||
replacement += "\r"
|
||||
}
|
||||
return replacement
|
||||
}
|
||||
|
||||
// uniformIndentDelta returns the indentation prefix that, prepended to every
|
||||
// non-blank find line, yields the corresponding span line's indentation. ok is
|
||||
// false when line counts differ, any line pair disagrees on the delta, or the
|
||||
// span is not simply a uniformly deeper-indented copy of find.
|
||||
func uniformIndentDelta(span, find string) (string, bool) {
|
||||
spanLines := strings.Split(span, "\n")
|
||||
findLines := splitFindLines(find)
|
||||
if len(spanLines) != len(findLines) {
|
||||
return "", false
|
||||
}
|
||||
leadingWhitespace := func(line string) string {
|
||||
return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
||||
}
|
||||
delta := ""
|
||||
haveDelta := false
|
||||
for i := range findLines {
|
||||
spanLine := strings.TrimSuffix(spanLines[i], "\r")
|
||||
findLine := strings.TrimSuffix(findLines[i], "\r")
|
||||
if strings.TrimSpace(spanLine) == "" && strings.TrimSpace(findLine) == "" {
|
||||
continue
|
||||
}
|
||||
spanIndent := leadingWhitespace(spanLine)
|
||||
findIndent := leadingWhitespace(findLine)
|
||||
if !strings.HasSuffix(spanIndent, findIndent) {
|
||||
return "", false
|
||||
}
|
||||
lineDelta := spanIndent[:len(spanIndent)-len(findIndent)]
|
||||
if !haveDelta {
|
||||
delta = lineDelta
|
||||
haveDelta = true
|
||||
continue
|
||||
}
|
||||
if lineDelta != delta {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return delta, haveDelta
|
||||
}
|
||||
|
||||
// levenshtein computes edit distance with a two-row rolling matrix.
|
||||
func levenshtein(a, b string) int {
|
||||
if a == "" {
|
||||
return len(b)
|
||||
}
|
||||
if b == "" {
|
||||
return len(a)
|
||||
}
|
||||
previous := make([]int, len(b)+1)
|
||||
current := make([]int, len(b)+1)
|
||||
for j := range previous {
|
||||
previous[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
current[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
minimum := previous[j] + 1
|
||||
if current[j-1]+1 < minimum {
|
||||
minimum = current[j-1] + 1
|
||||
}
|
||||
if previous[j-1]+cost < minimum {
|
||||
minimum = previous[j-1] + cost
|
||||
}
|
||||
current[j] = minimum
|
||||
}
|
||||
previous, current = current, previous
|
||||
}
|
||||
return previous[len(b)]
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if args["path"] == nil {
|
||||
args["path"] = "target.txt"
|
||||
}
|
||||
result := NewEditFileTool(dir).Run(context.Background(), args)
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result, string(after)
|
||||
}
|
||||
|
||||
func TestEditExactMatchStillPreferred(t *testing.T) {
|
||||
// The fast path must keep byte-exact semantics: an exact match wins even
|
||||
// when fuzzy strategies would also find candidates elsewhere.
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\nbeta\ngamma\n", map[string]any{
|
||||
"old_string": "beta",
|
||||
"new_string": "delta",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\ndelta\ngamma\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyLineTrimmed(t *testing.T) {
|
||||
// Model reproduced the lines without the file's leading indentation; the
|
||||
// line-trimmed strategy must find the real indented span and preserve the
|
||||
// file's own indentation on the replaced region boundary.
|
||||
initial := "func a() {\n\tx := 1\n\treturn x\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1\nreturn x",
|
||||
"new_string": "y := 2\nreturn y",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
// new_string was written at old_string's outdented shape; the span's
|
||||
// indentation must be re-applied so the file keeps its tabs.
|
||||
if !strings.Contains(after, "\ty := 2\n\treturn y\n}") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
if strings.Contains(after, "x := 1") {
|
||||
t.Fatalf("old span survived: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyWhitespaceNormalizedSingleLine(t *testing.T) {
|
||||
// Whitespace runs collapsed: "x := 1" in the file, single spaces from
|
||||
// the model.
|
||||
initial := "start\nx := 1\nend\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "x := 2") || strings.Contains(after, ":= 1") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyBlockAnchorToleratesMiddleDrift(t *testing.T) {
|
||||
// First and last lines anchor the block; one interior line differs slightly
|
||||
// (comment text drifted). Levenshtein similarity keeps it above 0.65.
|
||||
initial := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to the client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
find := strings.Join([]string{
|
||||
"func handler(w http.ResponseWriter, r *http.Request) {",
|
||||
"\t// write the response body to client",
|
||||
"\tw.WriteHeader(http.StatusOK)",
|
||||
"\tfmt.Fprint(w, \"done\")",
|
||||
"}",
|
||||
}, "\n")
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": find,
|
||||
"new_string": "func handler(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusNoContent)\n}",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "StatusNoContent") || strings.Contains(after, "StatusOK") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyIndentationFlexible(t *testing.T) {
|
||||
// The whole block sits one nesting level deeper in the file than in the
|
||||
// model's old_string, with interior relative indentation preserved.
|
||||
initial := "if ok {\n\t\tfor i := range xs {\n\t\t\tsum += xs[i]\n\t\t}\n}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "for i := range xs {\n\tsum += xs[i]\n}",
|
||||
"new_string": "sum += total(xs)",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
// The replacement lands at the span's real depth (two tabs), not at
|
||||
// old_string's outdented depth.
|
||||
if !strings.Contains(after, "\t\tsum += total(xs)") || strings.Contains(after, "range xs") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyCRLFLineTrimmedPreservesCarriageReturns(t *testing.T) {
|
||||
// CRLF file + a multi-line outdented old_string: the resolved span carries
|
||||
// tabs and trailing CRs; the replacement must inherit both so the file's
|
||||
// CRLF pairs and indentation survive the edit.
|
||||
initial := "func a() {\r\n\tx := 1\r\n\treturn x\r\n}\r\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1\nreturn x",
|
||||
"new_string": "y := 2\nreturn y",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\ty := 2\r\n\treturn y\r\n}") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
if strings.Contains(after, "\n\treturn y\n") {
|
||||
t.Fatalf("bare LF introduced into CRLF file: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniformIndentDelta(t *testing.T) {
|
||||
cases := []struct {
|
||||
span, find string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"\tx := 1\n\treturn x", "x := 1\nreturn x", "\t", true},
|
||||
{"\t\tfor {\n\t\t\tgo()\n\t\t}", "for {\n\tgo()\n}", "\t\t", true},
|
||||
{"x := 1", "x := 1", "", true}, // no shift needed
|
||||
{"\tx := 1\n return x", "x := 1\nreturn x", "", false}, // mixed delta
|
||||
{"\ta\n\tb\n\tc", "a\nb", "", false}, // line-count mismatch
|
||||
{" a", "\ta", "", false}, // find indent not a suffix
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := uniformIndentDelta(c.span, c.find)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("uniformIndentDelta(%q, %q) = %q,%v want %q,%v", c.span, c.find, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptReplacementLeavesNonUniformSpansAlone(t *testing.T) {
|
||||
// Block-anchor style match with a drifted interior: no uniform delta, so
|
||||
// the replacement must pass through untouched.
|
||||
span := "\tfunc h() {\n\t\t// drifted comment\n\t}"
|
||||
find := "func h() {\n// different comment\n}"
|
||||
if got := adaptReplacementToSpan(span, find, "replacement()"); got != "replacement()" {
|
||||
t.Fatalf("non-uniform span must not re-indent: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyEscapeNormalized(t *testing.T) {
|
||||
// Model reproduced the line as it would appear inside a quoted string
|
||||
// literal, with escaped quotes.
|
||||
initial := "console.log(\"hello world\")\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": `console.log(\"hello world\")`,
|
||||
"new_string": `console.log("goodbye")`,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, `console.log("goodbye")`) {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyTrimmedBoundary(t *testing.T) {
|
||||
// Stray whitespace around an otherwise-exact old_string; " two " is not a
|
||||
// substring of the file, so only the fuzzy cascade can resolve it.
|
||||
initial := "one\ntwo\nthree\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": " two ",
|
||||
"new_string": "2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if after != "one\n2\nthree\n" {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimmedBoundaryReplacerYieldsTrimmedSpan(t *testing.T) {
|
||||
candidates := trimmedBoundaryReplacer("one\ntwo\nthree\n", "\ntwo\n")
|
||||
if len(candidates) == 0 || candidates[0] != "two" {
|
||||
t.Fatalf("expected trimmed candidate \"two\", got %v", candidates)
|
||||
}
|
||||
if trimmedBoundaryReplacer("anything", "already-trimmed") != nil {
|
||||
t.Fatal("already-trimmed find must yield no candidates")
|
||||
}
|
||||
}
|
||||
|
||||
// Two identical blocks that only match old_string after indentation-tolerant
|
||||
// matching: exact search finds nothing, fuzzy finds both.
|
||||
const ambiguousBlocks = "\tif x {\n\t\tgo()\n\t}\nmid\n\tif x {\n\t\tgo()\n\t}\n"
|
||||
|
||||
func TestEditFuzzyAmbiguousReportsError(t *testing.T) {
|
||||
// No strategy can disambiguate identical blocks, so the tool must refuse
|
||||
// rather than guess, and the file must be untouched.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("expected ambiguity error, got %q", result.Output)
|
||||
}
|
||||
if after != ambiguousBlocks {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyReplaceAll(t *testing.T) {
|
||||
// replace_all applies the fuzzy-resolved span at every occurrence.
|
||||
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
"replace_all": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if strings.Count(after, "stop()") != 2 || strings.Contains(after, "go()") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyNotFoundKeepsExactError(t *testing.T) {
|
||||
result, after := runEdit(t, t.TempDir(), "alpha\n", map[string]any{
|
||||
"old_string": "omega",
|
||||
"new_string": "x",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "Could not find the exact string") {
|
||||
t.Fatalf("expected not-found error, got %q", result.Output)
|
||||
}
|
||||
if after != "alpha\n" {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyCRLFFile(t *testing.T) {
|
||||
// CRLF file + LF old_string with indentation drift: the CRLF translation
|
||||
// feeds the cascade and the replacement preserves CRLF endings.
|
||||
initial := "func a() {\r\n\tx := 1\r\n}\r\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "x := 1",
|
||||
"new_string": "x := 2",
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(after, "\tx := 2\r\n") {
|
||||
t.Fatalf("unexpected content: %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDisproportionateEditMatch(t *testing.T) {
|
||||
// A candidate span that dwarfs old_string must be refused: anchors bridging
|
||||
// unrelated code would otherwise delete it all.
|
||||
big := strings.Repeat("line\n", 10)
|
||||
if !isDisproportionateEditMatch(big, "a\nb\nc") {
|
||||
t.Fatal("10-line span for 3-line find must be disproportionate")
|
||||
}
|
||||
if isDisproportionateEditMatch("a\nb\nc\nd", "a\nb\nc") {
|
||||
t.Fatal("one extra line within tolerance must be allowed")
|
||||
}
|
||||
if isDisproportionateEditMatch("single line", "single") {
|
||||
t.Fatal("single-line finds are exempt from the byte-length guard")
|
||||
}
|
||||
if !isDisproportionateEditMatch("xx\n"+strings.Repeat("y", 900)+"\nzz", "xx\nab\nzz") {
|
||||
t.Fatal("byte-length blowup must be disproportionate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"abc", "", 3},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "sitting", 3},
|
||||
{"same", "same", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := levenshtein(c.a, c.b); got != c.want {
|
||||
t.Fatalf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyDistinctCandidatesAreAmbiguous(t *testing.T) {
|
||||
// Two same-content blocks at DIFFERENT indentation: each resolved span is
|
||||
// distinct and occurs exactly once, so the old literal-uniqueness check
|
||||
// passed and silently edited the first block. Distinct-shaped candidates
|
||||
// from one strategy must instead report ambiguity and leave the file alone.
|
||||
initial := "\tif x {\n\t\tgo()\n\t}\nmid\n\t\tif x {\n\t\t\tgo()\n\t\t}\n"
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": "if x {\n\tgo()\n}",
|
||||
"new_string": "stop()",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("distinct fuzzy candidates must be ambiguous, got %q", result.Output)
|
||||
}
|
||||
if after != initial {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFuzzyBlockAnchorTwoPlausibleBlocksAmbiguous(t *testing.T) {
|
||||
// Two blocks share the same first/last anchor lines and BOTH interiors sit
|
||||
// above the similarity threshold. Picking the "best" one would silently
|
||||
// edit a block the model may not have meant; both must surface so the
|
||||
// cascade reports ambiguity and the file stays untouched.
|
||||
initial := strings.Join([]string{
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, beta, gamma)",
|
||||
"}",
|
||||
"",
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, delta, gamma)",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
find := strings.Join([]string{
|
||||
"func setup(cfg Config) {",
|
||||
"\tvalue := compute(alpha, omega, gamma)",
|
||||
"}",
|
||||
}, "\n")
|
||||
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
|
||||
"old_string": find,
|
||||
"new_string": "func setup(cfg Config) {}",
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
|
||||
t.Fatalf("two plausible anchor blocks must be ambiguous, got %q", result.Output)
|
||||
}
|
||||
if after != initial {
|
||||
t.Fatalf("file must be unchanged, got %q", after)
|
||||
}
|
||||
}
|
||||
@@ -908,6 +908,15 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
|
||||
}
|
||||
|
||||
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
|
||||
}
|
||||
|
||||
// truncateExecOutputSpill keeps a head/tail window of the output within the
|
||||
// token budget and, on truncation, spills the full output to disk so the model
|
||||
// can grep/read the elided middle instead of re-running the command with a
|
||||
// bigger budget. The spill is best-effort: when it fails the notice simply
|
||||
// omits the file hint.
|
||||
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = defaultMaxOutputTokens
|
||||
}
|
||||
@@ -915,9 +924,13 @@ func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
|
||||
if len(output) <= maxBytes {
|
||||
return output, false
|
||||
}
|
||||
notice := "\n[zero] output truncated\n"
|
||||
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
|
||||
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
|
||||
}
|
||||
head := maxBytes / 2
|
||||
tail := maxBytes - head
|
||||
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
|
||||
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
|
||||
}
|
||||
|
||||
func utf8Prefix(value string, maxBytes int) string {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Format-on-write for the mutating file tools. When enabled, a successful
|
||||
// edit_file/write_file runs the language's standard formatter on the file it
|
||||
// just wrote, so the model's output always lands in project-canonical style
|
||||
// and never fails a CI format check it cannot see. Off by default (set
|
||||
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
|
||||
// write, which strict workflows may not want.
|
||||
//
|
||||
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
|
||||
// the caller records the POST-format content. Formatting after the baseline
|
||||
// would make the very next edit look like an external modification and trip
|
||||
// the conflict guard.
|
||||
|
||||
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
|
||||
// hang a tool call. On timeout the unformatted write stands.
|
||||
const formatOnWriteTimeout = 10 * time.Second
|
||||
|
||||
// formatterCommands maps a file extension to the formatter argv; the file path
|
||||
// is appended as the final argument. Only in-place, config-respecting,
|
||||
// community-standard formatters — a missing binary silently skips formatting.
|
||||
var formatterCommands = map[string][]string{
|
||||
".go": {"gofmt", "-w"},
|
||||
".rs": {"rustfmt"},
|
||||
".py": {"ruff", "format", "--quiet"},
|
||||
".ts": {"prettier", "--log-level", "silent", "--write"},
|
||||
".tsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".js": {"prettier", "--log-level", "silent", "--write"},
|
||||
".jsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".json": {"prettier", "--log-level", "silent", "--write"},
|
||||
".css": {"prettier", "--log-level", "silent", "--write"},
|
||||
".scss": {"prettier", "--log-level", "silent", "--write"},
|
||||
".html": {"prettier", "--log-level", "silent", "--write"},
|
||||
".md": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yaml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".zig": {"zig", "fmt"},
|
||||
".dart": {"dart", "format"},
|
||||
".tf": {"terraform", "fmt"},
|
||||
".gleam": {"gleam", "format"},
|
||||
".sh": {"shfmt", "-w"},
|
||||
".bash": {"shfmt", "-w"},
|
||||
".c": {"clang-format", "-i"},
|
||||
".h": {"clang-format", "-i"},
|
||||
".cpp": {"clang-format", "-i"},
|
||||
".hpp": {"clang-format", "-i"},
|
||||
".cc": {"clang-format", "-i"},
|
||||
".kt": {"ktlint", "-F"},
|
||||
".swift": {"swiftformat"},
|
||||
".lua": {"stylua"},
|
||||
}
|
||||
|
||||
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
|
||||
func formatOnWriteEnabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
|
||||
return value != "" && value != "0" && !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
|
||||
// enabled and on PATH) and returns the file's content afterwards. Best-effort
|
||||
// throughout: any failure — no formatter, formatter error, timeout, unreadable
|
||||
// result — returns writtenContent so the caller's state matches the last write
|
||||
// it performed itself.
|
||||
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
|
||||
if !formatOnWriteEnabled() {
|
||||
return writtenContent
|
||||
}
|
||||
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
|
||||
if !ok {
|
||||
return writtenContent
|
||||
}
|
||||
if _, err := exec.LookPath(command[0]); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
if err := formatter.Run(); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatted, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gofmt ships with the Go toolchain, so it is the one formatter guaranteed to
|
||||
// exist wherever these tests run.
|
||||
func requireGofmt(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("gofmt"); err != nil {
|
||||
t.Skip("gofmt not on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteDisabledByDefault(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "")
|
||||
dir := t.TempDir()
|
||||
ugly := "package a\n\nfunc A( ) { }\n"
|
||||
|
||||
result := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": ugly,
|
||||
}, RunOptions{})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", result.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(onDisk) != ugly {
|
||||
t.Fatalf("formatting must be off by default, got %q", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
|
||||
requireGofmt(t)
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
dir := t.TempDir()
|
||||
tracker := NewFileTracker()
|
||||
|
||||
write := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"content": "package a\n\nfunc A( ) { }\n",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if write.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", write.Output)
|
||||
}
|
||||
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(onDisk), "func A() {") {
|
||||
t.Fatalf("expected gofmt-formatted content, got %q", onDisk)
|
||||
}
|
||||
|
||||
// The tracker must have been re-baselined to the POST-format content: a
|
||||
// follow-up edit must not trip the external-modification conflict guard.
|
||||
edit := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "func A() {",
|
||||
"new_string": "func B() {",
|
||||
}, RunOptions{FileTracker: tracker})
|
||||
if edit.Status != StatusOK {
|
||||
t.Fatalf("follow-up edit must not conflict after formatting: %q", edit.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
|
||||
if content != "raw text" {
|
||||
t.Fatalf("unknown extension must pass through: %q", content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
|
||||
// appends to its output. Empty when no Diagnostics callback is wired, the file
|
||||
// is clean, or no language server is available — the tool output is then
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
|
||||
if options.Diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
block := options.Diagnostics(ctx, absolutePath)
|
||||
if block == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A wired Diagnostics callback must surface its block in the tool output for
|
||||
// both mutating tools, and a clean file (empty block) must leave the output
|
||||
// byte-identical to the pre-diagnostics behavior.
|
||||
func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostics := func(_ context.Context, absPath string) string {
|
||||
return absPath + ":1:1: error: undefined: x"
|
||||
}
|
||||
|
||||
editResult := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "a.go",
|
||||
"old_string": "package a",
|
||||
"new_string": "package b",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if editResult.Status != StatusOK {
|
||||
t.Fatalf("edit failed: %q", editResult.Output)
|
||||
}
|
||||
if !strings.Contains(editResult.Output, "Diagnostics in a.go after this change") || !strings.Contains(editResult.Output, "undefined: x") {
|
||||
t.Fatalf("edit output missing diagnostics block: %q", editResult.Output)
|
||||
}
|
||||
|
||||
writeResult := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "b.go",
|
||||
"content": "package b\n",
|
||||
}, RunOptions{Diagnostics: diagnostics})
|
||||
if writeResult.Status != StatusOK {
|
||||
t.Fatalf("write failed: %q", writeResult.Output)
|
||||
}
|
||||
if !strings.Contains(writeResult.Output, "Diagnostics in b.go after this change") {
|
||||
t.Fatalf("write output missing diagnostics block: %q", writeResult.Output)
|
||||
}
|
||||
|
||||
clean := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
|
||||
"path": "c.go",
|
||||
"content": "package c\n",
|
||||
}, RunOptions{Diagnostics: func(context.Context, string) string { return "" }})
|
||||
if strings.Contains(clean.Output, "Diagnostics") {
|
||||
t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ type RunOptions struct {
|
||||
// tool-call progress in the specialist card. nil is a no-op (the default
|
||||
// for every non-Task tool).
|
||||
Progress func(streamjson.Event)
|
||||
// Diagnostics, when set, returns a formatted language-diagnostics block for
|
||||
// a file a mutating tool just wrote ("" when clean or no server available).
|
||||
// edit_file/write_file append it to their output so the model sees an error
|
||||
// it introduced in the same turn instead of waiting for a later verification
|
||||
// pass. nil disables inline diagnostics.
|
||||
Diagnostics func(ctx context.Context, absPath string) string
|
||||
}
|
||||
|
||||
type sandboxAwareTool interface {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// Spill-to-disk for truncated tool output. When a command produces more than
|
||||
// its context budget, the tail was previously simply gone — the model's only
|
||||
// recourse was re-running the (possibly expensive or non-idempotent) command
|
||||
// with a bigger budget. Instead, the full output is written to a cache file
|
||||
// whose path is included in the truncation notice, so the model can grep or
|
||||
// read_file the remainder. Spilling is best-effort: on any error truncation
|
||||
// behaves exactly as before, just without the file hint.
|
||||
|
||||
// spillRetention is how long spilled outputs are kept; files older than this
|
||||
// are opportunistically removed whenever a new spill happens.
|
||||
const spillRetention = 7 * 24 * time.Hour
|
||||
|
||||
// spillRootPath returns the spill directory path without creating or checking
|
||||
// it. Used by the read-path resolver to recognize spill files as readable.
|
||||
func spillRootPath() string {
|
||||
name := "zero-tool-output"
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
name = fmt.Sprintf("zero-tool-output-%d", uid)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), name)
|
||||
}
|
||||
|
||||
// resolveSpillReadPath reports whether requestedPath is a file inside the
|
||||
// spill directory and, if so, returns its verified absolute path. Spill files
|
||||
// are zero-created, per-uid owned, and already secret-redacted, so letting the
|
||||
// scoped read tools open them is what makes the truncation notice's
|
||||
// "read_file it" recovery actually work. Symlinks are resolved and the result
|
||||
// must still be inside the spill dir, so a planted link cannot smuggle an
|
||||
// out-of-scope file through this gate.
|
||||
func resolveSpillReadPath(requestedPath string) (string, bool) {
|
||||
if requestedPath == "" || !filepath.IsAbs(requestedPath) {
|
||||
return "", false
|
||||
}
|
||||
root := spillRootPath()
|
||||
cleaned := filepath.Clean(requestedPath)
|
||||
if cleaned == root || !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(cleaned)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
// spillDir returns the per-user spill directory, creating it on first use.
|
||||
// Hardened for shared temp dirs (Linux /tmp): the name carries the uid so
|
||||
// users cannot collide, and a pre-existing path is only accepted when it is a
|
||||
// real directory (not a symlink that would redirect spills) owned by the
|
||||
// current user. Any doubt fails the spill — it is best-effort anyway.
|
||||
func spillDir() (string, error) {
|
||||
dir := spillRootPath()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// MkdirAll follows symlinks and leaves an existing directory untouched, so
|
||||
// verify what is actually at the path.
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("spill path %s is not a directory", dir)
|
||||
}
|
||||
if err := checkSpillDirOwner(info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// spillTruncatedOutput writes the full pre-truncation output to the spill
|
||||
// directory and returns the file path, or "" when spilling fails. Output is
|
||||
// scrubbed with the same configured-key redaction the registry applies at the
|
||||
// tool boundary, so a spilled file never holds a secret the transcript would
|
||||
// have hidden.
|
||||
func spillTruncatedOutput(toolName, output string) string {
|
||||
dir, err := spillDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sweepSpillDir(dir)
|
||||
prefix := strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, toolName)
|
||||
file, err := os.CreateTemp(dir, prefix+"-*.txt")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
scrubbed := redaction.RedactString(output, redaction.Options{})
|
||||
if _, err := file.WriteString(scrubbed); err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return ""
|
||||
}
|
||||
return file.Name()
|
||||
}
|
||||
|
||||
// sweepSpillDir removes spill files older than spillRetention. Best-effort:
|
||||
// errors are ignored, the directory is small, and a sweep runs only when a new
|
||||
// spill is about to happen anyway.
|
||||
func sweepSpillDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-spillRetention)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func spillDirName() string {
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
return fmt.Sprintf("zero-tool-output-%d", uid)
|
||||
}
|
||||
return "zero-tool-output"
|
||||
}
|
||||
|
||||
func TestSpillDirRejectsSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation needs privileges on Windows")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
elsewhere := filepath.Join(tmp, "elsewhere")
|
||||
if err := os.Mkdir(elsewhere, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An attacker pre-creates the spill path as a symlink to redirect spills.
|
||||
if err := os.Symlink(elsewhere, filepath.Join(tmp, spillDirName())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path := spillTruncatedOutput("bash", "sensitive output"); path != "" {
|
||||
t.Fatalf("spill must refuse a symlinked directory, wrote %s", path)
|
||||
}
|
||||
entries, _ := os.ReadDir(elsewhere)
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("nothing may land behind the symlink: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillDirAcceptsOwnedDirectory(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
if path := spillTruncatedOutput("bash", "ok"); path == "" {
|
||||
t.Fatal("spill must work in a clean per-user temp dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpillReadPathContainment(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
spillPath := spillTruncatedOutput("exec_command", "spilled body")
|
||||
if spillPath == "" {
|
||||
t.Fatal("spill must succeed in a clean temp dir")
|
||||
}
|
||||
resolved, ok := resolveSpillReadPath(spillPath)
|
||||
if !ok || resolved == "" {
|
||||
t.Fatalf("a real spill file must resolve, got ok=%v", ok)
|
||||
}
|
||||
if _, ok := resolveSpillReadPath(filepath.Join(spillRootPath(), "..", "escape.txt")); ok {
|
||||
t.Fatal("path traversal out of the spill dir must be rejected")
|
||||
}
|
||||
if _, ok := resolveSpillReadPath("/etc/hosts"); ok {
|
||||
t.Fatal("paths outside the spill dir must be rejected")
|
||||
}
|
||||
if _, ok := resolveSpillReadPath(spillRootPath()); ok {
|
||||
t.Fatal("the spill dir itself is not a readable file target")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
// A symlink planted inside the spill dir pointing outside must not leak.
|
||||
link := filepath.Join(spillRootPath(), "sneaky-link")
|
||||
if err := os.Symlink("/etc/hosts", link); err == nil {
|
||||
if _, ok := resolveSpillReadPath(link); ok {
|
||||
t.Fatal("symlink escaping the spill dir must be rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileCanReadSpillFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
spillPath := spillTruncatedOutput("exec_command", "line one\nline two\n")
|
||||
if spillPath == "" {
|
||||
t.Fatal("spill must succeed")
|
||||
}
|
||||
workspace := t.TempDir()
|
||||
result := NewReadFileTool(workspace).Run(context.Background(), map[string]any{"path": spillPath})
|
||||
if result.Status != StatusOK || !strings.Contains(result.Output, "line two") {
|
||||
t.Fatalf("read_file must be able to follow the truncation notice: %s %q", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// checkSpillDirOwner rejects a spill directory not owned by the current user:
|
||||
// on a shared /tmp another user could have pre-created the path and would then
|
||||
// control its lifetime (deletion/renaming) even though the 0600 spill files
|
||||
// keep their contents private.
|
||||
func checkSpillDirOwner(info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("spill directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import "os"
|
||||
|
||||
// checkSpillDirOwner is a no-op on Windows: %TEMP% is per-user by default and
|
||||
// there is no portable uid to compare. The Lstat symlink/dir check in spillDir
|
||||
// still applies.
|
||||
func checkSpillDirOwner(os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTruncateExecOutputSpillWritesFullOutput(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
long := strings.Repeat("line of build output\n", 5000) // ~100KB > 40KB budget
|
||||
|
||||
truncated, wasTruncated := truncateExecOutputSpill(long, defaultMaxOutputTokens, "bash")
|
||||
if !wasTruncated {
|
||||
t.Fatal("output over budget must truncate")
|
||||
}
|
||||
if !strings.Contains(truncated, "full output saved to ") {
|
||||
t.Fatalf("truncation notice missing spill path: %q", truncated[:200])
|
||||
}
|
||||
// Extract the path and verify the file holds the complete output.
|
||||
start := strings.Index(truncated, "full output saved to ") + len("full output saved to ")
|
||||
end := strings.Index(truncated[start:], " (grep")
|
||||
spillPath := truncated[start : start+end]
|
||||
content, err := os.ReadFile(spillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if string(content) != long {
|
||||
t.Fatalf("spill file must hold the full output: got %d bytes, want %d", len(content), len(long))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateExecOutputUnderBudgetUnchanged(t *testing.T) {
|
||||
output, wasTruncated := truncateExecOutputSpill("short output", defaultMaxOutputTokens, "bash")
|
||||
if wasTruncated || output != "short output" {
|
||||
t.Fatalf("under-budget output must pass through untouched: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldFile := filepath.Join(dir, "bash-old.txt")
|
||||
newFile := filepath.Join(dir, "bash-new.txt")
|
||||
for _, path := range []string{oldFile, newFile} {
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stale := time.Now().Add(-spillRetention - time.Hour)
|
||||
if err := os.Chtimes(oldFile, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sweepSpillDir(dir)
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Fatal("stale spill file must be removed")
|
||||
}
|
||||
if _, err := os.Stat(newFile); err != nil {
|
||||
t.Fatal("fresh spill file must survive the sweep")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
path := spillTruncatedOutput("exec_command", "some output body")
|
||||
if path == "" {
|
||||
t.Fatal("spill must return a file path")
|
||||
}
|
||||
if base := filepath.Base(path); !strings.HasPrefix(base, "exec_command-") {
|
||||
t.Fatalf("spill file name must carry the tool prefix: %s", base)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "some output body" {
|
||||
t.Fatalf("unexpected spill content: %q", content)
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebFetchMaxBytes = 64 * 1024
|
||||
maxWebFetchMaxBytes = 512 * 1024
|
||||
webFetchTimeout = 10 * time.Second
|
||||
// Raw-body budgets. HTML responses are converted to markdown before they
|
||||
// reach the model (see web_fetch_markdown.go), so a generous raw budget
|
||||
// does not translate into a generous context cost — conversion typically
|
||||
// shrinks a page by an order of magnitude. 64KiB of raw HTML often held
|
||||
// nothing but a page's <head>, which starved research tasks.
|
||||
defaultWebFetchMaxBytes = 256 * 1024
|
||||
maxWebFetchMaxBytes = 2 * 1024 * 1024
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchRedirectLimit = 5
|
||||
webFetchErrorBodyLimit = 4 * 1024
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
|
||||
@@ -115,11 +120,17 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
},
|
||||
"max_bytes": {
|
||||
Type: "integer",
|
||||
Description: "Maximum response body bytes to return.",
|
||||
Description: "Maximum raw response body bytes to download before conversion.",
|
||||
Default: defaultWebFetchMaxBytes,
|
||||
Minimum: intPtr(1),
|
||||
Maximum: intPtr(maxWebFetchMaxBytes),
|
||||
},
|
||||
"format": {
|
||||
Type: "string",
|
||||
Description: "auto (default): HTML responses are converted to compact markdown, everything else is returned as-is. raw: never convert. markdown: force conversion.",
|
||||
Enum: []string{"auto", "raw", "markdown"},
|
||||
Default: "auto",
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
AdditionalProperties: false,
|
||||
@@ -170,6 +181,18 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format, err := stringArg(args, "format", "auto", false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_fetch: " + err.Error())
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "", "auto":
|
||||
format = "auto"
|
||||
case "raw", "markdown":
|
||||
default:
|
||||
return errorResult(`Error: Invalid arguments for web_fetch: format must be "auto", "raw", or "markdown".`)
|
||||
}
|
||||
if err := validateWebFetchURLBeforePermission(rawURL); err != nil {
|
||||
return errorResult("Error: Unsafe URL for web_fetch: " + err.Error())
|
||||
}
|
||||
@@ -224,14 +247,28 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
}
|
||||
body = redactWebFetchText(body)
|
||||
|
||||
output := strings.Join([]string{
|
||||
// HTML responses are converted to compact markdown by default: raw HTML is
|
||||
// mostly markup, so conversion typically shrinks the page by an order of
|
||||
// magnitude and the model reads content instead of boilerplate. format=raw
|
||||
// is the escape hatch for pages the converter mangles.
|
||||
converted := false
|
||||
if format == "markdown" || (format == "auto" && looksLikeHTML(contentType, body)) {
|
||||
if markdown := htmlToMarkdown(body); markdown != "" {
|
||||
body = markdown
|
||||
converted = true
|
||||
}
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
"URL: " + finalURL,
|
||||
"Status: " + webFetchStatusLine(response),
|
||||
"Content-Type: " + firstNonEmptyString(contentType, "unknown"),
|
||||
"Bytes: " + strconv.Itoa(len(body)),
|
||||
"",
|
||||
body,
|
||||
}, "\n")
|
||||
}
|
||||
if converted {
|
||||
headers = append(headers, "Converted: html -> markdown (pass format: \"raw\" for the original HTML)")
|
||||
}
|
||||
output := strings.Join(append(headers, "", body), "\n")
|
||||
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
@@ -243,6 +280,7 @@ func (tool webFetchTool) run(ctx context.Context, args map[string]any) Result {
|
||||
"content_type": contentType,
|
||||
"bytes": strconv.Itoa(len(body)),
|
||||
"truncated": strconv.FormatBool(truncated),
|
||||
"converted": strconv.FormatBool(converted),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
|
||||
// on a typical page well under a tenth of the bytes are readable content — so
|
||||
// returning it raw wastes nearly the whole response budget on markup. The
|
||||
// converter is deliberately dependency-free (regex passes + stdlib entity
|
||||
// unescaping, no HTML parser): the output feeds a language model, not a
|
||||
// browser, so best-effort structure (headings, links, list markers) is enough,
|
||||
// and the format=raw escape hatch covers pages it mangles.
|
||||
|
||||
var (
|
||||
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
|
||||
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
|
||||
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
|
||||
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
|
||||
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
|
||||
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
|
||||
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
|
||||
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
|
||||
// headings become #-prefixed lines, anchors become [text](href), list items
|
||||
// become "- " bullets, block boundaries become blank lines, all other markup
|
||||
// is stripped, and entities are unescaped.
|
||||
func htmlToMarkdown(body string) string {
|
||||
text := webFetchCommentRe.ReplaceAllString(body, " ")
|
||||
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
|
||||
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchHeadingRe.FindStringSubmatch(match)
|
||||
level := int(groups[1][0] - '0')
|
||||
if level < 1 || level > 6 {
|
||||
level = 1
|
||||
}
|
||||
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
|
||||
})
|
||||
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
groups := webFetchAnchorRe.FindStringSubmatch(match)
|
||||
href := groups[1]
|
||||
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
|
||||
if label == "" {
|
||||
return " "
|
||||
}
|
||||
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||||
return label
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", label, href)
|
||||
})
|
||||
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
|
||||
text = webFetchBreakRe.ReplaceAllString(text, "\n")
|
||||
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
|
||||
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
|
||||
text = html.UnescapeString(text)
|
||||
|
||||
// Whitespace normalization: collapse space runs, trim line edges, cap blank
|
||||
// runs at one empty line.
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
|
||||
}
|
||||
text = strings.Join(lines, "\n")
|
||||
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// looksLikeHTML reports whether a response should be treated as an HTML
|
||||
// document, by Content-Type first and a body sniff as fallback (servers
|
||||
// mislabel HTML as text/plain often enough to matter).
|
||||
func looksLikeHTML(contentType, body string) bool {
|
||||
lowered := strings.ToLower(contentType)
|
||||
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
|
||||
return true
|
||||
}
|
||||
if lowered != "" && !strings.Contains(lowered, "text/plain") {
|
||||
return false
|
||||
}
|
||||
head := strings.ToLower(strings.TrimSpace(body))
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleHTML = `<!DOCTYPE html>
|
||||
<html><head><title>ignored</title><style>body{color:red}</style></head>
|
||||
<body>
|
||||
<script>var tracking = "noise";</script>
|
||||
<h1>Main Title</h1>
|
||||
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and & entity.</p>
|
||||
<ul><li>alpha</li><li>beta</li></ul>
|
||||
<!-- hidden comment -->
|
||||
<h2>Section</h2>
|
||||
<p>Body with extra spaces.</p>
|
||||
</body></html>`
|
||||
|
||||
func TestHTMLToMarkdown(t *testing.T) {
|
||||
markdown := htmlToMarkdown(sampleHTML)
|
||||
|
||||
for _, want := range []string{
|
||||
"# Main Title",
|
||||
"## Section",
|
||||
"[docs link](https://example.com/docs)",
|
||||
"- alpha",
|
||||
"- beta",
|
||||
"with a", // paragraph text survives
|
||||
"& entity",
|
||||
} {
|
||||
if !strings.Contains(markdown, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, markdown)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
|
||||
if strings.Contains(markdown, banned) {
|
||||
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
|
||||
}
|
||||
}
|
||||
if strings.Contains(markdown, "extra spaces") {
|
||||
t.Fatalf("space runs must collapse:\n%s", markdown)
|
||||
}
|
||||
if strings.Contains(markdown, "\n\n\n") {
|
||||
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHTML(t *testing.T) {
|
||||
cases := []struct {
|
||||
contentType string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"text/html; charset=utf-8", "anything", true},
|
||||
{"application/xhtml+xml", "anything", true},
|
||||
{"application/json", `{"html":"<html>"}`, false},
|
||||
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
|
||||
{"text/plain", "just text", false},
|
||||
{"", "<html><body>x</body></html>", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
|
||||
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
|
||||
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "true" {
|
||||
t.Fatalf("converted meta must be true: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
|
||||
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
|
||||
}
|
||||
if result.Meta["converted"] != "false" {
|
||||
t.Fatalf("converted meta must be false: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
|
||||
}))
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
|
||||
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
|
||||
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
||||
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
|
||||
}))
|
||||
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
|
||||
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,14 @@ func scopedReadRoots(workspaceRoot string, scope PathScope) ([]string, error) {
|
||||
}
|
||||
|
||||
func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
||||
// Spill files (truncated tool output saved under the per-uid temp dir) are
|
||||
// readable regardless of scope: the truncation notice tells the model to
|
||||
// read_file/grep them, which must actually work. resolveSpillReadPath
|
||||
// verifies containment after symlink resolution, so this cannot be used to
|
||||
// reach anything outside the spill dir.
|
||||
if spillPath, ok := resolveSpillReadPath(requestedPath); ok {
|
||||
return spillPath, spillPath, nil
|
||||
}
|
||||
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
||||
return resolveWorkspacePath(workspaceRoot, requestedPath)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
|
||||
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
|
||||
@@ -110,6 +110,10 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
||||
// FileTracker baseline: recording pre-format content would make the very
|
||||
// next edit look like an external modification and trip the conflict guard.
|
||||
content = maybeFormatWrittenFile(ctx, absolutePath, content)
|
||||
// Baseline the freshly written content so a later edit/overwrite in this
|
||||
// session compares against what is now on disk.
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
@@ -126,6 +130,7 @@ func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
lines++
|
||||
}
|
||||
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
|
||||
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = []string{relativePath}
|
||||
// Card-only preview: a real unified diff (all-green for a create, red/green for
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -237,8 +238,10 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
|
||||
}
|
||||
|
||||
func stripMarkdownRenderControls(text string) string {
|
||||
text = strings.ReplaceAll(text, markdownBoldStart, "")
|
||||
return strings.ReplaceAll(text, markdownBoldEnd, "")
|
||||
// renderAssistantMarkdownText embeds markdownBoldStart/markdownBoldEnd
|
||||
// markers for prose text, and highlightCodeAuto embeds ANSI color sequences
|
||||
// for fenced code blocks. Strip everything so clipboard text is pure plain text.
|
||||
return ansi.Strip(text)
|
||||
}
|
||||
|
||||
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
|
||||
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
|
||||
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
|
||||
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
|
||||
})
|
||||
return append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchSkillSuggestions(token)...)
|
||||
// Each source caps itself, but the merged list must honor the shared cap too
|
||||
// (three sources could otherwise stack up to ~3x the palette bound).
|
||||
if len(out) > maxCommandSuggestions {
|
||||
out = out[:maxCommandSuggestions]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchSkillSuggestions returns installed skills whose slash name has the typed
|
||||
// prefix, so skills are discoverable and invocable from the palette like user
|
||||
// commands. Precedence (builtin > user command > skill) is enforced here by
|
||||
// skipping any skill whose name is claimed by a builtin (or alias) or a user
|
||||
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
|
||||
// dispatch time, so it must not be advertised at all.
|
||||
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
|
||||
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
|
||||
if prefix == "" || m.loadSkills == nil {
|
||||
return nil
|
||||
}
|
||||
taken := m.takenSlashNames()
|
||||
var out []commandSuggestion
|
||||
// Read through the loader (not the startup snapshot in agentOptions) so the
|
||||
// palette matches what dispatch will actually resolve — a skill installed or
|
||||
// removed mid-session appears/disappears without a restart.
|
||||
for _, skill := range m.installedSkills() {
|
||||
name := skillSlashName(skill.Name)
|
||||
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
|
||||
// "deploy") map to one slash name; only the first — the one dispatch will
|
||||
// run — may be advertised, or the row's description and the executed
|
||||
// instructions silently diverge.
|
||||
taken[name] = true
|
||||
desc := strings.TrimSpace(skill.Description)
|
||||
if desc == "" {
|
||||
desc = "Skill: /" + name
|
||||
} else {
|
||||
desc += " (skill)"
|
||||
}
|
||||
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
|
||||
if len(out) >= maxCommandSuggestions {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
|
||||
|
||||
@@ -698,3 +698,19 @@ func (m model) debugText() string {
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// skillsText is the /skills fallback when NO skills are installed — an install
|
||||
// hint. With skills present /skills opens the searchable skill picker instead
|
||||
// (see newSkillPicker), matching how /model works.
|
||||
func (m model) skillsText() string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Skills",
|
||||
Status: commandStatusInfo,
|
||||
Sections: []commandSection{{
|
||||
Lines: []string{"No skills installed."},
|
||||
}},
|
||||
Hints: []string{
|
||||
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -131,6 +132,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "List registered tools.",
|
||||
kind: commandTools,
|
||||
},
|
||||
{
|
||||
name: "/skills",
|
||||
usage: "/skills",
|
||||
group: commandGroupTools,
|
||||
description: "List installed skills; run one directly with /<skill-name> [args].",
|
||||
kind: commandSkills,
|
||||
},
|
||||
{
|
||||
name: "/context",
|
||||
usage: "/context",
|
||||
|
||||
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
}
|
||||
|
||||
func (m model) composerMouseSelectionBlocked() bool {
|
||||
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
|
||||
}
|
||||
@@ -264,6 +264,55 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailedTranscriptClosesFileView: entering detailed transcript mode
|
||||
// closes an active file drill-in so the full transcript body replaces the file
|
||||
// content.
|
||||
func TestDetailedTranscriptClosesFileView(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
if !m.fileView.active {
|
||||
t.Fatal("sanity check: openFileView should activate the file view")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if m.fileView.active {
|
||||
t.Fatal("detailed transcript should close the file drill-in")
|
||||
}
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("Ctrl+O should enter detailed mode")
|
||||
}
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", true)
|
||||
if len(items) <= 1 {
|
||||
t.Fatalf("detailed transcript should show multiple body items, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailedTranscriptStaysClosedOnSecondToggle: toggling out and back into
|
||||
// detailed mode does not re-open the closed file view.
|
||||
func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.altScreen = true
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
updated, _ = m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if m.fileView.active {
|
||||
t.Fatal("exiting detailed mode must not re-open the file drill-in")
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
t.Fatal("second Ctrl+O should exit detailed mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
|
||||
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
|
||||
// (Esc exiting the view instead of reaching the prompt's deny handling).
|
||||
|
||||
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
startY := -1
|
||||
for _, span := range metrics.spans {
|
||||
|
||||
+171
-22
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
@@ -62,6 +63,7 @@ type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
@@ -735,6 +737,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
@@ -1354,7 +1357,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// home screen or a narrow terminal. Hiding reflows the chat to full
|
||||
// width, so mirror the width-change bookkeeping (re-wrap the streaming
|
||||
// fade, resize the composer) the WindowSizeMsg path does.
|
||||
if m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
// Just show/hide — no transcript notice. The reflow IS the feedback,
|
||||
// and emitting a line every toggle piled up noise in the chat.
|
||||
m.sidebarHidden = !m.sidebarHidden
|
||||
@@ -1421,22 +1424,50 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case keyIs(msg, tea.KeyPgUp):
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
// A stationary mouse over a bodyY-keyed transcript hover target would
|
||||
// otherwise stay lit at the scrolled-away bodyY until the next real
|
||||
// motion event (see clearHover) — same reasoning as the wheel-scroll
|
||||
// cases in mouse.go.
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyPgDown):
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
case keyShift(msg) && keyIs(msg, tea.KeyUp):
|
||||
// Shift+Up scrolls the transcript up one line. Must be checked before
|
||||
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
return m.scrollChat(1), nil
|
||||
case keyShift(msg) && keyIs(msg, tea.KeyDown):
|
||||
// Shift+Down scrolls the transcript down one line. Must be checked
|
||||
// before plain KeyDown so shifted arrows aren't consumed.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
@@ -1462,6 +1493,41 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle multiline navigation
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
if m.transcriptDetailed {
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil {
|
||||
return m.handleProviderWizardKey(msg)
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
return m.handleMCPAddWizardKey(msg)
|
||||
}
|
||||
if m.mcpManager != nil {
|
||||
return m.handleMCPManagerKey(msg)
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
m.moveSuggestion(1)
|
||||
return m, nil
|
||||
@@ -1480,7 +1546,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
@@ -1514,6 +1581,48 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.historyRecallActive() {
|
||||
return m.recallHistory(-1), nil
|
||||
}
|
||||
case keyCtrl(msg, 'u'):
|
||||
// Ctrl+U scrolls up half a page, or moves the cursor up in
|
||||
// permission/ask-user prompts. Falls through to the active modal
|
||||
// (wizard, etc.) when none of the above are in focus.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(-1), nil
|
||||
}
|
||||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle its own Ctrl+U (delete-to-bol)
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||||
case keyCtrl(msg, 'd'):
|
||||
// Ctrl+D scrolls down half a page, or moves the cursor down in
|
||||
// permission/ask-user prompts. Falls through to the active modal
|
||||
// when none of the above are in focus.
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(1), nil
|
||||
}
|
||||
if m.pendingAskUser != nil {
|
||||
return m.moveAskUserCursor(1), nil
|
||||
}
|
||||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||||
break
|
||||
}
|
||||
if m.composerValue() != "" {
|
||||
break // let the input handle its own Ctrl+D (delete-next-char)
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
@@ -2215,14 +2324,23 @@ func (m model) View() tea.View {
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
if m.wantsMouseCapture() {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting: it
|
||||
// reports cursor movement even with no button pressed. CellMotion only
|
||||
// reports motion while a button is held (drag) — see bubbletea's
|
||||
// MouseMode docs. AllMotion has marginally worse terminal compatibility
|
||||
// but is well supported by the terminals this app targets; the existing
|
||||
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
|
||||
// the redraw rate from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
if isRunningUnderPRoot() {
|
||||
// Under PRoot the AllMotion (1003) sequence doesn't work
|
||||
// reliably, breaking touch-gesture scrolling. Fall back to
|
||||
// CellMotion which still delivers wheel events, clicks, and
|
||||
// drag — the only thing lost is hover-highlighting.
|
||||
view.MouseMode = tea.MouseModeCellMotion
|
||||
} else {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting:
|
||||
// it reports cursor movement even with no button pressed.
|
||||
// CellMotion only reports motion while a button is held (drag) —
|
||||
// see bubbletea's MouseMode docs. AllMotion has marginally worse
|
||||
// terminal compatibility but is well supported by the terminals
|
||||
// this app targets; the existing 15ms mouse-event throttle
|
||||
// (mouseEventThrottleInterval) already bounds the redraw rate
|
||||
// from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -2297,7 +2415,7 @@ func (m model) transcriptView() string {
|
||||
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
|
||||
emptyOverlay = viewportOverlay
|
||||
}
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
|
||||
|
||||
footer := m.footerView(width)
|
||||
|
||||
@@ -2337,7 +2455,7 @@ func (m model) twoColumnTranscriptView() string {
|
||||
width := chatW
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
bodyItems := m.transcriptBodyItems(width, "")
|
||||
bodyItems := m.transcriptBodyItems(width, "", false)
|
||||
footer := m.footerView(width)
|
||||
overlayForViewport := suggestionOverlay
|
||||
if m.transcriptEmpty() && !m.pending {
|
||||
@@ -2767,7 +2885,15 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
|
||||
return transcriptViewport{}, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
if m.transcriptDetailed {
|
||||
items := m.transcriptBodyItems(width, "", true)
|
||||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||||
footer := m.detailedTranscriptFooter(width)
|
||||
frame := m.scrollableTranscriptFrame(header, footer)
|
||||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||||
}
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||||
@@ -3607,6 +3733,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
case pickerSkill:
|
||||
// Fill the composer with "/name " so the user adds their request before
|
||||
// submitting (a bare second Enter runs it without one); names the slash
|
||||
// path cannot reach run immediately instead.
|
||||
m, cmd = m.chooseSkillFromPicker(item)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -3722,6 +3853,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
case commandTools:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
|
||||
return m, nil
|
||||
case commandSkills:
|
||||
// With skills installed, /skills opens a searchable picker (like /model);
|
||||
// the text card remains only as the no-skills install hint.
|
||||
if picker := m.newSkillPicker(); picker != nil {
|
||||
m.picker = picker
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
|
||||
return m, nil
|
||||
case commandMCP:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
return m.openMCPManager(), nil
|
||||
@@ -3917,6 +4057,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
if next, cmd, handled := m.handleUserCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
|
||||
// (deterministic invocation, vs waiting for the model to pull it in).
|
||||
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "unknown command: " + command.text,
|
||||
@@ -4323,6 +4468,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
IncludeLSP: true,
|
||||
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
|
||||
})
|
||||
// Inline post-edit diagnostics: edit_file/write_file append error
|
||||
// diagnostics for the file they just wrote to their own output, so the
|
||||
// model sees a break in the same turn. Shares the run's lazy manager.
|
||||
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
|
||||
}
|
||||
|
||||
// Some providers synthesize tool-call ids that repeat within a run (e.g.
|
||||
|
||||
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
findRow := func(mm model) (transcriptBodyItem, bool) {
|
||||
for _, it := range mm.transcriptBodyItems(width, "") {
|
||||
for _, it := range mm.transcriptBodyItems(width, "", false) {
|
||||
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
|
||||
return it, true
|
||||
}
|
||||
@@ -2198,7 +2198,7 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
|
||||
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
reasoningIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2223,7 +2223,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
toolIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2247,7 +2247,7 @@ func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowUser, text: "create the landing page"},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
toolIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2273,7 +2273,7 @@ func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
|
||||
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
finalIdx := -1
|
||||
for i := range items {
|
||||
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
|
||||
@@ -2307,7 +2307,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
|
||||
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
pendingIdx := -1
|
||||
for i := range items {
|
||||
if items[i].kind == transcriptBodyItemPendingInterim {
|
||||
@@ -2357,7 +2357,7 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
|
||||
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
|
||||
}
|
||||
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
var row *transcriptBodyItem
|
||||
for i := range items {
|
||||
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
|
||||
|
||||
+32
-2
@@ -1,6 +1,36 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// parseTracerPid returns true when /proc/self/status contains a non-zero
|
||||
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
|
||||
// not PRoot specifically. That's intentional: under any ptrace tracer the
|
||||
// AllMotion (1003) sequence is unreliable, so CellMotion is the safer
|
||||
// fallback. The only loss is hover-highlighting, which has no functional
|
||||
// impact.
|
||||
func parseTracerPid(data []byte) bool {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "TracerPid:") {
|
||||
pid := strings.TrimSpace(line[10:])
|
||||
return pid != "0"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var isRunningUnderPRoot = sync.OnceValue(func() bool {
|
||||
data, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parseTracerPid(data)
|
||||
})
|
||||
|
||||
type mouseOverlayHit struct {
|
||||
x int
|
||||
@@ -257,7 +287,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
|
||||
if !m.altScreen || m.height <= 0 {
|
||||
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
@@ -920,6 +920,30 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposerMouseSelectionBlockedInDetailedMode: composer mouse hit-testing
|
||||
// is disabled while the detailed transcript view is active, so wheel and click
|
||||
// events in the composer area reach the transcript body instead.
|
||||
func TestComposerMouseSelectionBlockedInDetailedMode(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.input.SetValue("some text")
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("sanity check: Ctrl+O should enter detailed mode")
|
||||
}
|
||||
if !m.composerMouseSelectionBlocked() {
|
||||
t.Fatal("composerMouseSelectionBlocked should be true in detailed mode")
|
||||
}
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
|
||||
t.Fatal("mouseOverComposer should return false in detailed mode")
|
||||
}
|
||||
}
|
||||
|
||||
func firstTranscriptTextMouseY(t *testing.T, m model) int {
|
||||
t.Helper()
|
||||
_, y := firstTranscriptTextMousePoint(t, m)
|
||||
@@ -986,6 +1010,38 @@ func withSidebarContent(m model) model {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestParseTracerPidMissing(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("Pid: 123\nName: foo\n")); got {
|
||||
t.Fatal("missing TracerPid should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidZero(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("TracerPid: 0\nName: foo\n")); got {
|
||||
t.Fatal("TracerPid: 0 should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidNonZero(t *testing.T) {
|
||||
if got := parseTracerPid([]byte("Name: foo\nTracerPid: 42\n")); !got {
|
||||
t.Fatal("TracerPid: 42 should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidMalformed(t *testing.T) {
|
||||
// Non-numeric values are treated as non-zero since any non-"0" string
|
||||
// indicates a tracer is attached, matching the function's semantics.
|
||||
if got := parseTracerPid([]byte("TracerPid: abc\n")); !got {
|
||||
t.Fatal("malformed TracerPid should return true (non-zero string)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTracerPidEmpty(t *testing.T) {
|
||||
if got := parseTracerPid([]byte{}); got {
|
||||
t.Fatal("empty input should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func setupMouseTestModel() model {
|
||||
m := newModel(context.Background(), Options{
|
||||
Setup: SetupOptions{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -48,7 +49,13 @@ type Options struct {
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
AgentOptions agent.Options
|
||||
// LoadSkills returns the installed skills (default skills dir merged with any
|
||||
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
|
||||
// invocation. Called lazily per use so newly installed skills are picked up
|
||||
// without a restart. Nil means the session has no skills wiring (skills stay
|
||||
// model-pulled via the skill tool only).
|
||||
LoadSkills func() []skills.Skill
|
||||
PermissionMode agent.PermissionMode
|
||||
ReasoningEffort modelregistry.ReasoningEffort
|
||||
ResponseStyle string
|
||||
|
||||
@@ -309,3 +309,70 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
|
||||
t.Fatalf("network prompt should render target label, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionCursorCtrlU ensures Ctrl+U moves the permission cursor UP
|
||||
// (toward the first option). Regression: the original implementation moved
|
||||
// the cursor DOWN on Ctrl+U and UP on Ctrl+D.
|
||||
func TestPermissionCursorCtrlU(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
m.pendingPermission.cursor = 2 // middle option
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('u'))
|
||||
next := updated.(model)
|
||||
if next.pendingPermission.cursor != 1 {
|
||||
t.Fatalf("Ctrl+U moved cursor to %d, want 1 (one step up)", next.pendingPermission.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionCursorCtrlD ensures Ctrl+D moves the permission cursor DOWN
|
||||
// (toward the last option). Regression: the original implementation moved
|
||||
// the cursor UP on Ctrl+D and DOWN on Ctrl+U.
|
||||
func TestPermissionCursorCtrlD(t *testing.T) {
|
||||
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
|
||||
m.pendingPermission.cursor = 0 // first option
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('d'))
|
||||
next := updated.(model)
|
||||
if next.pendingPermission.cursor != 1 {
|
||||
t.Fatalf("Ctrl+D moved cursor to %d, want 1 (one step down)", next.pendingPermission.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShiftUpComposerGuard ensures Shift+Up does NOT scroll the transcript
|
||||
// when the composer has text, so it falls through to the input's own line
|
||||
// navigation.
|
||||
func TestShiftUpComposerGuard(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
// Add enough transcript rows so scrolling is valid.
|
||||
for i := 0; i < 20; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowAssistant, "line")
|
||||
}
|
||||
m.input.SetValue("some text")
|
||||
m.chatScrollOffset = 5
|
||||
|
||||
updated, cmd := m.Update(testKeyShift(tea.KeyUp))
|
||||
next := updated.(model)
|
||||
_ = cmd
|
||||
if got := next.chatScrollOffset; got != 5 {
|
||||
t.Fatalf("Shift+Up with non-empty composer scrolled offset to %d, want 5 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShiftDownComposerGuard ensures Shift+Down does NOT scroll the transcript
|
||||
// when the composer has text, falling through to the input's navigation.
|
||||
func TestShiftDownComposerGuard(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
// Add enough transcript rows so scrolling is valid.
|
||||
for i := 0; i < 20; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowAssistant, "line")
|
||||
}
|
||||
m.input.SetValue("some text")
|
||||
m.chatScrollOffset = 3
|
||||
|
||||
updated, cmd := m.Update(testKeyShift(tea.KeyDown))
|
||||
next := updated.(model)
|
||||
_ = cmd
|
||||
if got := next.chatScrollOffset; got != 3 {
|
||||
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
pickerEffort
|
||||
pickerSession
|
||||
pickerTheme
|
||||
pickerSkill
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
|
||||
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
|
||||
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
|
||||
// the skill's EXACT name (item.Value), so even skills the slash path cannot
|
||||
// reach — names shadowed by a builtin or user command, or names that don't fit
|
||||
// a slash token — are runnable here: picking from the Skills picker is
|
||||
// unambiguous. Returns nil when no skills are installed (the caller falls back
|
||||
// to the install-hint card).
|
||||
func (m model) newSkillPicker() *commandPicker {
|
||||
installed := m.installedSkills()
|
||||
items := make([]pickerItem, 0, len(installed))
|
||||
for _, skill := range installed {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
label := name
|
||||
if slash := skillSlashName(name); slash != "" {
|
||||
// Show the slash form where one exists — it doubles as documentation
|
||||
// of the direct "/name [args]" invocation.
|
||||
label = "/" + slash
|
||||
}
|
||||
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// modelPickerProviders returns the providers to list in /model: all saved
|
||||
// providers, falling back to the active profile when none were threaded in.
|
||||
func (m model) modelPickerProviders() []config.ProviderProfile {
|
||||
|
||||
@@ -511,6 +511,50 @@ func TestSelectableAssistantRowKeepsMarkdownSemanticsPlain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripMarkdownRenderControlsStripsAllANSI(t *testing.T) {
|
||||
// Bold markers that renderAssistantMarkdownText embeds for prose formatting.
|
||||
withBold := "\x1b[1mhello\x1b[22m world"
|
||||
if got := stripMarkdownRenderControls(withBold); got != "hello world" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBold, got, "hello world")
|
||||
}
|
||||
// Color sequences from highlightCodeAuto for fenced code blocks.
|
||||
withColor := "\x1b[38;2;236;236;238mdef\x1b[0m hello():"
|
||||
if got := stripMarkdownRenderControls(withColor); got != "def hello():" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withColor, got, "def hello():")
|
||||
}
|
||||
// Sequences that combine both (bold + underline + color).
|
||||
withBoldColor := "\x1b[1;4;38;2;236;236;238mW\x1b[m"
|
||||
if got := stripMarkdownRenderControls(withBoldColor); got != "W" {
|
||||
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBoldColor, got, "W")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedTranscriptTextStripsANSIFromHighlightedCode(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.width, m.height = 120, 40
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
code := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello")
|
||||
}`
|
||||
row := transcriptRow{kind: rowAssistant, text: "First, here's the code:\n\n```go\n" + code + "\n```\n\nThat's it.", final: true}
|
||||
m.transcript = append(m.transcript, row)
|
||||
m.flushed = len(m.transcript)
|
||||
|
||||
// The highlighted code block contains ANSI color sequences from chroma;
|
||||
// verify none survive in the selectable text used for clipboard copy.
|
||||
_, selectable := m.renderSelectableAssistantRow(0, row, 72, 0)
|
||||
for _, line := range selectable {
|
||||
if ansiPattern.MatchString(line.text) {
|
||||
t.Fatalf("selectable text leaked ANSI escapes: %q", line.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalAnswerRendersPlainTextAndCompletionLine(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
row := transcriptRow{
|
||||
|
||||
@@ -82,7 +82,7 @@ func (m model) sidebarToggleAllowed() bool {
|
||||
// would have any visible effect. The subchat drill-in keeps its own single-column
|
||||
// view, so the sidebar is suppressed there.
|
||||
func (m model) sidebarAvailable() bool {
|
||||
if !m.altScreen || m.height <= 0 || m.subchat.active {
|
||||
if !m.altScreen || m.height <= 0 || m.subchat.active || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
if sidebarWidth(m.width) <= 0 {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
)
|
||||
|
||||
// Skill slash invocation: "/<skill-name> [args]" runs an installed skill
|
||||
// directly, mirroring user commands (.zero/commands). Skills were previously
|
||||
// model-pulled only (the skill tool), which made invocation a matter of model
|
||||
// discretion; typing the skill's name makes it deterministic. Precedence is
|
||||
// builtin > user command > skill: parseCommand resolves builtins first, and the
|
||||
// commandUnknown fallback tries user commands before skills.
|
||||
|
||||
// handleSkillCommand resolves a "/name args" that wasn't a builtin or a user
|
||||
// command against the installed skills. On a match it launches a normal agent
|
||||
// turn whose prompt inlines the skill body (its instructions) followed by the
|
||||
// typed args, returning handled=true. handled=false means no skill matched, so
|
||||
// the caller falls through to "unknown command".
|
||||
func (m model) handleSkillCommand(raw string) (model, tea.Cmd, bool) {
|
||||
name, args := splitUserCommand(raw)
|
||||
if name == "" {
|
||||
return m, nil, false
|
||||
}
|
||||
skill, ok := m.lookupSkillCommand(name)
|
||||
if !ok {
|
||||
return m, nil, false
|
||||
}
|
||||
body := strings.TrimSpace(skill.Content)
|
||||
if body == "" {
|
||||
// The name matched a real skill, so falling through to "unknown command"
|
||||
// would mislead; surface the actual problem instead.
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill /" + name + " has an empty SKILL.md body (" + skill.Path + ")",
|
||||
})
|
||||
return m, nil, true
|
||||
}
|
||||
return m.launchOrDeferExpandedPrompt(raw, skillInvocationPrompt(body, args))
|
||||
}
|
||||
|
||||
// bareSkillInvocationNote is appended when a skill is invoked with no request.
|
||||
// The body alone is instructions with no target ("review the PR" — which PR?),
|
||||
// and without this note the model improvises one instead of asking. The wording
|
||||
// is conditional so self-contained skills (no target needed) still just run.
|
||||
const bareSkillInvocationNote = "The user invoked this skill directly without providing a request. " +
|
||||
"If these instructions need a target or details that are not already clear from the conversation " +
|
||||
"(which pull request, file, branch, topic, …), ask for them first — do not guess or pick one yourself. " +
|
||||
"If the instructions are self-contained, proceed."
|
||||
|
||||
// skillInvocationPrompt builds the agent prompt for a skill invocation: the
|
||||
// skill body (its instructions), then either the user's request or — for a bare
|
||||
// invocation — the ask-first note above. Mirrors usercommands.Expand's
|
||||
// no-placeholder behavior for the args case.
|
||||
func skillInvocationPrompt(body, args string) string {
|
||||
if args != "" {
|
||||
return body + "\n\n" + args
|
||||
}
|
||||
return body + "\n\n" + bareSkillInvocationNote
|
||||
}
|
||||
|
||||
// launchOrDeferExpandedPrompt applies the same run-state guards the plain
|
||||
// commandPrompt path has to an expanded (user command / skill) prompt: while
|
||||
// exiting nothing may start, a prompt submitted mid-run is queued for the next
|
||||
// turn, and compaction-in-flight warns instead of racing the compactor. The
|
||||
// EXPANDED prompt is what gets queued — the queue flush path resubmits text as
|
||||
// a literal prompt, so queuing the raw "/name args" would send it to the model
|
||||
// as prose instead of re-dispatching it. raw is the typed invocation; on the
|
||||
// compaction warning it is restored into the composer for an easy re-submit
|
||||
// (the commandUnknown dispatch cleared the composer before we got here, unlike
|
||||
// the plain-prompt path whose guard fires before the clear). Empty raw (the
|
||||
// picker path has no typed form) restores nothing.
|
||||
func (m model) launchOrDeferExpandedPrompt(raw, prompt string) (model, tea.Cmd, bool) {
|
||||
if m.exiting {
|
||||
return m, nil, true
|
||||
}
|
||||
if m.pending {
|
||||
return m.queueMessage(prompt), nil, true
|
||||
}
|
||||
if m.compactInFlight {
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
m.input.SetValue(raw)
|
||||
m.input.CursorEnd()
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Compact\nstatus: warning\nCompaction is running. Re-run the command when it finishes.",
|
||||
})
|
||||
return m, nil, true
|
||||
}
|
||||
next, teaCmd := m.launchPrompt(prompt)
|
||||
return next, teaCmd, true
|
||||
}
|
||||
|
||||
// takenSlashNames returns every slash token already claimed by a builtin
|
||||
// command (or alias) or a user command — the names a typed "/x" would dispatch
|
||||
// to before ever reaching a skill.
|
||||
func (m model) takenSlashNames() map[string]bool {
|
||||
taken := map[string]bool{}
|
||||
for _, command := range commandDefinitions {
|
||||
taken[strings.TrimPrefix(command.name, "/")] = true
|
||||
for _, alias := range command.aliases {
|
||||
taken[strings.TrimPrefix(alias, "/")] = true
|
||||
}
|
||||
}
|
||||
for _, cmd := range m.userCommands {
|
||||
taken[cmd.Name] = true
|
||||
}
|
||||
return taken
|
||||
}
|
||||
|
||||
// chooseSkillFromPicker handles Enter on a skill-picker row. Most skills want a
|
||||
// request ("review WHICH pr?"), so selection fills the composer with
|
||||
// "/name " — cursor at the end — for the user to complete and submit (Enter
|
||||
// again runs it bare). Skills whose names cannot be typed as a slash command
|
||||
// (non-slash shapes, or names shadowed by a builtin or user command) fall back
|
||||
// to running immediately: the picker is the only path that reaches them, and an
|
||||
// unfillable composer would be a dead end.
|
||||
func (m model) chooseSkillFromPicker(item pickerItem) (model, tea.Cmd) {
|
||||
slash := skillSlashName(item.Value)
|
||||
if slash == "" || m.takenSlashNames()[slash] {
|
||||
return m.invokeSkillByName(item.Value)
|
||||
}
|
||||
m.input.SetValue("/" + slash + " ")
|
||||
m.input.CursorEnd()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// invokeSkillByName runs the installed skill with the given EXACT raw name (the
|
||||
// skill-picker path). The same run-state guards as slash invocation apply; the
|
||||
// picker offers no args affordance, so the prompt is the skill body alone.
|
||||
func (m model) invokeSkillByName(name string) (model, tea.Cmd) {
|
||||
for _, skill := range m.installedSkills() {
|
||||
if strings.TrimSpace(skill.Name) != name {
|
||||
continue
|
||||
}
|
||||
body := strings.TrimSpace(skill.Content)
|
||||
if body == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill " + name + " has an empty SKILL.md body (" + skill.Path + ")",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
next, teaCmd, _ := m.launchOrDeferExpandedPrompt("", skillInvocationPrompt(body, ""))
|
||||
return next, teaCmd
|
||||
}
|
||||
// The picker row came from a slightly older load (TTL cache) and the skill
|
||||
// has since been removed.
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill " + name + " is no longer installed",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// lookupSkillCommand returns the installed skill whose slash name matches the
|
||||
// given (lowercased) name. Linear scan over a fresh load — skills are re-read
|
||||
// per invocation so a skill installed mid-session is invocable without a
|
||||
// restart, and the body is never held on the model.
|
||||
func (m model) lookupSkillCommand(name string) (skills.Skill, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return skills.Skill{}, false
|
||||
}
|
||||
for _, skill := range m.installedSkills() {
|
||||
if skillSlashName(skill.Name) == name {
|
||||
return skill, true
|
||||
}
|
||||
}
|
||||
return skills.Skill{}, false
|
||||
}
|
||||
|
||||
// installedSkills returns the session's installed skills (default dir merged
|
||||
// with plugin skill roots) via the injected loader, or nil when the session has
|
||||
// no skills wiring (e.g. bare test models).
|
||||
func (m model) installedSkills() []skills.Skill {
|
||||
if m.loadSkills == nil {
|
||||
return nil
|
||||
}
|
||||
return m.loadSkills()
|
||||
}
|
||||
|
||||
// skillSlashName maps a skill's frontmatter name to its slash-command form:
|
||||
// lowercased, and only if it fits the slash-token shape (letters, digits,
|
||||
// dot/underscore/hyphen — a superset of user-command names, since skill names
|
||||
// are free-form frontmatter). Returns "" for names that cannot be typed as a
|
||||
// /command (e.g. containing spaces); those skills remain loadable by the model
|
||||
// via the skill tool and are still listed by /skills.
|
||||
func skillSlashName(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range name {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
)
|
||||
|
||||
// newSkillTestModel builds a model with an injected skills loader (and the
|
||||
// matching palette metadata), no filesystem involved.
|
||||
func newSkillTestModel(t *testing.T, installed ...skills.Skill) model {
|
||||
t.Helper()
|
||||
infos := make([]agent.SkillInfo, 0, len(installed))
|
||||
for _, s := range installed {
|
||||
infos = append(infos, agent.SkillInfo{Name: s.Name, Description: s.Description})
|
||||
}
|
||||
return newModel(context.Background(), Options{
|
||||
Cwd: t.TempDir(),
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
AgentOptions: agent.Options{Skills: infos},
|
||||
})
|
||||
}
|
||||
|
||||
func submitInput(t *testing.T, m model, input string) model {
|
||||
t.Helper()
|
||||
m.input.SetValue(input)
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
return updated.(model)
|
||||
}
|
||||
|
||||
func TestSkillCommandInvokesAndSubmits(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{
|
||||
Name: "deploy-checks",
|
||||
Description: "Run the pre-deploy verification suite.",
|
||||
Content: "Run every verification step and summarize failures.",
|
||||
Path: "/skills/deploy-checks/SKILL.md",
|
||||
})
|
||||
|
||||
next := submitInput(t, m, "/deploy-checks ship v2 to production")
|
||||
|
||||
if transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("an installed skill must not be 'unknown', got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Run every verification step and summarize failures.") {
|
||||
t.Fatalf("skill body should be inlined into the prompt, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "ship v2 to production") {
|
||||
t.Fatalf("typed args should follow the skill body, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCommandWithoutArgs(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
|
||||
|
||||
next := submitInput(t, m, "/reviewer")
|
||||
|
||||
if !transcriptContains(next.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("bare skill invocation should submit the body, got %#v", next.transcript)
|
||||
}
|
||||
// A bare invocation carries the ask-first note: instructions with no target
|
||||
// must make the model ask ("which PR?") instead of improvising one.
|
||||
if !transcriptContains(next.transcript, "ask for them first") {
|
||||
t.Fatalf("bare invocation should carry the clarify-first note, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// An invocation WITH a request must not carry the ask-first note — the user
|
||||
// already said what to apply the skill to.
|
||||
func TestSkillCommandWithArgsHasNoClarifyNote(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
|
||||
|
||||
next := submitInput(t, m, "/reviewer PR 484 reconnect changes")
|
||||
|
||||
if transcriptContains(next.transcript, "ask for them first") {
|
||||
t.Fatalf("args invocation must not carry the clarify note, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// A skill's frontmatter name is free-form; invocation matches its lowercased
|
||||
// slash form.
|
||||
func TestSkillCommandNameIsCaseInsensitive(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "Deploy-Checks", Content: "Check things."})
|
||||
|
||||
next := submitInput(t, m, "/deploy-checks")
|
||||
|
||||
if !transcriptContains(next.transcript, "Check things.") {
|
||||
t.Fatalf("uppercase frontmatter name should be invocable lowercased, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: a user command (.zero/commands) shadows a same-named skill.
|
||||
func TestUserCommandShadowsSkill(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "greet.md", "Say hello from the user command.")
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
LoadSkills: func() []skills.Skill { return []skills.Skill{{Name: "greet", Content: "Skill body must not run."}} },
|
||||
})
|
||||
|
||||
next := submitInput(t, m, "/greet")
|
||||
|
||||
if !transcriptContains(next.transcript, "Say hello from the user command.") {
|
||||
t.Fatalf("user command should win the name collision, got %#v", next.transcript)
|
||||
}
|
||||
if transcriptContains(next.transcript, "Skill body must not run.") {
|
||||
t.Fatalf("shadowed skill must not run, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty skill body is a real, named match — surface the problem instead of
|
||||
// falling through to a misleading "unknown command".
|
||||
func TestSkillCommandEmptyBody(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "hollow", Path: "/skills/hollow/SKILL.md"})
|
||||
|
||||
next := submitInput(t, m, "/hollow")
|
||||
|
||||
if transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("an empty skill must not be 'unknown', got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "empty SKILL.md body") {
|
||||
t.Fatalf("empty-body error should be surfaced, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSlashStillUnknownWithSkillsInstalled(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
|
||||
next := submitInput(t, m, "/definitelynotaskill")
|
||||
|
||||
if !transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("non-matching name should still be unknown, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"deploy-checks": "deploy-checks",
|
||||
"Deploy-Checks": "deploy-checks",
|
||||
" pdf_tools ": "pdf_tools",
|
||||
"v2.migrate": "v2.migrate",
|
||||
"has space": "",
|
||||
"emoji✨": "",
|
||||
"": "",
|
||||
"slash/inside": "",
|
||||
"tab\tseparated": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := skillSlashName(in); got != want {
|
||||
t.Errorf("skillSlashName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAppearsInAutocomplete(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"})
|
||||
|
||||
got := m.matchCommandSuggestions("/dep")
|
||||
found := false
|
||||
for _, s := range got {
|
||||
if s.Name == "/deploy-checks" {
|
||||
found = true
|
||||
if s.Desc != "Run the pre-deploy suite. (skill)" {
|
||||
t.Fatalf("skill suggestion should be labeled, got %q", s.Desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("skill /deploy-checks should appear for '/dep', got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A skill shadowed by a builtin (or its alias) or a user command would be dead
|
||||
// at dispatch time, so the palette must not advertise it.
|
||||
func TestShadowedSkillNotSuggested(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "deploy.md", "User deploy.")
|
||||
installed := []skills.Skill{
|
||||
{Name: "help", Content: "x"}, // builtin collision
|
||||
{Name: "sessions", Content: "x"}, // builtin ALIAS collision (/resume alias)
|
||||
{Name: "deploy", Content: "x"}, // user-command collision
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(installed))
|
||||
for _, s := range installed {
|
||||
infos = append(infos, agent.SkillInfo{Name: s.Name})
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
AgentOptions: agent.Options{Skills: infos},
|
||||
})
|
||||
|
||||
// Assert on the advertised NAME with a skill-marked Desc, not on the exact
|
||||
// fallback Desc format — the invariant is that no skill row carries a
|
||||
// builtin/user-command name, regardless of description wording.
|
||||
for _, token := range []string{"/hel", "/ses", "/dep"} {
|
||||
for _, s := range m.matchSkillSuggestions(token) {
|
||||
if s.Name == "/help" || s.Name == "/sessions" || s.Name == "/deploy" {
|
||||
t.Fatalf("shadowed skill advertised for %q: %#v", token, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid slash shapes (spaces etc.) and a bare "/" must not surface skills.
|
||||
func TestSkillSuggestionEdgeCases(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "has space", Description: "not invocable", Content: "x"})
|
||||
|
||||
if got := m.matchSkillSuggestions("/has"); len(got) != 0 {
|
||||
t.Fatalf("invalid slash-shape skill must not be suggested, got %#v", got)
|
||||
}
|
||||
if got := m.matchSkillSuggestions("/"); got != nil {
|
||||
t.Fatalf("bare '/' should not list skills, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandResolves(t *testing.T) {
|
||||
command, ok := resolveCommand("/skills")
|
||||
if !ok || command.kind != commandSkills {
|
||||
t.Fatalf("resolveCommand(/skills) = %#v, %v", command, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// With skills installed, /skills opens a searchable picker (like /model).
|
||||
func TestSkillsCommandOpensPicker(t *testing.T) {
|
||||
m := newSkillTestModel(t,
|
||||
skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"},
|
||||
skills.Skill{Name: "has space", Description: "tool-only skill", Content: "x"},
|
||||
)
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
|
||||
if next.picker == nil || next.picker.kind != pickerSkill {
|
||||
t.Fatalf("/skills should open the skill picker, got %#v", next.picker)
|
||||
}
|
||||
labels := map[string]string{}
|
||||
for _, item := range next.picker.items {
|
||||
labels[item.Label] = item.Value
|
||||
}
|
||||
if labels["/deploy-checks"] != "deploy-checks" {
|
||||
t.Fatalf("slash-invocable skill should show its slash form, got %#v", labels)
|
||||
}
|
||||
// A non-slash-shaped name is still selectable (bare label, exact-name value).
|
||||
if labels["has space"] != "has space" {
|
||||
t.Fatalf("non-slash skill should be listed bare but selectable, got %#v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on a picker row fills the composer with "/name " so the user can add
|
||||
// their request (which PR? which file?) before submitting — it must NOT fire
|
||||
// the skill with no request attached.
|
||||
func TestSkillPickerEnterFillsComposer(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should be open")
|
||||
}
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
after := updated.(model)
|
||||
|
||||
if after.picker != nil {
|
||||
t.Fatal("picker should close on Enter")
|
||||
}
|
||||
if got := after.input.Value(); got != "/reviewer " {
|
||||
t.Fatalf("composer should be filled with the invocation, got %q", got)
|
||||
}
|
||||
if transcriptContains(after.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("skill must not run before the user submits, got %#v", after.transcript)
|
||||
}
|
||||
|
||||
// A second Enter submits the (bare) invocation and runs the skill.
|
||||
final, _ := after.Update(testKey(tea.KeyEnter))
|
||||
done := final.(model)
|
||||
if !transcriptContains(done.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("submitting the filled composer should run the skill, got %#v", done.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Picker selection is by exact name, so a skill shadowed by a builtin (dead on
|
||||
// the slash path) is still runnable from the picker.
|
||||
func TestSkillPickerRunsBuiltinShadowedSkill(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "help", Description: "shadowed", Content: "Shadowed skill body."})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should be open")
|
||||
}
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
after := updated.(model)
|
||||
|
||||
if !transcriptContains(after.transcript, "Shadowed skill body.") {
|
||||
t.Fatalf("picker must run the shadowed skill directly, got %#v", after.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandEmptyState(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
|
||||
if !transcriptContains(next.transcript, "No skills installed.") {
|
||||
t.Fatalf("empty state should say no skills, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Run-state guards: an expanded skill prompt must not race an active run — it
|
||||
// queues (as the EXPANDED prompt, since the queue flush submits literal text).
|
||||
func TestSkillInvocationQueuedWhileRunPending(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
|
||||
m.pending = true
|
||||
|
||||
next, _, handled := m.handleSkillCommand("/reviewer focus on tests")
|
||||
if !handled {
|
||||
t.Fatal("skill must be handled even while a run is pending")
|
||||
}
|
||||
if next.queuedMessage == "" || !strings.Contains(next.queuedMessage, "Review the diff.") {
|
||||
t.Fatalf("expanded prompt should be queued, got %q", next.queuedMessage)
|
||||
}
|
||||
if !strings.Contains(next.queuedMessage, "focus on tests") {
|
||||
t.Fatalf("queued prompt should carry the args, got %q", next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInvocationDroppedWhileExiting(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
m.exiting = true
|
||||
|
||||
next, cmd, handled := m.handleSkillCommand("/reviewer")
|
||||
if !handled || cmd != nil || next.queuedMessage != "" {
|
||||
t.Fatalf("exiting must swallow the invocation: handled=%v cmd=%v queued=%q", handled, cmd, next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInvocationWarnsDuringCompaction(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
m.compactInFlight = true
|
||||
|
||||
next, _, handled := m.handleSkillCommand("/reviewer")
|
||||
if !handled {
|
||||
t.Fatal("skill must be handled during compaction")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Compaction is running") {
|
||||
t.Fatalf("compaction warning expected, got %#v", next.transcript)
|
||||
}
|
||||
// The typed invocation is restored for an easy re-submit (the dispatch path
|
||||
// cleared the composer before the guard could fire).
|
||||
if got := next.input.Value(); got != "/reviewer" {
|
||||
t.Fatalf("typed invocation should be restored into the composer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard now protects user commands (previously they could start a
|
||||
// second concurrent run).
|
||||
func TestUserCommandQueuedWhileRunPending(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "greet.md", "Say hello to $1.")
|
||||
m := newModel(context.Background(), Options{Cwd: root})
|
||||
m.pending = true
|
||||
|
||||
next, _, handled := m.handleUserCommand("/greet world")
|
||||
if !handled {
|
||||
t.Fatal("user command must be handled while pending")
|
||||
}
|
||||
if !strings.Contains(next.queuedMessage, "Say hello to world.") {
|
||||
t.Fatalf("expanded user-command prompt should be queued, got %q", next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Two skills whose names collide after lowercasing map to one slash name; only
|
||||
// the one dispatch will run may be advertised.
|
||||
func TestCaseCollidingSkillsAdvertisedOnce(t *testing.T) {
|
||||
m := newSkillTestModel(t,
|
||||
skills.Skill{Name: "Deploy", Description: "wins", Content: "a"},
|
||||
skills.Skill{Name: "deploy", Description: "loses", Content: "b"},
|
||||
)
|
||||
|
||||
rows := 0
|
||||
for _, s := range m.matchSkillSuggestions("/dep") {
|
||||
if s.Name == "/deploy" {
|
||||
rows++
|
||||
if !strings.Contains(s.Desc, "wins") {
|
||||
t.Fatalf("advertised row must describe the skill dispatch runs, got %q", s.Desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("case-colliding skills must yield exactly one /deploy row, got %d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The palette reads through the loader, so a skill installed mid-session (the
|
||||
// loader's next result) appears without a restart.
|
||||
func TestSkillPaletteReadsThroughLoader(t *testing.T) {
|
||||
installed := []skills.Skill{}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: t.TempDir(),
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
})
|
||||
if got := m.matchSkillSuggestions("/lat"); len(got) != 0 {
|
||||
t.Fatalf("no skills installed yet, got %#v", got)
|
||||
}
|
||||
installed = append(installed, skills.Skill{Name: "late-arrival", Description: "installed mid-session", Content: "x"})
|
||||
got := m.matchSkillSuggestions("/lat")
|
||||
if len(got) != 1 || got[0].Name != "/late-arrival" {
|
||||
t.Fatalf("mid-session skill should appear via the loader, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestTranscriptBodyItemsIncludesSummaryBeforeCards(t *testing.T) {
|
||||
specialistInfo: &specialistInfo{name: "explorer", description: "map code", childSessionID: "s2", status: specialistCompleted, startedAt: now, completedAt: now, tokenCount: 2000},
|
||||
})
|
||||
|
||||
items := m.transcriptBodyItems(80, "")
|
||||
items := m.transcriptBodyItems(80, "", false)
|
||||
body := bodyItemsString(m, items, 80)
|
||||
if !strings.Contains(body, "specialists") {
|
||||
t.Errorf("transcript body should contain summary line with 'specialists', got:\n%s", body)
|
||||
|
||||
@@ -6,7 +6,7 @@ func TestTranscriptBodyItemsRepresentEmptyState(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
layout := layoutTranscriptBodyItems(items)
|
||||
|
||||
if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemEmpty {
|
||||
@@ -25,7 +25,7 @@ func TestTranscriptBodyItemsShiftSelectableLinesByItemStart(t *testing.T) {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "hello")
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
|
||||
|
||||
if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemRow {
|
||||
t.Fatalf("spans = %#v, want one transcript row item", layout.spans)
|
||||
@@ -51,7 +51,7 @@ func TestTranscriptBodyItemsKeepPendingInterimSelectableLocal(t *testing.T) {
|
||||
m.streamingReasoning = "private thought"
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
|
||||
|
||||
if len(layout.spans) != 2 {
|
||||
t.Fatalf("spans = %#v, want separator plus pending interim", layout.spans)
|
||||
@@ -181,7 +181,7 @@ func TestScrollableTranscriptItemsViewMatchesFullLayout(t *testing.T) {
|
||||
width := m.chatColumnWidth()
|
||||
header := m.pinnedTitleBar(width)
|
||||
footer := m.footerView(width)
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
full := layoutTranscriptBodyItems(items)
|
||||
|
||||
got := m.scrollableTranscriptItemsView(header, items, footer, width, "")
|
||||
|
||||
@@ -107,7 +107,7 @@ type transcriptBodyLayout struct {
|
||||
}
|
||||
|
||||
func (m model) transcriptBodyLayout(width int, emptyOverlay string) transcriptBodyLayout {
|
||||
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay))
|
||||
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay, false))
|
||||
}
|
||||
|
||||
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
|
||||
@@ -210,7 +210,7 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
|
||||
func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
|
||||
// File drill-in: the chat column's body swaps to the viewed file's
|
||||
// diff/content. Swapping HERE (the single source every consumer reads) keeps
|
||||
// the viewport, scroll engine, renderer, and mouse hit-tests consistent.
|
||||
@@ -241,9 +241,19 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
} else {
|
||||
rc := buildRowContext(m.transcript)
|
||||
shownAny := false
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
// The detailed view shows the full transcript from index 0, not
|
||||
// the managed region after m.flushed.
|
||||
startIdx := m.flushed
|
||||
if detailed {
|
||||
startIdx = 0
|
||||
}
|
||||
renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow)
|
||||
if detailed {
|
||||
renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow)
|
||||
}
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
|
||||
specialistSummaryEmitted := false
|
||||
for index := m.flushed; index < len(m.transcript); index++ {
|
||||
for index := startIdx; index < len(m.transcript); index++ {
|
||||
row := m.transcript[index]
|
||||
// A welcome row carries no Lime visual (the empty state replaced it)
|
||||
// and a resolved tool call collapses into its result's card.
|
||||
@@ -344,16 +354,18 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
}
|
||||
}
|
||||
rowIndex, transcriptRow := index, row
|
||||
// Key the height cache on contentWidth (the wrap width drives line count);
|
||||
// the gutter is a horizontal-only post-pad and never changes height.
|
||||
heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKey(transcriptRow, contentWidth, rc)
|
||||
bodyCap := cardBodyMaxLines
|
||||
if detailed {
|
||||
bodyCap = 0
|
||||
}
|
||||
heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKeyOpts(transcriptRow, contentWidth, rc, bodyCap)
|
||||
items = append(items, transcriptBodyItem{
|
||||
kind: transcriptBodyItemRow,
|
||||
rowIndex: rowIndex,
|
||||
heightCacheKey: heightCacheKey,
|
||||
heightCacheStable: heightCacheStable,
|
||||
render: func(startBodyY int) transcriptBodyRenderedItem {
|
||||
rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
||||
rendered, selectable := renderRowFn(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
||||
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
||||
},
|
||||
})
|
||||
@@ -635,8 +647,17 @@ func transcriptBlockBodyHeightCacheKey(kind transcriptBodyItemKind, block string
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) {
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
// rowRenderFn abstracts row rendering for normal vs detailed mode:
|
||||
// m.renderRow for normal (bodyCap: cardBodyMaxLines) and m.renderRowDetailed
|
||||
// for detailed (bodyCap: 0).
|
||||
type rowRenderFn func(transcriptRow, int, rowContext) string
|
||||
|
||||
// transcriptRowDispatchFn dispatches row-kind rendering for the transcript body
|
||||
// pipeline, producing rendered text and selectable-line geometry.
|
||||
type transcriptRowDispatchFn func(int, transcriptRow, int, rowContext, int) (string, []transcriptSelectableLine)
|
||||
|
||||
func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int, rc rowContext, bodyCap int) (string, bool) {
|
||||
opts := cardRenderOptions{bodyCap: bodyCap, cwd: m.cwd}
|
||||
rowKey, stable := m.renderRowCacheKey(row, width, rc, opts, false)
|
||||
if rowKey == "" {
|
||||
return "", false
|
||||
@@ -648,6 +669,17 @@ func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc
|
||||
}
|
||||
|
||||
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
}
|
||||
|
||||
// renderTranscriptDetailedRow routes through renderTranscriptRowFn with
|
||||
// renderRowDetailed (bodyCap: 0) so tool output appears uncapped.
|
||||
func (m model) renderTranscriptDetailedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed)
|
||||
}
|
||||
|
||||
// renderTranscriptRowFn dispatches row-kind rendering using the provided renderFn.
|
||||
func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
switch row.kind {
|
||||
case rowUser:
|
||||
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
|
||||
@@ -656,21 +688,26 @@ func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, r
|
||||
case rowReasoning:
|
||||
return m.renderSelectableReasoningRow(rowIndex, row, width, startBodyY)
|
||||
case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser:
|
||||
return m.renderSelectableRenderedRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableRenderedRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
case rowToolResult:
|
||||
return m.renderSelectableToolResultRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
case rowSpecialist:
|
||||
return m.renderSelectableSpecialistRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
default:
|
||||
return m.renderRow(row, width, rc), nil
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
selectable := selectableLinesFromRendered(rowIndex, rendered, startBodyY, 0)
|
||||
return rendered, selectable
|
||||
}
|
||||
}
|
||||
|
||||
// renderSelectableToolResultRow renders the tool result card and marks its head
|
||||
// (first line) as a clickable collapse/expand toggle. Body/footer text remains
|
||||
// selectable so copying a visible transcript range includes command output.
|
||||
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
rendered := m.renderRow(row, width, rc)
|
||||
func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -694,8 +731,12 @@ func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, wi
|
||||
return rendered, selectable
|
||||
}
|
||||
|
||||
func (m model) renderSelectableRenderedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
rendered := m.renderRow(row, width, rc)
|
||||
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
}
|
||||
|
||||
func (m model) renderSelectableRenderedRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -828,11 +869,8 @@ func (m model) renderTranscriptSelectableStyledLine(line transcriptSelectableLin
|
||||
return prefix + zeroTheme.selection.Render(selected) + suffix
|
||||
}
|
||||
|
||||
// renderSelectableSpecialistRow renders a specialist card and marks every line
|
||||
// as a clickable specialistCard selectable line carrying the childSessionID.
|
||||
// A left-click or Enter on any card line drills into that specialist's subchat.
|
||||
func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
rendered := m.renderRow(row, width, rc)
|
||||
func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" || row.specialistInfo == nil {
|
||||
return "", nil
|
||||
}
|
||||
@@ -846,9 +884,6 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
specialistCard: true,
|
||||
specialistID: row.specialistInfo.childSessionID,
|
||||
}
|
||||
// Carry the line's plain text + start so a selection dragged THROUGH the
|
||||
// card copies its content; the specialistCard flag still makes a direct
|
||||
// click drill into the sub-session (handled on press, before selection).
|
||||
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+i, lines[i], boxed); ok {
|
||||
sl.text = meta.text
|
||||
sl.textStart = meta.textStart
|
||||
@@ -858,6 +893,13 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
return rendered, selectable
|
||||
}
|
||||
|
||||
// renderSelectableSpecialistRow renders a specialist card and marks every line
|
||||
// as a clickable specialistCard selectable line carrying the childSessionID.
|
||||
// A left-click or Enter on any card line drills into that specialist's subchat.
|
||||
func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
}
|
||||
|
||||
func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
contentWidth := userPromptContentWidth(width)
|
||||
wrapped := wrapPlainText(row.text, maxInt(1, contentWidth))
|
||||
@@ -1030,12 +1072,18 @@ func splitPlainAtDisplayWidth(text string, width int) (string, string) {
|
||||
// selection previously resolved against transcript rows that weren't even
|
||||
// visible while viewing a subagent/swarm child session.
|
||||
func (m model) transcriptHitTestSource() (header string, items []transcriptBodyItem, width int) {
|
||||
if m.transcriptDetailed {
|
||||
width = chatWidth(m.width)
|
||||
header = detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||||
items = m.transcriptBodyItems(width, "", true)
|
||||
return
|
||||
}
|
||||
if m.subchat.active {
|
||||
width = chatWidth(m.width)
|
||||
return renderSubchatNavBar(m.subchat.childSessionTitle, width), m.transcriptBodyItemsFromRows(m.subchat.childRows, width), width
|
||||
}
|
||||
width = m.chatColumnWidth()
|
||||
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, ""), width
|
||||
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, "", false), width
|
||||
}
|
||||
|
||||
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
|
||||
@@ -1049,7 +1097,11 @@ func (m model) transcriptHitTestBlocked() bool {
|
||||
// (nearest-line fallback for scroll-driven selection extension).
|
||||
func (m model) transcriptHitTestLayout() (frame transcriptFrameLayout, window transcriptViewportWindow, layout transcriptBodyLayout) {
|
||||
header, items, width := m.transcriptHitTestSource()
|
||||
frame = m.scrollableTranscriptFrame(header, m.footerView(width))
|
||||
footer := m.footerView(width)
|
||||
if m.transcriptDetailed {
|
||||
footer = m.detailedTranscriptFooter(width)
|
||||
}
|
||||
frame = m.scrollableTranscriptFrame(header, footer)
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
|
||||
layout = layoutVisibleTranscriptBodyItems(items, metrics, window)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m model) toggleDetailedTranscript() model {
|
||||
m.transcriptDetailed = !m.transcriptDetailed
|
||||
m.clearSuggestions()
|
||||
if m.fileView.active {
|
||||
m = m.exitFileView()
|
||||
}
|
||||
if m.picker != nil && m.picker.kind == pickerTheme {
|
||||
// Dropping a theme picker mid-preview must undo the live palette, or the
|
||||
// previewed theme would stick while m.themeMode still holds the old one.
|
||||
@@ -14,55 +20,36 @@ func (m model) toggleDetailedTranscript() model {
|
||||
return m
|
||||
}
|
||||
|
||||
// detailedTranscriptView renders the full (uncapped) transcript viewport.
|
||||
// Tool output that would be truncated in the live view appears in full here.
|
||||
func (m model) detailedTranscriptView() string {
|
||||
width := chatWidth(m.width)
|
||||
rc := buildRowContext(m.transcript)
|
||||
renderer := m
|
||||
renderer.pending = false
|
||||
renderer.activeRunID = 0
|
||||
items := m.transcriptBodyItems(width, "", true)
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(detailedTranscriptHeader(width))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width)))
|
||||
builder.WriteString("\n")
|
||||
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||||
footer := m.detailedTranscriptFooter(width)
|
||||
|
||||
shownAny := false
|
||||
var previousKind rowKind
|
||||
for _, row := range m.transcript {
|
||||
if rc.skip(row) {
|
||||
continue
|
||||
}
|
||||
rendered := ""
|
||||
if row.kind == rowWelcome {
|
||||
rendered = fitStyledLine(zeroTheme.faint.Render(row.text), width)
|
||||
} else {
|
||||
rendered = renderer.renderRowDetailed(row, width, rc)
|
||||
}
|
||||
if rendered == "" {
|
||||
continue
|
||||
}
|
||||
if shownAny && startsTurn(row.kind) {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if shownAny && previousKind == rowUser && row.kind == rowReasoning {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(rendered)
|
||||
builder.WriteString("\n")
|
||||
shownAny = true
|
||||
previousKind = row.kind
|
||||
if m.altScreen && m.height > 0 {
|
||||
return m.scrollableTranscriptItemsView(header, items, footer, width, "")
|
||||
}
|
||||
// Popup/fallback: render the full body without viewport clipping.
|
||||
layout := layoutTranscriptBodyItems(items)
|
||||
return header + "\n" + layout.String() + "\n" + footer
|
||||
}
|
||||
|
||||
// detailedTranscriptFooter builds the one-line footer for the detailed transcript
|
||||
// view: copy status when active, otherwise the key hint with jump-to-bottom cue.
|
||||
func (m model) detailedTranscriptFooter(width int) string {
|
||||
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
|
||||
return rightAlignedLine(zeroTheme.ink.Render(copyStatus), width)
|
||||
}
|
||||
|
||||
if !shownAny {
|
||||
builder.WriteString(zeroTheme.faint.Render("No transcript rows."))
|
||||
builder.WriteString("\n")
|
||||
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
|
||||
hint := zeroTheme.faint.Render(fmt.Sprintf("Esc close | %s toggle", detailKey))
|
||||
if jt := m.jumpToBottomHint(); jt != "" {
|
||||
return fitStyledLine(joinHeaderLine(hint, jt, width), width)
|
||||
}
|
||||
|
||||
builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width)))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(fitStyledLine(zeroTheme.faint.Render("Esc close | Ctrl+O toggle"), width))
|
||||
return builder.String()
|
||||
return fitStyledLine(hint, width)
|
||||
}
|
||||
|
||||
func detailedTranscriptHeader(width int) string {
|
||||
|
||||
@@ -124,6 +124,78 @@ func transcriptViewTestModel() model {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestPgUpDownScrollsDetailedTranscript(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
m.altScreen = true
|
||||
// Fill the transcript enough to overflow the 30-row viewport.
|
||||
for i := 0; i < 60; i++ {
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true})
|
||||
}
|
||||
m.flushed = len(m.transcript)
|
||||
|
||||
// Enter detailed mode.
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("sanity check failed: Ctrl+O should enter detailed mode")
|
||||
}
|
||||
if m.chatScrollOffset != 0 {
|
||||
t.Fatal("sanity check failed: detailed transcript should start at bottom")
|
||||
}
|
||||
|
||||
// PgUp reveals older content = positive scroll offset.
|
||||
updated, _ = m.Update(testKey(tea.KeyPgUp))
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset <= 0 {
|
||||
t.Fatalf("PgUp in detailed mode should scroll toward older content, got chatScrollOffset=%d", m.chatScrollOffset)
|
||||
}
|
||||
|
||||
// PgDown scrolls back toward bottom.
|
||||
updated, _ = m.Update(testKey(tea.KeyPgDown))
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset != 0 {
|
||||
t.Fatalf("PgDown after PgUp should return to bottom in detailed mode, got chatScrollOffset=%d", m.chatScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpDownArrowsScrollDetailedTranscript(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
m.altScreen = true
|
||||
for i := 0; i < 60; i++ {
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true})
|
||||
}
|
||||
m.flushed = len(m.transcript)
|
||||
|
||||
updated, _ := m.Update(testKeyCtrl('o'))
|
||||
m = updated.(model)
|
||||
|
||||
if !m.transcriptDetailed {
|
||||
t.Fatal("sanity check failed: Ctrl+O should enter detailed mode")
|
||||
}
|
||||
|
||||
// Arrow down scrolls one line toward newer content; at bottom it stays at 0.
|
||||
updated, _ = m.Update(testKey(tea.KeyDown))
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset != 0 {
|
||||
t.Fatalf("KeyDown at bottom should stay at 0, got %d", m.chatScrollOffset)
|
||||
}
|
||||
|
||||
// Arrow up scrolls one line toward older content.
|
||||
updated, _ = m.Update(testKey(tea.KeyUp))
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset != 1 {
|
||||
t.Fatalf("KeyUp should scroll one line up, got chatScrollOffset=%d", m.chatScrollOffset)
|
||||
}
|
||||
|
||||
// Arrow down scrolls back toward bottom.
|
||||
updated, _ = m.Update(testKey(tea.KeyDown))
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset != 0 {
|
||||
t.Fatalf("KeyDown after KeyUp should return to bottom, got chatScrollOffset=%d", m.chatScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func numberedLines(count int) string {
|
||||
lines := make([]string, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
|
||||
@@ -30,8 +30,9 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return m, nil, false
|
||||
}
|
||||
next, teaCmd := m.launchPrompt(prompt)
|
||||
return next, teaCmd, true
|
||||
// Same run-state guards as a plain prompt: a user command invoked mid-run is
|
||||
// queued (as its expanded prompt), not raced into a second concurrent turn.
|
||||
return m.launchOrDeferExpandedPrompt(raw, prompt)
|
||||
}
|
||||
|
||||
// lookupUserCommand returns the loaded user command with the given (lowercased)
|
||||
|
||||
@@ -526,3 +526,159 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PushOptions struct {
|
||||
Cwd string
|
||||
Remote string
|
||||
Branch string
|
||||
Force bool
|
||||
DryRun bool
|
||||
AllowPushDefaultBranch bool
|
||||
RunGit Runner
|
||||
RunGitEnv EnvRunner
|
||||
}
|
||||
|
||||
type PushResult struct {
|
||||
Remote string `json:"remote"`
|
||||
Branch string `json:"branch"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func Push(ctx context.Context, options PushOptions) (PushResult, error) {
|
||||
cwd, err := resolveCwd(options.Cwd)
|
||||
if err != nil {
|
||||
return PushResult{}, err
|
||||
}
|
||||
runGit, _ := resolveRunners(options.RunGit, options.RunGitEnv)
|
||||
|
||||
root, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("not a git repository: %w", err)
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
|
||||
branch := strings.TrimSpace(options.Branch)
|
||||
if branch == "" {
|
||||
branch, err = gitOutput(ctx, runGit, root, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("resolve current branch: %w", err)
|
||||
}
|
||||
}
|
||||
if branch == "HEAD" {
|
||||
return PushResult{}, fmt.Errorf("cannot push: not currently on a branch")
|
||||
}
|
||||
|
||||
remote := strings.TrimSpace(options.Remote)
|
||||
if remote == "" {
|
||||
if upstream, err := gitOutput(ctx, runGit, root, "config", "branch."+branch+".remote"); err == nil && upstream != "" {
|
||||
remote = upstream
|
||||
} else {
|
||||
remote = "origin"
|
||||
}
|
||||
}
|
||||
|
||||
if !options.AllowPushDefaultBranch {
|
||||
if isDefaultBranch(ctx, runGit, root, remote, branch) {
|
||||
return PushResult{}, fmt.Errorf("refusing to push to %q (default/protected branch); use --yes to override", branch)
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"push"}
|
||||
if options.DryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
if options.Force {
|
||||
args = append(args, "--force-with-lease")
|
||||
}
|
||||
args = append(args, "-u", "--", remote, branch)
|
||||
|
||||
output, err := gitRawOutput(ctx, runGit, root, args...)
|
||||
if err != nil {
|
||||
return PushResult{}, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
|
||||
return PushResult{
|
||||
Remote: remote,
|
||||
Branch: branch,
|
||||
Output: output,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isDefaultBranch(ctx context.Context, runGit Runner, dir, remote, branch string) bool {
|
||||
if out, err := gitOutput(ctx, runGit, dir, "ls-remote", "--symref", remote, "HEAD"); err == nil {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "ref: refs/heads/") && strings.HasSuffix(line, "\tHEAD") {
|
||||
symref := strings.TrimPrefix(line, "ref: refs/heads/")
|
||||
symref = strings.TrimSuffix(symref, "\tHEAD")
|
||||
return branch == symref
|
||||
}
|
||||
}
|
||||
}
|
||||
return branch == "main" || branch == "master"
|
||||
}
|
||||
|
||||
type PROptions struct {
|
||||
Cwd string
|
||||
Fill bool
|
||||
Draft bool
|
||||
Title string
|
||||
Body string
|
||||
RunGH Runner
|
||||
}
|
||||
|
||||
type PRResult struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func CreatePR(ctx context.Context, options PROptions) (PRResult, error) {
|
||||
cwd, err := resolveCwd(options.Cwd)
|
||||
if err != nil {
|
||||
return PRResult{}, err
|
||||
}
|
||||
|
||||
runGH := options.RunGH
|
||||
if runGH == nil {
|
||||
runGH = func(ctx context.Context, dir string, args ...string) (CommandResult, error) {
|
||||
cmd := exec.CommandContext(ctx, "gh", args...)
|
||||
cmd.Dir = dir
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
|
||||
var exitCode int
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
|
||||
}
|
||||
}
|
||||
|
||||
prArgs := []string{"pr", "create"}
|
||||
if options.Fill {
|
||||
prArgs = append(prArgs, "--fill")
|
||||
}
|
||||
if options.Draft {
|
||||
prArgs = append(prArgs, "--draft")
|
||||
}
|
||||
if options.Title != "" {
|
||||
prArgs = append(prArgs, "--title", options.Title)
|
||||
}
|
||||
if options.Body != "" {
|
||||
prArgs = append(prArgs, "--body", options.Body)
|
||||
}
|
||||
|
||||
res, err := runGH(ctx, cwd, prArgs...)
|
||||
if err != nil {
|
||||
return PRResult{}, fmt.Errorf("gh pr create failed: %w\n%s", err, res.Stderr)
|
||||
}
|
||||
|
||||
return PRResult{
|
||||
Output: res.Stdout,
|
||||
}, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -521,3 +522,208 @@ func TestParseStatusZHandlesRenamesAndSpecialPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushBranchesToRemote(t *testing.T) {
|
||||
t.Run("HappyPath", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
|
||||
{}, // ls-remote --symref (no match → falls through)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Remote != "origin" || result.Branch != "feat/some-feature" || !strings.Contains(result.Output, "Everything up-to-date") {
|
||||
t.Fatalf("unexpected push result: %#v", result)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FlagsForceAndDryRun", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
|
||||
{}, // ls-remote --symref (no match)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
Force: true,
|
||||
DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push --dry-run --force-with-lease -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DetachedHEAD", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "HEAD\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on detached HEAD push, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot push: not currently on a branch") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RejectsDefaultBranch", func(t *testing.T) {
|
||||
for _, branch := range []string{"main", "master"} {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: branch + "\n"},
|
||||
}}
|
||||
|
||||
_, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when pushing %q, got nil", branch)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "default/protected branch") {
|
||||
t.Fatalf("unexpected error for %q: %v", branch, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowDefaultBranchWithYes", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "main\n"},
|
||||
{Stdout: "origin\n"},
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
AllowPushDefaultBranch: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
if result.Branch != "main" {
|
||||
t.Fatalf("expected branch main, got %q", result.Branch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FallbackRemoteToOrigin", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: root + "\n"},
|
||||
{Stdout: "feat/some-feature\n"},
|
||||
{ExitCode: 1, Stderr: "error: no such section"}, // config lookup fails
|
||||
{}, // ls-remote --symref (no match)
|
||||
{Stdout: "Everything up-to-date\n"},
|
||||
}}
|
||||
|
||||
result, err := Push(context.Background(), PushOptions{
|
||||
Cwd: root,
|
||||
RunGit: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Push returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Remote != "origin" {
|
||||
t.Fatalf("expected fallback remote to be origin, got: %q", result.Remote)
|
||||
}
|
||||
|
||||
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
|
||||
t.Fatalf("unexpected push command: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePRCommandConstruction(t *testing.T) {
|
||||
t.Run("CreatePRWithAllOptions", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: "https://github.com/Gitlawb/zero/pull/123\n"},
|
||||
}}
|
||||
|
||||
result, err := CreatePR(context.Background(), PROptions{
|
||||
Cwd: root,
|
||||
Fill: true,
|
||||
Draft: true,
|
||||
Title: "Feat: some title",
|
||||
Body: "Some body description",
|
||||
RunGH: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
|
||||
if result.Output != "https://github.com/Gitlawb/zero/pull/123\n" {
|
||||
t.Fatalf("unexpected PR result: %#v", result)
|
||||
}
|
||||
|
||||
expectedArgs := []string{"pr", "create", "--fill", "--draft", "--title", "Feat: some title", "--body", "Some body description"}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
|
||||
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
|
||||
}
|
||||
if runner.calls[0].dir != root {
|
||||
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreatePRMinimal", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runner := &fakeRunner{results: []CommandResult{
|
||||
{Stdout: "https://github.com/Gitlawb/zero/pull/124\n"},
|
||||
}}
|
||||
|
||||
_, err := CreatePR(context.Background(), PROptions{
|
||||
Cwd: root,
|
||||
RunGH: runner.Run,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
|
||||
expectedArgs := []string{"pr", "create"}
|
||||
if len(runner.calls) != 1 {
|
||||
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
|
||||
}
|
||||
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
|
||||
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
|
||||
}
|
||||
if runner.calls[0].dir != root {
|
||||
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,9 @@ type CollectedStream struct {
|
||||
// thinking blocks) that must be replayed on the next turn. Empty for providers
|
||||
// or runs without extended thinking.
|
||||
ReasoningBlocks []ReasoningBlock
|
||||
// HasReasoning records whether the provider streamed reasoning deltas. The
|
||||
// deltas remain non-answer content, but they still prove the turn was live.
|
||||
HasReasoning bool
|
||||
}
|
||||
|
||||
// Truncated reports whether the response ended for a non-normal reason (the
|
||||
@@ -132,6 +135,9 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
|
||||
options.OnText(event.Content)
|
||||
}
|
||||
case StreamEventReasoning:
|
||||
if strings.TrimSpace(event.Content) != "" {
|
||||
collected.HasReasoning = true
|
||||
}
|
||||
if options.OnReasoning != nil {
|
||||
options.OnReasoning(event.Content)
|
||||
}
|
||||
|
||||
@@ -312,6 +312,9 @@ func TestCollectStreamWithOptionsEmitsTextReasoningAndUsageCallbacks(t *testing.
|
||||
if collected.Text != "Hello zero" {
|
||||
t.Fatalf("text = %q, want Hello zero", collected.Text)
|
||||
}
|
||||
if !collected.HasReasoning {
|
||||
t.Fatal("expected reasoning stream to mark collected turn as reasoning-bearing")
|
||||
}
|
||||
if len(textDeltas) != 2 || textDeltas[0] != "Hello " || textDeltas[1] != "zero" {
|
||||
t.Fatalf("unexpected text callbacks: %#v", textDeltas)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,13 @@ type CompletionRequest struct {
|
||||
// Anthropic/Gemini thinking budgets) and ignores it for models that do not
|
||||
// support reasoning. Empty means "let the provider decide".
|
||||
ReasoningEffort string
|
||||
// PromptCacheKey, when non-empty, is an opaque stable identifier for the
|
||||
// conversation (the session ID). Providers with server-side prefix-cache
|
||||
// routing forward it — OpenAI `prompt_cache_key` — so consecutive requests
|
||||
// land on a replica that already holds the cached prompt prefix instead of
|
||||
// re-billing the full prefix each turn. Providers without an equivalent
|
||||
// ignore it.
|
||||
PromptCacheKey string
|
||||
}
|
||||
|
||||
// Provider streams normalized completion events for one request.
|
||||
|
||||
Reference in new issue
Block a user