mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 | ||
|
|
d185f3ef0d | ||
|
|
8c6c49ad52 | ||
|
|
809489a6e3 | ||
|
|
56147a708e | ||
|
|
81aad58d97 | ||
|
|
95ccd5bc32 | ||
|
|
284a6c86cb | ||
|
|
8f3b8f0d13 |
No files matched your search
@@ -100,9 +100,10 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck and deadcode resolve a toolchain from their own modules, which
|
||||
# can drop below the version go.mod requires and then fail to load our
|
||||
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
@@ -120,3 +121,13 @@ jobs:
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -37,6 +37,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-skills
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
|
||||
@@ -186,6 +186,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
@@ -283,6 +284,39 @@ zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
Zero appends project-specific guidance to the system prompt from the first
|
||||
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
|
||||
the git root down to your current working directory (checked in that order
|
||||
per directory). Files are injected general-to-specific, capped at 8 KiB per
|
||||
file and 32 KiB total.
|
||||
|
||||
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
|
||||
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
|
||||
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
|
||||
|
||||
### Plugins
|
||||
|
||||
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
|
||||
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
|
||||
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
|
||||
(project scope — resolved from the current working directory, not the repo
|
||||
root), and managed with `zero plugins`. A manifest can declare:
|
||||
|
||||
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
|
||||
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
|
||||
auto-approval is enabled)
|
||||
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
|
||||
`sessionEnd`
|
||||
- `prompts` and `skills` — additional prompt/skill files
|
||||
|
||||
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
|
||||
the same extension points and can also be wired up outside of a plugin
|
||||
manifest.
|
||||
|
||||
## Appearance And Accessibility
|
||||
|
||||
| Control | Effect |
|
||||
|
||||
+4
-4
@@ -180,6 +180,7 @@ runs:
|
||||
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
||||
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
||||
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -247,10 +248,9 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Best-effort one-line summary: last non-empty line of the captured output.
|
||||
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
|
||||
# Keep the summary single-line and bounded for the step output.
|
||||
summary="$(printf '%s' "${summary}" | cut -c1-280)"
|
||||
# Best-effort one-line summary. Structured output ends with lifecycle
|
||||
# metadata, so parse final/error events instead of tailing stdout.
|
||||
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||
// single worker checks it while the rest of the tool batch (and any
|
||||
// self-correct verification) runs, and the loop drains completed results just
|
||||
// before building the NEXT provider request, appending errors as a user-role
|
||||
// nudge. The model always issues another request after an edit turn — it has
|
||||
// to read its tool results — so it still sees introduced errors at the same
|
||||
// decision point as before, without any tool call stalling on the language
|
||||
// server.
|
||||
//
|
||||
// A file re-edited before its check runs is checked once in its final state
|
||||
// (the checker re-reads the file); a file re-edited after its check completed
|
||||
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||
// drain time, the drain gives up after a short wait and the results are
|
||||
// delivered on the following turn instead — they stay accurate because any
|
||||
// further edit re-enqueues the file.
|
||||
|
||||
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||
// check before deferring delivery to the next turn. Most checks finish well
|
||||
// under it (debounce floor + analysis); a cold language server on a large
|
||||
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||
// A var so tests can shorten the wait.
|
||||
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||
|
||||
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||
// than silently dropping an error the last edit introduced. A var so tests
|
||||
// can shorten the wait.
|
||||
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||
|
||||
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||
// the old inline block so the model reacts the same way.
|
||||
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||
|
||||
type asyncDiagnostics struct {
|
||||
check func(context.Context, string) string
|
||||
workspaceRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
queued map[string]bool
|
||||
results map[string]string
|
||||
// working is non-nil while the worker goroutine runs and is closed when it
|
||||
// exits, so drain can wait for quiescence without polling.
|
||||
working chan struct{}
|
||||
}
|
||||
|
||||
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||
// receiver is a no-op — callers wire it unconditionally.
|
||||
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||
if check == nil {
|
||||
return nil
|
||||
}
|
||||
return &asyncDiagnostics{
|
||||
check: check,
|
||||
workspaceRoot: workspaceRoot,
|
||||
queued: map[string]bool{},
|
||||
results: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue schedules changed files for a background check. Paths are
|
||||
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||
// extra write root), mirroring SelfCorrector's resolution.
|
||||
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||
if diagnostics == nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
for _, file := range files {
|
||||
path := diagnostics.absPath(file)
|
||||
if diagnostics.queued[path] {
|
||||
continue
|
||||
}
|
||||
diagnostics.queued[path] = true
|
||||
// A completed result for this file describes a pre-edit state now.
|
||||
delete(diagnostics.results, path)
|
||||
diagnostics.queue = append(diagnostics.queue, path)
|
||||
}
|
||||
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||
done := make(chan struct{})
|
||||
diagnostics.working = done
|
||||
go diagnostics.work(ctx, done)
|
||||
}
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||
diagnostics.working = nil
|
||||
diagnostics.mu.Unlock()
|
||||
return
|
||||
}
|
||||
path := diagnostics.queue[0]
|
||||
diagnostics.queue = diagnostics.queue[1:]
|
||||
// Un-mark before checking so an edit that lands DURING the check
|
||||
// re-enqueues the file and it is checked again in its final state.
|
||||
delete(diagnostics.queued, path)
|
||||
diagnostics.mu.Unlock()
|
||||
|
||||
block := diagnostics.check(ctx, path)
|
||||
|
||||
diagnostics.mu.Lock()
|
||||
if block != "" {
|
||||
diagnostics.results[path] = block
|
||||
} else {
|
||||
delete(diagnostics.results, path)
|
||||
}
|
||||
diagnostics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||
// to report or the worker is still busy (results then surface next turn).
|
||||
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||
}
|
||||
|
||||
// drainFinal is drain with the finalization budget: called before the run's
|
||||
// last model request, where an undelivered result would otherwise be lost.
|
||||
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||
if diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy != nil {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-busy:
|
||||
case <-timer.C:
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
}
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
if len(diagnostics.results) == 0 {
|
||||
return ""
|
||||
}
|
||||
paths := make([]string, 0, len(diagnostics.results))
|
||||
for path := range diagnostics.results {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
blocks := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
blocks = append(blocks, diagnostics.results[path])
|
||||
}
|
||||
diagnostics.results = map[string]string{}
|
||||
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
var diagnostics *asyncDiagnostics
|
||||
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||
t.Fatal("nil check must yield a nil collector")
|
||||
}
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||
// (no drive letter), which is exactly what the assertions below check.
|
||||
root := t.TempDir()
|
||||
var mu sync.Mutex
|
||||
var checked []string
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checked = append(checked, absPath)
|
||||
mu.Unlock()
|
||||
if filepath.Base(absPath) == "clean.go" {
|
||||
return ""
|
||||
}
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, root)
|
||||
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||
}
|
||||
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||
}
|
||||
mu.Lock()
|
||||
for _, path := range checked {
|
||||
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if second := diagnostics.drain(context.Background()); second != "" {
|
||||
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// A check still running at drain time defers delivery to the next drain
|
||||
// instead of blocking the turn.
|
||||
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
release := make(chan struct{})
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
<-release
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||
|
||||
start := time.Now()
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
if !strings.Contains(nudge, "ERR slow.go") {
|
||||
t.Fatalf("deferred nudge = %q", nudge)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("deferred diagnostics never delivered")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
response := "ERR old"
|
||||
check := func(context.Context, string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return response
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||
func (changedFilesTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (changedFilesTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||
// result (the old inline path), but the next request contains the background
|
||||
// diagnostics nudge, before the model finalizes.
|
||||
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
root := t.TempDir()
|
||||
var checkedPath string
|
||||
var mu sync.Mutex
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: root,
|
||||
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checkedPath = absPath
|
||||
mu.Unlock()
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// The tool result itself must NOT block on / embed diagnostics.
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||
}
|
||||
}
|
||||
// The nudge must be present in the second request as a user message.
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||
// error introduced by the last edit would go unreported in the summary.
|
||||
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
MaxTurns: 1,
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "summary" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||
}
|
||||
}
|
||||
|
||||
// When the model finalizes while a slow check is still in flight (the
|
||||
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||
// the model one more turn with the nudge instead of dropping the errors.
|
||||
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
// Slower than the (shortened) per-turn drain, well under the
|
||||
// finalization budget.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done after fix" {
|
||||
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[2].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
// Only propose approving a prefix of one segment when every other segment
|
||||
// in the command is independently known-safe. Once a prefix is approved,
|
||||
// shellExecutionArgsForApproval escalates the whole command (every
|
||||
// segment, not just the approved one) to bypass the sandbox, so offering a
|
||||
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
|
||||
// would let that segment run unsandboxed without ever being reviewed.
|
||||
for index, tokens := range segments {
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
@@ -38,6 +49,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
return append([]string(nil), segments[0]...)
|
||||
}
|
||||
|
||||
// otherSegmentsKnownSafe reports whether every segment other than the one at
|
||||
// skip is known-safe on its own.
|
||||
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
|
||||
for index, tokens := range segments {
|
||||
if index == skip {
|
||||
continue
|
||||
}
|
||||
if !knownSafeCommandSegment(tokens) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
@@ -168,10 +193,6 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
return false
|
||||
@@ -196,14 +217,6 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
return matched
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok || len(segments) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return segments[0], true
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
switch commandName(command[0]) {
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
@@ -35,12 +37,37 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
||||
// not offer "ps aux" as a reusable prefix here: approving it would
|
||||
// escalate the whole command, including the uncovered head segment,
|
||||
// to bypass the sandbox unreviewed. See
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
||||
// the platform-independent regression coverage of this behavior.
|
||||
if got != nil {
|
||||
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
||||
// against proposedCommandPrefix offering to approve one segment of a
|
||||
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
||||
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
||||
// entire command once any prefix is approved, so an uncovered unsafe segment
|
||||
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
||||
// any platform, so the assertion does not depend on runtime.GOOS.
|
||||
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
||||
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
@@ -120,13 +147,35 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
||||
command := "ps aux | head -5"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "ps aux | echo ok"
|
||||
}
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only known-safe MSYS guard")
|
||||
}
|
||||
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
||||
if knownSafeCommandSegment(command) {
|
||||
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
||||
}
|
||||
}
|
||||
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
||||
t.Fatal("expected echo to remain known-safe on Windows")
|
||||
}
|
||||
if !tools.MsysProneCommandName("head") {
|
||||
t.Fatal("expected head to be MSYS-prone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
+54
-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
|
||||
@@ -474,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
|
||||
@@ -590,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,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
|
||||
@@ -1100,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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -1945,6 +1946,11 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
|
||||
|
||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
segmentedCommand := `ps aux | head -5`
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
|
||||
segmentedCommand = `ps aux | echo ok`
|
||||
}
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
@@ -1958,7 +1964,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2907,8 +2913,10 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
|
||||
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
if readPos >= alphaPos || searchPos >= alphaPos {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
|
||||
@@ -7,14 +7,18 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
|
||||
// the 200-rune truncation) total well past the 4096-byte list budget, so the
|
||||
// overflow summary must kick in while the block stays bounded.
|
||||
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
|
||||
skills := make([]SkillInfo, 0, 60)
|
||||
for i := 0; i < 60; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
if len(got) > skillsContextListBudget+1000 {
|
||||
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -25,6 +29,27 @@ func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
|
||||
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
|
||||
// budget, under which skills past ~#6 were invisible to the model and therefore
|
||||
// never triggered.
|
||||
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
|
||||
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
|
||||
skills := make([]SkillInfo, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
|
||||
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
|
||||
@@ -191,10 +191,19 @@ func specialistDelegationContext(options Options) string {
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
// workspace with an extreme number of them can't bloat every turn's prompt.
|
||||
// Skills past the budget are summarized as a count rather than dropped — the
|
||||
// model can still load any of them by name (an unknown name returns the full
|
||||
// list from the skill tool).
|
||||
//
|
||||
// The budget is deliberately generous — roughly 18 skills with maximally long
|
||||
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
|
||||
// descriptions consume it faster since the budget counts bytes. This list is
|
||||
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
|
||||
// never triggers. The previous 640-byte cap silently broke invocation for
|
||||
// anyone with more than ~6 skills — the model cannot match a request against a
|
||||
// skill it cannot see.
|
||||
const skillsContextListBudget = 4096
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -202,7 +211,7 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -232,9 +241,11 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
// verbose description can't dominate the skills-list budget. The cap is roomy on
|
||||
// purpose: the description carries the skill's trigger conditions ("use when…"),
|
||||
// and truncating those is what stops the model from ever invoking the skill.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
const maxDescRunes = 200
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -275,7 +286,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -782,6 +783,14 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
|
||||
// It resolves against the same merged set (default dir + plugin skill
|
||||
// roots) as the skill tool and the system-prompt list, re-read per use so
|
||||
// newly installed skills work without a restart.
|
||||
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
|
||||
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||
return merged
|
||||
}),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
@@ -1277,3 +1286,26 @@ Flags:
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
|
||||
// calls the loader from autocomplete on every "/x" keystroke; without a cache
|
||||
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
|
||||
// that a newly installed skill still shows up "immediately" while typing.
|
||||
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
cached []skills.Skill
|
||||
)
|
||||
const ttl = 2 * time.Second
|
||||
return func() []skills.Skill {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cached != nil && time.Since(at) < ttl {
|
||||
return cached
|
||||
}
|
||||
cached = load()
|
||||
at = time.Now()
|
||||
return cached
|
||||
}
|
||||
}
|
||||
@@ -711,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)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStoreAppendEventsBatchesSequencesAndMetadata(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
|
||||
time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 1, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 2, 0, time.UTC),
|
||||
time.Date(2026, 6, 4, 15, 0, 3, 0, time.UTC),
|
||||
})})
|
||||
session, err := store.Create(CreateInput{SessionID: "batch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]any{"role": "user", "content": "one"}},
|
||||
{Type: EventToolCall, Payload: map[string]any{"id": "call_1", "name": "read_file"}},
|
||||
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call_1", "status": "ok"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 3 {
|
||||
t.Fatalf("expected 3 appended events, got %#v", appended)
|
||||
}
|
||||
for index, event := range appended {
|
||||
wantSequence := index + 1
|
||||
if event.Sequence != wantSequence || event.ID != fmt.Sprintf("batch:%d", wantSequence) {
|
||||
t.Fatalf("event identity mismatch at %d: %#v", index, event)
|
||||
}
|
||||
}
|
||||
if appended[0].CreatedAt != "2026-06-04T15:00:01Z" || appended[2].CreatedAt != "2026-06-04T15:00:03Z" {
|
||||
t.Fatalf("unexpected event timestamps: %#v", appended)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult || loaded.UpdatedAt != appended[2].CreatedAt {
|
||||
t.Fatalf("metadata not updated from final batch event: %#v", loaded)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(eventTypesForTest(events), []EventType{EventMessage, EventToolCall, EventToolResult}) {
|
||||
t.Fatalf("unexpected event types: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsEmptyBatchDoesNotRewriteMetadata(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:10:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "empty_batch", Title: "unchanged"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents empty returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 0 {
|
||||
t.Fatalf("empty AppendEvents returned %#v", appended)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || !reflect.DeepEqual(*loaded, session) {
|
||||
t.Fatalf("empty AppendEvents rewrote metadata: before=%#v after=%#v", session, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsInvalidPayloadDoesNotPartiallyAppend(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:20:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "invalid_batch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]any{"content": "valid first"}},
|
||||
{Type: EventMessage, Payload: json.RawMessage(`{"broken"`)},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid raw JSON payload") {
|
||||
t.Fatalf("expected invalid raw payload error, got %v", err)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("invalid batch should not append partial events: %#v", events)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 0 || loaded.LastEventType != "" {
|
||||
t.Fatalf("invalid batch should not update metadata: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsSequencesAfterMetadataLag(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:30:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "lagging"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"content": "already durable"}}); err != nil {
|
||||
t.Fatalf("AppendEvent returned error: %v", err)
|
||||
}
|
||||
lagging, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
lagging.EventCount = 0
|
||||
lagging.LastEventType = ""
|
||||
if err := store.writeMetadata(*lagging); err != nil {
|
||||
t.Fatalf("writeMetadata returned error: %v", err)
|
||||
}
|
||||
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventToolCall, Payload: map[string]any{"id": "call"}},
|
||||
{Type: EventToolResult, Payload: map[string]any{"toolCallId": "call"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
if len(appended) != 2 || appended[0].Sequence != 2 || appended[1].Sequence != 3 {
|
||||
t.Fatalf("batch did not sequence after durable log tail: %#v", appended)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != 3 || loaded.LastEventType != EventToolResult {
|
||||
t.Fatalf("metadata not repaired by batch append: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAppendEventsSerializesConcurrentBatches(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T15:40:00Z")})
|
||||
session, err := store.Create(CreateInput{SessionID: "concurrent_batches"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
const writers = 8
|
||||
const perBatch = 3
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
for writer := 0; writer < writers; writer++ {
|
||||
wg.Add(1)
|
||||
go func(writer int) {
|
||||
defer wg.Done()
|
||||
appended, err := store.AppendEvents(session.SessionID, []AppendEventInput{
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 0}},
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 1}},
|
||||
{Type: EventMessage, Payload: map[string]int{"writer": writer, "index": 2}},
|
||||
})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if len(appended) != perBatch {
|
||||
errs <- fmt.Errorf("writer %d appended %d events", writer, len(appended))
|
||||
return
|
||||
}
|
||||
for index := 1; index < len(appended); index++ {
|
||||
if appended[index].Sequence != appended[index-1].Sequence+1 {
|
||||
errs <- fmt.Errorf("writer %d batch was interleaved: %#v", writer, appended)
|
||||
return
|
||||
}
|
||||
}
|
||||
errs <- nil
|
||||
}(writer)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("AppendEvents returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
total := writers * perBatch
|
||||
if len(events) != total {
|
||||
t.Fatalf("expected %d events, got %d", total, len(events))
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, event := range events {
|
||||
if seen[event.Sequence] {
|
||||
t.Fatalf("duplicate sequence %d in %#v", event.Sequence, events)
|
||||
}
|
||||
seen[event.Sequence] = true
|
||||
}
|
||||
for sequence := 1; sequence <= total; sequence++ {
|
||||
if !seen[sequence] {
|
||||
t.Fatalf("missing sequence %d in %#v", sequence, events)
|
||||
}
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != total || loaded.LastEventType != EventMessage {
|
||||
t.Fatalf("metadata not updated after concurrent batches: %#v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func eventTypesForTest(events []Event) []EventType {
|
||||
types := make([]EventType, 0, len(events))
|
||||
for _, event := range events {
|
||||
types = append(types, event.Type)
|
||||
}
|
||||
return types
|
||||
}
|
||||
+99
-39
@@ -153,6 +153,11 @@ type AppendEventInput struct {
|
||||
Payload any
|
||||
}
|
||||
|
||||
type preparedAppendEvent struct {
|
||||
Type EventType
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
type RecordSpecInput struct {
|
||||
SpecID string
|
||||
SpecFilePath string
|
||||
@@ -426,7 +431,7 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied := 0
|
||||
copyInputs := []AppendEventInput{}
|
||||
for _, event := range events {
|
||||
// Do NOT copy usage accounting into the fork. It already counted against the
|
||||
// parent, and a usage report that aggregates the parent and the fork would
|
||||
@@ -435,11 +440,12 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if event.Type == EventUsage {
|
||||
continue
|
||||
}
|
||||
if _, err := store.AppendEvent(fork.SessionID, AppendEventInput{Type: event.Type, Payload: event.Payload}); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied++
|
||||
copyInputs = append(copyInputs, AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
if _, err := store.AppendEvents(fork.SessionID, copyInputs); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
copied := len(copyInputs)
|
||||
// Copy the parent's content-addressed checkpoint blobs into the fork so the
|
||||
// copied EventSessionCheckpoint events resolve to real blobs and a rewind on
|
||||
// the fork can restore file content (otherwise rewind reads missing blobs
|
||||
@@ -513,19 +519,34 @@ func (store *Store) RecordSpec(sessionID string, input RecordSpecInput) (Metadat
|
||||
}
|
||||
|
||||
func (store *Store) AppendEvent(sessionID string, input AppendEventInput) (Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return Event{}, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
events, err := store.AppendEvents(sessionID, []AppendEventInput{input})
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// AppendEvents appends a batch of events atomically under one session lock and
|
||||
// durability pass. The returned events have contiguous sequences in input order.
|
||||
// An empty batch is a no-op and does not rewrite metadata.
|
||||
func (store *Store) AppendEvents(sessionID string, inputs []AppendEventInput) ([]Event, error) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
prepared, err := prepareAppendEventInputs(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(prepared) == 0 {
|
||||
return []Event{}, nil
|
||||
}
|
||||
unlock, err := store.lockSession(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
return store.appendEventLocked(sessionID, input)
|
||||
return store.appendPreparedEventsLocked(sessionID, prepared)
|
||||
}
|
||||
|
||||
// AppendEventUnlessExists appends input only when exists(currentEvents) is false,
|
||||
@@ -572,14 +593,27 @@ func (store *Store) AppendEventUnlessExists(sessionID string, input AppendEventI
|
||||
// the single lock they already hold, instead of re-locking (which would deadlock
|
||||
// on the non-reentrant in-process mutex).
|
||||
func (store *Store) appendEventLocked(sessionID string, input AppendEventInput) (Event, error) {
|
||||
session, err := store.readMetadata(sessionID)
|
||||
prepared, err := prepareAppendEventInputs([]AppendEventInput{input})
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
events, err := store.appendPreparedEventsLocked(sessionID, prepared)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// appendPreparedEventsLocked appends pre-validated events WITHOUT acquiring the
|
||||
// session lock. The caller MUST already hold store.lockSession(sessionID).
|
||||
func (store *Store) appendPreparedEventsLocked(sessionID string, inputs []preparedAppendEvent) ([]Event, error) {
|
||||
if len(inputs) == 0 {
|
||||
return []Event{}, nil
|
||||
}
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sequence := session.EventCount + 1
|
||||
// The events log is the source of truth for sequencing. metadata.EventCount is
|
||||
// persisted separately (after the event line below), so a crash between the two
|
||||
@@ -590,46 +624,54 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
|
||||
if logSeq, err := store.lastEventSequence(sessionID); err == nil && logSeq >= sequence {
|
||||
sequence = logSeq + 1
|
||||
}
|
||||
timestamp := store.timestamp()
|
||||
event := Event{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, sequence),
|
||||
SessionID: sessionID,
|
||||
Sequence: sequence,
|
||||
Type: input.Type,
|
||||
CreatedAt: timestamp,
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("encode zero session event: %w", err)
|
||||
events := make([]Event, 0, len(inputs))
|
||||
var batch bytes.Buffer
|
||||
for index, input := range inputs {
|
||||
timestamp := store.timestamp()
|
||||
event := Event{
|
||||
ID: fmt.Sprintf("%s:%d", sessionID, sequence+index),
|
||||
SessionID: sessionID,
|
||||
Sequence: sequence + index,
|
||||
Type: input.Type,
|
||||
CreatedAt: timestamp,
|
||||
Payload: input.Payload,
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode zero session event: %w", err)
|
||||
}
|
||||
batch.Write(data)
|
||||
batch.WriteByte('\n')
|
||||
events = append(events, event)
|
||||
}
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
if _, err := file.Write(batch.Bytes()); err != nil {
|
||||
_ = file.Close()
|
||||
return Event{}, fmt.Errorf("append zero session event: %w", err)
|
||||
return nil, fmt.Errorf("append zero session event: %w", err)
|
||||
}
|
||||
// fsync the event before reporting success: the derived metadata.json IS
|
||||
// fsync the events before reporting success: the derived metadata.json IS
|
||||
// fsync'd (writeMetadata), so without this a crash after the metadata flush
|
||||
// but before the events.jsonl page reaches disk leaves EventCount ahead of the
|
||||
// durable log — silently losing the just-appended event (incl. the checkpoint
|
||||
// durable log — silently losing just-appended events (incl. checkpoints
|
||||
// that /rewind targets). Make the log at least as durable as its metadata. (AUDIT-M12)
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return Event{}, fmt.Errorf("sync zero session event: %w", err)
|
||||
return nil, fmt.Errorf("sync zero session event: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return Event{}, fmt.Errorf("close zero session event file: %w", err)
|
||||
return nil, fmt.Errorf("close zero session event file: %w", err)
|
||||
}
|
||||
session.UpdatedAt = timestamp
|
||||
session.EventCount = sequence
|
||||
session.LastEventType = input.Type
|
||||
last := events[len(events)-1]
|
||||
session.UpdatedAt = last.CreatedAt
|
||||
session.EventCount = last.Sequence
|
||||
session.LastEventType = last.Type
|
||||
if err := store.writeMetadata(session); err != nil {
|
||||
return Event{}, err
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
|
||||
@@ -964,6 +1006,24 @@ func rawPayload(payload any) (json.RawMessage, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func prepareAppendEventInputs(inputs []AppendEventInput) ([]preparedAppendEvent, error) {
|
||||
prepared := make([]preparedAppendEvent, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
if strings.TrimSpace(string(input.Type)) == "" {
|
||||
return nil, fmt.Errorf("zero session event type is required")
|
||||
}
|
||||
payload, err := rawPayload(input.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prepared = append(prepared, preparedAppendEvent{
|
||||
Type: input.Type,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
|
||||
+89
-22
@@ -83,7 +83,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil {
|
||||
// Resolve the command engine before the MSYS preflight check so an
|
||||
// approved require_escalated call (commandEngine == nil, truly
|
||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||
// hard-blocked by the same check it was meant to escalate past.
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
|
||||
@@ -106,7 +111,6 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
"cwd": relativeCwd,
|
||||
"timeout_ms": strconv.Itoa(timeoutMS),
|
||||
}
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if commandEngine == nil && sandboxPermissions == SandboxPermissionsRequireEscalated {
|
||||
meta["sandbox_permissions"] = string(SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
@@ -126,8 +130,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
|
||||
|
||||
@@ -167,7 +171,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
@@ -178,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
@@ -198,6 +202,18 @@ func commandEngineForSandboxPermissions(engine *zeroSandbox.Engine, sandboxPermi
|
||||
return engine
|
||||
}
|
||||
|
||||
// msysGuardBypassed reports whether a windows_msys_sandbox preflight issue no
|
||||
// longer applies because sandbox_permissions: require_escalated actually
|
||||
// resolved to unsandboxed, host-level execution (commandEngine is nil only
|
||||
// then, per commandEngineForSandboxPermissions). MSYS coreutils fail because
|
||||
// of the sandbox's restricted token/handles, so running outside it removes
|
||||
// the failure mode this guard exists for. Any other issue kind (e.g. a real
|
||||
// cmd.exe syntax problem) still blocks regardless of escalation, since
|
||||
// running outside the sandbox does not fix a syntax error.
|
||||
func msysGuardBypassed(issue *shellIssue, commandEngine *zeroSandbox.Engine) bool {
|
||||
return issue.Kind == windowsMsysSandboxKind && commandEngine == nil
|
||||
}
|
||||
|
||||
// appendSandboxBlocks appends a <sandbox_blocks> block listing the denials
|
||||
// the sandbox log monitor captured, so the model can see what was blocked. With no
|
||||
// blocks the stderr is returned unchanged.
|
||||
@@ -370,12 +386,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 +420,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 +445,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
|
||||
@@ -461,14 +536,6 @@ func (b *boundedBuffer) retained() string {
|
||||
return string(b.head) + string(b.tail)
|
||||
}
|
||||
|
||||
// truncateHeadTail keeps the first and last halves of value when it exceeds
|
||||
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
|
||||
// text, the raw byte length, and whether truncation happened.
|
||||
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
|
||||
// value holds the whole stream, so its length is the true raw size.
|
||||
return truncateHeadTailWithTotal(value, len(value), maxBytes)
|
||||
}
|
||||
|
||||
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
|
||||
// the full original byte count — for the "N bytes omitted" marker and the raw
|
||||
// count. total may exceed len(value) when the middle was already discarded during
|
||||
@@ -488,9 +555,9 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
|
||||
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
|
||||
}
|
||||
|
||||
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
|
||||
output := formatBashOutput(stdout, stderr, exitCode)
|
||||
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
output = appendShellIssueHint(output, *issue)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -181,9 +181,13 @@ func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil {
|
||||
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
||||
}
|
||||
if issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %q", command, issue.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +199,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`sed.exe --help`,
|
||||
`mytool.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
@@ -204,7 +208,7 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
t.Fatal("expected Windows syntax error to get shell guidance")
|
||||
}
|
||||
@@ -434,6 +438,105 @@ func TestRegistryRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBashToolRequireEscalatedMsysGuard covers the boundary of the fix for an
|
||||
// escalation-vs-preflight ordering bug: the MSYS sandbox guard exists only
|
||||
// because MSYS/Cygwin coreutils fail under the write-restricted sandbox, so
|
||||
// once sandbox_permissions: require_escalated is actually approved (running
|
||||
// the command unsandboxed), the guard must get out of the way, but an
|
||||
// unrelated windows_shell_syntax issue (a real cmd.exe syntax problem, not a
|
||||
// sandbox interaction) must still block regardless of escalation.
|
||||
func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS sandbox guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
newEngine := func() *sandbox.Engine {
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
}
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewBashTool(root))
|
||||
|
||||
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox block without escalation, got %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": "cat somefile.txt",
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
||||
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
||||
// bypassed, "cat somefile.txt" actually runs, and its real,
|
||||
// PATH-dependent output could otherwise trip the unrelated
|
||||
// post-execution detectShellOutputIssue heuristic and make this
|
||||
// assertion flaky for reasons unrelated to the guard under test.
|
||||
if result.Meta["exit_code"] == "-1" {
|
||||
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approved require_escalated still blocks an unrelated syntax issue", func(t *testing.T) {
|
||||
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
|
||||
"command": `cd /d/tmp/zero-pr-158 && dir`,
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: newEngine(),
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
if result.Meta["shell_issue"] != "windows_shell_syntax" {
|
||||
t.Fatalf("expected windows_shell_syntax block to still apply under require_escalated, got %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure guards the
|
||||
// post-execution MSYS heuristic against treating the command line itself as
|
||||
// evidence. The helper command below fails for a reason unrelated to MSYS
|
||||
// (a plain non-zero exit), but its own argument text quotes an MSYS crash
|
||||
// marker the way a PR-comment or commit-message argument might. Before the
|
||||
// fix, detectShellOutputIssue scanned command+output together and would have
|
||||
// misdiagnosed this as an MSYS sandbox failure even though the real
|
||||
// stdout/stderr never mentions MSYS at all.
|
||||
func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS output guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
// The helper only reads argv[2] ("fail"); this trailing quoted argument is
|
||||
// otherwise unused by the helper but still part of the command line text.
|
||||
command := helperCommand("fail") + ` "fatal error - CreateFileMapping S-1-5-21, Win32 error 5. Terminating. cygheap_user::init"`
|
||||
|
||||
result := NewBashTool(root).Run(context.Background(), map[string]any{
|
||||
"command": command,
|
||||
})
|
||||
|
||||
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
|
||||
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
|
||||
}
|
||||
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
|
||||
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashToolRunsWithDegradedUnavailableNativeSandbox(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
policy := sandbox.DefaultPolicy()
|
||||
|
||||
@@ -58,7 +58,7 @@ func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
|
||||
continue
|
||||
}
|
||||
seen[search] = true
|
||||
if strings.Index(content, search) < 0 {
|
||||
if !strings.Contains(content, search) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, search)
|
||||
|
||||
@@ -517,7 +517,12 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
|
||||
}
|
||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil {
|
||||
// Resolve the command engine before the MSYS preflight check so an
|
||||
// approved require_escalated call (commandEngine == nil, truly
|
||||
// unsandboxed) can actually bypass the MSYS guard instead of being
|
||||
// hard-blocked by the same check it was meant to escalate past.
|
||||
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
|
||||
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
|
||||
return shellIssueBlockResult(*issue)
|
||||
}
|
||||
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
|
||||
@@ -860,6 +865,12 @@ func execToolResult(input execToolResultInput) Result {
|
||||
status = StatusError
|
||||
}
|
||||
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
|
||||
if status == StatusError && input.exited && !input.interrupted {
|
||||
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
|
||||
meta["shell_issue"] = issue.Kind
|
||||
body = appendShellIssueHint(body, *issue)
|
||||
}
|
||||
}
|
||||
if input.outputBufferTruncated {
|
||||
// Appended after truncateExecOutput's own head/tail slicing, not
|
||||
// embedded in the text that goes through it — a marker inside that
|
||||
|
||||
@@ -141,6 +141,46 @@ func TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval mirrors
|
||||
// TestBashToolRequireEscalatedMsysGuard for exec_command: the MSYS sandbox
|
||||
// guard exists only because MSYS/Cygwin coreutils fail under the
|
||||
// write-restricted sandbox, so once require_escalated is actually approved
|
||||
// (unsandboxed execution), the guard must not block the same command it was
|
||||
// meant to let escalate past.
|
||||
func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only MSYS sandbox guard")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := newExecSessionManager()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedExecCommandTool(root, nil, manager))
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
})
|
||||
|
||||
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
|
||||
"cmd": "cat somefile.txt",
|
||||
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
|
||||
}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
Sandbox: engine,
|
||||
PermissionMode: string(sandbox.PermissionModeAsk),
|
||||
})
|
||||
|
||||
// Assert on the preflight block sentinel (exit_code "-1", set only by
|
||||
// shellIssueBlockResult) rather than shell_issue: once the guard is
|
||||
// bypassed, "cat somefile.txt" actually runs, and its real,
|
||||
// PATH-dependent output could otherwise trip the unrelated
|
||||
// post-execution detectShellOutputIssue heuristic and make this
|
||||
// assertion flaky for reasons unrelated to the guard under test.
|
||||
if result.Meta["exit_code"] == "-1" {
|
||||
t.Fatalf("expected approved require_escalated to bypass the MSYS guard, got blocked: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := newExecSessionManager()
|
||||
|
||||
@@ -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
|
||||
|
||||
+179
-21
@@ -3,6 +3,7 @@ package tools
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type shellRuntime struct {
|
||||
@@ -17,14 +18,31 @@ type shellIssue struct {
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
const windowsMsysSandboxKind = "windows_msys_sandbox"
|
||||
|
||||
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils from Git for Windows cannot run under Zero's write-restricted Windows sandbox. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr/more, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
|
||||
|
||||
// windowsMsysProneNames is the single source of truth for POSIX coreutil names
|
||||
// that commonly resolve to a Git-for-Windows MSYS/Cygwin binary rather than a
|
||||
// cmd.exe-native command, and so fail under the write-restricted Windows
|
||||
// sandbox (#458). Every Windows MSYS-detection path (the preflight command
|
||||
// scan below, the exported MsysProneCommandName, and the known-safe-segment
|
||||
// guard in internal/agent/command_prefix.go) derives from this one set, so
|
||||
// they cannot drift out of sync with each other.
|
||||
var windowsMsysProneNames = map[string]bool{
|
||||
"cat": true, "cut": true, "expr": true, "grep": true, "head": true,
|
||||
"id": true, "ls": true, "nl": true, "paste": true, "rev": true,
|
||||
"seq": true, "stat": true, "tail": true, "tr": true, "uname": true,
|
||||
"uniq": true, "wc": true, "which": true, "awk": true, "sed": true,
|
||||
"xargs": true,
|
||||
}
|
||||
|
||||
var (
|
||||
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
||||
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
|
||||
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
|
||||
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
|
||||
// which sound similar enough that a model might reach for the POSIX name
|
||||
// instead) — most often seen piped in, e.g. `git log ... | head`.
|
||||
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
|
||||
// windowsMsysBinaryPathPattern catches explicit Git-for-Windows / MSYS usr\bin
|
||||
// paths. These executables are valid Windows PE files but fail under the
|
||||
// write-restricted sandbox with CreateFileMapping ACCESS_DENIED (#458).
|
||||
windowsMsysBinaryPathPattern = regexp.MustCompile(`(?i)(?:\\usr\\bin\\|\\mingw64\\bin\\|msys-2\.0\.dll|cygwin1\.dll)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -37,48 +55,162 @@ func detectShellRuntime(goos string) shellRuntime {
|
||||
func shellGuidanceForGOOS(goos string) string {
|
||||
runtime := detectShellRuntime(goos)
|
||||
if goos == "windows" {
|
||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories."
|
||||
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
|
||||
}
|
||||
guidance := "Uses " + runtime.Syntax + " syntax."
|
||||
if goos == "darwin" {
|
||||
// `ps` is setuid root and cannot run under the macOS sandbox; `pgrep` needs a
|
||||
// blocked system service. Point the model at the tools that DO work so it
|
||||
// doesn't waste turns: lsof to find a process, kill to stop it.
|
||||
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
|
||||
}
|
||||
return guidance
|
||||
}
|
||||
|
||||
// MsysProneCommandName reports whether a bare command name commonly resolves to
|
||||
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
|
||||
func MsysProneCommandName(name string) bool {
|
||||
return windowsMsysProneNames[strings.ToLower(strings.TrimSpace(name))]
|
||||
}
|
||||
|
||||
func windowsMsysSandboxIssue(message string) *shellIssue {
|
||||
return &shellIssue{
|
||||
Kind: windowsMsysSandboxKind,
|
||||
Message: message,
|
||||
Suggestion: windowsMsysSandboxSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
// windowsCommandSegments splits a command into cmd.exe-operator-separated
|
||||
// segments (&, |, and their doubled forms &&/||), respecting double quotes
|
||||
// (cmd.exe's grouping construct) and the caret (^) escape character, so an
|
||||
// operator or command name mentioned inside a quoted argument (e.g. a commit
|
||||
// message or PR comment body), or an operator escaped with ^ (cmd.exe prints
|
||||
// `echo ^| head` as literal text instead of piping to head), is not mistaken
|
||||
// for a real segment boundary or invocation. Unlike bash, cmd.exe does not
|
||||
// treat ; as a statement separator, so it is left as ordinary argument text
|
||||
// (e.g. `echo foo; head` is a single `echo` invocation with literal args).
|
||||
func windowsCommandSegments(command string) []string {
|
||||
var segments []string
|
||||
var current strings.Builder
|
||||
inQuotes := false
|
||||
runes := []rune(command)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
c := runes[i]
|
||||
if !inQuotes && c == '^' && i+1 < len(runes) {
|
||||
current.WriteRune(c)
|
||||
i++
|
||||
current.WriteRune(runes[i])
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuotes = !inQuotes
|
||||
current.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
if !inQuotes && (c == '&' || c == '|') {
|
||||
if seg := strings.TrimSpace(current.String()); seg != "" {
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
current.Reset()
|
||||
continue
|
||||
}
|
||||
current.WriteRune(c)
|
||||
}
|
||||
if seg := strings.TrimSpace(current.String()); seg != "" {
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// firstCommandWord returns the first token of a command segment. A leading
|
||||
// double-quoted span counts as one token with its quotes stripped, since
|
||||
// cmd.exe treats a quoted path as a single argument: the command invoked by
|
||||
// `"C:\Program Files\Git\usr\bin\head.exe" file.txt` is the quoted path, not
|
||||
// "C:\Program. For an unquoted command, the token ends at whitespace or a
|
||||
// redirection operator (<, >): cmd.exe accepts redirection attached directly
|
||||
// to the command name with no separating space (head>out.txt, cat<in.txt), so
|
||||
// stopping only at whitespace would return "head>out.txt" as one word and
|
||||
// miss the invoked command.
|
||||
func firstCommandWord(segment string) string {
|
||||
trimmed := strings.TrimSpace(segment)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
if end := strings.IndexByte(trimmed[1:], '"'); end >= 0 {
|
||||
return trimmed[1 : end+1]
|
||||
}
|
||||
return trimmed[1:]
|
||||
}
|
||||
if end := strings.IndexFunc(trimmed, isCommandWordBoundary); end >= 0 {
|
||||
return trimmed[:end]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isCommandWordBoundary(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == '<' || r == '>'
|
||||
}
|
||||
|
||||
// msysProneCommandWord reports whether word (the first token of a command
|
||||
// segment, as returned by firstCommandWord) names an MSYS-prone coreutil,
|
||||
// bare or with a directory prefix and/or .exe suffix (head, head.exe,
|
||||
// C:\...\usr\bin\head.exe, ...).
|
||||
func msysProneCommandWord(word string) bool {
|
||||
word = strings.Trim(word, `"`)
|
||||
if i := strings.LastIndexAny(word, `\/`); i >= 0 {
|
||||
word = word[i+1:]
|
||||
}
|
||||
word = strings.TrimSuffix(strings.ToLower(word), ".exe")
|
||||
return MsysProneCommandName(word)
|
||||
}
|
||||
|
||||
func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(command)
|
||||
if windowsBashStyleCDPattern.MatchString(trimmed) ||
|
||||
windowsLSCommandPattern.MatchString(trimmed) {
|
||||
if windowsBashStyleCDPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
|
||||
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
||||
}
|
||||
}
|
||||
if windowsPosixUtilityPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
|
||||
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
|
||||
// Check the first word of each operator-separated segment (not the raw
|
||||
// text anywhere in the command) against the MSYS binary-path pattern and
|
||||
// the single MSYS-prone name set, covering bare names (head), .exe names
|
||||
// (head.exe), and directory-prefixed forms (C:\...\head.exe) uniformly.
|
||||
// Being segment/word anchored rather than a whole-string regex or scan,
|
||||
// neither check matches text that only appears inside a quoted argument
|
||||
// (e.g. a commit message or PR comment body discussing head.exe).
|
||||
for _, segment := range windowsCommandSegments(trimmed) {
|
||||
word := firstCommandWord(segment)
|
||||
if windowsMsysBinaryPathPattern.MatchString(word) {
|
||||
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
|
||||
}
|
||||
if msysProneCommandWord(word) {
|
||||
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func detectShellOutputIssue(command string, output string, goos string) *shellIssue {
|
||||
// detectShellOutputIssue looks for MSYS runtime crash markers and cmd.exe
|
||||
// syntax-error text in output only, never in the command that was run. The
|
||||
// command line is attacker/user-controlled argument text (e.g. a `gh pr
|
||||
// comment --body` quoting a sample failure), not something the shell
|
||||
// produced, so treating it as evidence would reintroduce the same
|
||||
// quoted-text false positives the preflight command-position check exists to
|
||||
// avoid, just after execution instead of before it.
|
||||
func detectShellOutputIssue(output string, goos string) *shellIssue {
|
||||
if goos != "windows" {
|
||||
return nil
|
||||
}
|
||||
haystack := strings.ToLower(command + "\n" + output)
|
||||
if strings.Contains(haystack, "the syntax of the command is incorrect") ||
|
||||
strings.Contains(haystack, "is not recognized as an internal or external command") {
|
||||
lower := strings.ToLower(output)
|
||||
if msysRuntimeFailedInOutput(lower) {
|
||||
return windowsMsysSandboxIssue("An MSYS/Cygwin runtime failed under Zero's Windows sandbox (ACCESS_DENIED during MSYS startup).")
|
||||
}
|
||||
if strings.Contains(lower, "the syntax of the command is incorrect") ||
|
||||
strings.Contains(lower, "is not recognized as an internal or external command") {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Windows cmd.exe rejected the command syntax.",
|
||||
@@ -88,6 +220,32 @@ func detectShellOutputIssue(command string, output string, goos string) *shellIs
|
||||
return nil
|
||||
}
|
||||
|
||||
func msysRuntimeFailedInOutput(lower string) bool {
|
||||
if strings.Contains(lower, "fatal error - createfilemapping") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "couldn't create signal pipe") && strings.Contains(lower, "win32 error 5") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "cygheap_user::init") && strings.Contains(lower, "fatal error") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "usr\\bin\\") && strings.Contains(lower, "fatal error") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(lower, "win32 error 5") || !strings.Contains(lower, "terminating") {
|
||||
return false
|
||||
}
|
||||
// Anchor the broad win32-error-5 fallback to an MSYS-specific marker so
|
||||
// unrelated access-denied failures are not mislabeled as MSYS sandbox
|
||||
// incompatibilities.
|
||||
return strings.Contains(lower, `usr\bin\`) ||
|
||||
strings.Contains(lower, "cygheap") ||
|
||||
strings.Contains(lower, "msys-2.0.dll") ||
|
||||
strings.Contains(lower, "cygwin1.dll") ||
|
||||
strings.Contains(lower, "[main]")
|
||||
}
|
||||
|
||||
func appendShellIssueHint(output string, issue shellIssue) string {
|
||||
output = strings.TrimRight(output, "\r\n")
|
||||
hint := "[zero] shell issue: " + issue.Message
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
|
||||
`C:\Git\usr\bin\grep.exe pattern file.txt`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil {
|
||||
t.Fatalf("expected MSYS path issue for %q", command)
|
||||
}
|
||||
if issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected windows_msys_sandbox kind, got %q", issue.Kind)
|
||||
}
|
||||
if !strings.Contains(issue.Suggestion, "require_escalated") {
|
||||
t.Fatalf("expected escalation guidance, got %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueFlagsStandaloneCat(t *testing.T) {
|
||||
issue := detectShellCommandIssue(`cat README.md`, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS sandbox issue for cat, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysCreateFileMappingError(t *testing.T) {
|
||||
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping S-1-5-21-3149109338-1484423945-518236903-1001.1, Win32 error 5. Terminating.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
if !strings.Contains(issue.Suggestion, "require_escalated") {
|
||||
t.Fatalf("expected escalation guidance, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysSignalPipeError(t *testing.T) {
|
||||
output := `0 [main] head (39684) cygheap_user::init: NtSetInformationToken (TokenDefaultDacl), 0xC0000022
|
||||
648 [main] head (39684) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - couldn't create signal pipe, Win32 error 5`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueFlagsMsysTerminatingWithMsysMarker(t *testing.T) {
|
||||
output := `1 [main] tail (4321) tail: *** MapViewOfFileEx failed, Win32 error 5. Terminating.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue == nil || issue.Kind != "windows_msys_sandbox" {
|
||||
t.Fatalf("expected MSYS output issue, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueIgnoresNonMsysWin32Error5(t *testing.T) {
|
||||
output := `myapp.exe: unable to open service handle, Win32 error 5 (access denied). Terminating worker.`
|
||||
issue := detectShellOutputIssue(output, "windows")
|
||||
if issue != nil {
|
||||
t.Fatalf("expected no issue for non-MSYS access-denied output, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellIssueBlockResultMsysCommand(t *testing.T) {
|
||||
result := shellIssueBlockResult(*detectShellCommandIssue(`cat README.md`, "windows"))
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
for _, want := range []string{"[zero] shell issue:", "MSYS/Cygwin", "grep", "read_file", "require_escalated"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in blocked output, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
if result.Meta["shell_issue"] != "windows_msys_sandbox" {
|
||||
t.Fatalf("meta shell_issue = %q", result.Meta["shell_issue"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsysProneCommandName(t *testing.T) {
|
||||
if !MsysProneCommandName("HEAD") || MsysProneCommandName("echo") {
|
||||
t.Fatalf("unexpected MsysProneCommandName results")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueFlagsExprAndLsConsistently guards against the
|
||||
// preflight regex list drifting from the canonical windowsMsysProneNames set
|
||||
// (both listed expr and ls as MSYS-prone, but the old regex alternations
|
||||
// omitted expr entirely and let ls hit the older windows_shell_syntax branch
|
||||
// first, so it never got MSYS-kind guidance).
|
||||
func TestDetectShellCommandIssueFlagsExprAndLsConsistently(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`expr 1 + 1`,
|
||||
`expr.exe 1 + 1`,
|
||||
`ls -la`,
|
||||
`ls`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueIgnoresQuotedMsysMentions guards against
|
||||
// treating an MSYS-prone name that only appears inside a quoted argument
|
||||
// (e.g. a commit message, a PR comment body, or a doc string discussing the
|
||||
// command) as an actual invocation. The preflight check must anchor on the
|
||||
// first word of each command segment, not scan the raw text anywhere.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedMsysMentions(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git commit -m "fix head.exe crash"`,
|
||||
`gh pr comment --body "grep.exe fails under MSYS"`,
|
||||
`echo "log | head is broken on windows"`,
|
||||
`git commit -m "note: | head does not work here"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted MSYS mention to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// A real invocation alongside quoted text must still be caught.
|
||||
if issue := detectShellCommandIssue(`echo "not a real command" && head file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions guards the
|
||||
// explicit MSYS-binary-path check the same way as the coreutil-name check:
|
||||
// a full usr\bin\ path that only appears inside a quoted argument (e.g. a
|
||||
// commit message describing the failure) must not be treated as an
|
||||
// invocation, since the path check is now anchored to the first word of each
|
||||
// command segment rather than scanned across the raw command text.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedMsysPathMentions(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git commit -m "C:\Program Files\Git\usr\bin\head.exe fails"`,
|
||||
`gh pr comment --body "C:\Git\usr\bin\grep.exe is blocked"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted MSYS path mention to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// A real invocation by full path must still be caught.
|
||||
if issue := detectShellCommandIssue(`C:\Git\usr\bin\grep.exe pattern file.txt`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real MSYS path invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueRespectsCaretEscapedOperators guards against
|
||||
// misreading cmd.exe's ^ escape character: `echo ^| head` prints the pipe and
|
||||
// "head" as literal text (the caret escapes the pipe so it never splits into
|
||||
// a separate `head` invocation), and `echo foo; head` is a single `echo`
|
||||
// command with literal arguments since cmd.exe (unlike bash) does not treat
|
||||
// ; as a statement separator.
|
||||
func TestDetectShellCommandIssueRespectsCaretEscapedOperators(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`echo ^| head`,
|
||||
`echo ^& head`,
|
||||
`echo foo; head`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected no issue for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// An unescaped pipe must still split into a real head invocation.
|
||||
if issue := detectShellCommandIssue(`echo foo | head`, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real head invocation to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand guards against
|
||||
// firstCommandWord treating cmd.exe redirection operators as part of the
|
||||
// command name. cmd.exe accepts redirection with no separating space
|
||||
// (head>out.txt, cat<in.txt), so splitting only on whitespace would return
|
||||
// "head>out.txt" as one word and miss the invoked command entirely.
|
||||
func TestDetectShellCommandIssueFlagsRedirectionAttachedToCommand(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`some-command | head>out.txt`,
|
||||
`cat<in.txt`,
|
||||
`grep>matches.txt pattern`,
|
||||
} {
|
||||
issue := detectShellCommandIssue(command, "windows")
|
||||
if issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected windows_msys_sandbox for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectShellOutputIssueSignatureOmitsCommandText documents, at the type
|
||||
// level, that detectShellOutputIssue can no longer take the command line as
|
||||
// evidence: it only accepts the real output. Harmless output must not be
|
||||
// flagged, and output that genuinely carries the MSYS failure markers must
|
||||
// still be flagged.
|
||||
func TestDetectShellOutputIssueSignatureOmitsCommandText(t *testing.T) {
|
||||
if issue := detectShellOutputIssue("hello from bash", "windows"); issue != nil {
|
||||
t.Fatalf("expected no issue for harmless output, got %#v", issue)
|
||||
}
|
||||
output := `0 [main] head (3568) C:\Program Files\Git\usr\bin\head.exe: *** fatal error - CreateFileMapping ..., Win32 error 5. Terminating.`
|
||||
if issue := detectShellOutputIssue(output, "windows"); issue == nil || issue.Kind != windowsMsysSandboxKind {
|
||||
t.Fatalf("expected real MSYS output to still be flagged, got %#v", issue)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
webFetchTimeout = 30 * time.Second
|
||||
webFetchRedirectLimit = 5
|
||||
webFetchErrorBodyLimit = 4 * 1024
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply."
|
||||
webFetchPublicOnlyHint = "web_fetch only supports public remote HTTP/HTTPS URLs. For localhost or private network URLs, use bash with curl so sandbox network permission can apply"
|
||||
)
|
||||
|
||||
type webFetchTool struct {
|
||||
@@ -348,7 +348,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
|
||||
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
|
||||
}
|
||||
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver); err != nil {
|
||||
return fmt.Errorf("Unsafe redirect URL: %w", err)
|
||||
return fmt.Errorf("unsafe redirect URL: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
return previous(request, via)
|
||||
|
||||
@@ -405,7 +405,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Unsafe redirect URL") {
|
||||
if !strings.Contains(result.Output, "unsafe redirect URL") {
|
||||
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -426,7 +426,6 @@ func stringListArgWebSearch(args map[string]any, key string) (domains []string,
|
||||
if !ok || value == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
provided = true
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
if asString, ok2 := value.([]string); ok2 {
|
||||
|
||||
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
|
||||
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
|
||||
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
|
||||
})
|
||||
return append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchUserCommandSuggestions(token)...)
|
||||
out = append(out, m.matchSkillSuggestions(token)...)
|
||||
// Each source caps itself, but the merged list must honor the shared cap too
|
||||
// (three sources could otherwise stack up to ~3x the palette bound).
|
||||
if len(out) > maxCommandSuggestions {
|
||||
out = out[:maxCommandSuggestions]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchSkillSuggestions returns installed skills whose slash name has the typed
|
||||
// prefix, so skills are discoverable and invocable from the palette like user
|
||||
// commands. Precedence (builtin > user command > skill) is enforced here by
|
||||
// skipping any skill whose name is claimed by a builtin (or alias) or a user
|
||||
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
|
||||
// dispatch time, so it must not be advertised at all.
|
||||
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
|
||||
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
|
||||
if prefix == "" || m.loadSkills == nil {
|
||||
return nil
|
||||
}
|
||||
taken := m.takenSlashNames()
|
||||
var out []commandSuggestion
|
||||
// Read through the loader (not the startup snapshot in agentOptions) so the
|
||||
// palette matches what dispatch will actually resolve — a skill installed or
|
||||
// removed mid-session appears/disappears without a restart.
|
||||
for _, skill := range m.installedSkills() {
|
||||
name := skillSlashName(skill.Name)
|
||||
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
|
||||
// "deploy") map to one slash name; only the first — the one dispatch will
|
||||
// run — may be advertised, or the row's description and the executed
|
||||
// instructions silently diverge.
|
||||
taken[name] = true
|
||||
desc := strings.TrimSpace(skill.Description)
|
||||
if desc == "" {
|
||||
desc = "Skill: /" + name
|
||||
} else {
|
||||
desc += " (skill)"
|
||||
}
|
||||
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
|
||||
if len(out) >= maxCommandSuggestions {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
|
||||
|
||||
@@ -698,3 +698,19 @@ func (m model) debugText() string {
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// skillsText is the /skills fallback when NO skills are installed — an install
|
||||
// hint. With skills present /skills opens the searchable skill picker instead
|
||||
// (see newSkillPicker), matching how /model works.
|
||||
func (m model) skillsText() string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Skills",
|
||||
Status: commandStatusInfo,
|
||||
Sections: []commandSection{{
|
||||
Lines: []string{"No skills installed."},
|
||||
}},
|
||||
Hints: []string{
|
||||
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,8 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -131,6 +133,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "List registered tools.",
|
||||
kind: commandTools,
|
||||
},
|
||||
{
|
||||
name: "/skills",
|
||||
usage: "/skills",
|
||||
group: commandGroupTools,
|
||||
description: "List installed skills; run one directly with /<skill-name> [args].",
|
||||
kind: commandSkills,
|
||||
},
|
||||
{
|
||||
name: "/context",
|
||||
usage: "/context",
|
||||
@@ -289,6 +298,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Write the conversation transcript to a file.",
|
||||
kind: commandExport,
|
||||
},
|
||||
{
|
||||
name: "/loop",
|
||||
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
|
||||
group: commandGroupSession,
|
||||
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
|
||||
kind: commandLoop,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The /loop command repeats a prompt or slash command in one of two modes:
|
||||
//
|
||||
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
|
||||
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
|
||||
// does one increment of work each iteration and ends its reply with a control
|
||||
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
|
||||
// the next wake. With no control line it falls back to an adaptive cadence.
|
||||
//
|
||||
// Loops are foreground and session-scoped: a tick only fires while the session is
|
||||
// idle between turns (it never interrupts a streaming turn), the loop lives with the
|
||||
// session that created it, and switching sessions (/new, /resume) stops it. Safety
|
||||
// rails cap iterations, age, consecutive failures, and no-progress repetition.
|
||||
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
|
||||
// continuation) lives in model.go.
|
||||
|
||||
type loopMode int
|
||||
|
||||
const (
|
||||
loopModeFixed loopMode = iota // re-fire every fixed interval
|
||||
loopModeSelfPaced // model chooses the next wake each iteration
|
||||
)
|
||||
|
||||
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
|
||||
// or forgotten loop from burning tokens forever.
|
||||
const (
|
||||
loopMinInterval = 30 * time.Second
|
||||
loopMaxInterval = 24 * time.Hour
|
||||
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
|
||||
|
||||
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
|
||||
// loop stops on its own even if it never reaches the iteration cap.
|
||||
loopMaxAge = 24 * time.Hour
|
||||
|
||||
loopSelfPaceMin = 1 * time.Minute
|
||||
loopSelfPaceMax = 1 * time.Hour
|
||||
loopSelfPaceDefault = 15 * time.Minute
|
||||
|
||||
// loopDoomThreshold ends a loop after this many consecutive identical final
|
||||
// answers — a self-paced or interval loop that gets stuck repeating the same
|
||||
// no-progress result should stop rather than churn.
|
||||
loopDoomThreshold = 5
|
||||
|
||||
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
|
||||
// a loop whose iterations keep erroring stops instead of churning to the
|
||||
// iteration cap (a failed turn has no final answer, so the doom guard can't see
|
||||
// the lack of progress).
|
||||
loopMaxFailures = 3
|
||||
|
||||
// loopPollInterval is how often the controller checks whether a loop is due.
|
||||
// A loop only fires while idle, so this is a cheap between-turn poll.
|
||||
loopPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// loopState is one active loop owned by the current session.
|
||||
type loopState struct {
|
||||
id string
|
||||
mode loopMode
|
||||
prompt string // the prompt or "/command" re-run each iteration
|
||||
interval time.Duration
|
||||
iteration int
|
||||
maxIter int // 0 => loopMaxIterations
|
||||
createdAt time.Time
|
||||
nextRunAt time.Time
|
||||
// lastResult / repeatRun drive doom-loop detection: consecutive identical
|
||||
// final answers past loopDoomThreshold end the loop.
|
||||
lastResult string
|
||||
repeatRun int
|
||||
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
|
||||
// stops (a failed turn has no final answer for the doom guard to compare).
|
||||
failRun int
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (l *loopState) iterationCap() int {
|
||||
if l.maxIter > 0 {
|
||||
return l.maxIter
|
||||
}
|
||||
return loopMaxIterations
|
||||
}
|
||||
|
||||
// due reports whether the loop should fire at now (active, not paused, past its
|
||||
// next-run mark).
|
||||
func (l *loopState) due(now time.Time) bool {
|
||||
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
|
||||
}
|
||||
|
||||
// cadenceText renders the loop's cadence for status lines.
|
||||
func (l *loopState) cadenceText() string {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
return "self-paced"
|
||||
}
|
||||
return "every " + formatLoopDuration(l.interval)
|
||||
}
|
||||
|
||||
type loopAction int
|
||||
|
||||
const (
|
||||
loopActionUsage loopAction = iota // show usage / list when empty
|
||||
loopActionStart // start a new loop
|
||||
loopActionStop // stop one by id
|
||||
loopActionStopAll // stop every loop
|
||||
loopActionList // list active loops
|
||||
)
|
||||
|
||||
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
|
||||
// command name).
|
||||
type loopCommand struct {
|
||||
action loopAction
|
||||
targetID string
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
|
||||
// surface back to the user.
|
||||
note string
|
||||
}
|
||||
|
||||
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
// loopControlRe matches a self-paced loop's control line at the start of a line,
|
||||
// tolerant of a leading markdown bullet/quote marker and any trailing note after
|
||||
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
|
||||
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
|
||||
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
|
||||
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
|
||||
|
||||
// parseLoopCommand interprets the arguments to `/loop`. Forms:
|
||||
//
|
||||
// (empty) -> usage/list
|
||||
// list | status -> list active loops
|
||||
// stop | stop all | stop <id>
|
||||
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
|
||||
// <prompt> every <interval>
|
||||
// <prompt> -> self-paced loop (no interval)
|
||||
func parseLoopCommand(args string) loopCommand {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return loopCommand{action: loopActionUsage}
|
||||
}
|
||||
|
||||
first := strings.ToLower(strings.Fields(trimmed)[0])
|
||||
switch first {
|
||||
case "list", "status", "ls":
|
||||
return loopCommand{action: loopActionList}
|
||||
case "stop", "cancel", "kill":
|
||||
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
|
||||
if rest == "" || strings.EqualFold(rest, "all") {
|
||||
if strings.EqualFold(rest, "all") {
|
||||
return loopCommand{action: loopActionStopAll}
|
||||
}
|
||||
// bare "stop" stops all when ambiguous; the caller may special-case a
|
||||
// single active loop.
|
||||
return loopCommand{action: loopActionStop, targetID: ""}
|
||||
}
|
||||
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
|
||||
}
|
||||
|
||||
// Leading interval token: "5m <prompt>".
|
||||
if fields := strings.Fields(trimmed); len(fields) >= 2 {
|
||||
if d, ok := parseLoopInterval(fields[0]); ok {
|
||||
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing "every <interval>".
|
||||
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// No interval anywhere -> self-paced.
|
||||
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
|
||||
}
|
||||
|
||||
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
|
||||
// real duration token so "check every PR" is not mis-parsed as a cadence).
|
||||
func splitTrailingEvery(input string) (string, time.Duration, bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) < 3 {
|
||||
return "", 0, false
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
prev := fields[len(fields)-2]
|
||||
if !strings.EqualFold(prev, "every") {
|
||||
return "", 0, false
|
||||
}
|
||||
d, ok := parseLoopInterval(last)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
|
||||
if prompt == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return prompt, d, true
|
||||
}
|
||||
|
||||
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
|
||||
func parseLoopInterval(token string) (time.Duration, bool) {
|
||||
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
unit := map[string]time.Duration{
|
||||
"s": time.Second,
|
||||
"m": time.Minute,
|
||||
"h": time.Hour,
|
||||
"d": 24 * time.Hour,
|
||||
}[m[2]]
|
||||
return time.Duration(n) * unit, true
|
||||
}
|
||||
|
||||
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
|
||||
// returns an advisory note when it was adjusted.
|
||||
func clampLoopInterval(d time.Duration) (time.Duration, string) {
|
||||
switch {
|
||||
case d < loopMinInterval:
|
||||
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
|
||||
case d > loopMaxInterval:
|
||||
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
|
||||
default:
|
||||
return d, ""
|
||||
}
|
||||
}
|
||||
|
||||
// loopTickMsg is the between-turn poll that fires a due loop while the session is
|
||||
// idle. seq invalidates a tick left over from before the loop set changed.
|
||||
type loopTickMsg struct{ seq int }
|
||||
|
||||
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
|
||||
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
|
||||
cmd := parseLoopCommand(args)
|
||||
switch cmd.action {
|
||||
case loopActionList:
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
case loopActionUsage:
|
||||
if len(m.loops) > 0 {
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
}
|
||||
return m.appendLoopSystem(loopUsageText()), nil
|
||||
case loopActionStop:
|
||||
return m.stopLoop(cmd.targetID)
|
||||
case loopActionStopAll:
|
||||
return m.stopAllLoops()
|
||||
case loopActionStart:
|
||||
return m.startLoop(cmd)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
|
||||
// iteration fires on the next idle tick (nextRunAt = now).
|
||||
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
|
||||
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
|
||||
return m.appendLoopSystem(reason), nil
|
||||
}
|
||||
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
|
||||
// goal through the playbook + LOOP: control line (see firePrompt), which a
|
||||
// custom command's expanded template never carries — so a bare self-paced
|
||||
// /command would silently degrade to adaptive polling with no way to report
|
||||
// done. Require an explicit interval for command loops instead.
|
||||
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
|
||||
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
|
||||
}
|
||||
m.loopCounter++
|
||||
id := "L" + strconv.Itoa(m.loopCounter)
|
||||
loop := &loopState{
|
||||
id: id,
|
||||
mode: cmd.mode,
|
||||
prompt: cmd.prompt,
|
||||
interval: cmd.interval,
|
||||
createdAt: m.now(),
|
||||
nextRunAt: m.now(), // fire the first iteration on the next idle tick
|
||||
}
|
||||
m.loops = append(m.loops, loop)
|
||||
note := ""
|
||||
if cmd.note != "" {
|
||||
note = " (" + cmd.note + ")"
|
||||
}
|
||||
m = m.appendLoopSystem(fmt.Sprintf(
|
||||
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
|
||||
id, loop.cadenceText(), note, id))
|
||||
return m.ensureLoopTick()
|
||||
}
|
||||
|
||||
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
|
||||
func (m model) stopLoop(id string) (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
if id == "" {
|
||||
if len(m.loops) == 1 {
|
||||
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
|
||||
}
|
||||
if m.findLoop(id) == nil {
|
||||
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
|
||||
}
|
||||
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
|
||||
// keep firing (ensureLoopTick no-ops when this was the last loop).
|
||||
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
|
||||
func (m model) stopAllLoops() (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
n := len(m.loops)
|
||||
m.loops = nil
|
||||
m.loopSeq++ // invalidate the pending poll tick
|
||||
m.loopTicking = false
|
||||
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
|
||||
}
|
||||
|
||||
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
|
||||
// from the poll tick; a no-op while a turn, modal, or queued user message is
|
||||
// pending (the loop simply waits for the next idle tick).
|
||||
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
|
||||
if m.loopBusy() || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
now := m.now()
|
||||
var due *loopState
|
||||
for _, l := range m.loops {
|
||||
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
|
||||
due = l
|
||||
}
|
||||
}
|
||||
if due == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.activeLoopID = due.id
|
||||
due.nextRunAt = time.Time{} // mark running
|
||||
return m.fireLoopPrompt(due.firePrompt())
|
||||
}
|
||||
|
||||
// firePrompt is the text actually sent for this iteration. A self-paced plain
|
||||
// prompt is wrapped in the maintenance playbook so the model knows to do one
|
||||
// increment of work and end with a LOOP: control line; a /command or a
|
||||
// fixed-interval prompt is sent verbatim.
|
||||
func (l *loopState) firePrompt() string {
|
||||
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
|
||||
return loopSelfPacePlaybook(l)
|
||||
}
|
||||
return l.prompt
|
||||
}
|
||||
|
||||
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
|
||||
// to one, otherwise a plain prompt.
|
||||
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
|
||||
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
|
||||
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
|
||||
return next, teaCmd
|
||||
}
|
||||
}
|
||||
return m.launchPrompt(prompt)
|
||||
}
|
||||
|
||||
// advanceLoop updates a loop after one of its iterations completes: it folds the
|
||||
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
|
||||
// control line, enforces the iteration and age caps, then schedules the next wake
|
||||
// (or stops the loop).
|
||||
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
|
||||
l := m.findLoop(id)
|
||||
if l == nil {
|
||||
return m // stopped mid-run
|
||||
}
|
||||
l.iteration++
|
||||
if runErr != nil {
|
||||
l.failRun++
|
||||
} else {
|
||||
l.failRun = 0
|
||||
}
|
||||
res := strings.TrimSpace(finalAnswer)
|
||||
if res != "" && res == l.lastResult {
|
||||
l.repeatRun++
|
||||
} else {
|
||||
l.repeatRun = 0
|
||||
}
|
||||
l.lastResult = res
|
||||
|
||||
// Only self-paced iterations run the playbook, so only they can carry a control
|
||||
// line; a failed turn has no trustworthy final answer to read one from.
|
||||
done, wake := false, time.Duration(0)
|
||||
if l.mode == loopModeSelfPaced && runErr == nil {
|
||||
done, wake = parseLoopControl(finalAnswer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case done:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
|
||||
case l.failRun >= loopMaxFailures:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
|
||||
case l.iteration >= l.iterationCap():
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
|
||||
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
|
||||
case l.repeatRun >= loopDoomThreshold:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
|
||||
}
|
||||
|
||||
if l.mode == loopModeSelfPaced {
|
||||
// A model-chosen next wake (from the control line) wins; otherwise fall back to
|
||||
// an adaptive cadence that checks back often while work is fresh and settles to
|
||||
// a slow heartbeat as iterations accrue.
|
||||
delay := wake
|
||||
if delay <= 0 {
|
||||
delay = adaptiveSelfPaceDelay(l.iteration)
|
||||
}
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLoopControl reads a self-paced loop's control signal from the model's final
|
||||
// answer. The last control line wins (the model may reference the protocol before
|
||||
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
|
||||
func parseLoopControl(answer string) (done bool, wake time.Duration) {
|
||||
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
|
||||
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
|
||||
norm := strings.ReplaceAll(answer, "\r\n", "\n")
|
||||
norm = strings.ReplaceAll(norm, "\r", "\n")
|
||||
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
|
||||
if len(matches) == 0 {
|
||||
return false, 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
if strings.EqualFold(last[1], "done") {
|
||||
return true, 0
|
||||
}
|
||||
if last[2] != "" {
|
||||
if d, ok := parseLoopInterval(last[2]); ok {
|
||||
return false, d
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
|
||||
// instructions the model follows each iteration: do one real increment of work,
|
||||
// then emit a single control line so the harness knows whether to stop or when to
|
||||
// wake next — the self-paced maintenance playbook the feature ships with.
|
||||
func loopSelfPacePlaybook(l *loopState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
|
||||
b.WriteString(strconv.Itoa(l.iteration + 1))
|
||||
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
|
||||
b.WriteString("Goal: ")
|
||||
b.WriteString(strings.TrimSpace(l.prompt))
|
||||
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
|
||||
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
|
||||
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
|
||||
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
|
||||
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
|
||||
// early (work is actively progressing), slowing to a heartbeat as it matures.
|
||||
func adaptiveSelfPaceDelay(iteration int) time.Duration {
|
||||
switch {
|
||||
case iteration < 2:
|
||||
return 2 * time.Minute
|
||||
case iteration < 5:
|
||||
return 5 * time.Minute
|
||||
case iteration < 10:
|
||||
return 15 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) findLoop(id string) *loopState {
|
||||
for _, l := range m.loops {
|
||||
if l.id == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) removeLoop(id, note string) model {
|
||||
kept := make([]*loopState, 0, len(m.loops))
|
||||
for _, l := range m.loops {
|
||||
if l.id != id {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
m.loops = kept
|
||||
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
|
||||
m.loopTicking = false
|
||||
if note != "" {
|
||||
m = m.appendLoopSystem(note)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ensureLoopTick starts the idle poll ticker if it is not already running.
|
||||
func (m model) ensureLoopTick() (model, tea.Cmd) {
|
||||
if m.loopTicking || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.loopTicking = true
|
||||
return m, m.scheduleLoopTick()
|
||||
}
|
||||
|
||||
func (m model) scheduleLoopTick() tea.Cmd {
|
||||
seq := m.loopSeq
|
||||
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
|
||||
return loopTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
|
||||
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
|
||||
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
|
||||
// The modal check is load-bearing: a loop must not launch a run behind an open
|
||||
// /resume picker, or that run would complete into whatever session the user then
|
||||
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
|
||||
// collide.
|
||||
func (m model) loopBusy() bool {
|
||||
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
|
||||
}
|
||||
|
||||
func (m model) appendLoopSystem(text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m
|
||||
}
|
||||
|
||||
// loopActive reports whether any loop is running (for the footer + leave warning).
|
||||
func (m model) loopActive() bool { return len(m.loops) > 0 }
|
||||
|
||||
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
|
||||
// or "" when no loops are active.
|
||||
func (m model) loopFooterSummary() string {
|
||||
if len(m.loops) == 0 {
|
||||
return ""
|
||||
}
|
||||
var next time.Time
|
||||
for _, l := range m.loops {
|
||||
if l.nextRunAt.IsZero() || l.paused {
|
||||
continue
|
||||
}
|
||||
if next.IsZero() || l.nextRunAt.Before(next) {
|
||||
next = l.nextRunAt
|
||||
}
|
||||
}
|
||||
label := fmt.Sprintf("%d loop", len(m.loops))
|
||||
if len(m.loops) != 1 {
|
||||
label += "s"
|
||||
}
|
||||
if next.IsZero() {
|
||||
return label
|
||||
}
|
||||
return label + " · next " + next.Format("3:04pm")
|
||||
}
|
||||
|
||||
func (m model) loopListText() string {
|
||||
if len(m.loops) == 0 {
|
||||
return "No active loops."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Active loops:\n")
|
||||
now := m.now()
|
||||
for _, l := range m.loops {
|
||||
when := "running"
|
||||
if !l.nextRunAt.IsZero() {
|
||||
if d := l.nextRunAt.Sub(now); d > 0 {
|
||||
when = "next in " + formatLoopDuration(d.Round(time.Second))
|
||||
} else {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loopUsageText() string {
|
||||
return "Repeat a prompt or command:\n" +
|
||||
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
|
||||
" /loop watch CI every 2m — trailing interval form\n" +
|
||||
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
|
||||
" /loop list — show active loops\n" +
|
||||
" /loop stop [id|all] — stop a loop\n" +
|
||||
"Loops run while this session is idle and stop when you close it."
|
||||
}
|
||||
|
||||
// validateLoopTarget rejects looping a built-in command or an unresolved custom
|
||||
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
|
||||
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
|
||||
// being scheduled and then re-sent as literal text on every tick.
|
||||
func (m model) validateLoopTarget(prompt string) (string, bool) {
|
||||
p := strings.TrimSpace(prompt)
|
||||
if p == "" {
|
||||
return "Nothing to loop — give a prompt or a /command.", false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
switch parseCommand(p).kind {
|
||||
case commandPrompt:
|
||||
// A bare "/" or similar that isn't a command — treat as a plain prompt.
|
||||
case commandUnknown:
|
||||
name, _ := splitUserCommand(p)
|
||||
if _, ok := m.lookupUserCommand(name); !ok {
|
||||
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
|
||||
}
|
||||
default:
|
||||
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
|
||||
// the session that created them; carrying them across /new or /resume would fire the
|
||||
// old session's prompt into an unrelated conversation. Returns the count cleared so
|
||||
// the caller can note it. Pure state reset — no transcript writes.
|
||||
func (m model) clearLoopsForSessionSwitch() (model, int) {
|
||||
n := len(m.loops)
|
||||
if n == 0 {
|
||||
return m, 0
|
||||
}
|
||||
m.loops = nil
|
||||
m.activeLoopID = ""
|
||||
m.loopTicking = false
|
||||
m.loopSeq++ // invalidate any pending poll tick
|
||||
return m, n
|
||||
}
|
||||
|
||||
func truncateLoopPrompt(prompt string) string {
|
||||
const max = 48
|
||||
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
|
||||
runes := []rune(p)
|
||||
if len(runes) <= max {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
|
||||
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
|
||||
// band, defaulting an unset/invalid value.
|
||||
func clampSelfPaceDelay(d time.Duration) time.Duration {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return loopSelfPaceDefault
|
||||
case d < loopSelfPaceMin:
|
||||
return loopSelfPaceMin
|
||||
case d > loopSelfPaceMax:
|
||||
return loopSelfPaceMax
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
|
||||
// text, preferring the largest whole unit.
|
||||
func formatLoopDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
case d%time.Hour == 0 && d >= time.Hour:
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
case d%time.Minute == 0 && d >= time.Minute:
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
default:
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/usercommands"
|
||||
)
|
||||
|
||||
func loopTestModel(t *testing.T, now time.Time) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.now = func() time.Time { return now }
|
||||
return m
|
||||
}
|
||||
|
||||
func startFixedLoop(m model, prompt string, interval time.Duration) model {
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStartLoopRegistersAndSchedules(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "check the build", 5*time.Minute)
|
||||
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("want 1 loop, got %d", len(m.loops))
|
||||
}
|
||||
l := m.loops[0]
|
||||
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
|
||||
t.Fatalf("unexpected loop %+v", l)
|
||||
}
|
||||
// First iteration is due immediately (nextRunAt == now).
|
||||
if !l.due(now) {
|
||||
t.Errorf("first iteration should be due at creation")
|
||||
}
|
||||
if !m.loopTicking {
|
||||
t.Errorf("starting a loop should start the poll ticker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", 5*time.Minute)
|
||||
id := m.loops[0].id
|
||||
|
||||
m = m.advanceLoop(id, "did some work", nil)
|
||||
l := m.loops[0]
|
||||
if l.iteration != 1 {
|
||||
t.Fatalf("iteration = %d, want 1", l.iteration)
|
||||
}
|
||||
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedUsesModelDelay(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
|
||||
id := m.loops[0].id
|
||||
// The model carries its chosen next wake in the reply's control line.
|
||||
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
|
||||
if len(m.loops) == 0 {
|
||||
t.Fatal("a CONTINUE control line should keep the loop running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
|
||||
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoneStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedLoopIgnoresControlLine(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "watch CI", time.Minute)
|
||||
id := m.loops[0].id
|
||||
// A fixed-interval loop never runs the playbook, so a stray control line in the
|
||||
// output is not a completion signal — it keeps its wall-clock cadence.
|
||||
m = m.advanceLoop(id, "LOOP: DONE", nil)
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
|
||||
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopExpiresByAge(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "forgotten", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
|
||||
m = m.advanceLoop(id, "still going", nil)
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a loop past its age cap should auto-expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDoomGuardStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "spin", time.Minute)
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "identical answer", nil)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("doom guard should stop a loop repeating identical results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopIterationCapStops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "bounded", time.Minute)
|
||||
m.loops[0].maxIter = 3
|
||||
id := m.loops[0].id
|
||||
for i := 0; i < 3 && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop at its iteration cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopLoopByID(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m, cmd := m.stopLoop(id)
|
||||
if len(m.loops) != 1 || m.findLoop(id) != nil {
|
||||
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
|
||||
}
|
||||
// Stopping one of several loops must keep the poll ticker alive for the rest:
|
||||
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
|
||||
if cmd == nil || !m.loopTicking {
|
||||
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
|
||||
}
|
||||
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
|
||||
// leaves the target empty and stopLoop special-cases the one-loop case).
|
||||
soleID := m.loops[0].id
|
||||
m, _ = m.stopLoop("")
|
||||
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
|
||||
t.Fatalf("bare stop should remove the sole active loop")
|
||||
}
|
||||
m = startFixedLoop(m, "c", time.Minute)
|
||||
m = startFixedLoop(m, "d", time.Minute)
|
||||
m, _ = m.stopAllLoops()
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("stopAllLoops should clear all loops")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.pending = true // a turn is in flight
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatalf("a due loop must not fire while a turn is pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "poll", time.Minute) // due immediately
|
||||
m.picker = &commandPicker{} // e.g. an open /resume session picker
|
||||
got, cmd := m.fireDueLoopIfIdle()
|
||||
if got.activeLoopID != "" || cmd != nil {
|
||||
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
|
||||
}
|
||||
if got.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
|
||||
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
|
||||
t.Error("early iterations should check back sooner than later ones")
|
||||
}
|
||||
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
|
||||
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFooterSummary(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if m.loopFooterSummary() != "" {
|
||||
t.Error("no loops -> empty footer summary")
|
||||
}
|
||||
m = startFixedLoop(m, "a", 5*time.Minute)
|
||||
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
|
||||
got := m.loopFooterSummary()
|
||||
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
|
||||
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
|
||||
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCommandThroughSubmit(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.input.SetValue("/loop 5m tidy the imports")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.loops) != 1 {
|
||||
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
|
||||
}
|
||||
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
|
||||
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
|
||||
}
|
||||
if !next.loopTicking {
|
||||
t.Fatalf("submitting /loop should start the poll ticker")
|
||||
}
|
||||
|
||||
// /loop stop all through the same path clears it.
|
||||
next.input.SetValue("/loop stop all")
|
||||
updated2, _ := next.Update(testKey(tea.KeyEnter))
|
||||
final := updated2.(model)
|
||||
if len(final.loops) != 0 {
|
||||
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleLoopTickIgnored(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m.loopSeq = 7
|
||||
// A tick from before the seq changed must be dropped and stop the ticker.
|
||||
updated, cmd := m.Update(loopTickMsg{seq: 3})
|
||||
next := updated.(model)
|
||||
if next.loopTicking {
|
||||
t.Error("a stale loop tick should stop the ticker")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Error("a stale loop tick should not reschedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "flaky", time.Minute)
|
||||
id := m.loops[0].id
|
||||
boom := errors.New("boom")
|
||||
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
|
||||
m = m.advanceLoop(id, "", boom)
|
||||
}
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "recovers", time.Minute)
|
||||
id := m.loops[0].id
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "", errors.New("x"))
|
||||
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatalf("a recovered failure streak should not stop the loop")
|
||||
}
|
||||
if m.loops[0].failRun != 0 {
|
||||
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", 5*time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.runCancel = func() {}
|
||||
|
||||
m.cancelRun()
|
||||
|
||||
if m.activeLoopID != "" {
|
||||
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
|
||||
}
|
||||
if m.loops[0].nextRunAt.IsZero() {
|
||||
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
|
||||
}
|
||||
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
||||
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
if _, ok := m.validateLoopTarget("/model list"); ok {
|
||||
t.Error("looping a built-in command should be rejected")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
|
||||
t.Error("a plain prompt should be a valid loop target")
|
||||
}
|
||||
if _, ok := m.validateLoopTarget(""); ok {
|
||||
t.Error("an empty target should be rejected")
|
||||
}
|
||||
// A custom command that does not exist is rejected up front, so it is never
|
||||
// scheduled and then re-sent as literal "/…" text on every tick.
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
|
||||
t.Error("an unresolved custom command should be rejected")
|
||||
}
|
||||
// A registered custom command is accepted.
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
|
||||
t.Error("an existing custom command should be a valid loop target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfPacedCommandLoopRejected(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
||||
// A /command with no interval parses as self-paced, but self-paced mode drives
|
||||
// a free-form goal via the playbook + LOOP: protocol, which a command's
|
||||
// expanded template never carries — so a bare self-paced /command is rejected.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
|
||||
if len(m.loops) != 0 {
|
||||
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
|
||||
}
|
||||
// The same command with an interval (fixed) is accepted.
|
||||
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
|
||||
if len(m.loops) != 1 {
|
||||
t.Fatal("a fixed-interval /command loop should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearLoopsForSessionSwitch(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "a", time.Minute)
|
||||
m = startFixedLoop(m, "b", time.Minute)
|
||||
m.activeLoopID = m.loops[0].id
|
||||
m, n := m.clearLoopsForSessionSwitch()
|
||||
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
|
||||
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
|
||||
n, len(m.loops), m.activeLoopID, m.loopTicking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartNewSessionStopsLoops(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
m = m.startNewSession()
|
||||
if len(m.loops) != 0 || m.loopTicking {
|
||||
t.Fatal("/new should stop loops tied to the previous session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopClearRequiresConfirm(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// First /clear with a loop active arms the confirm and does not clear yet.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear with active loops should arm the confirm, not clear")
|
||||
}
|
||||
if len(n1.loops) != 1 {
|
||||
t.Fatal("the loop should still be active after the warning")
|
||||
}
|
||||
|
||||
// A different command disarms the confirm.
|
||||
n1.input.SetValue("/loop list")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("an intervening command should disarm the leave confirm")
|
||||
}
|
||||
|
||||
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
|
||||
n2.input.SetValue("/clear")
|
||||
u3, _ := n2.Update(testKey(tea.KeyEnter))
|
||||
n3 := u3.(model)
|
||||
n3.input.SetValue("/clear")
|
||||
u4, _ := n3.Update(testKey(tea.KeyEnter))
|
||||
n4 := u4.(model)
|
||||
if n4.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a second consecutive /clear should confirm and disarm")
|
||||
}
|
||||
if len(n4.loops) != 1 {
|
||||
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
|
||||
}
|
||||
// The confirmed clear must actually wipe the transcript, not silently no-op:
|
||||
// only the clear path appends the "Transcript cleared" note, and the pre-clear
|
||||
// loop-start row must be gone.
|
||||
clearedNote, staleRow := false, false
|
||||
for _, r := range n4.transcript {
|
||||
if strings.Contains(r.text, "Transcript cleared") {
|
||||
clearedNote = true
|
||||
}
|
||||
if strings.Contains(r.text, "Loop L1 started") {
|
||||
staleRow = true
|
||||
}
|
||||
}
|
||||
if !clearedNote || staleRow {
|
||||
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
|
||||
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
m := loopTestModel(t, now)
|
||||
m = startFixedLoop(m, "x", time.Minute)
|
||||
|
||||
// Arm the /clear confirm.
|
||||
m.input.SetValue("/clear")
|
||||
u1, _ := m.Update(testKey(tea.KeyEnter))
|
||||
n1 := u1.(model)
|
||||
if n1.loopLeavePrompt != commandClear {
|
||||
t.Fatal("first /clear should arm the confirm")
|
||||
}
|
||||
|
||||
// A prompt submitted while a run streams is queued via an early return in
|
||||
// handleSubmit; it must still disarm the confirm (the disarm runs before those
|
||||
// returns). Otherwise a later /clear would be treated as the second press.
|
||||
n1.pending = true
|
||||
n1.input.SetValue("a follow-up question")
|
||||
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
||||
n2 := u2.(model)
|
||||
if n2.loopLeavePrompt != commandEmpty {
|
||||
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLoopCommandModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
action loopAction
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
}{
|
||||
{"", loopActionUsage, 0, "", 0},
|
||||
{"list", loopActionList, 0, "", 0},
|
||||
{"status", loopActionList, 0, "", 0},
|
||||
{"stop all", loopActionStopAll, 0, "", 0},
|
||||
{"stop ab12", loopActionStop, 0, "", 0},
|
||||
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
|
||||
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
|
||||
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
|
||||
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
|
||||
// "every PR" is not a cadence — the token after "every" isn't a duration.
|
||||
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseLoopCommand(c.in)
|
||||
if got.action != c.action {
|
||||
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
|
||||
}
|
||||
if c.action == loopActionStart {
|
||||
if got.mode != c.mode {
|
||||
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
|
||||
}
|
||||
if got.prompt != c.prompt {
|
||||
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
|
||||
}
|
||||
if got.interval != c.interval {
|
||||
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopCommandStopTarget(t *testing.T) {
|
||||
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
|
||||
t.Fatalf("bare stop = %+v, want stop with empty target", got)
|
||||
}
|
||||
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
|
||||
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLoopInterval(t *testing.T) {
|
||||
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
|
||||
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
|
||||
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
|
||||
}
|
||||
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
|
||||
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampSelfPaceDelay(t *testing.T) {
|
||||
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
|
||||
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
|
||||
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
|
||||
}
|
||||
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
|
||||
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
|
||||
}
|
||||
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
|
||||
t.Errorf("in-band = %v, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLoopDuration(t *testing.T) {
|
||||
cases := map[time.Duration]string{
|
||||
30 * time.Second: "30s",
|
||||
90 * time.Second: "90s",
|
||||
5 * time.Minute: "5m",
|
||||
2 * time.Hour: "2h",
|
||||
24 * time.Hour: "1d",
|
||||
}
|
||||
for d, want := range cases {
|
||||
if got := formatLoopDuration(d); got != want {
|
||||
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoopControl(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
done bool
|
||||
wake time.Duration
|
||||
}{
|
||||
{"did the work\nLOOP: DONE", true, 0},
|
||||
{"progress\nLOOP: CONTINUE", false, 0},
|
||||
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
|
||||
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
|
||||
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
|
||||
{"no control line here", false, 0}, // absent -> adaptive
|
||||
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
|
||||
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
|
||||
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
|
||||
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
|
||||
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
|
||||
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
|
||||
}
|
||||
for _, c := range cases {
|
||||
done, wake := parseLoopControl(c.in)
|
||||
if done != c.done || wake != c.wake {
|
||||
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
|
||||
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
|
||||
got := self.firePrompt()
|
||||
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
|
||||
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
|
||||
}
|
||||
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
|
||||
if fixed.firePrompt() != "check the build" {
|
||||
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
|
||||
}
|
||||
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
|
||||
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
|
||||
if cmd.firePrompt() != "/babysit" {
|
||||
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopStateDue(t *testing.T) {
|
||||
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
||||
l := &loopState{nextRunAt: base}
|
||||
if l.due(base.Add(-time.Second)) {
|
||||
t.Error("loop should not be due before nextRunAt")
|
||||
}
|
||||
if !l.due(base) {
|
||||
t.Error("loop should be due at nextRunAt")
|
||||
}
|
||||
l.paused = true
|
||||
if l.due(base.Add(time.Hour)) {
|
||||
t.Error("paused loop should never be due")
|
||||
}
|
||||
}
|
||||
+114
-8
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
@@ -62,6 +63,7 @@ type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
@@ -188,10 +190,22 @@ type model struct {
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
// pending poll tick when loops are stopped; loopTicking guards against scheduling
|
||||
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
|
||||
// /quit while loops are active (see handleSubmit).
|
||||
loops []*loopState
|
||||
activeLoopID string
|
||||
loopSeq int
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -735,6 +749,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
@@ -1893,6 +1908,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
case loopTickMsg:
|
||||
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
|
||||
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
|
||||
if msg.seq != m.loopSeq || len(m.loops) == 0 {
|
||||
m.loopTicking = false
|
||||
return m, nil
|
||||
}
|
||||
var fireCmd tea.Cmd
|
||||
m, fireCmd = m.fireDueLoopIfIdle()
|
||||
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
// A run cancelled while in flight still finishes in its goroutine and
|
||||
@@ -2062,8 +2087,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
var loopTickCmd tea.Cmd
|
||||
if loopID := m.activeLoopID; loopID != "" {
|
||||
m.activeLoopID = ""
|
||||
loopFinalAnswer := ""
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
loopFinalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
|
||||
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
|
||||
// other loops remain.
|
||||
m, loopTickCmd = m.ensureLoopTick()
|
||||
}
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -3730,6 +3772,11 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
case pickerSkill:
|
||||
// Fill the composer with "/name " so the user adds their request before
|
||||
// submitting (a bare second Enter runs it without one); names the slash
|
||||
// path cannot reach run immediately instead.
|
||||
m, cmd = m.chooseSkillFromPicker(item)
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
@@ -3781,6 +3828,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
command := parseCommand(input)
|
||||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||||
// repeat of that same command only; any other submission — including one queued
|
||||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||||
@@ -3814,6 +3868,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
// A foreground loop keeps firing after /clear (it wipes the screen, not the
|
||||
// session), so warn once before clearing the context it will run into.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandClear {
|
||||
m.loopLeavePrompt = commandClear
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
@@ -3822,6 +3884,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
// /clear wipes the transcript but not the session, so loops keep running;
|
||||
// say so rather than let them silently fire into a "cleared" screen.
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
@@ -3832,7 +3899,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||||
m.loopLeavePrompt = commandExit
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
// immediately would orphan the blobs and break /rewind.
|
||||
@@ -3845,6 +3922,15 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
case commandTools:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
|
||||
return m, nil
|
||||
case commandSkills:
|
||||
// With skills installed, /skills opens a searchable picker (like /model);
|
||||
// the text card remains only as the no-skills install hint.
|
||||
if picker := m.newSkillPicker(); picker != nil {
|
||||
m.picker = picker
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
|
||||
return m, nil
|
||||
case commandMCP:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
return m.openMCPManager(), nil
|
||||
@@ -4040,6 +4126,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
if next, cmd, handled := m.handleUserCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
|
||||
// (deterministic invocation, vs waiting for the model to pull it in).
|
||||
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
|
||||
return next, cmd
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "unknown command: " + command.text,
|
||||
@@ -4311,6 +4402,21 @@ func (m *model) cancelRun() {
|
||||
m.runCancel()
|
||||
}
|
||||
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
|
||||
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
|
||||
// late message is drained through flushRunIDs, not advanceLoop), so clear the
|
||||
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
|
||||
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
|
||||
// unrelated turn would be misattributed as this loop's completion.
|
||||
if m.activeLoopID != "" {
|
||||
if l := m.findLoop(m.activeLoopID); l != nil {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
}
|
||||
m.activeLoopID = ""
|
||||
}
|
||||
// Remember the in-flight run — and the session it was recording into — so
|
||||
// its final agentResponseMsg is still drained for session-event persistence
|
||||
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
|
||||
@@ -4446,9 +4552,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -48,7 +49,13 @@ type Options struct {
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
AgentOptions agent.Options
|
||||
// LoadSkills returns the installed skills (default skills dir merged with any
|
||||
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
|
||||
// invocation. Called lazily per use so newly installed skills are picked up
|
||||
// without a restart. Nil means the session has no skills wiring (skills stay
|
||||
// model-pulled via the skill tool only).
|
||||
LoadSkills func() []skills.Skill
|
||||
PermissionMode agent.PermissionMode
|
||||
ReasoningEffort modelregistry.ReasoningEffort
|
||||
ResponseStyle string
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
pickerEffort
|
||||
pickerSession
|
||||
pickerTheme
|
||||
pickerSkill
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -225,6 +226,35 @@ func (m model) newModelPicker() *commandPicker {
|
||||
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
|
||||
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
|
||||
// the skill's EXACT name (item.Value), so even skills the slash path cannot
|
||||
// reach — names shadowed by a builtin or user command, or names that don't fit
|
||||
// a slash token — are runnable here: picking from the Skills picker is
|
||||
// unambiguous. Returns nil when no skills are installed (the caller falls back
|
||||
// to the install-hint card).
|
||||
func (m model) newSkillPicker() *commandPicker {
|
||||
installed := m.installedSkills()
|
||||
items := make([]pickerItem, 0, len(installed))
|
||||
for _, skill := range installed {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
label := name
|
||||
if slash := skillSlashName(name); slash != "" {
|
||||
// Show the slash form where one exists — it doubles as documentation
|
||||
// of the direct "/name [args]" invocation.
|
||||
label = "/" + slash
|
||||
}
|
||||
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// modelPickerProviders returns the providers to list in /model: all saved
|
||||
// providers, falling back to the active profile when none were threaded in.
|
||||
func (m model) modelPickerProviders() []config.ProviderProfile {
|
||||
|
||||
+39
-12
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
// Loops belong to the previous session; stop them so they don't fire the old
|
||||
// conversation's prompt into the fresh one.
|
||||
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
m = updated
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -123,13 +129,24 @@ func (m model) appendSessionEvent(eventType sessions.EventType, payload any) (mo
|
||||
|
||||
func (m model) appendSessionEvents(events []pendingSessionEvent) (model, []transcriptRow) {
|
||||
rows := []transcriptRow{}
|
||||
if m.activeSession.SessionID == "" || len(events) == 0 {
|
||||
return m, rows
|
||||
}
|
||||
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
||||
for _, event := range events {
|
||||
next, err := m.appendSessionEvent(event.Type, event.Payload)
|
||||
if err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
m = next
|
||||
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
appended, err := m.sessionStore.AppendEvents(m.activeSession.SessionID, inputs)
|
||||
if err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
return m, rows
|
||||
}
|
||||
if len(appended) > 0 {
|
||||
last := appended[len(appended)-1]
|
||||
m.activeSession.UpdatedAt = last.CreatedAt
|
||||
m.activeSession.EventCount = last.Sequence
|
||||
m.activeSession.LastEventType = last.Type
|
||||
m.sessionEvents = append(m.sessionEvents, appended...)
|
||||
}
|
||||
return m, rows
|
||||
}
|
||||
@@ -142,13 +159,12 @@ func (m model) appendSessionEventsTo(sessionID string, events []pendingSessionEv
|
||||
if m.sessionStore == nil || sessionID == "" {
|
||||
return rows
|
||||
}
|
||||
inputs := make([]sessions.AppendEventInput, 0, len(events))
|
||||
for _, event := range events {
|
||||
if _, err := m.sessionStore.AppendEvent(sessionID, sessions.AppendEventInput{
|
||||
Type: event.Type,
|
||||
Payload: event.Payload,
|
||||
}); err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
}
|
||||
inputs = append(inputs, sessions.AppendEventInput{Type: event.Type, Payload: event.Payload})
|
||||
}
|
||||
if _, err := m.sessionStore.AppendEvents(sessionID, inputs); err != nil {
|
||||
rows = append(rows, transcriptRow{kind: rowError, text: "session record error: " + err.Error()})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -195,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
return m, "Sessions\nerror: " + err.Error()
|
||||
}
|
||||
|
||||
// Capture the current session id before switching so loops are only torn down
|
||||
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
@@ -203,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
if m.modelName == "" {
|
||||
m.modelName = session.ModelID
|
||||
}
|
||||
loopsCleared := 0
|
||||
if session.SessionID != previousID {
|
||||
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
||||
}
|
||||
|
||||
rows := initialTranscript()
|
||||
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
||||
if loopsCleared > 0 {
|
||||
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
||||
}
|
||||
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
||||
m.transcript = rows
|
||||
// Every rehydrated row is settled by construction, so resetting the flush
|
||||
|
||||
@@ -108,6 +108,46 @@ func TestPromptSubmitPersistsTUISessionEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSessionEventsBatchesAndUpdatesActiveSession(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
session, err := store.Create(sessions.CreateInput{SessionID: "tui_batch", Title: "batch"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
m := model{
|
||||
sessionStore: store,
|
||||
activeSession: session,
|
||||
}
|
||||
|
||||
next, rows := m.appendSessionEvents([]pendingSessionEvent{
|
||||
{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "done"}},
|
||||
{Type: sessions.EventUsage, Payload: map[string]any{"totalTokens": 3}},
|
||||
})
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("appendSessionEvents returned error rows: %#v", rows)
|
||||
}
|
||||
if next.activeSession.EventCount != 2 || next.activeSession.LastEventType != sessions.EventUsage {
|
||||
t.Fatalf("active session metadata not updated from batch: %#v", next.activeSession)
|
||||
}
|
||||
if len(next.sessionEvents) != 2 || next.sessionEvents[0].Sequence != 1 || next.sessionEvents[1].Sequence != 2 {
|
||||
t.Fatalf("in-memory session events not updated from batch: %#v", next.sessionEvents)
|
||||
}
|
||||
events, err := store.ReadEvents(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if got := eventTypes(events); !equalEventTypes(got, []sessions.EventType{sessions.EventMessage, sessions.EventUsage}) {
|
||||
t.Fatalf("unexpected persisted event types: %#v", got)
|
||||
}
|
||||
loaded, err := store.Get(session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if loaded == nil || loaded.EventCount != next.activeSession.EventCount || loaded.LastEventType != next.activeSession.LastEventType {
|
||||
t.Fatalf("store metadata diverged from active session: store=%#v active=%#v", loaded, next.activeSession)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptWithoutProviderDoesNotCreateSession(t *testing.T) {
|
||||
store := testSessionStore(t)
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
)
|
||||
|
||||
// Skill slash invocation: "/<skill-name> [args]" runs an installed skill
|
||||
// directly, mirroring user commands (.zero/commands). Skills were previously
|
||||
// model-pulled only (the skill tool), which made invocation a matter of model
|
||||
// discretion; typing the skill's name makes it deterministic. Precedence is
|
||||
// builtin > user command > skill: parseCommand resolves builtins first, and the
|
||||
// commandUnknown fallback tries user commands before skills.
|
||||
|
||||
// handleSkillCommand resolves a "/name args" that wasn't a builtin or a user
|
||||
// command against the installed skills. On a match it launches a normal agent
|
||||
// turn whose prompt inlines the skill body (its instructions) followed by the
|
||||
// typed args, returning handled=true. handled=false means no skill matched, so
|
||||
// the caller falls through to "unknown command".
|
||||
func (m model) handleSkillCommand(raw string) (model, tea.Cmd, bool) {
|
||||
name, args := splitUserCommand(raw)
|
||||
if name == "" {
|
||||
return m, nil, false
|
||||
}
|
||||
skill, ok := m.lookupSkillCommand(name)
|
||||
if !ok {
|
||||
return m, nil, false
|
||||
}
|
||||
body := strings.TrimSpace(skill.Content)
|
||||
if body == "" {
|
||||
// The name matched a real skill, so falling through to "unknown command"
|
||||
// would mislead; surface the actual problem instead.
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill /" + name + " has an empty SKILL.md body (" + skill.Path + ")",
|
||||
})
|
||||
return m, nil, true
|
||||
}
|
||||
return m.launchOrDeferExpandedPrompt(raw, skillInvocationPrompt(body, args))
|
||||
}
|
||||
|
||||
// bareSkillInvocationNote is appended when a skill is invoked with no request.
|
||||
// The body alone is instructions with no target ("review the PR" — which PR?),
|
||||
// and without this note the model improvises one instead of asking. The wording
|
||||
// is conditional so self-contained skills (no target needed) still just run.
|
||||
const bareSkillInvocationNote = "The user invoked this skill directly without providing a request. " +
|
||||
"If these instructions need a target or details that are not already clear from the conversation " +
|
||||
"(which pull request, file, branch, topic, …), ask for them first — do not guess or pick one yourself. " +
|
||||
"If the instructions are self-contained, proceed."
|
||||
|
||||
// skillInvocationPrompt builds the agent prompt for a skill invocation: the
|
||||
// skill body (its instructions), then either the user's request or — for a bare
|
||||
// invocation — the ask-first note above. Mirrors usercommands.Expand's
|
||||
// no-placeholder behavior for the args case.
|
||||
func skillInvocationPrompt(body, args string) string {
|
||||
if args != "" {
|
||||
return body + "\n\n" + args
|
||||
}
|
||||
return body + "\n\n" + bareSkillInvocationNote
|
||||
}
|
||||
|
||||
// launchOrDeferExpandedPrompt applies the same run-state guards the plain
|
||||
// commandPrompt path has to an expanded (user command / skill) prompt: while
|
||||
// exiting nothing may start, a prompt submitted mid-run is queued for the next
|
||||
// turn, and compaction-in-flight warns instead of racing the compactor. The
|
||||
// EXPANDED prompt is what gets queued — the queue flush path resubmits text as
|
||||
// a literal prompt, so queuing the raw "/name args" would send it to the model
|
||||
// as prose instead of re-dispatching it. raw is the typed invocation; on the
|
||||
// compaction warning it is restored into the composer for an easy re-submit
|
||||
// (the commandUnknown dispatch cleared the composer before we got here, unlike
|
||||
// the plain-prompt path whose guard fires before the clear). Empty raw (the
|
||||
// picker path has no typed form) restores nothing.
|
||||
func (m model) launchOrDeferExpandedPrompt(raw, prompt string) (model, tea.Cmd, bool) {
|
||||
if m.exiting {
|
||||
return m, nil, true
|
||||
}
|
||||
if m.pending {
|
||||
return m.queueMessage(prompt), nil, true
|
||||
}
|
||||
if m.compactInFlight {
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
m.input.SetValue(raw)
|
||||
m.input.CursorEnd()
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendSystem,
|
||||
text: "Compact\nstatus: warning\nCompaction is running. Re-run the command when it finishes.",
|
||||
})
|
||||
return m, nil, true
|
||||
}
|
||||
next, teaCmd := m.launchPrompt(prompt)
|
||||
return next, teaCmd, true
|
||||
}
|
||||
|
||||
// takenSlashNames returns every slash token already claimed by a builtin
|
||||
// command (or alias) or a user command — the names a typed "/x" would dispatch
|
||||
// to before ever reaching a skill.
|
||||
func (m model) takenSlashNames() map[string]bool {
|
||||
taken := map[string]bool{}
|
||||
for _, command := range commandDefinitions {
|
||||
taken[strings.TrimPrefix(command.name, "/")] = true
|
||||
for _, alias := range command.aliases {
|
||||
taken[strings.TrimPrefix(alias, "/")] = true
|
||||
}
|
||||
}
|
||||
for _, cmd := range m.userCommands {
|
||||
taken[cmd.Name] = true
|
||||
}
|
||||
return taken
|
||||
}
|
||||
|
||||
// chooseSkillFromPicker handles Enter on a skill-picker row. Most skills want a
|
||||
// request ("review WHICH pr?"), so selection fills the composer with
|
||||
// "/name " — cursor at the end — for the user to complete and submit (Enter
|
||||
// again runs it bare). Skills whose names cannot be typed as a slash command
|
||||
// (non-slash shapes, or names shadowed by a builtin or user command) fall back
|
||||
// to running immediately: the picker is the only path that reaches them, and an
|
||||
// unfillable composer would be a dead end.
|
||||
func (m model) chooseSkillFromPicker(item pickerItem) (model, tea.Cmd) {
|
||||
slash := skillSlashName(item.Value)
|
||||
if slash == "" || m.takenSlashNames()[slash] {
|
||||
return m.invokeSkillByName(item.Value)
|
||||
}
|
||||
m.input.SetValue("/" + slash + " ")
|
||||
m.input.CursorEnd()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// invokeSkillByName runs the installed skill with the given EXACT raw name (the
|
||||
// skill-picker path). The same run-state guards as slash invocation apply; the
|
||||
// picker offers no args affordance, so the prompt is the skill body alone.
|
||||
func (m model) invokeSkillByName(name string) (model, tea.Cmd) {
|
||||
for _, skill := range m.installedSkills() {
|
||||
if strings.TrimSpace(skill.Name) != name {
|
||||
continue
|
||||
}
|
||||
body := strings.TrimSpace(skill.Content)
|
||||
if body == "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill " + name + " has an empty SKILL.md body (" + skill.Path + ")",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
next, teaCmd, _ := m.launchOrDeferExpandedPrompt("", skillInvocationPrompt(body, ""))
|
||||
return next, teaCmd
|
||||
}
|
||||
// The picker row came from a slightly older load (TTL cache) and the skill
|
||||
// has since been removed.
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||||
kind: actionAppendError,
|
||||
text: "skill " + name + " is no longer installed",
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// lookupSkillCommand returns the installed skill whose slash name matches the
|
||||
// given (lowercased) name. Linear scan over a fresh load — skills are re-read
|
||||
// per invocation so a skill installed mid-session is invocable without a
|
||||
// restart, and the body is never held on the model.
|
||||
func (m model) lookupSkillCommand(name string) (skills.Skill, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return skills.Skill{}, false
|
||||
}
|
||||
for _, skill := range m.installedSkills() {
|
||||
if skillSlashName(skill.Name) == name {
|
||||
return skill, true
|
||||
}
|
||||
}
|
||||
return skills.Skill{}, false
|
||||
}
|
||||
|
||||
// installedSkills returns the session's installed skills (default dir merged
|
||||
// with plugin skill roots) via the injected loader, or nil when the session has
|
||||
// no skills wiring (e.g. bare test models).
|
||||
func (m model) installedSkills() []skills.Skill {
|
||||
if m.loadSkills == nil {
|
||||
return nil
|
||||
}
|
||||
return m.loadSkills()
|
||||
}
|
||||
|
||||
// skillSlashName maps a skill's frontmatter name to its slash-command form:
|
||||
// lowercased, and only if it fits the slash-token shape (letters, digits,
|
||||
// dot/underscore/hyphen — a superset of user-command names, since skill names
|
||||
// are free-form frontmatter). Returns "" for names that cannot be typed as a
|
||||
// /command (e.g. containing spaces); those skills remain loadable by the model
|
||||
// via the skill tool and are still listed by /skills.
|
||||
func skillSlashName(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range name {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
)
|
||||
|
||||
// newSkillTestModel builds a model with an injected skills loader (and the
|
||||
// matching palette metadata), no filesystem involved.
|
||||
func newSkillTestModel(t *testing.T, installed ...skills.Skill) model {
|
||||
t.Helper()
|
||||
infos := make([]agent.SkillInfo, 0, len(installed))
|
||||
for _, s := range installed {
|
||||
infos = append(infos, agent.SkillInfo{Name: s.Name, Description: s.Description})
|
||||
}
|
||||
return newModel(context.Background(), Options{
|
||||
Cwd: t.TempDir(),
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
AgentOptions: agent.Options{Skills: infos},
|
||||
})
|
||||
}
|
||||
|
||||
func submitInput(t *testing.T, m model, input string) model {
|
||||
t.Helper()
|
||||
m.input.SetValue(input)
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
return updated.(model)
|
||||
}
|
||||
|
||||
func TestSkillCommandInvokesAndSubmits(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{
|
||||
Name: "deploy-checks",
|
||||
Description: "Run the pre-deploy verification suite.",
|
||||
Content: "Run every verification step and summarize failures.",
|
||||
Path: "/skills/deploy-checks/SKILL.md",
|
||||
})
|
||||
|
||||
next := submitInput(t, m, "/deploy-checks ship v2 to production")
|
||||
|
||||
if transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("an installed skill must not be 'unknown', got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Run every verification step and summarize failures.") {
|
||||
t.Fatalf("skill body should be inlined into the prompt, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "ship v2 to production") {
|
||||
t.Fatalf("typed args should follow the skill body, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCommandWithoutArgs(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
|
||||
|
||||
next := submitInput(t, m, "/reviewer")
|
||||
|
||||
if !transcriptContains(next.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("bare skill invocation should submit the body, got %#v", next.transcript)
|
||||
}
|
||||
// A bare invocation carries the ask-first note: instructions with no target
|
||||
// must make the model ask ("which PR?") instead of improvising one.
|
||||
if !transcriptContains(next.transcript, "ask for them first") {
|
||||
t.Fatalf("bare invocation should carry the clarify-first note, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// An invocation WITH a request must not carry the ask-first note — the user
|
||||
// already said what to apply the skill to.
|
||||
func TestSkillCommandWithArgsHasNoClarifyNote(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
|
||||
|
||||
next := submitInput(t, m, "/reviewer PR 484 reconnect changes")
|
||||
|
||||
if transcriptContains(next.transcript, "ask for them first") {
|
||||
t.Fatalf("args invocation must not carry the clarify note, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// A skill's frontmatter name is free-form; invocation matches its lowercased
|
||||
// slash form.
|
||||
func TestSkillCommandNameIsCaseInsensitive(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "Deploy-Checks", Content: "Check things."})
|
||||
|
||||
next := submitInput(t, m, "/deploy-checks")
|
||||
|
||||
if !transcriptContains(next.transcript, "Check things.") {
|
||||
t.Fatalf("uppercase frontmatter name should be invocable lowercased, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: a user command (.zero/commands) shadows a same-named skill.
|
||||
func TestUserCommandShadowsSkill(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "greet.md", "Say hello from the user command.")
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
LoadSkills: func() []skills.Skill { return []skills.Skill{{Name: "greet", Content: "Skill body must not run."}} },
|
||||
})
|
||||
|
||||
next := submitInput(t, m, "/greet")
|
||||
|
||||
if !transcriptContains(next.transcript, "Say hello from the user command.") {
|
||||
t.Fatalf("user command should win the name collision, got %#v", next.transcript)
|
||||
}
|
||||
if transcriptContains(next.transcript, "Skill body must not run.") {
|
||||
t.Fatalf("shadowed skill must not run, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty skill body is a real, named match — surface the problem instead of
|
||||
// falling through to a misleading "unknown command".
|
||||
func TestSkillCommandEmptyBody(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "hollow", Path: "/skills/hollow/SKILL.md"})
|
||||
|
||||
next := submitInput(t, m, "/hollow")
|
||||
|
||||
if transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("an empty skill must not be 'unknown', got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "empty SKILL.md body") {
|
||||
t.Fatalf("empty-body error should be surfaced, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSlashStillUnknownWithSkillsInstalled(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
|
||||
next := submitInput(t, m, "/definitelynotaskill")
|
||||
|
||||
if !transcriptContains(next.transcript, "unknown command") {
|
||||
t.Fatalf("non-matching name should still be unknown, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSlashName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"deploy-checks": "deploy-checks",
|
||||
"Deploy-Checks": "deploy-checks",
|
||||
" pdf_tools ": "pdf_tools",
|
||||
"v2.migrate": "v2.migrate",
|
||||
"has space": "",
|
||||
"emoji✨": "",
|
||||
"": "",
|
||||
"slash/inside": "",
|
||||
"tab\tseparated": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := skillSlashName(in); got != want {
|
||||
t.Errorf("skillSlashName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAppearsInAutocomplete(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"})
|
||||
|
||||
got := m.matchCommandSuggestions("/dep")
|
||||
found := false
|
||||
for _, s := range got {
|
||||
if s.Name == "/deploy-checks" {
|
||||
found = true
|
||||
if s.Desc != "Run the pre-deploy suite. (skill)" {
|
||||
t.Fatalf("skill suggestion should be labeled, got %q", s.Desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("skill /deploy-checks should appear for '/dep', got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A skill shadowed by a builtin (or its alias) or a user command would be dead
|
||||
// at dispatch time, so the palette must not advertise it.
|
||||
func TestShadowedSkillNotSuggested(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "deploy.md", "User deploy.")
|
||||
installed := []skills.Skill{
|
||||
{Name: "help", Content: "x"}, // builtin collision
|
||||
{Name: "sessions", Content: "x"}, // builtin ALIAS collision (/resume alias)
|
||||
{Name: "deploy", Content: "x"}, // user-command collision
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(installed))
|
||||
for _, s := range installed {
|
||||
infos = append(infos, agent.SkillInfo{Name: s.Name})
|
||||
}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: root,
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
AgentOptions: agent.Options{Skills: infos},
|
||||
})
|
||||
|
||||
// Assert on the advertised NAME with a skill-marked Desc, not on the exact
|
||||
// fallback Desc format — the invariant is that no skill row carries a
|
||||
// builtin/user-command name, regardless of description wording.
|
||||
for _, token := range []string{"/hel", "/ses", "/dep"} {
|
||||
for _, s := range m.matchSkillSuggestions(token) {
|
||||
if s.Name == "/help" || s.Name == "/sessions" || s.Name == "/deploy" {
|
||||
t.Fatalf("shadowed skill advertised for %q: %#v", token, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid slash shapes (spaces etc.) and a bare "/" must not surface skills.
|
||||
func TestSkillSuggestionEdgeCases(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "has space", Description: "not invocable", Content: "x"})
|
||||
|
||||
if got := m.matchSkillSuggestions("/has"); len(got) != 0 {
|
||||
t.Fatalf("invalid slash-shape skill must not be suggested, got %#v", got)
|
||||
}
|
||||
if got := m.matchSkillSuggestions("/"); got != nil {
|
||||
t.Fatalf("bare '/' should not list skills, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandResolves(t *testing.T) {
|
||||
command, ok := resolveCommand("/skills")
|
||||
if !ok || command.kind != commandSkills {
|
||||
t.Fatalf("resolveCommand(/skills) = %#v, %v", command, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// With skills installed, /skills opens a searchable picker (like /model).
|
||||
func TestSkillsCommandOpensPicker(t *testing.T) {
|
||||
m := newSkillTestModel(t,
|
||||
skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"},
|
||||
skills.Skill{Name: "has space", Description: "tool-only skill", Content: "x"},
|
||||
)
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
|
||||
if next.picker == nil || next.picker.kind != pickerSkill {
|
||||
t.Fatalf("/skills should open the skill picker, got %#v", next.picker)
|
||||
}
|
||||
labels := map[string]string{}
|
||||
for _, item := range next.picker.items {
|
||||
labels[item.Label] = item.Value
|
||||
}
|
||||
if labels["/deploy-checks"] != "deploy-checks" {
|
||||
t.Fatalf("slash-invocable skill should show its slash form, got %#v", labels)
|
||||
}
|
||||
// A non-slash-shaped name is still selectable (bare label, exact-name value).
|
||||
if labels["has space"] != "has space" {
|
||||
t.Fatalf("non-slash skill should be listed bare but selectable, got %#v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter on a picker row fills the composer with "/name " so the user can add
|
||||
// their request (which PR? which file?) before submitting — it must NOT fire
|
||||
// the skill with no request attached.
|
||||
func TestSkillPickerEnterFillsComposer(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should be open")
|
||||
}
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
after := updated.(model)
|
||||
|
||||
if after.picker != nil {
|
||||
t.Fatal("picker should close on Enter")
|
||||
}
|
||||
if got := after.input.Value(); got != "/reviewer " {
|
||||
t.Fatalf("composer should be filled with the invocation, got %q", got)
|
||||
}
|
||||
if transcriptContains(after.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("skill must not run before the user submits, got %#v", after.transcript)
|
||||
}
|
||||
|
||||
// A second Enter submits the (bare) invocation and runs the skill.
|
||||
final, _ := after.Update(testKey(tea.KeyEnter))
|
||||
done := final.(model)
|
||||
if !transcriptContains(done.transcript, "Review the diff for correctness.") {
|
||||
t.Fatalf("submitting the filled composer should run the skill, got %#v", done.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Picker selection is by exact name, so a skill shadowed by a builtin (dead on
|
||||
// the slash path) is still runnable from the picker.
|
||||
func TestSkillPickerRunsBuiltinShadowedSkill(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "help", Description: "shadowed", Content: "Shadowed skill body."})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
if next.picker == nil {
|
||||
t.Fatal("picker should be open")
|
||||
}
|
||||
updated, _ := next.Update(testKey(tea.KeyEnter))
|
||||
after := updated.(model)
|
||||
|
||||
if !transcriptContains(after.transcript, "Shadowed skill body.") {
|
||||
t.Fatalf("picker must run the shadowed skill directly, got %#v", after.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandEmptyState(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
|
||||
|
||||
next := submitInput(t, m, "/skills")
|
||||
|
||||
if !transcriptContains(next.transcript, "No skills installed.") {
|
||||
t.Fatalf("empty state should say no skills, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// Run-state guards: an expanded skill prompt must not race an active run — it
|
||||
// queues (as the EXPANDED prompt, since the queue flush submits literal text).
|
||||
func TestSkillInvocationQueuedWhileRunPending(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
|
||||
m.pending = true
|
||||
|
||||
next, _, handled := m.handleSkillCommand("/reviewer focus on tests")
|
||||
if !handled {
|
||||
t.Fatal("skill must be handled even while a run is pending")
|
||||
}
|
||||
if next.queuedMessage == "" || !strings.Contains(next.queuedMessage, "Review the diff.") {
|
||||
t.Fatalf("expanded prompt should be queued, got %q", next.queuedMessage)
|
||||
}
|
||||
if !strings.Contains(next.queuedMessage, "focus on tests") {
|
||||
t.Fatalf("queued prompt should carry the args, got %q", next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInvocationDroppedWhileExiting(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
m.exiting = true
|
||||
|
||||
next, cmd, handled := m.handleSkillCommand("/reviewer")
|
||||
if !handled || cmd != nil || next.queuedMessage != "" {
|
||||
t.Fatalf("exiting must swallow the invocation: handled=%v cmd=%v queued=%q", handled, cmd, next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillInvocationWarnsDuringCompaction(t *testing.T) {
|
||||
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
|
||||
m.compactInFlight = true
|
||||
|
||||
next, _, handled := m.handleSkillCommand("/reviewer")
|
||||
if !handled {
|
||||
t.Fatal("skill must be handled during compaction")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Compaction is running") {
|
||||
t.Fatalf("compaction warning expected, got %#v", next.transcript)
|
||||
}
|
||||
// The typed invocation is restored for an easy re-submit (the dispatch path
|
||||
// cleared the composer before the guard could fire).
|
||||
if got := next.input.Value(); got != "/reviewer" {
|
||||
t.Fatalf("typed invocation should be restored into the composer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guard now protects user commands (previously they could start a
|
||||
// second concurrent run).
|
||||
func TestUserCommandQueuedWhileRunPending(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeUserCommand(t, root, "greet.md", "Say hello to $1.")
|
||||
m := newModel(context.Background(), Options{Cwd: root})
|
||||
m.pending = true
|
||||
|
||||
next, _, handled := m.handleUserCommand("/greet world")
|
||||
if !handled {
|
||||
t.Fatal("user command must be handled while pending")
|
||||
}
|
||||
if !strings.Contains(next.queuedMessage, "Say hello to world.") {
|
||||
t.Fatalf("expanded user-command prompt should be queued, got %q", next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Two skills whose names collide after lowercasing map to one slash name; only
|
||||
// the one dispatch will run may be advertised.
|
||||
func TestCaseCollidingSkillsAdvertisedOnce(t *testing.T) {
|
||||
m := newSkillTestModel(t,
|
||||
skills.Skill{Name: "Deploy", Description: "wins", Content: "a"},
|
||||
skills.Skill{Name: "deploy", Description: "loses", Content: "b"},
|
||||
)
|
||||
|
||||
rows := 0
|
||||
for _, s := range m.matchSkillSuggestions("/dep") {
|
||||
if s.Name == "/deploy" {
|
||||
rows++
|
||||
if !strings.Contains(s.Desc, "wins") {
|
||||
t.Fatalf("advertised row must describe the skill dispatch runs, got %q", s.Desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rows != 1 {
|
||||
t.Fatalf("case-colliding skills must yield exactly one /deploy row, got %d", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The palette reads through the loader, so a skill installed mid-session (the
|
||||
// loader's next result) appears without a restart.
|
||||
func TestSkillPaletteReadsThroughLoader(t *testing.T) {
|
||||
installed := []skills.Skill{}
|
||||
m := newModel(context.Background(), Options{
|
||||
Cwd: t.TempDir(),
|
||||
LoadSkills: func() []skills.Skill { return installed },
|
||||
})
|
||||
if got := m.matchSkillSuggestions("/lat"); len(got) != 0 {
|
||||
t.Fatalf("no skills installed yet, got %#v", got)
|
||||
}
|
||||
installed = append(installed, skills.Skill{Name: "late-arrival", Description: "installed mid-session", Content: "x"})
|
||||
got := m.matchSkillSuggestions("/lat")
|
||||
if len(got) != 1 || got[0].Name != "/late-arrival" {
|
||||
t.Fatalf("mid-session skill should appear via the loader, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return m, nil, false
|
||||
}
|
||||
next, teaCmd := m.launchPrompt(prompt)
|
||||
return next, teaCmd, true
|
||||
// Same run-state guards as a plain prompt: a user command invoked mid-run is
|
||||
// queued (as its expanded prompt), not raced into a second concurrent turn.
|
||||
return m.launchOrDeferExpandedPrompt(raw, prompt)
|
||||
}
|
||||
|
||||
// lookupUserCommand returns the loaded user command with the given (lowercased)
|
||||
|
||||
@@ -218,6 +218,13 @@ func (m model) statusLine(width int) string {
|
||||
} else if summary := m.backgroundTerminalSummary(); summary != "" {
|
||||
left += separator + zeroTheme.muted.Render(summary)
|
||||
}
|
||||
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
|
||||
// running loop is always visible (hidden during an exit/cancel confirm above).
|
||||
if !m.exitConfirmActive && !m.cancelConfirmActive {
|
||||
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
|
||||
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
|
||||
}
|
||||
}
|
||||
|
||||
rightGroups := []string{}
|
||||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const SUMMARY_LIMIT = 280;
|
||||
const STRUCTURED_FORMATS = new Set(['json', 'stream-json']);
|
||||
|
||||
function normalizeSummary(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, SUMMARY_LIMIT);
|
||||
}
|
||||
|
||||
function truncateSummary(value) {
|
||||
return String(value ?? '').slice(0, SUMMARY_LIMIT);
|
||||
}
|
||||
|
||||
function lastNonEmptyLine(contents) {
|
||||
let last = '';
|
||||
for (const line of String(contents ?? '').split(/\r?\n/)) {
|
||||
if (line.trim()) {
|
||||
last = line;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function summarizeStructured(contents) {
|
||||
let summary;
|
||||
|
||||
for (const line of String(contents ?? '').split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event?.type === 'final' && typeof event.text === 'string') {
|
||||
summary = event.text;
|
||||
} else if (event?.type === 'error' && typeof event.message === 'string') {
|
||||
summary = event.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (summary !== undefined) {
|
||||
return normalizeSummary(summary);
|
||||
}
|
||||
return normalizeSummary(lastNonEmptyLine(contents));
|
||||
}
|
||||
|
||||
export function summarizeOutput(format, contents) {
|
||||
const normalizedFormat = String(format ?? 'text').toLowerCase();
|
||||
if (!STRUCTURED_FORMATS.has(normalizedFormat)) {
|
||||
return truncateSummary(lastNonEmptyLine(contents));
|
||||
}
|
||||
return summarizeStructured(contents);
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
return (
|
||||
process.argv[1] !== undefined &&
|
||||
pathToFileURL(resolve(process.argv[1])).href === import.meta.url
|
||||
);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const [format, outputFile] = process.argv.slice(2);
|
||||
let contents = '';
|
||||
try {
|
||||
contents = readFileSync(outputFile, 'utf8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
process.stdout.write(`${summarizeOutput(format, contents)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { summarizeOutput } from './action-summary.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(new URL('./action-summary.mjs', import.meta.url));
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
function jsonl(events) {
|
||||
return events.map((event) => JSON.stringify(event)).join('\n');
|
||||
}
|
||||
|
||||
test('stream-json final followed by run_end returns final text', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'final', text: 'Done. Tests passed.' },
|
||||
{ type: 'run_end', status: 'success', exitCode: 0 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('stream-json', output), 'Done. Tests passed.');
|
||||
});
|
||||
|
||||
test('stream-json error followed by run_end returns error message', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'error', code: 'provider_error', message: 'provider request failed' },
|
||||
{ type: 'run_end', status: 'error', exitCode: 3 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('stream-json', output), 'provider request failed');
|
||||
});
|
||||
|
||||
test('stream-json final then error returns error message', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'final', text: 'partial answer' },
|
||||
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
|
||||
{ type: 'run_end', status: 'incomplete', exitCode: 4 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('stream-json', output), 'run stopped with work unfinished');
|
||||
});
|
||||
|
||||
test('json final followed by done returns final text', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'final', text: 'Review complete.' },
|
||||
{ type: 'done', exit_code: 0 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('json', output), 'Review complete.');
|
||||
});
|
||||
|
||||
test('json error followed by done returns error message', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'error', code: 'exec_failed', message: 'command failed' },
|
||||
{ type: 'done', exit_code: 1 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('json', output), 'command failed');
|
||||
});
|
||||
|
||||
test('json final then error returns error message', () => {
|
||||
const output = jsonl([
|
||||
{ type: 'final', text: 'partial answer' },
|
||||
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
|
||||
{ type: 'done', exit_code: 4 },
|
||||
]);
|
||||
|
||||
assert.equal(summarizeOutput('json', output), 'run stopped with work unfinished');
|
||||
});
|
||||
|
||||
test('text output returns last non-empty line', () => {
|
||||
const output = '\nfirst line\n\n final streamed line \n';
|
||||
|
||||
assert.equal(summarizeOutput('text', output), ' final streamed line ');
|
||||
});
|
||||
|
||||
test('unknown format falls back to text behavior', () => {
|
||||
const output = 'alpha\n beta \n';
|
||||
|
||||
assert.equal(summarizeOutput('xml', output), ' beta ');
|
||||
});
|
||||
|
||||
test('malformed structured output falls back safely', () => {
|
||||
const output = '{not-json}\nraw fallback\n';
|
||||
|
||||
assert.equal(summarizeOutput('stream-json', output), 'raw fallback');
|
||||
});
|
||||
|
||||
test('long multiline final becomes one-line 280-char summary', () => {
|
||||
const text = `first line\n${'x'.repeat(400)}`;
|
||||
const summary = summarizeOutput('stream-json', jsonl([{ type: 'final', text }]));
|
||||
|
||||
assert.equal(summary.length, 280);
|
||||
assert.equal(summary.includes('\n'), false);
|
||||
assert.equal(summary, `first line ${'x'.repeat(400)}`.slice(0, 280));
|
||||
});
|
||||
|
||||
test('cli reads output file and prints summary', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
|
||||
try {
|
||||
const outputFile = join(dir, 'zero-output.jsonl');
|
||||
writeFileSync(
|
||||
outputFile,
|
||||
jsonl([
|
||||
{ type: 'final', text: 'CLI summary' },
|
||||
{ type: 'run_end', status: 'success', exitCode: 0 },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, 'stream-json', outputFile], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout, 'CLI summary\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('cli runs when invoked with relative script path', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
|
||||
try {
|
||||
const outputFile = join(dir, 'zero-output.jsonl');
|
||||
writeFileSync(
|
||||
outputFile,
|
||||
jsonl([
|
||||
{ type: 'final', text: 'relative CLI summary' },
|
||||
{ type: 'run_end', status: 'success', exitCode: 0 },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, ['scripts/action-summary.mjs', 'stream-json', outputFile], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout, 'relative CLI summary\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user