mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07af8c451d | ||
|
|
fc89f2a36d | ||
|
|
4e74b65dd9 | ||
|
|
541acf9b43 | ||
|
|
3fa8b39672 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd |
No files matched your search
@@ -42,3 +42,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# 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,185 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||
// single worker checks it while the rest of the tool batch (and any
|
||||
// self-correct verification) runs, and the loop drains completed results just
|
||||
// before building the NEXT provider request, appending errors as a user-role
|
||||
// nudge. The model always issues another request after an edit turn — it has
|
||||
// to read its tool results — so it still sees introduced errors at the same
|
||||
// decision point as before, without any tool call stalling on the language
|
||||
// server.
|
||||
//
|
||||
// A file re-edited before its check runs is checked once in its final state
|
||||
// (the checker re-reads the file); a file re-edited after its check completed
|
||||
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||
// drain time, the drain gives up after a short wait and the results are
|
||||
// delivered on the following turn instead — they stay accurate because any
|
||||
// further edit re-enqueues the file.
|
||||
|
||||
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||
// check before deferring delivery to the next turn. Most checks finish well
|
||||
// under it (debounce floor + analysis); a cold language server on a large
|
||||
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||
// A var so tests can shorten the wait.
|
||||
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||
|
||||
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||
// than silently dropping an error the last edit introduced. A var so tests
|
||||
// can shorten the wait.
|
||||
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||
|
||||
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||
// the old inline block so the model reacts the same way.
|
||||
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||
|
||||
type asyncDiagnostics struct {
|
||||
check func(context.Context, string) string
|
||||
workspaceRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
queued map[string]bool
|
||||
results map[string]string
|
||||
// working is non-nil while the worker goroutine runs and is closed when it
|
||||
// exits, so drain can wait for quiescence without polling.
|
||||
working chan struct{}
|
||||
}
|
||||
|
||||
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||
// receiver is a no-op — callers wire it unconditionally.
|
||||
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||
if check == nil {
|
||||
return nil
|
||||
}
|
||||
return &asyncDiagnostics{
|
||||
check: check,
|
||||
workspaceRoot: workspaceRoot,
|
||||
queued: map[string]bool{},
|
||||
results: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue schedules changed files for a background check. Paths are
|
||||
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||
// extra write root), mirroring SelfCorrector's resolution.
|
||||
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||
if diagnostics == nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
for _, file := range files {
|
||||
path := diagnostics.absPath(file)
|
||||
if diagnostics.queued[path] {
|
||||
continue
|
||||
}
|
||||
diagnostics.queued[path] = true
|
||||
// A completed result for this file describes a pre-edit state now.
|
||||
delete(diagnostics.results, path)
|
||||
diagnostics.queue = append(diagnostics.queue, path)
|
||||
}
|
||||
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||
done := make(chan struct{})
|
||||
diagnostics.working = done
|
||||
go diagnostics.work(ctx, done)
|
||||
}
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||
diagnostics.working = nil
|
||||
diagnostics.mu.Unlock()
|
||||
return
|
||||
}
|
||||
path := diagnostics.queue[0]
|
||||
diagnostics.queue = diagnostics.queue[1:]
|
||||
// Un-mark before checking so an edit that lands DURING the check
|
||||
// re-enqueues the file and it is checked again in its final state.
|
||||
delete(diagnostics.queued, path)
|
||||
diagnostics.mu.Unlock()
|
||||
|
||||
block := diagnostics.check(ctx, path)
|
||||
|
||||
diagnostics.mu.Lock()
|
||||
if block != "" {
|
||||
diagnostics.results[path] = block
|
||||
} else {
|
||||
delete(diagnostics.results, path)
|
||||
}
|
||||
diagnostics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||
// to report or the worker is still busy (results then surface next turn).
|
||||
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||
}
|
||||
|
||||
// drainFinal is drain with the finalization budget: called before the run's
|
||||
// last model request, where an undelivered result would otherwise be lost.
|
||||
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||
if diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy != nil {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-busy:
|
||||
case <-timer.C:
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
}
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
if len(diagnostics.results) == 0 {
|
||||
return ""
|
||||
}
|
||||
paths := make([]string, 0, len(diagnostics.results))
|
||||
for path := range diagnostics.results {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
blocks := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
blocks = append(blocks, diagnostics.results[path])
|
||||
}
|
||||
diagnostics.results = map[string]string{}
|
||||
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
var diagnostics *asyncDiagnostics
|
||||
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||
t.Fatal("nil check must yield a nil collector")
|
||||
}
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||
// (no drive letter), which is exactly what the assertions below check.
|
||||
root := t.TempDir()
|
||||
var mu sync.Mutex
|
||||
var checked []string
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checked = append(checked, absPath)
|
||||
mu.Unlock()
|
||||
if filepath.Base(absPath) == "clean.go" {
|
||||
return ""
|
||||
}
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, root)
|
||||
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||
}
|
||||
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||
}
|
||||
mu.Lock()
|
||||
for _, path := range checked {
|
||||
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if second := diagnostics.drain(context.Background()); second != "" {
|
||||
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// A check still running at drain time defers delivery to the next drain
|
||||
// instead of blocking the turn.
|
||||
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
release := make(chan struct{})
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
<-release
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||
|
||||
start := time.Now()
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
if !strings.Contains(nudge, "ERR slow.go") {
|
||||
t.Fatalf("deferred nudge = %q", nudge)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("deferred diagnostics never delivered")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
response := "ERR old"
|
||||
check := func(context.Context, string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return response
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||
func (changedFilesTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (changedFilesTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||
// result (the old inline path), but the next request contains the background
|
||||
// diagnostics nudge, before the model finalizes.
|
||||
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
root := t.TempDir()
|
||||
var checkedPath string
|
||||
var mu sync.Mutex
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: root,
|
||||
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checkedPath = absPath
|
||||
mu.Unlock()
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// The tool result itself must NOT block on / embed diagnostics.
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||
}
|
||||
}
|
||||
// The nudge must be present in the second request as a user message.
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||
// error introduced by the last edit would go unreported in the summary.
|
||||
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
MaxTurns: 1,
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "summary" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||
}
|
||||
}
|
||||
|
||||
// When the model finalizes while a slow check is still in flight (the
|
||||
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||
// the model one more turn with the nudge instead of dropping the errors.
|
||||
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
// Slower than the (shortened) per-turn drain, well under the
|
||||
// finalization budget.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done after fix" {
|
||||
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[2].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
+59
-6
@@ -137,6 +137,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
|
||||
// Background post-edit diagnostics: files changed by mutating tools are
|
||||
// checked off the tool-call critical path and any errors are appended as a
|
||||
// nudge before the NEXT request (see async_diagnostics.go). nil when no
|
||||
// FileDiagnostics callback is wired; every method no-ops on nil.
|
||||
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
|
||||
|
||||
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
||||
// during THIS run. It is consulted by partitionTools each turn to expose a
|
||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||
@@ -159,6 +165,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
|
||||
// Deliver background post-edit diagnostics from the previous turn's edits
|
||||
// BEFORE compaction so the nudge is part of the request being budgeted.
|
||||
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
|
||||
// simply delivers on a later turn.
|
||||
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
@@ -173,6 +190,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 +226,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 +287,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 +346,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 {
|
||||
@@ -470,6 +491,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Finalization diagnostics gate: edits from this run may still have
|
||||
// checks in flight — the per-turn drain waits only briefly and defers
|
||||
// to "a later turn", but a final answer means there is no later turn.
|
||||
// Wait out the full inline-era budget once; an introduced error gives
|
||||
// the model one more turn to see (and fix) it instead of being lost.
|
||||
// Free for runs that never edited: an idle collector returns "".
|
||||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -586,11 +620,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
}
|
||||
|
||||
// Post-edit self-correction: collect the files this successful mutating
|
||||
// tool changed; verification runs once over the union after the batch.
|
||||
// A read-only tool (no ChangedFiles) never contributes.
|
||||
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||||
// Collect the files this successful mutating tool changed. Self-correct
|
||||
// verification runs once over the union after the batch; background
|
||||
// diagnostics start NOW so the language server analyzes while the rest
|
||||
// of the batch executes. A read-only tool (no ChangedFiles) never
|
||||
// contributes.
|
||||
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
|
||||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +710,17 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// mid-run, not a natural completion (a finished run returns via the no-tool-call
|
||||
// success path before this). Under the headless completion gate that is INCOMPLETE,
|
||||
// not success, so a run that loops to the turn limit isn't reported as done.
|
||||
//
|
||||
// The final-answer call is one more model request, so diagnostics from the
|
||||
// LAST turn's edits get a drain too — with the finalization budget, since
|
||||
// there is no later turn to defer to — otherwise an error introduced by
|
||||
// the final edit would go unreported in the summary.
|
||||
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
@@ -705,6 +753,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, ""
|
||||
@@ -1095,8 +1144,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// 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,
|
||||
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
|
||||
// synchronously inside edit_file/write_file and block every mutating tool
|
||||
// call on the language server (≥300ms debounce floor, 10s cap). The loop
|
||||
// now checks changed files in the background and nudges the model before
|
||||
// its next request instead (see async_diagnostics.go). The tools' inline
|
||||
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
|
||||
// Forward the run's operator tool filters so a filter-aware tool
|
||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||
EnabledTools: options.EnabledTools,
|
||||
|
||||
+108
-19
@@ -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)
|
||||
}
|
||||
@@ -312,13 +328,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 +336,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 +474,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,11 +285,13 @@ 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, when set, checks files changed by mutating tools for
|
||||
// error-severity language diagnostics IN THE BACKGROUND and appends any
|
||||
// errors as a nudge before the model's next request — the model still sees
|
||||
// an error it introduced at its next decision point, but no tool call ever
|
||||
// blocks on the language server (the old inline path stalled every edit on
|
||||
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
|
||||
// disables post-edit diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
|
||||
@@ -78,6 +78,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)
|
||||
@@ -179,6 +181,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,
|
||||
@@ -517,6 +521,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 +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{
|
||||
@@ -710,9 +711,10 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// sessions); it is a no-op when self-correct is off.
|
||||
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.
|
||||
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
|
||||
// collected off the tool-call path by the loop), and it stays lazy — no
|
||||
// language server is spawned unless an edited file's language actually has
|
||||
// one installed on PATH.
|
||||
manager := lsp.NewManager(workspaceRoot)
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+67
-8
@@ -126,8 +126,8 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
// can't grow Zero's memory before truncation: only the head+tail each stream
|
||||
// will ever surface to the model are retained, the middle is discarded as it
|
||||
// streams, and the true size is counted for the truncation marker.
|
||||
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
|
||||
stdout := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
stderr := newBoundedBuffer(bashCaptureBudgetBytes, bashCaptureBudgetBytes)
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
|
||||
@@ -370,12 +370,21 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
|
||||
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
|
||||
// runs); every other read/search tool already budgets its output. Head+tail
|
||||
// truncation keeps both the start and the end of an oversized stream, since
|
||||
// build/test failures usually surface at the tail.
|
||||
const bashOutputBudgetBytes = 96 * 1024
|
||||
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model (32 KiB
|
||||
// ≈ 8k tokens per stream). bash is the one tool that can emit unbounded output
|
||||
// (`cat large.log`, `find /`, verbose test runs); a chatty build log at the old
|
||||
// 96 KiB budget injected ~24k tokens per call and was then re-billed on every
|
||||
// following turn. Head+tail truncation keeps both the start and the end of an
|
||||
// oversized stream, since build/test failures usually surface at the tail, and
|
||||
// the full captured text is spilled to disk so nothing is lost — the model
|
||||
// greps/reads the spill instead of re-running the command.
|
||||
const bashOutputBudgetBytes = 32 * 1024
|
||||
|
||||
// bashCaptureBudgetBytes bounds the head and tail retained IN MEMORY per
|
||||
// stream during capture. Deliberately larger than the emit budget: the extra
|
||||
// retained bytes never reach the transcript, but they make the spill file —
|
||||
// the model's recovery path — cover 6× more of the output.
|
||||
const bashCaptureBudgetBytes = 96 * 1024
|
||||
|
||||
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
|
||||
// keeping the head and tail of anything larger, and records raw/emitted byte
|
||||
@@ -395,6 +404,19 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
|
||||
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
|
||||
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
|
||||
truncated := outTrunc || errTrunc
|
||||
if truncated {
|
||||
if spillPath := spillBashStreams(out, outTotal, errStr, errTotal); spillPath != "" {
|
||||
hint := "\n[zero] captured output saved to " + spillPath + " (grep or read_file it instead of re-running)"
|
||||
if errTrunc {
|
||||
errText += hint
|
||||
} else {
|
||||
outText += hint
|
||||
}
|
||||
if meta != nil {
|
||||
meta["spill_path"] = spillPath
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta != nil {
|
||||
emitted := len(outText) + len(errText)
|
||||
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
|
||||
@@ -407,6 +429,43 @@ func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, me
|
||||
return outText, errText, truncated
|
||||
}
|
||||
|
||||
// spillBashStreams writes the retained (capture-bounded) stdout and stderr to
|
||||
// the spill directory as one sectioned file and returns its path, or "" when
|
||||
// spilling fails. The spill holds up to bashCaptureBudgetBytes of head and
|
||||
// tail per stream — everything zero kept in memory, which is more than the
|
||||
// emit budget shows the model but not necessarily the whole output. When the
|
||||
// capture itself dropped the middle of a stream (total exceeds the retained
|
||||
// bytes), a gap marker is inserted at the head/tail junction so the spilled
|
||||
// log never reads as contiguous when it is not.
|
||||
func spillBashStreams(stdout string, stdoutTotal int, stderr string, stderrTotal int) string {
|
||||
stdout = sectionWithCaptureGap(stdout, stdoutTotal)
|
||||
stderr = sectionWithCaptureGap(stderr, stderrTotal)
|
||||
var combined strings.Builder
|
||||
combined.Grow(len(stdout) + len(stderr) + 64)
|
||||
combined.WriteString("### stdout\n")
|
||||
combined.WriteString(stdout)
|
||||
if !strings.HasSuffix(stdout, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
combined.WriteString("### stderr\n")
|
||||
combined.WriteString(stderr)
|
||||
return spillTruncatedOutput("bash", combined.String())
|
||||
}
|
||||
|
||||
// sectionWithCaptureGap marks the point where boundedBuffer dropped the middle
|
||||
// of a stream. When total exceeds the retained bytes, the retained text is the
|
||||
// frozen head (bashCaptureBudgetBytes, always full once overflow happened)
|
||||
// followed immediately by the rolling tail — the junction sits at the head cap,
|
||||
// snapped back to a rune boundary.
|
||||
func sectionWithCaptureGap(text string, total int) string {
|
||||
if total <= len(text) || len(text) <= bashCaptureBudgetBytes {
|
||||
return text
|
||||
}
|
||||
head := utf8Prefix(text, bashCaptureBudgetBytes)
|
||||
marker := fmt.Sprintf("\n[zero] capture gap: %d bytes omitted from the middle of this stream\n", total-len(text))
|
||||
return head + marker + text[len(head):]
|
||||
}
|
||||
|
||||
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
|
||||
// and tailCap bytes from the end of a stream while counting the total written, so
|
||||
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
|
||||
|
||||
@@ -2,11 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setTestTempDir points every platform's temp-dir lookup at a per-test
|
||||
// directory so spill files never land in the real temp dir: os.TempDir reads
|
||||
// TMPDIR on Unix but TMP/TEMP on Windows.
|
||||
func setTestTempDir(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("TMPDIR", dir)
|
||||
t.Setenv("TMP", dir)
|
||||
t.Setenv("TEMP", dir)
|
||||
}
|
||||
|
||||
// Small output passes through untouched and records raw==emitted, no truncated flag.
|
||||
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
|
||||
meta := map[string]string{}
|
||||
@@ -26,8 +38,10 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
|
||||
}
|
||||
|
||||
// Oversized stdout is truncated head+tail: both the first and last lines survive,
|
||||
// the middle is dropped behind a marker, and meta is flagged.
|
||||
// the middle is dropped behind a marker, meta is flagged, and the captured text
|
||||
// is spilled to a re-readable file.
|
||||
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
head := "FIRST_LINE_MARKER\n"
|
||||
tail := "\nLAST_LINE_MARKER"
|
||||
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
|
||||
@@ -47,9 +61,23 @@ func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
|
||||
if !strings.Contains(out, "output truncated") {
|
||||
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
// The spill hint (path length varies) is the only allowed overage past the budget.
|
||||
if len(out) > bashOutputBudgetBytes+512 {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if !strings.Contains(out, "captured output saved to ") {
|
||||
t.Fatalf("truncated bash output must carry a spill hint:\n%s", out[len(out)-300:])
|
||||
}
|
||||
if meta["spill_path"] == "" {
|
||||
t.Fatal("spill path missing from meta")
|
||||
}
|
||||
content, err := os.ReadFile(meta["spill_path"])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "### stdout") || !strings.Contains(string(content), strings.Repeat("x", 1024)) {
|
||||
t.Fatalf("spill must hold the sectioned captured streams (got %d bytes)", len(content))
|
||||
}
|
||||
if meta["truncated"] != "true" {
|
||||
t.Fatalf("expected truncated=true, got %v", meta)
|
||||
}
|
||||
@@ -100,10 +128,12 @@ func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
|
||||
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
|
||||
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
|
||||
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
// Retained head+tail as boundedBuffer would hand over; the real command produced
|
||||
// far more than was kept.
|
||||
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
|
||||
total := 10 * bashOutputBudgetBytes
|
||||
setTestTempDir(t)
|
||||
// Retained head+tail as boundedBuffer would hand over after overflow: the
|
||||
// frozen head is full at bashCaptureBudgetBytes, then the rolling tail; the
|
||||
// real command produced far more than was kept.
|
||||
retained := "HEAD_START" + strings.Repeat("y", bashCaptureBudgetBytes) + "TAIL_END"
|
||||
total := 10 * bashCaptureBudgetBytes
|
||||
|
||||
meta := map[string]string{}
|
||||
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
|
||||
@@ -114,7 +144,8 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
|
||||
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
|
||||
}
|
||||
if len(out) > bashOutputBudgetBytes {
|
||||
// Spill hint is the only allowed overage past the budget.
|
||||
if len(out) > bashOutputBudgetBytes+512 {
|
||||
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
|
||||
}
|
||||
if meta["raw_bytes"] != strconv.Itoa(total) {
|
||||
@@ -124,4 +155,29 @@ func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
|
||||
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
|
||||
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
|
||||
}
|
||||
|
||||
// The spill must be recorded, sectioned, and carry a capture-gap marker at
|
||||
// the head/tail junction — the capture dropped the middle, so the spilled
|
||||
// log must never read as contiguous.
|
||||
if meta["spill_path"] == "" {
|
||||
t.Fatal("spill path missing from meta")
|
||||
}
|
||||
content, err := os.ReadFile(meta["spill_path"])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "### stdout") {
|
||||
t.Fatal("spill must be sectioned by stream")
|
||||
}
|
||||
gapMarker := fmt.Sprintf("capture gap: %d bytes omitted", total-len(retained))
|
||||
gapIndex := strings.Index(string(content), gapMarker)
|
||||
if gapIndex < 0 {
|
||||
t.Fatalf("spill missing capture-gap marker %q", gapMarker)
|
||||
}
|
||||
// The junction sits right after the frozen head (plus the section header),
|
||||
// not appended at the end of the stream.
|
||||
headerOffset := len("### stdout\n")
|
||||
if gapIndex < headerOffset+bashCaptureBudgetBytes-64 || gapIndex > headerOffset+bashCaptureBudgetBytes+64 {
|
||||
t.Fatalf("capture-gap marker at offset %d, want ~%d (the head cap)", gapIndex, headerOffset+bashCaptureBudgetBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Universal output ceiling. Every tool result leaving the registry boundary is
|
||||
// capped at a single token-denominated budget unless the tool manages its own
|
||||
// deliberate budget (selfBudgeting below). This is the safety net for tools
|
||||
// with no cap of their own — web_fetch bodies, skill files, MCP server tools,
|
||||
// browser snapshots, anything added later — so no single call can flood the
|
||||
// context window with output that is then re-billed on every following turn.
|
||||
// Truncation keeps head+tail and spills the full text to disk (re-readable via
|
||||
// grep/read_file), so nothing is lost, only deferred.
|
||||
|
||||
// defaultOutputCeilingTokens is the per-call ceiling in estimated tokens
|
||||
// (bytes/4). 16k tokens = 64 KiB — deliberately equal to the search-tool
|
||||
// budget: generous enough for a large fetched document, small enough that one
|
||||
// call cannot eat a third of a small context window.
|
||||
const defaultOutputCeilingTokens = 16_000
|
||||
|
||||
// outputCeilingEnv overrides the ceiling (in tokens). Zero or negative
|
||||
// disables the ceiling entirely; unset or unparsable keeps the default.
|
||||
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
|
||||
|
||||
// selfBudgeting marks a tool that enforces its own deliberate output budget —
|
||||
// possibly model-raisable (exec_command) — which the registry ceiling must not
|
||||
// second-guess. The method is unexported on purpose: only tools in this
|
||||
// package can opt out, so an MCP-served tool can never exempt itself.
|
||||
type selfBudgeting interface{ managesOutputBudget() }
|
||||
|
||||
// The exemption list, kept in one place. Each of these applies its own budget
|
||||
// before returning: bash (bashOutputBudgetBytes per stream + spill),
|
||||
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
|
||||
// search tools (64 KiB).
|
||||
func (bashTool) managesOutputBudget() {}
|
||||
func (execCommandTool) managesOutputBudget() {}
|
||||
func (readFileTool) managesOutputBudget() {}
|
||||
func (readMinifiedFileTool) managesOutputBudget() {}
|
||||
func (grepTool) managesOutputBudget() {}
|
||||
func (globTool) managesOutputBudget() {}
|
||||
func (listDirectoryTool) managesOutputBudget() {}
|
||||
|
||||
func resolveOutputCeilingTokens() int {
|
||||
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
|
||||
if raw == "" {
|
||||
return defaultOutputCeilingTokens
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultOutputCeilingTokens
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// enforceOutputCeiling truncates an over-ceiling result to head+tail within
|
||||
// the token budget, spilling the full (already redaction-scrubbed) output to
|
||||
// disk with a recovery hint. Runs after scrubResultSecrets so the transcript
|
||||
// and the spill file agree on what was hidden.
|
||||
func enforceOutputCeiling(toolName string, result Result) Result {
|
||||
ceiling := resolveOutputCeilingTokens()
|
||||
if ceiling <= 0 {
|
||||
return result
|
||||
}
|
||||
if len(result.Output) <= ceiling*4 {
|
||||
return result
|
||||
}
|
||||
rawBytes := len(result.Output)
|
||||
truncated, _ := truncateExecOutputSpill(result.Output, ceiling, toolName)
|
||||
result.Output = truncated
|
||||
result.Truncated = true
|
||||
if result.Meta == nil {
|
||||
result.Meta = map[string]string{}
|
||||
}
|
||||
result.Meta["raw_bytes"] = strconv.Itoa(rawBytes)
|
||||
result.Meta["emitted_bytes"] = strconv.Itoa(len(result.Output))
|
||||
result.Meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(result.Output)))
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ceilingFakeTool is a minimal tool with no output budget of its own — the
|
||||
// stand-in for an MCP-served tool or any future builtin that forgets to cap
|
||||
// its output.
|
||||
type ceilingFakeTool struct {
|
||||
baseTool
|
||||
output string
|
||||
}
|
||||
|
||||
func (tool ceilingFakeTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult(tool.output)
|
||||
}
|
||||
|
||||
// ceilingExemptTool is the same fake but marked self-budgeting.
|
||||
type ceilingExemptTool struct{ ceilingFakeTool }
|
||||
|
||||
func (ceilingExemptTool) managesOutputBudget() {}
|
||||
|
||||
func newCeilingFakeTool(name, output string) ceilingFakeTool {
|
||||
return ceilingFakeTool{
|
||||
baseTool: baseTool{name: name, safety: readOnlySafety("test tool")},
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
|
||||
// An unbudgeted tool's oversized output is capped at the universal ceiling,
|
||||
// head and tail survive, and the full output is spilled to a re-readable file.
|
||||
func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
big := "HEAD_MARK\n" + strings.Repeat("z", defaultOutputCeilingTokens*4*3) + "\nTAIL_MARK"
|
||||
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_big", big))
|
||||
result := registry.Run(context.Background(), "fake_big", map[string]any{})
|
||||
|
||||
if !result.Truncated {
|
||||
t.Fatal("over-ceiling output must report truncated=true")
|
||||
}
|
||||
maxBytes := defaultOutputCeilingTokens * 4
|
||||
if len(result.Output) > maxBytes+512 {
|
||||
t.Fatalf("emitted %d bytes, ceiling is %d", len(result.Output), maxBytes)
|
||||
}
|
||||
if !strings.Contains(result.Output, "HEAD_MARK") || !strings.Contains(result.Output, "TAIL_MARK") {
|
||||
t.Fatal("head/tail lost at the ceiling")
|
||||
}
|
||||
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
|
||||
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
|
||||
}
|
||||
if !strings.Contains(result.Output, "full output saved to ") {
|
||||
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
|
||||
}
|
||||
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
|
||||
end := strings.Index(result.Output[start:], " (grep")
|
||||
content, err := os.ReadFile(result.Output[start : start+end])
|
||||
if err != nil {
|
||||
t.Fatalf("spill file unreadable: %v", err)
|
||||
}
|
||||
if string(content) != big {
|
||||
t.Fatalf("spill must hold the full output: got %d bytes, want %d", len(content), len(big))
|
||||
}
|
||||
}
|
||||
|
||||
// A self-budgeting tool's output passes the boundary untouched even when it
|
||||
// exceeds the universal ceiling — its own (possibly model-raised) budget wins.
|
||||
func TestOutputCeilingSkipsSelfBudgetingTool(t *testing.T) {
|
||||
big := strings.Repeat("z", defaultOutputCeilingTokens*4*2)
|
||||
registry := NewRegistry()
|
||||
registry.Register(ceilingExemptTool{newCeilingFakeTool("fake_exempt", big)})
|
||||
|
||||
result := registry.Run(context.Background(), "fake_exempt", map[string]any{})
|
||||
if result.Truncated || result.Output != big {
|
||||
t.Fatalf("self-budgeting tool must bypass the ceiling (truncated=%v, %d bytes)", result.Truncated, len(result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// Under-ceiling output is untouched and gains no budget metadata.
|
||||
func TestOutputCeilingSmallOutputUntouched(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_small", "hello"))
|
||||
|
||||
result := registry.Run(context.Background(), "fake_small", map[string]any{})
|
||||
if result.Truncated || result.Output != "hello" {
|
||||
t.Fatalf("small output altered: %+v", result)
|
||||
}
|
||||
if _, ok := result.Meta["raw_bytes"]; ok {
|
||||
t.Fatal("small output must not gain budget metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// ZERO_TOOL_OUTPUT_CEILING_TOKENS tightens, loosens, or disables the ceiling.
|
||||
func TestOutputCeilingEnvOverride(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
big := strings.Repeat("z", 100*1024)
|
||||
|
||||
t.Setenv(outputCeilingEnv, "1000")
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool("fake_env", big))
|
||||
result := registry.Run(context.Background(), "fake_env", map[string]any{})
|
||||
if !result.Truncated || len(result.Output) > 1000*4+512 {
|
||||
t.Fatalf("ceiling override ignored: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
||||
}
|
||||
|
||||
t.Setenv(outputCeilingEnv, "0")
|
||||
result = registry.Run(context.Background(), "fake_env", map[string]any{})
|
||||
if result.Truncated || len(result.Output) != len(big) {
|
||||
t.Fatalf("ceiling=0 must disable the net: truncated=%v, %d bytes", result.Truncated, len(result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// The exemption list matches the tools that really do budget themselves, and
|
||||
// the unbudgeted ones (web_fetch, skill, browser) stay under the net.
|
||||
func TestSelfBudgetingExemptionList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exempt := []Tool{
|
||||
NewBashTool(dir),
|
||||
NewExecCommandTool(dir, newExecSessionManager()),
|
||||
NewReadFileTool(dir),
|
||||
NewReadMinifiedFileTool(dir),
|
||||
NewGrepTool(dir),
|
||||
NewGlobTool(dir),
|
||||
NewListDirectoryTool(dir),
|
||||
}
|
||||
for _, tool := range exempt {
|
||||
if _, ok := tool.(selfBudgeting); !ok {
|
||||
t.Errorf("%s must be exempt from the output ceiling", tool.Name())
|
||||
}
|
||||
}
|
||||
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
|
||||
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
|
||||
}
|
||||
}
|
||||
@@ -123,13 +123,24 @@ func (registry *Registry) Run(ctx context.Context, name string, args map[string]
|
||||
func (registry *Registry) RunWithOptions(ctx context.Context, name string, args map[string]any, options RunOptions) (result Result) {
|
||||
// Every return path passes through scrubResultSecrets exactly once, so denial,
|
||||
// permission, and unknown-tool error messages (which can echo secret-bearing
|
||||
// args/paths) are redacted at the boundary just like tool output.
|
||||
defer func() { result = scrubResultSecrets(result) }()
|
||||
// args/paths) are redacted at the boundary just like tool output. The output
|
||||
// ceiling runs after the scrub so the transcript and the spill file agree on
|
||||
// what was hidden.
|
||||
ceilingExempt := false
|
||||
defer func() {
|
||||
result = scrubResultSecrets(result)
|
||||
if !ceilingExempt {
|
||||
result = enforceOutputCeiling(name, result)
|
||||
}
|
||||
}()
|
||||
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
return errorResult(`Error: Unknown tool "` + name + `".`)
|
||||
}
|
||||
if _, ok := tool.(selfBudgeting); ok {
|
||||
ceilingExempt = true
|
||||
}
|
||||
if rejecter, ok := tool.(PrePermissionRejecter); ok {
|
||||
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
|
||||
return res
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/secrets"
|
||||
)
|
||||
|
||||
// Spill-to-disk for truncated tool output. When a command produces more than
|
||||
@@ -90,8 +91,10 @@ func spillDir() (string, error) {
|
||||
// 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.
|
||||
// tool boundary PLUS the pattern-based secret scanner (AWS keys, tokens, PEM
|
||||
// blocks, JWTs) that bash applies to its model-visible output — a spill runs
|
||||
// before that formatter, so without the scan here a spilled file would hold
|
||||
// pattern-matched credentials in cleartext that the transcript hides.
|
||||
func spillTruncatedOutput(toolName, output string) string {
|
||||
dir, err := spillDir()
|
||||
if err != nil {
|
||||
@@ -110,6 +113,7 @@ func spillTruncatedOutput(toolName, output string) string {
|
||||
}
|
||||
defer file.Close()
|
||||
scrubbed := redaction.RedactString(output, redaction.Options{})
|
||||
scrubbed, _ = secrets.Redact(scrubbed)
|
||||
if _, err := file.WriteString(scrubbed); err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
return ""
|
||||
|
||||
@@ -63,6 +63,33 @@ func TestSweepSpillDirRemovesOnlyOldFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A spill happens BEFORE bash's model-facing formatter runs its pattern-based
|
||||
// secret scan, so the spill itself must scrub pattern-matched credentials —
|
||||
// otherwise the file would hold in cleartext exactly what the transcript hides.
|
||||
func TestSpillTruncatedOutputScrubsPatternSecrets(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
githubToken := "ghp_" + strings.Repeat("a", 36)
|
||||
body := "before\nAKIAIOSFODNN7EXAMPLE\n" + githubToken + "\nafter"
|
||||
|
||||
path := spillTruncatedOutput("bash", body)
|
||||
if path == "" {
|
||||
t.Fatal("spill must return a file path")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(content), "AKIAIOSFODNN7EXAMPLE") {
|
||||
t.Fatal("AWS access key reached the spill file in cleartext")
|
||||
}
|
||||
if strings.Contains(string(content), githubToken) {
|
||||
t.Fatal("GitHub token reached the spill file in cleartext")
|
||||
}
|
||||
if !strings.Contains(string(content), "before") || !strings.Contains(string(content), "after") {
|
||||
t.Fatalf("non-secret content must survive the scrub: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillTruncatedOutputWritesFile(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
path := spillTruncatedOutput("exec_command", "some output body")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+148
-25
@@ -1354,7 +1354,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 +1421,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 +1490,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 +1543,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 +1578,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 +2321,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 +2412,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 +2452,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 +2882,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
|
||||
@@ -4323,9 +4446,9 @@ 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.
|
||||
// Background post-edit diagnostics: the loop checks files changed by
|
||||
// edit_file/write_file off the tool-call path and nudges the model with
|
||||
// any errors before its next request. Shares the run's lazy manager.
|
||||
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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