Compare commits

..
146 changed files with 2403 additions and 13481 deletions

No files matched your search

-8
View File
@@ -91,14 +91,6 @@ These classes drive multi-round reviews. Fix them before requesting review:
final-component-only no-follow is insufficient. On multi-step setup, roll back
only what this run created; never destroy pre-existing resources you did
not create; never report success when cleanup or unlock failed.
- **Normalize before matching:** Any transform that REMOVES bytes without
leaving a gap is also a reassembler, so it has to run before whatever matches
on the result. Redacting by shape and then stripping control bytes lets a
credential split by a NUL, an ESC or a C1 byte pass the patterns as two
fragments and be rejoined on the way out; comparing a path against a form
produced by the same resolver the kernel just used makes a redirect agree with
itself. Strip, decode, or canonicalize first, then match. The unsplit value
passing is not evidence, so the test needs a split case.
- **Atomic shared state:** Write a complete temporary file, then atomically
replace the destination so concurrent readers never see a partial write.
Exclusive create or a write lock alone is not enough for readers. Serialize
+3 -7
View File
@@ -3087,15 +3087,13 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3210,9 +3208,7 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
-18
View File
@@ -67,21 +67,3 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
+6 -7
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, apply_patch - over shelling out to
write_file, edit_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,11 +61,10 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -105,7 +104,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/apply_patch for file changes.
with exec_command, or use write_file/edit_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+2 -2
View File
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, apply_patch) over shelling out to cat/sed/awk/python for
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"for edits to existing files, prefer apply_patch",
"prefer edit_file or apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+4
View File
@@ -29,6 +29,10 @@ func ParseTraceEventKeys(stdout string) []string {
return keys
}
func TraceEventKeys(stdout string) []string {
return ParseTraceEventKeys(stdout)
}
func MissingTraceEvents(required []string, stdout string) []string {
seen := map[string]bool{}
for _, key := range ParseTraceEventKeys(stdout) {
+1 -32
View File
@@ -453,12 +453,7 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
return Task{}, false, err
}
if outputFile != task.OutputFile {
changed = true
@@ -567,32 +562,6 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
-83
View File
@@ -1,7 +1,6 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -192,88 +191,6 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
-18
View File
@@ -39,21 +39,3 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
// terminateOwnedProcess terminates cmd's process group directly when its launch
// configuration proves it was made a group leader. This avoids fragile Getpgid
// rediscovery after an owned leader exits. Ordinary commands fall back to the
// safe PID/tree path rather than assuming their PID is also a process-group ID.
//
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
// own convention. A command made its own session leader via Setsid (as opposed
// to Setpgid) is also its own process-group leader in practice, but takes the
// slower rediscovery path here since Setsid isn't checked. Harmless today
// because TerminateCommand has exactly one caller in this codebase; worth
// covering explicitly if Setsid-configured commands start using this path too.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
}
return false, terminateProcess(cmd.Process.Pid)
}
+4 -75
View File
@@ -147,12 +147,11 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
t.Fatalf("terminateProcess: %v", err)
}
// The forked child must no longer be running. An orphaned zombie is already
// dead but may remain visible briefly until the platform's init reaps it.
// The forked child must be gone too (poll until reaped by init).
deadline := time.Now().Add(2 * time.Second)
// processStopped treats only ESRCH or an observed zombie state as stopped;
// other errors (for example EPERM) remain live and keep polling.
for !processStopped(childPID) {
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
@@ -160,73 +159,3 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
}
})
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
if cmd.ProcessState == nil {
t.Fatal("unconfigured command was not reaped")
}
}
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
// The leader exits immediately after launching the child. TerminateCommand
// must capture and signal the process group before Wait reaps the leader and
// makes its group identity unavailable.
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
ConfigureChildProcessGroup(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read forked child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse forked child pid %q: %v", line, err)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for !processStopped(childPID) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived TerminateCommand", childPID)
}
time.Sleep(20 * time.Millisecond)
}
}
func processStopped(pid int) bool {
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
return true
}
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
}
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
}
-34
View File
@@ -3,20 +3,11 @@
package background
import (
"fmt"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/windows"
)
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
// constant.
const stillActiveExitCode uint32 = 259
var terminateProcessForTest = terminateProcess
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
@@ -25,28 +16,3 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, 0, 0)
}
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
// can discover descendants only while the root PID still exists; unlike a POSIX
// process group, a Windows tree has no independently addressable identity after
// its root exits.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
var (
alreadyExited bool
terminateErr error
)
err := cmd.Process.WithHandle(func(handle uintptr) {
var exitCode uint32
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
alreadyExited = exitCode != stillActiveExitCode
}
// Keep the exact process identity pinned while the PID-based taskkill
// operation runs, preventing the PID from being recycled underneath it.
terminateErr = terminateProcessForTest(cmd.Process.Pid)
})
if err != nil {
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
}
return alreadyExited, terminateErr
}
-106
View File
@@ -1,106 +0,0 @@
//go:build windows
package background
import (
"bufio"
"errors"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
treeErr := errors.New("taskkill /T failed")
originalTerminateProcess := terminateProcessForTest
terminateProcessForTest = func(pid int) error {
if err := cmd.Process.Kill(); err != nil {
return errors.Join(treeErr, err)
}
return treeErr
}
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
err := TerminateCommand(cmd)
if !errors.Is(err, treeErr) {
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
}
if cmd.ProcessState == nil {
t.Fatal("root process was not reaped")
}
}
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Let the child exit without calling Wait so cleanup exercises the Windows
// taskkill/TerminateProcess race while it still owns the process handle.
time.Sleep(500 * time.Millisecond)
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
// Process.Kill fallback) both fail here, but the reap below still succeeds —
// the process-handle exit-code check proves the leader was already gone, so
// TerminateCommand must not surface the expected kill-attempt error.
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
}
if cmd.ProcessState == nil {
t.Fatal("exited process was not reaped")
}
}
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read descendant pid: %v", err)
}
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse descendant pid %q: %v", line, err)
}
t.Cleanup(func() {
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
})
// Wait until the leader is observably exited but deliberately leave it
// unreaped, matching the POSIX regression's ordering.
deadline := time.Now().Add(5 * time.Second)
for processExistsOnWindows(cmd.Process.Pid) {
if time.Now().After(deadline) {
t.Fatal("leader did not exit")
}
time.Sleep(20 * time.Millisecond)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
// Windows has no persistent group ID analogous to POSIX: once the leader is
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
// distinction rather than claiming TerminateCommand can provide it here.
if !processExistsOnWindows(descendantPID) {
t.Fatal("descendant unexpectedly stopped after its root exited")
}
}
func processExistsOnWindows(pid int) bool {
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
}
-75
View File
@@ -1,14 +1,5 @@
package background
import (
"errors"
"fmt"
"os/exec"
"time"
)
const commandReapTimeout = 3 * time.Second
// TerminateProcess stops a background process by PID — on Windows its process
// tree; on POSIX its whole process group when the PID leads its own group (the
// invariant ConfigureChildProcessGroup establishes for processes started through
@@ -21,69 +12,3 @@ const commandReapTimeout = 3 * time.Second
func TerminateProcess(pid int) error {
return terminateProcess(pid)
}
// TerminateCommand stops a started command and reaps its leader. The caller must
// have exclusive ownership of cmd: it must not have previously called Wait or
// Process.Release, and no goroutine may call either concurrently. On POSIX it
// stops the whole group when the command was configured as its leader; ordinary
// commands safely fall back to PID/tree discovery. On Windows, success for a
// leader that was already dead confirms only that the leader was reaped;
// descendants may survive because Windows cannot rediscover a tree from a dead
// root. `zero daemon start` needs this operation when readiness times out: it
// launched the child, so it must both stop the tree and collect the leader.
//
// The order matters: the tree is signalled first, because Wait releases the
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
// to a recycled PID). Termination stays in the shared execution lifecycle
// primitives rather than introducing a second platform-specific killer.
//
// If the bounded reap times out, the Wait call already started by this function
// continues in its goroutine. From that point onward the caller must not access
// cmd at all, including ProcessState, because that would race with Wait.
func TerminateCommand(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return errors.New("terminate command: process was never started")
}
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
if reapErr != nil {
if terminateErr != nil {
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
}
return reapErr
}
if terminateErr != nil && !leaderAlreadyExited {
return terminateErr
}
// Only discard a termination error when the platform demonstrated before
// attempting termination that the leader had already exited. A successful
// reap alone says nothing about whether a live descendant tree was stopped.
return nil
}
// classifyWaitError treats a non-zero exit status as success: a process being
// terminated is expected to report one (or a signal), so the only interesting
// failure is Wait itself not working.
func classifyWaitError(waitErr error) error {
var exitErr *exec.ExitError
if waitErr != nil && !errors.As(waitErr, &exitErr) {
return fmt.Errorf("reap process: %w", waitErr)
}
return nil
}
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
// termination cannot hang the caller — the daemon-start cleanup path runs while a
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
// would leak the child once it eventually exits.
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
select {
case waitErr := <-waitDone:
return classifyWaitError(waitErr)
case <-time.After(timeout):
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
}
}
+37 -132
View File
@@ -64,53 +64,38 @@ type appDeps struct {
// getenv reads a process environment variable (production: os.Getenv, set in
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
currentGitUser func(context.Context, string) string
headCommitSubject func(context.Context, string) string
commitsAhead func(context.Context, string, string, string) (int, error)
isUnbornRemote func(context.Context, string, string) (bool, error)
refreshTrackingRef func(context.Context, string, string, string) error
branchUpstreamRemote func(context.Context, string, string) string
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
remoteHasBranch func(context.Context, string, string, string) (bool, error)
currentGitBranch func(context.Context, string) string
currentBranchTip func(context.Context, string) string
deleteBranch func(context.Context, string, string, string) error
resetBranchRef func(context.Context, string, string, string, string) error
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -213,52 +198,11 @@ func defaultAppDeps() appDeps {
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
createBranch: zerogit.CreateBranch,
isDefaultBranch: zerogit.IsDefaultBranch,
currentGitUser: func(ctx context.Context, cwd string) string {
return zerogit.CurrentGitUser(ctx, cwd, nil)
},
headCommitSubject: func(ctx context.Context, cwd string) string {
return zerogit.HeadCommitSubject(ctx, cwd, nil)
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
},
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
},
currentGitBranch: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranch(ctx, cwd, nil)
},
currentBranchTip: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranchTip(ctx, cwd, nil)
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
},
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -618,45 +562,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.createBranch == nil {
deps.createBranch = defaults.createBranch
}
if deps.isDefaultBranch == nil {
deps.isDefaultBranch = defaults.isDefaultBranch
}
if deps.currentGitUser == nil {
deps.currentGitUser = defaults.currentGitUser
}
if deps.headCommitSubject == nil {
deps.headCommitSubject = defaults.headCommitSubject
}
if deps.commitsAhead == nil {
deps.commitsAhead = defaults.commitsAhead
}
if deps.isUnbornRemote == nil {
deps.isUnbornRemote = defaults.isUnbornRemote
}
if deps.refreshTrackingRef == nil {
deps.refreshTrackingRef = defaults.refreshTrackingRef
}
if deps.branchUpstreamRemote == nil {
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
}
if deps.branchUpstreamRemoteAndMerge == nil {
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
}
if deps.remoteHasBranch == nil {
deps.remoteHasBranch = defaults.remoteHasBranch
}
if deps.currentGitBranch == nil {
deps.currentGitBranch = defaults.currentGitBranch
}
if deps.currentBranchTip == nil {
deps.currentBranchTip = defaults.currentBranchTip
}
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
// unset so unit tests that mock createBranch without a real git tree do not
// hit real git restore/delete commands.
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
+9 -51
View File
@@ -1,7 +1,6 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
@@ -161,61 +160,20 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
cmd.Stdout = logFile
cmd.Stderr = logFile
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
if errors.Is(err, errDaemonStartTimeout) {
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
}
if err := cmd.Start(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
_ = cmd.Process.Release()
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
if err := cmd.Start(); err != nil {
return err
}
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
if err := cmd.Process.Release(); err != nil {
cleanupErr := terminateAndReapDaemonProcess(cmd)
if cleanupErr != nil {
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
}
return fmt.Errorf("release daemon process: %w", err)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if daemonReachable(paths) {
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
return nil
time.Sleep(25 * time.Millisecond)
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
}
return errDaemonStartTimeout
}
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if reachable() {
return true
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
time.Sleep(min(pollInterval, remaining))
}
// The loop's last reachable() call already ran immediately before this one
// (right after the final Sleep, with the same deadline check separating
// them), so this adds no extra waiting time and is not a grace window for a
// slow-starting daemon — it only re-checks the same instant in case
// reachable() itself is non-deterministic. To tolerate a genuinely slow
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
return reachable()
}
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
return background.TerminateCommand(cmd)
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
}
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
-88
View File
@@ -2,15 +2,8 @@ package cli
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/background"
)
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
@@ -128,84 +121,3 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
}
}
}
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
checks := 0
ready := waitForDaemonReadiness(func() bool {
checks++
return checks == 2
}, 0, time.Millisecond)
if !ready {
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
}
if checks != 2 {
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
}
}
func TestTerminateAndReapDaemonProcess(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := cmd.Start(); err != nil {
t.Fatalf("start helper process: %v", err)
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
t.Fatalf("terminate and reap helper process: %v", err)
}
if cmd.ProcessState == nil {
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
}
if cmd.ProcessState.Success() {
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
}
}
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
t.Fatalf("start and await error = %v, want timeout", err)
}
if cmd.ProcessState == nil {
t.Fatal("timed-out helper process was not reaped")
}
}
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
marker := filepath.Join(t.TempDir(), "child-finished")
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
t.Fatalf("start and await ready helper: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("released helper process did not remain alive to write its marker")
}
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
cmd.Env = append(cmd.Env, extraEnv...)
background.ConfigureChildProcessGroup(cmd)
return cmd
}
func TestDaemonDetachedChildProcess(t *testing.T) {
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
case "":
return
case "mark":
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
os.Exit(2)
}
os.Exit(0)
}
for {
time.Sleep(time.Hour)
}
}
File diff suppressed because it is too large. Load diff
+8 -538
View File
@@ -2,12 +2,9 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -605,20 +602,18 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && options.hasMessage {
if command != "commit" && options.message != "" {
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command != "commit" && command != "push" && options.dryRun {
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
}
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
// ensureFeatureBranch); on commit it opts into the LLM commit message.
if command != "commit" && command != "push" && command != "pr" && options.auto {
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
}
if command != "inspect" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
@@ -939,7 +934,8 @@ Flags:
--fill Automatically populate PR title and body from commits
--draft Create PR as a draft
--yes Confirm pushing to a default/protected branch
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
-h, --help Show this help
`)
@@ -962,31 +958,11 @@ func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appD
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: options.dryRun,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// context.Background() is deliberate: the 2-minute ctx above is the
// feature-branch preflight (inspection, remote probes, branch creation).
// The push itself must not be cancelled mid-transfer just because the
// preflight budget elapsed; a large push can legitimately take longer.
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Remote: options.remote,
Force: options.force,
DryRun: options.dryRun,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -1034,62 +1010,15 @@ func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: false,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
// fall back to the branch upstream or "origin".
targetRemote := strings.TrimSpace(options.remote)
if targetRemote == "" {
targetRemote = remote
}
if targetRemote == "" {
currentBranch := ""
if deps.currentGitBranch != nil {
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
}
if deps.branchUpstreamRemote != nil && currentBranch != "" {
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
}
if targetRemote == "" {
targetRemote = "origin"
}
}
// Probe errors are intentionally ignored so the flow can continue; if the
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
if deps.isUnbornRemote != nil {
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
}
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
return exitCrash
}
}
// context.Background() is deliberate here too: runChangesPR reuses the
// 2-minute ctx for preflight (branch creation and remote probes), but the
// push must not be cancelled mid-transfer if that budget elapsed.
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Remote: options.remote,
Force: options.force,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -1171,462 +1100,3 @@ func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provide
}
return msg, nil
}
type featureBranchOptions struct {
JSONMode bool
AllowDefaultBranch bool
DryRun bool
AutoNaming bool
MaxDiffBytes int
}
// branchPushPlan holds the resolved parameters for a push or PR workflow
// before any branches are created or refs mutated. Separating plan resolution
// from mutation allows tests to assert the resolution matrix directly without
// touching a git repository.
type branchPushPlan struct {
ShouldBranch bool
CurrentBranch string
PushRemote string
RequireNewRemoteBranch bool
TrackingRemote string
TrackingBranch string
RestoreTip string
PublishabilityBase string
Summary zerogit.ChangeSummary
Slug string
}
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
// slug for changes push / pr workflows. It performs all checks (clean working
// tree, unborn remote detection, ahead count, restore tip resolution) but
// executes no mutation (no branch creation, ref resets, or deletions).
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
if opts.AllowDefaultBranch || opts.DryRun {
return branchPushPlan{
ShouldBranch: false,
PushRemote: strings.TrimSpace(requestedRemote),
RequireNewRemoteBranch: false,
}, nil
}
if deps.isDefaultBranch == nil {
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
}
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
if err != nil {
return branchPushPlan{}, err
}
if !isDefault {
// Whether this push needs the nonexistence lease is decided by one
// live check against the destination remote, not by inference from
// local git state (upstream config, a generated-branch marker):
// every local signal that stood in for that check turned out to be
// wrong in some ordering (inherited config from checkout -b, a
// config write that failed after a successful push, a push aimed at
// a different remote than the one local config describes). A branch
// missing on the remote gets the nonexistence lease, protecting a
// concurrent creator of the same name; a branch already there gets a
// plain push, and git's own fast-forward check is the safety net —
// if the remote history isn't ours, the push fails with a clear
// non-fast-forward error instead of an attempted silent recovery.
if deps.remoteHasBranch == nil {
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: true,
}, nil
}
targetRemote := firstNonEmptyString(requestedRemote, remote)
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
if existsErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
}
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: !exists,
}, nil
}
// CreateBranch and Push publish commits only. A dirty working tree would
// leave uncommitted edits behind under a branch/PR that does not include
// them, so refuse until the tree is clean (commit or stash first).
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
}
if !workingTree.Clean {
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
}
// Capture the source default branch's own upstream before refreshing or
// leaving it. --remote selects the push destination; restore must not
// rewrite local main to upstream/main when main still tracks origin/main
// (fork setups), and refreshing must update the exact tracking ref that
// will supply both the ahead count and the restore target.
trackingRemote := remote
trackingBranch := currentBranch
if deps.branchUpstreamRemoteAndMerge != nil {
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
trackingRemote = r
trackingBranch = m
}
}
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
// already contacted the remote for its symref check, but the tracking ref
// commitsAhead reads from is only a local cache (written at clone or the
// last fetch) and can sit behind the remote's live tip. Left stale, a real
// publishable range could look like zero commits ahead, and the diff
// derived below (which reuses this same ref as its base) would be stale
// too. A failure here (offline, or a genuinely unborn remote with no such
// ref to fetch yet) is folded into the same fail-closed path as an
// unresolvable ahead count below.
var fetchErr error
if deps.refreshTrackingRef != nil {
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
}
// The ahead check gates on the source upstream (trackingRemote), but the
// feature branch is pushed to `pushRemote`, which can be a different
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
// destination: without an initial default branch the new feature branch
// becomes the destination's first ref and its HEAD is left pointing at it.
// This is checked once, here, against the push destination, before resolving
// commit tips or creating any branches.
pushRemote := firstNonEmptyString(requestedRemote, remote)
if deps.isUnbornRemote != nil {
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
}
}
// Branching off the default branch only makes sense when HEAD carries a
// commit that is not already on the remote default branch. A clean,
// up-to-date default branch would otherwise publish a feature branch at the
// exact default tip. If the ahead count cannot be determined (for example
// the remote-tracking ref was never fetched), fail rather than guess. When
// the remote is confirmed unborn, refuse auto-branching entirely: the
// initial default branch must be established first (changes push --yes)
// so the remote has a real HEAD before any feature branch is published.
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
if fetchErr != nil || aheadErr != nil {
var unbornRemote bool
var unbornErr error
if deps.isUnbornRemote != nil {
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
}
if unbornErr == nil && unbornRemote {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
}
cause := aheadErr
if cause == nil {
cause = fetchErr
}
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
}
if ahead == 0 {
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
}
// Resolve the restore tip to a concrete commit before creating any branch.
// It must never be a bare local branch name: resolving "main" yields the
// very commit the feature branch is about to own, so restore becomes a
// silent no-op and the publishable commits stay on the default branch. A
// named remote resolves through its just-refreshed remote-tracking ref; a
// direct URL or path asks the remote itself. Fail closed rather than
// substitute a local branch name.
restoreTip := ""
if deps.resolveRemoteBranchTip != nil {
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
if tipErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
}
restoreTip = tip
}
if restoreTip == "" {
restoreTip = trackingRemote + "/" + trackingBranch
}
// Name the branch (and, with --auto, send the provider) from what HEAD is
// actually ahead of the resolved remote branch by, using the same ref
// commitsAhead just checked. A working-tree snapshot can describe edits a
// commit-only push will never include.
baseRef := restoreTip
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
}
slug := fallbackBranchSlug(summary)
if len(summary.Files) == 0 {
// An empty commit (or one whose only content the diff omits) leaves
// nothing to derive a slug from; name the branch from the commit
// subject instead.
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
slug = zerogit.SlugifyBranchComponent(subject)
}
}
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
llmSlug := ""
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
if !opts.JSONMode {
fmt.Fprintln(stdout, "Generating branch name using LLM...")
}
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
cancel()
if genErr == nil && generated != "" {
llmSlug = generated
}
}
}
if llmSlug != "" {
slug = llmSlug
} else if !opts.JSONMode {
// Cover resolveConfig failures, missing provider profiles,
// newProvider errors, generation errors, and empty results so the
// user is not left guessing after "Generating..." or silence.
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
}
}
return branchPushPlan{
ShouldBranch: true,
CurrentBranch: currentBranch,
PushRemote: pushRemote,
RequireNewRemoteBranch: true,
TrackingRemote: trackingRemote,
TrackingBranch: trackingBranch,
RestoreTip: restoreTip,
PublishabilityBase: baseRef,
Summary: summary,
Slug: slug,
}, nil
}
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
// before pushing: pushing straight to the default branch is refused deeper in
// zerogit.Push, so rather than surface that as a dead end, create and switch
// to a conventionally named "<user>/<slug>" branch first. It returns the
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
// (zerogit.Push already treats an empty Branch that way), plus the remote the
// preflight resolved (requestedRemote, then the original branch's configured
// upstream, then "origin"), plus whether Push should require that branch not
// already exist on that remote. Callers must pass the remote to Push: a
// freshly created branch has no tracking configuration, so Push's own
// fallback would silently retarget "origin" even when the work came from a
// branch tracking a different remote. Callers must also pass that third value
// through as Push's RequireNewRemoteBranch: CreateBranch's own
// remote-collision probe runs before this returns, and closing that race
// requires Push's push itself to assert the destination is still new. That
// requirement also carries across a retry: if the current branch is already
// non-default (this call didn't create it) but has not been successfully
// published to the exact target remote under the same branch name, keep the
// nonexistence lease. branch.<name>.remote alone is not enough: with
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
// source branch before any push. After a successful create, the original
// default branch ref is moved back to its own upstream tip (not necessarily
// the push destination) so the feature branch exclusively owns the
// publishable commits; a failed restore rolls the feature branch back.
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
// branch / false return, leaving Push's own guard/preview behavior on the
// default branch unaffected.
//
// autoNaming gates the LLM naming path (--auto): these commands were
// git-only, and sending the change diff to a configured provider on every
// default-branch push would silently export source code nobody asked to
// share. Without the opt-in the name comes from deterministic local
// information only. maxDiffBytes caps the committed-range diff Inspect
// returns, so a user who passed --diff-bytes to bound the proprietary source
// sent for LLM naming has that cap honored here just as the commit path does.
// The working tree must be clean and HEAD must be ahead of the resolved
// remote branch; both are verified before any branch is created. The
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
// createBranch dependencies are required on the default-branch path and are
// called without nil guards; fillAppDeps populates all of them. The
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
// and deleteBranch dependencies are optional and are skipped when nil.
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
if err != nil {
return "", "", false, err
}
if !plan.ShouldBranch {
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
}
expectedRestoreTip := ""
if deps.currentBranchTip != nil {
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
}
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
if err != nil {
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
}
// The normal flow commits on the default branch first, then creates the
// feature branch at the same HEAD. Without moving the original default
// ref back, local main keeps the pre-squash commits and diverges from
// its upstream after a squash-merge (and a later pull/push can re-publish
// them). Once the feature branch owns those commits, restore the default
// branch to the source branch's own upstream tip captured above.
// Failure-safe: if the restore fails, delete the feature branch and
// check the default branch back out so the tree is not left half-moved.
if deps.resetBranchRef != nil {
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
// A compare-and-swap conflict means the default branch moved
// while we were branching, so restoreTip is no longer the tip we
// meant to restore. The rollback below deletes the branch that
// now exclusively owns the user's commits, which would destroy
// work to recover from a race we can simply report. Keep the
// branch and let the user reconcile the default branch.
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
}
var rollbackErr error
if deps.deleteBranch != nil {
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
}
if rollbackErr != nil {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
}
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
}
}
if !opts.JSONMode {
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
}
return result.Branch, plan.PushRemote, true, nil
}
// fallbackBranchSlug derives a deterministic branch-name slug from a change
// summary without calling an LLM, so ensureFeatureBranch still works when no
// provider is configured.
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
switch len(summary.Files) {
case 0:
return "changes"
case 1:
// Git paths are always slash-separated regardless of platform, so
// path.Base (not filepath.Base) extracts the filename portably.
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
default:
return fmt.Sprintf("update-%d-files", len(summary.Files))
}
}
// generateAutoBranchSlug asks the model for a short kebab-case slug
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
if slug == "" {
return "", fmt.Errorf("provider returned empty branch slug")
}
return slug, nil
}
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
// digits, and internal single hyphens only). extractBranchSlug prefers such a
// line so a preamble sentence is never mistaken for the slug.
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
func isPreambleText(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
return true
}
preambles := []string{
"here is", "here's", "below is", "below are", "sure", "certainly",
"i suggest", "suggested branch", "branch name", "recommended branch",
"how about", "you could use",
}
for _, p := range preambles {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// extractBranchSlug pulls the intended slug out of a model response that didn't
// follow the "output only the raw slug" instruction exactly. It drops Markdown
// code-fence lines, then prefers a line that already looks like a kebab-case
// slug: that skips a leading preamble such as "Here is a suggested branch
// name:" in favor of the "add-login-page" line that follows it. One-word
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
// are filtered as preamble before the early return, so "Sure\nadd login page"
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
// still work: the preamble check runs on the extracted value after the label
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
// non-preamble line so plain multi-word replies still slugify correctly.
func extractBranchSlug(text string) string {
var firstLine string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "```") {
continue
}
line = strings.TrimSpace(strings.Trim(line, `"'`))
if line == "" {
continue
}
candidate := line
if idx := strings.Index(line, ":"); idx != -1 {
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
if after != "" {
candidate = after
}
}
// Models often terminate a suggestion with sentence punctuation;
// strip it so "add login page." is recovered as the slug rather than
// rejected as preamble (isPreambleText treats a trailing '.' as a
// full sentence). True preamble lines still match the prefix list.
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
if candidate == "" {
continue
}
// Preamble classification runs before accepting a slug-shaped line so
// "Sure" does not win over a later "add-login-page". The check is on
// candidate (post label-strip) so "Branch name: add-login-page" still
// returns the extracted slug.
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
return candidate
}
if firstLine == "" && !isPreambleText(candidate) {
firstLine = candidate
}
}
return firstLine
}
+5 -15
View File
@@ -105,17 +105,7 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
const providerCommandTestBudget = 60 * time.Second
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -351,15 +341,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+1 -2
View File
@@ -151,8 +151,7 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
-246
View File
@@ -1,246 +0,0 @@
package credstore
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
func fileStore(t *testing.T, dir string) *Store {
t.Helper()
store, err := New(Options{Dir: dir, Storage: "file"})
if err != nil {
t.Fatalf("New: %v", err)
}
return store
}
// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept
// 1 of 100: every writer read the same map, added its own provider, and the
// last rename published a file missing all the others.
func TestConcurrentSetKeepsEveryKey(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
const n = 100
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("Set: %v", err)
}
got, err := store.Providers()
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(got) != n {
t.Fatalf("kept %d of %d concurrent writes", len(got), n)
}
// ...and every value is the one its own writer wrote, not a neighbour's.
for i := 0; i < n; i++ {
key, ok, err := store.Get(fmt.Sprintf("p%03d", i))
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok || key != fmt.Sprintf("k%03d", i) {
t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok)
}
}
}
// DELETE RACES SET, and an unlocked delete loses the other writer's key.
//
// A first version of this only checked that keys nobody deleted survived — and
// every reader sees those, so it passed with the lock removed from Delete. The
// case that actually breaks is a Delete whose stale read predates a concurrent
// Set: it writes back a map that never contained the new key, and the Set is
// gone. So the assertion is on the SETS surviving, not on the bystanders.
func TestADeleteCannotClobberAConcurrentSet(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
// Throwaway keys for the deleters to remove, written first so a delete
// always has something to do.
const churn = 40
for i := 0; i < churn; i++ {
if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil {
t.Fatal(err)
}
}
const adds = 60
var wg sync.WaitGroup
for i := 0; i < adds; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = store.Set(fmt.Sprintf("added%02d", i), "v")
}(i)
}
for i := 0; i < churn; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _ = store.Delete(fmt.Sprintf("churn%02d", i))
}(i)
}
wg.Wait()
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
survivors := map[string]bool{}
for _, name := range got {
survivors[name] = true
}
missing := []string{}
for i := 0; i < adds; i++ {
name := fmt.Sprintf("added%02d", i)
if !survivors[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v",
len(missing), adds, missing)
}
// ...and every churn key really was removed, so the deletes were not
// no-ops that would make the check above vacuous.
for i := 0; i < churn; i++ {
if survivors[fmt.Sprintf("churn%02d", i)] {
t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i)
}
}
}
// ACROSS PROCESSES, which is the case that matters: a plan running with
// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not
// have helped any of them. The race detector cannot see this one either — it is
// two OS processes — so it is driven for real.
func TestConcurrentSetAcrossProcesses(t *testing.T) {
if testing.Short() {
t.Skip("spawns processes")
}
helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR")
if helper != "" {
// Child mode: write our slice of the keys and exit.
store, err := New(Options{Dir: helper, Storage: "file"})
if err != nil {
os.Exit(3)
}
prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX")
for i := 0; i < 25; i++ {
if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil {
os.Exit(4)
}
}
os.Exit(0)
}
dir := t.TempDir()
const children = 4
var wg sync.WaitGroup
failures := make(chan string, children)
for c := 0; c < children; c++ {
wg.Add(1)
go func(c int) {
defer wg.Done()
cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v")
cmd.Env = append(os.Environ(),
"ZERO_CREDSTORE_HELPER_DIR="+dir,
fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c),
)
if out, err := cmd.CombinedOutput(); err != nil {
failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out)
}
}(c)
}
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
store := fileStore(t, dir)
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
if len(got) != children*25 {
t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children)
}
}
// The lock lives BESIDE the data file, never on it. write publishes by rename,
// so a lock taken on the data file would be attached to an inode the next
// writer has already replaced — every writer would appear to hold it.
func TestTheLockIsNotTheDataFile(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
if store.lockPath() == store.file {
t.Fatal("the lock is the data file; rename would carry it away")
}
if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) {
t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file)
}
if err := store.Set("p", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file was not created: %v", err)
}
// ...and it survives a write, since the rename replaces only the data file.
if err := store.Set("q", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file did not survive a write: %v", err)
}
}
// The lock is a security boundary, so the path where it CANNOT be taken needs
// coverage too: an operation that could not lock must report that, never proceed
// unserialized and never report success. Replacing the credential directory with
// a regular file makes the lock's MkdirAll fail on every platform.
func TestOperationsFailWhenTheLockCannotBeAcquired(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "creds")
store := fileStore(t, dir)
// Where the store expects its directory, put a regular file.
if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.Set("openai", "sk-test"); err == nil {
t.Error("Set reported success although the lock could not be acquired")
}
if _, err := store.Delete("openai"); err == nil {
t.Error("Delete reported success although the lock could not be acquired")
}
if _, _, err := store.Get("openai"); err == nil {
t.Error("Get reported success although the lock could not be acquired")
}
if _, err := store.Providers(); err == nil {
t.Error("Providers reported success although the lock could not be acquired")
}
// And nothing may have been written through the failed path.
if raw, err := os.ReadFile(dir); err != nil || string(raw) != "not a directory" {
t.Errorf("the blocking file was modified: %q (err %v)", raw, err)
}
}
+5 -49
View File
@@ -9,7 +9,6 @@ package credstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -126,27 +125,14 @@ func (s *Store) Backend() string { return s.backend }
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
// Set stores (or overwrites) the API key for provider.
func (s *Store) Set(provider, key string) (err error) {
func (s *Store) Set(provider, key string) error {
provider = normalizeProvider(provider)
if provider == "" {
return fmt.Errorf("credstore: provider is required")
}
if s.backend == "keyring" {
// The OS keyring serializes its own writes; the file lock guards the
// file backends only.
return s.kr.Set(keyringService, keyringPrefix+provider, key)
}
// READ AND WRITE UNDER ONE LOCK. Separately locking each half would still
// lose keys: two writers would each read the same map, each add their own
// provider, and the second rename would publish a file missing the first.
// Measured before this existed: 1 of 100 concurrent Set calls survived.
release, err := s.acquireFileLock(true)
if err != nil {
return err
}
// Joined, not chosen between: a release failure annotates the result instead
// of masking the write error that actually explains what went wrong.
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return err
@@ -156,7 +142,7 @@ func (s *Store) Set(provider, key string) (err error) {
}
// Get returns the API key for provider and whether one is stored.
func (s *Store) Get(provider string) (value string, found bool, err error) {
func (s *Store) Get(provider string) (string, bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return "", false, nil
@@ -164,15 +150,6 @@ func (s *Store) Get(provider string) (value string, found bool, err error) {
if s.backend == "keyring" {
return s.kr.Get(keyringService, keyringPrefix+provider)
}
// A shared lock, so a read serializes against a writer's publish. On unix
// the rename is atomic and this only prevents reading an empty file mid-swap;
// on Windows an unsynchronized reader holding the file open would block the
// writer's rename outright, so readers must coordinate through the same lock.
release, err := s.acquireFileLock(false)
if err != nil {
return "", false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return "", false, err
@@ -182,7 +159,7 @@ func (s *Store) Get(provider string) (value string, found bool, err error) {
}
// Delete removes the API key for provider, reporting whether one existed.
func (s *Store) Delete(provider string) (existed bool, err error) {
func (s *Store) Delete(provider string) (bool, error) {
provider = normalizeProvider(provider)
if provider == "" {
return false, nil
@@ -190,13 +167,6 @@ func (s *Store) Delete(provider string) (existed bool, err error) {
if s.backend == "keyring" {
return s.kr.Delete(keyringService, keyringPrefix+provider)
}
// The same lock as Set: a delete is a read-modify-write too, and one racing
// a set would otherwise resurrect or erase an unrelated provider.
release, err := s.acquireFileLock(true)
if err != nil {
return false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return false, err
@@ -209,22 +179,17 @@ func (s *Store) Delete(provider string) (existed bool, err error) {
}
// Providers lists providers with a stored key (sorted), for display/audit.
func (s *Store) Providers() (names []string, err error) {
func (s *Store) Providers() ([]string, error) {
if s.backend == "keyring" {
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
// that need a list pass known provider names to Get. Return empty here.
return nil, nil
}
release, err := s.acquireFileLock(false)
if err != nil {
return nil, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return nil, err
}
names = make([]string, 0, len(data))
names := make([]string, 0, len(data))
for name := range data {
names = append(names, name)
}
@@ -299,15 +264,6 @@ func (s *Store) write(data map[string]string) error {
return nil
}
// lockPath is the advisory lock guarding a read-modify-write of the credential
// file. Beside the data file, never the data file itself — write publishes by
// rename and would carry the lock away with the old inode.
func (s *Store) lockPath() string { return s.file + ".lock" }
// filepathDir is filepath.Dir, named locally so the two platform lock files can
// share it without either importing path/filepath for one call.
func filepathDir(path string) string { return filepath.Dir(path) }
func normalizeProvider(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
-55
View File
@@ -1,55 +0,0 @@
//go:build !windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write
// of the credential file is serialized against every other one — across
// processes AND across goroutines, since flock is held per open file
// description and two opens in one process contend exactly as two processes do.
//
// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write
// publishes by os.Rename, which replaces the inode; a lock taken on the data
// file would be attached to an inode that the next writer has already replaced,
// so every writer would appear to hold it. The lock lives on a file nothing
// renames.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
// Writers take LOCK_EX; readers take LOCK_SH so they run concurrently with
// each other but still serialize against a writer's publish (see Get).
how := unix.LOCK_SH
if exclusive {
how = unix.LOCK_EX
}
if err := unix.Flock(int(file.Fd()), how); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Close alone drops the flock, so the explicit unlock is belt-and-braces —
// but its failure is reported rather than swallowed, because a cleanup that
// did not complete must not be indistinguishable from one that did. The
// callers join this into their result, so it annotates a successful write
// instead of masking it.
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
-55
View File
@@ -1,55 +0,0 @@
//go:build windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/windows"
)
// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write
// of the credential file is serialized, matching the flock behaviour on unix.
//
// The lock file is SEPARATE from the data file for the same reason: write
// publishes by rename, and a lock on the renamed file would be attached to
// something the next writer has already replaced.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
// A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a
// waiter queues rather than failing the operation. Writers pass
// LOCKFILE_EXCLUSIVE_LOCK; readers omit it (a shared lock) so they still
// serialize against a writer's publish — which matters more on Windows,
// where an unsynchronized reader holding the file open blocks the rename.
flags := uint32(0)
if exclusive {
flags = windows.LOCKFILE_EXCLUSIVE_LOCK
}
if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Reported rather than swallowed, for the same reason as the unix side: a
// cleanup that did not complete must not look identical to one that did.
// It matters more here — on Windows the handle staying open is what blocks
// the next writer's rename, so a failed release has a visible consequence.
unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
+8 -39
View File
@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -61,15 +60,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
})
if err != nil {
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
return "", fmt.Errorf("connecting to Deepgram: %w", err)
}
defer conn.CloseNow()
@@ -77,26 +68,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
// so no conversion (unlike sherpa-onnx, which wants float32).
writeErrCh := make(chan error, 1)
go func() {
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
for chunk := range chunks {
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
return
case chunk, ok := <-chunks:
if !ok {
// CloseStream flushes any buffered audio and returns final
// results before Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
return
}
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
return
}
}
}
// CloseStream flushes any buffered audio and returns final results before
// Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
}()
// Deepgram results are per-utterance segments, not cumulative: accumulate the
@@ -120,18 +100,7 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
}
default:
}
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
//nolint:staticcheck // Preserve established user-facing error text.
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
}
if typ != websocket.MessageText {
continue
@@ -1,278 +0,0 @@
package dictation
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestDeepgramDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to Deepgram") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with
// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with
// that value in the error string. Hijacks and closes immediately to avoid the
// library's 3s body-drain timeout on failed dials.
func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack not supported", http.StatusInternalServerError)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
_, _ = bufrw.WriteString("Connection: Upgrade\r\n")
_, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n")
_ = bufrw.Flush()
}))
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Drain the client's audio frames, then wait for CloseStream so the
// client has flushed its writes before we reject the connection. Closing
// immediately (before CloseStream) risks the client failing on a write
// error and never observing this API-key-bearing close reason.
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 320)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewDeepgramTranscriber(DeepgramConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can race the first write or the first Read; both
// setup/write redaction sites must still yield context.Canceled.
func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
// No frames yet: the writer blocks on chunks or the reader blocks on Read.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error
// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close
// reason that embeds the key next to an X-Api-Key-shaped header label.
func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") {
t.Errorf("Custom header API key leaked: %v", ferr)
}
}
func TestDeepgramSinglePassRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
got := ferr.Error()
if strings.Contains(got, "sk-key1234567890abcdef1234") {
t.Fatalf("API key leaked into stream error: %q", got)
}
// One key occurrence must produce exactly one redaction marker (no double wrap).
if strings.Count(got, "[REDACTED]") != 1 {
t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got)
}
}
@@ -8,7 +8,6 @@ import (
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -23,10 +22,6 @@ type OpenAIRealtimeConfig struct {
Model string // default "gpt-4o-transcribe"
// BaseURL overrides the wss endpoint (tests point it at a fake server).
BaseURL string
// writeErrInjector, when set, is consulted before each JSON websocket write.
// Used by same-package tests to force controllable write failures that embed
// secrets; production callers leave it nil.
writeErrInjector func() error
}
type openAIRealtimeTranscriber struct {
@@ -64,15 +59,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
})
if err != nil {
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
}
defer conn.CloseNow()
@@ -86,53 +73,24 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
},
}
if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil {
// Same cancellation short-circuit as the dial above: an Esc-abort
// while the session update is in flight must stay context.Canceled.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
}
writeErrCh := make(chan error, 1)
go func() {
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
for chunk := range chunks {
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
return
case chunk, ok := <-chunks:
if !ok {
// Commit the buffered audio to force final transcription.
if err := o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}); err != nil {
writeErrCh <- err
// Unblock the reader: without a close, conn.Read stays
// parked on a silent peer and the error is never drained
// from writeErrCh. CloseNow aborts instead of waiting on
// the close handshake, which a silent peer never answers.
conn.CloseNow()
return
}
writeErrCh <- nil
return
}
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := o.writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
// Same unblocking close as the commit path: a server that
// goes silent after session setup would otherwise leave the
// reader blocked in conn.Read with the error undrained.
conn.CloseNow()
return
}
}
}
// Commit the buffered audio to force final transcription of the utterance.
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
}()
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
@@ -155,17 +113,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
}
default:
}
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
}
if typ != websocket.MessageText {
continue
@@ -177,12 +125,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), false)
}
// onPartial may deliver the partial that makes the TUI cancel
// (Esc). Observe that before the next Read, which can already
// have a racing OpenAI error frame buffered.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
case realtimeCompleted:
// A completed item replaces the in-progress delta buffer with the
// server's authoritative transcript for that segment.
@@ -196,9 +138,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), true)
}
if ctx.Err() != nil {
return compose(), ctx.Err()
}
// Once we've committed the buffer (user stopped) and the server has
// returned a completed transcription, the utterance is done.
if committed {
@@ -208,15 +147,7 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case realtimeCommitted:
committed = true
case realtimeError:
// Esc can race an incoming error event: conn.Read may already have
// the OpenAI error in hand by the time cancellation lands. Key on
// ctx.Err() first, same as every other return point in this loop,
// so a cancel that wins the race still surfaces as context.Canceled
// instead of a spurious redacted failure alongside it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
}
// The writer signals commit completion out of band; observe it so a
// stop with no further audio still flips `committed`.
@@ -224,10 +155,6 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case werr := <-writeErrCh:
if werr == nil {
committed = true
} else if ctx.Err() != nil {
return compose(), ctx.Err()
} else {
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey))
}
default:
}
@@ -274,15 +201,6 @@ func parseRealtimeEvent(data []byte) realtimeEvent {
return realtimeEvent{kind: realtimeOther}
}
func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
if o.cfg.writeErrInjector != nil {
if err := o.cfg.writeErrInjector(); err != nil {
return err
}
}
return writeJSON(ctx, conn, v)
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
@@ -1,437 +0,0 @@
package dictation
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// Session-configuration write fails after dial: inject a write error that embeds
// the API key (peer close cannot reach this path reliably — the session write
// always lands in the kernel buffer first) and assert redaction.
func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) {
const key = "sk-test-session-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold open; the client fails on the injected session write before reading.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected session-config failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("session-config failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from session-config failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") {
t.Errorf("expected session-config error prefix, got: %v", ferr)
}
}
// Asynchronous writer failure: session update succeeds, then the audio writer
// injects a key-bearing write error. The server keeps the connection open and
// emits a delta so the reader loop observes writeErrCh (not a peer close).
func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) {
const key = "sk-test-writer-key-abcdef"
sessionDone := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionDone)
// Keep reading and emit a delta so the client processes an event and
// drains writeErrCh while the connection is still open.
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail subsequent audio/commit writes.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave open after the first frame so the writer hits the injected error
// on that append rather than a clean commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
done <- ferr
}()
var ferr error
select {
case ferr = <-done:
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not complete after writer failure")
}
close(chunks)
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
select {
case <-sessionDone:
default:
// Session may race the injected audio write; prefix assertion above is enough.
}
}
// Writer failure with a silent server: the peer never sends a frame after
// session setup, so the reader is parked in conn.Read. The writer must close
// the connection on failure or the writeErrCh error is never observed and
// StreamTranscribe blocks forever.
func TestOpenAIRealtimeWriterFailureUnblocksSilentServer(t *testing.T) {
const key = "sk-test-silent-writer-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open without ever writing a frame.
<-ctx.Done()
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail the subsequent append write.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave the channel open: the writer must fail on the append, not the commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {})
done <- ferr
}()
select {
case ferr := <-done:
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not unblock after writer failure with a silent server")
}
}
func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
for {
typ, _, err := c.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can hit the session-update write or the first Read;
// both redaction sites must still yield context.Canceled.
func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
// Do not read: leave the client in session-update write or first Read.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// Cancel while the writer is blocked on more chunks. Whichever path observes
// the cancellation first (the blocked conn.Read or the writeErrCh drain) must
// return context.Canceled rather than a redacted flat string.
func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) {
sessionReceived := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionReceived)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-sessionReceived:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can race an incoming OpenAI error event. conn.Read may already have
// the error frame in hand by the time the cancellation lands. The server
// fires a delta immediately followed by the error, back to back. Cancel from
// inside the delta callback (same path the TUI uses when a partial triggers
// Esc handling) so the cancel is observed before the next event is processed.
// The returned error must still be context.Canceled, not the redacted OpenAI
// error.
func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`,
))
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
var once sync.Once
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {
// Cancel synchronously from the partial callback, before the
// stream loop continues to the already-buffered error frame.
once.Do(cancel)
})
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr)
}
}
+6 -125
View File
@@ -6,8 +6,6 @@ import (
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
)
@@ -40,45 +38,12 @@ func KillProcessTree(pid int) error {
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
//
// The signal target is REDISCOVERED here via processSignalTarget(pid), which
// asks the OS for pid's current group. A caller that already knows pid was
// configured as its own group leader at launch (e.g. via ConfigureProcessGroup)
// should use TerminateProcessGroup instead — see its doc comment for why this
// rediscovery is fragile once the leader may have already exited.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
return terminateTarget(pid, target, grace, poll)
}
// TerminateProcessGroup is like TerminateProcessTree, but for a caller that
// already knows pid is (or was, at launch) its own process-group leader — e.g.
// a command started after ConfigureProcessGroup(cmd), which sets Setpgid so
// cmd.Process.Pid always leads its own group at the moment it is signalled.
//
// TerminateProcessTree instead rediscovers the group via syscall.Getpgid(pid)
// at call time. On Darwin, that lookup can return ESRCH once an unreaped group
// leader has already exited, even though live descendants remain in the group
// it configured — TerminateProcessTree would then silently fall back to
// signalling only the (already dead) leader PID and leave those descendants
// running. Skipping rediscovery in favor of the launch-time invariant avoids
// that: the negative PID is used directly, regardless of whether the leader is
// still alive to be looked up.
func TerminateProcessGroup(pid int, grace, poll time.Duration) error {
if pid <= 1 {
return fmt.Errorf("refusing to signal invalid pid %d", pid)
}
return terminateTarget(pid, -pid, grace, poll)
}
// terminateTarget signals target (an individual PID or, negative, a process
// group) with SIGTERM then, after grace, SIGKILL. reportPid identifies the
// original process in error messages regardless of which form target takes.
func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
alive := func() bool { return signalTargetRunning(target) }
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
@@ -112,91 +77,11 @@ func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
return nil
}
// signalTargetRunning reports whether the signal target still has a process that
// is actually RUNNING, as opposed to one waiting to be reaped.
//
// kill(2) with signal 0 succeeds for a zombie: the PID still exists until its
// parent collects it. That matters because the target here is usually a process
// GROUP, and a terminated leader's child is briefly a zombie before init/launchd
// reaps it. Treating that window as "still alive" made termination sit through
// both grace periods, SIGKILL an already-dead group, and then report
// "did not exit after SIGKILL" for a tree that had in fact stopped — turning a
// successful cleanup into a spurious failure (and a flaky one, since it depends on
// reap timing).
//
// Zombie detection goes through ps, which is available on every Unix we target and
// avoids a /proc dependency Darwin does not have. It is only consulted when the
// cheap kill check says something is still there, so the common path costs nothing
// extra. If ps cannot be run or its output cannot be parsed, the conservative
// pre-existing answer (the kill result) stands.
func signalTargetRunning(target int) bool {
if syscall.Kill(target, syscall.Signal(0)) != nil {
return false
}
// A negative target is a process GROUP: check whether any member is
// non-zombie. A positive target is an individual PID that is NOT its own
// group leader (processSignalTarget's fallback) — its actual PGID differs
// from its own PID, so matching rows by PGID here would look for a group
// that doesn't exist and always report "unknown" (conservatively alive).
// Check its own PID's state instead.
var running, ok bool
if target < 0 {
running, ok = processGroupHasRunningMember(-target)
} else {
running, ok = processIsRunning(target)
}
if !ok {
return true
}
return running
}
// processGroupHasRunningMember reports whether any member of pgid is in a state
// other than zombie. ok is false when the group's states could not be determined.
func processGroupHasRunningMember(pgid int) (running bool, ok bool) {
return processTableStateMatches(func(_, rowPgid int) bool { return rowPgid == pgid })
}
// processIsRunning reports whether pid itself is in a state other than zombie.
// ok is false when pid's state could not be determined.
func processIsRunning(pid int) (running bool, ok bool) {
return processTableStateMatches(func(rowPid, _ int) bool { return rowPid == pid })
}
// processTableStateMatches scans the process table once, via ps, and reports
// whether any row satisfying match is non-zombie, and whether at least one
// matching row was found at all (ok). No rows found means either the process
// (or group) genuinely doesn't exist (a race with exit) or ps's output was
// unparseable/restricted; either way, the caller should not claim knowledge.
func processTableStateMatches(match func(pid, pgid int) bool) (running bool, ok bool) {
output, err := exec.Command("ps", "-A", "-o", "pid=,pgid=,stat=").Output()
if err != nil {
return false, false
}
found := false
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
rowPid, errPid := strconv.Atoi(fields[0])
rowPgid, errPgid := strconv.Atoi(fields[1])
if errPid != nil || errPgid != nil || !match(rowPid, rowPgid) {
continue
}
found = true
if !strings.HasPrefix(fields[2], "Z") {
return true, true
}
}
return false, found
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
@@ -206,14 +91,10 @@ func processSignalTarget(pid int) (int, error) {
if pgid == pid {
target = -pid
}
} else {
if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
// Conservatively retain the individual PID after other lookup failures;
// guessing and signalling a process group could affect unrelated processes.
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
return target, nil
}
-133
View File
@@ -1,133 +0,0 @@
//go:build !windows
package execution
import (
"os/exec"
"strconv"
"syscall"
"testing"
"time"
)
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
// sit through both grace periods, take a pointless SIGKILL, and then be reported
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
}
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
defer func() { _ = cmd.Wait() }()
if !signalTargetRunning(-pid) {
t.Fatal("a group with a live member must count as running")
}
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
// matched the individual-PID fallback target (a process that is NOT its own
// group leader — processSignalTarget's positive-PID case) against process-table
// rows by PGID. That target's actual PGID differs from its own PID by
// definition (that's exactly why processSignalTarget fell back to the
// individual PID instead of a negative group target), so no row ever matched,
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
// falls back to the individual (positive) PID.
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
if target != pid {
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
return state[0] == 'Z', true
}
+1 -27
View File
@@ -3,8 +3,6 @@
package execution
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -14,34 +12,10 @@ import (
func ConfigureProcessGroup(cmd *exec.Cmd) {}
// KillProcessTree asks taskkill /T to kill pid and descendants it can still
// discover from that live root. If taskkill fails, it falls back to killing pid
// alone but still returns the tree error because descendants may have survived.
// Neither path can recover descendants after the root has exited.
func KillProcessTree(pid int) error {
return killProcessTree(pid, runTaskkill, killRootProcess)
}
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
treeErr := killTree(pid)
if treeErr == nil {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
rootErr := killRoot(pid)
if rootErr != nil {
return errors.Join(
fmt.Errorf("kill process tree rooted at %d: %w", pid, treeErr),
fmt.Errorf("kill root process %d: %w", pid, rootErr),
)
}
return fmt.Errorf("kill process tree rooted at %d (root process killed directly): %w", pid, treeErr)
}
func runTaskkill(pid int) error {
return exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
}
func killRootProcess(pid int) error {
process, err := os.FindProcess(pid)
if err != nil {
return err
@@ -1,29 +0,0 @@
//go:build windows
package execution
import (
"errors"
"strings"
"testing"
)
func TestKillProcessTreeRetainsTreeErrorWhenRootFallbackSucceeds(t *testing.T) {
treeErr := errors.New("taskkill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return nil })
if !errors.Is(err, treeErr) {
t.Fatalf("killProcessTree error = %v, want tree error", err)
}
if !strings.Contains(err.Error(), "root process killed directly") {
t.Fatalf("killProcessTree error = %q, want fallback context", err)
}
}
func TestKillProcessTreeJoinsTreeAndRootErrors(t *testing.T) {
treeErr := errors.New("taskkill failed")
rootErr := errors.New("root kill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return rootErr })
if !errors.Is(err, treeErr) || !errors.Is(err, rootErr) {
t.Fatalf("killProcessTree error = %v, want both failures", err)
}
}
-177
View File
@@ -1,177 +0,0 @@
// Package pathjail confines file operations to a directory tree.
//
// It exists because the obvious guard does not work. Checking a path with
// Lstat and then handing the same string to MkdirAll, CreateTemp, Rename or
// Remove is two independent resolutions with a gap between them, and every
// operation after the check re-resolves the ANCESTORS as well. A store that
// verified only its own directory and file was therefore writable outside its
// tree whenever any component above them was a link: the check passed, and the
// syscall that followed went somewhere else.
//
// On Windows the same guard failed for a second, independent reason. A junction
// is a reparse point but NOT a symlink, so os.Lstat reports it as ModeIrregular
// with ModeSymlink clear, and a guard testing only ModeSymlink returns "not a
// link" when pointed straight at one. Junctions also need no privilege to
// create, unlike Windows symlinks, so this is reachable by exactly the
// unprivileged user a workspace guard is meant to contain.
//
// The fix for both is to stop naming things by path. Open a handle on the root
// once and address everything below it relative to that handle, so the kernel
// resolves each component against an object that cannot be swapped underneath.
// os.Root is that handle: it refuses any component that would escape, including
// a Windows reparse point, and it is the same idea the sandbox uses for ACL
// materialization.
package pathjail
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// ErrEscapes reports a target that is not inside the root it was confined to.
// Returned before anything is opened, so a caller cannot act on the path first.
var ErrEscapes = errors.New("path escapes its root")
// ErrReparse reports a link or reparse point where a real file or directory was
// required.
var ErrReparse = errors.New("refusing to write through a link")
// Open confines dir to root and returns a handle on root plus dir expressed
// relative to it. The caller closes the handle.
//
// root is resolved normally, because it is the boundary the caller chose and
// trusts (a workspace the operator opened, say, which may legitimately sit under
// a link). Everything BELOW it is resolved against the returned handle and
// cannot escape.
func Open(root, dir string) (*os.Root, string, error) {
// Only a genuinely EMPTY value is treated as missing; every other spelling is
// used verbatim. Testing TrimSpace(...) == "" instead would retarget the very
// paths this guards: " " is a legal directory name on unix, so trimming would
// reject it as "no root" or silently swap dir for root, which is the same
// retargeting bug as trimming "root " down to "root".
if root == "" {
return nil, "", fmt.Errorf("%w: no root to confine %q to", ErrEscapes, dir)
}
if dir == "" {
dir = root
}
// Rel before Open, so an escaping target is refused without opening
// anything. filepath.Rel is lexical, which is the right test here: it says
// what the caller ASKED for, and the handle enforces what actually happens.
relative, err := filepath.Rel(root, dir)
if err != nil {
return nil, "", fmt.Errorf("%w: %q against %q: %v", ErrEscapes, dir, root, err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf("%w: %q is outside %q", ErrEscapes, dir, root)
}
// The root is created if it is missing, by ordinary pathname, because it is
// the boundary the caller declared rather than anything being confined. The
// stores this replaced called MkdirAll on the whole tree, so this creates
// strictly less by pathname than before, and everything BELOW root is opened
// relative to the handle.
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, "", fmt.Errorf("create %s: %w", root, err)
}
handle, err := os.OpenRoot(root)
if err != nil {
return nil, "", fmt.Errorf("open %s: %w", root, err)
}
return handle, relative, nil
}
// RefuseReparse fails when relative names a link or reparse point.
//
// Absent is fine: a name that does not exist yet is what a create is for.
//
// This is belt to os.Root's braces. The handle already refuses to traverse a
// reparse point, so this exists to give the FINAL component a clear error
// instead of an "escapes from parent" one, and to keep the refusal explicit at
// the point a reader looks for it.
//
// ModeIrregular is tested alongside ModeSymlink deliberately: that is what a
// Windows junction reports, and a guard checking only ModeSymlink lets one
// straight through.
func RefuseReparse(handle *os.Root, relative string) error {
// A trailing separator makes os.Root.Lstat resolve the terminal component on
// Windows (doInRootAlwaysResolveTerminalSlash), so a junction named with a
// trailing slash would be stat'd as its target and wave through as "not a
// link". Strip it so the final component itself is the thing inspected.
//
// os.IsPathSeparator rather than a literal `/\` set, because backslash is a
// separator only on Windows: on unix it is an ordinary filename character, and
// trimming it would inspect "linked" when asked about a symlink genuinely
// named `linked\` — reporting "not a link" for one that is.
if trimmed := trimTrailingSeparators(relative); trimmed != "" {
relative = trimmed
}
info, err := handle.Lstat(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("inspect %s: %w", relative, err)
}
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: %s", ErrReparse, relative)
}
return nil
}
// trimTrailingSeparators drops the path separators at the end of a name, using
// the ACTIVE platform's definition of one: `/` and `\` on Windows, `/` only on
// unix, where a backslash is a legal character in a filename.
func trimTrailingSeparators(path string) string {
end := len(path)
for end > 0 && os.IsPathSeparator(path[end-1]) {
end--
}
return path[:end]
}
// randomBytes fills b with cryptographic randomness. A variable rather than a
// direct crypto/rand call so a test can force the name collision that exercises
// the O_EXCL retry below — the branch that matters, and one a real random source
// will not reach on demand.
var randomBytes = rand.Read
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
// relative to handle, and returns it with its relative path.
//
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
// between the check and the create is the same race this package exists to
// close, and O_EXCL makes the kernel refuse it rather than us.
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
// prefix and suffix are NAME fragments, not path fragments. A separator or a
// "."/".." in either would relocate the file out of dir — a prefix of
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
// component and with it the O_EXCL unpredictability the file relies on.
// Reject them up front so the file lands where dir promises.
for _, fragment := range []string{prefix, suffix} {
if strings.ContainsAny(fragment, `/\`) || fragment == "." || fragment == ".." {
return nil, "", fmt.Errorf("%w: temporary name fragment %q must be a single path component", ErrEscapes, fragment)
}
}
for attempt := 0; attempt < 10; attempt++ {
noise := make([]byte, 8)
if _, err := randomBytes(noise); err != nil {
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
}
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
return file, relative, nil
}
if errors.Is(err, fs.ErrExist) {
continue
}
return nil, "", err
}
return nil, "", errors.New("could not create a temporary file with an unused name")
}
-360
View File
@@ -1,360 +0,0 @@
package pathjail
import (
"encoding/hex"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// linkDir points link at target, using whatever kind of link this platform lets
// an unprivileged process create.
//
// On Windows that is a JUNCTION, not a symlink, and the difference is the whole
// reason this package exists twice over. A symlink needs
// SeCreateSymbolicLinkPrivilege, which an ordinary account does not hold, so a
// test written with os.Symlink silently skips on Windows and proves nothing
// there. A junction needs no privilege at all, so it is both the reachable
// attack and the only one that can be tested.
func linkDir(t *testing.T, target, link string) {
t.Helper()
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction: %v %s", err, out)
}
return
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create a symlink: %v", err)
}
}
// The reported defect: a link in an ANCESTOR of the target, not at the target
// itself. Every guard that checked only the final directory and file passed
// this, because the components it checked were genuinely not links; the ones
// the syscall then traversed were.
func TestAnAncestorLinkCannotRedirectAWrite(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
workspace := filepath.Join(base, "workspace")
for _, dir := range []string{outside, workspace} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, outside, filepath.Join(workspace, ".zero"))
handle, relative, err := Open(workspace, filepath.Join(workspace, ".zero", "store"))
if err != nil {
t.Fatalf("Open should confine, not refuse outright: %v", err)
}
defer handle.Close()
if err := handle.MkdirAll(relative, 0o700); err == nil {
t.Fatal("created a directory through a linked ancestor, so a write can still leave the workspace")
}
if _, err := os.Stat(filepath.Join(outside, "store")); !os.IsNotExist(err) {
t.Errorf("something was created outside the workspace, stat error = %v", err)
}
}
// RefuseReparse has to catch a junction, which is the case os.ModeSymlink alone
// misses. Asserted through the exported helper rather than by reading the mode,
// so the test fails if the predicate is ever narrowed back.
func TestRefuseReparseCatchesAJunctionNotJustASymlink(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
err = RefuseReparse(handle, filepath.Join(relative, "linked"))
if !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(link) = %v, want ErrReparse; on Windows a junction reports ModeIrregular, so a ModeSymlink-only check returns nil here", err)
}
}
// A name that does not exist yet is what a create is for, so it must not be
// refused. Without this the guard above would be satisfied by refusing
// everything.
func TestRefuseReparseAllowsAnAbsentName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
if err := RefuseReparse(handle, filepath.Join(relative, "not-there.md")); err != nil {
t.Errorf("an absent name was refused: %v", err)
}
if err := RefuseReparse(handle, relative); err != nil {
t.Errorf("an ordinary directory was refused: %v", err)
}
}
// Open refuses a target outside its root before opening anything, so a caller
// cannot act on the path first.
func TestOpenRefusesATargetOutsideTheRoot(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
for _, target := range []string{
filepath.Join(base, "sibling"),
filepath.Join(inside, "..", "..", "escape"),
} {
if _, _, err := Open(inside, target); !errors.Is(err, ErrEscapes) {
t.Errorf("Open(%q, %q) = %v, want ErrEscapes", inside, target, err)
}
}
if _, _, err := Open("", filepath.Join(base, "anything")); !errors.Is(err, ErrEscapes) {
t.Error("an empty root was accepted; a boundary is not optional")
}
}
// CreateTemp must use O_EXCL and an unpredictable name, so a link planted at a
// guessable temp path is refused by the kernel rather than followed.
func TestCreateTempIsExclusiveAndUnpredictable(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
seen := map[string]bool{}
for i := 0; i < 8; i++ {
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
file.Close()
if seen[name] {
t.Fatalf("CreateTemp reused the name %q, so two concurrent writes would stomp each other", name)
}
seen[name] = true
if filepath.Ext(name) != ".tmp" {
t.Errorf("temp name %q lost its suffix, so a listing may pick it up as real content", name)
}
}
}
// A legitimate directory whose name carries a space must be confined as spelled.
// Trimming it would open a different filesystem object.
//
// A LEADING space, deliberately: Win32 normalizes a component's trailing spaces
// and dots away, so "root " and "root" are one object there and a trailing-space
// case cannot express the bug on Windows. A leading space survives on every
// platform, so this runs everywhere rather than being gated to unix.
// The path is RELATIVE, under t.Chdir, so the leading space sits at the start of
// the whole string. An absolute filepath.Join(base, " root") would put the space
// in the middle, where TrimSpace cannot reach it — the test would then pass
// against the very guard it exists to catch, on every platform.
func TestOpenPreservesSpaceInPaths(t *testing.T) {
base := t.TempDir()
t.Chdir(base)
const spaced = " root" // the leading space is part of the name
handle, relative, err := Open(spaced, spaced)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
// Write through the handle, then confirm the bytes landed under the spelling
// the caller asked for — the property that actually matters, and one
// handle.Name() alone would not prove.
file, _, err := CreateTemp(handle, relative, "probe", ".tmp")
if err != nil {
t.Fatal(err)
}
name := file.Name()
if err := file.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(base, spaced, filepath.Base(name))); err != nil {
t.Errorf("a file created through the handle is not under %q: %v — the leading space was trimmed away", spaced, err)
}
if _, err := os.Stat(filepath.Join(base, "root")); err == nil {
t.Error(`a trimmed "root" was created instead of the requested " root"`)
}
}
// A whitespace-only name is a legal directory on unix, so it must be confined as
// given rather than treated as "no root" or silently swapped for the root.
func TestOpenAcceptsAWhitespaceOnlyComponent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Win32 normalizes a whitespace-only component away; the name is not expressible there")
}
// The path must be whitespace-only IN FULL for this to bite, so it has to be
// relative: an absolute path ending in " " still has a non-empty TrimSpace and
// would sail past the old guard, making the test prove nothing.
t.Chdir(t.TempDir())
handle, _, err := Open(" ", " ")
if err != nil {
t.Fatalf("a whitespace-only path was rejected as missing: %v", err)
}
defer handle.Close()
if _, err := os.Stat(" "); err != nil {
t.Errorf("the whitespace-only directory was not created as spelled: %v", err)
}
}
// prefix and suffix are name fragments. A separator or traversal in either must
// be refused, or the file lands outside dir and (for a suffix erasing the random
// component) loses the O_EXCL unpredictability it depends on.
func TestCreateTempRejectsPathFragmentsThatEscapeTheDir(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
// root is base, dir is the subdirectory: a "../escaped" fragment lands in
// base — still inside the os.Root, so os.Root does NOT catch it — but outside
// the dir CreateTemp promised. This is the case the reviewers reproduced.
handle, relative, err := Open(base, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
cases := []struct{ prefix, suffix string }{
{"../escaped", ".tmp"},
{`..\sibling`, ".tmp"},
{"note", ".tmp/../evil"},
{"note", ".."},
{"..", ".tmp"},
}
for _, c := range cases {
if _, _, err := CreateTemp(handle, relative, c.prefix, c.suffix); !errors.Is(err, ErrEscapes) {
t.Errorf("CreateTemp(prefix=%q, suffix=%q) = %v, want ErrEscapes", c.prefix, c.suffix, err)
}
}
// Nothing may have leaked into base above the promised dir.
entries, _ := os.ReadDir(base)
for _, entry := range entries {
if entry.Name() != "inside" {
t.Errorf("a temp fragment escaped into the parent: %q", entry.Name())
}
}
}
// A junction named with a trailing separator must still be refused. On Windows
// os.Root.Lstat resolves the terminal component when the name ends in a
// separator, so without trimming it the link is stat'd as its target and waved
// through.
func TestRefuseReparseCatchesALinkNamedWithATrailingSeparator(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, "linked") + string(filepath.Separator)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse", name, err)
}
}
// The O_EXCL retry is the branch that protects an existing file, and a real
// random source will not collide on demand — so force it. The first two draws
// return the same bytes: the name is pre-created, CreateTemp must refuse to
// reuse it, leave its contents alone, and come back with a different name.
func TestCreateTempRetriesWithoutClobberingAnExistingName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
draws := [][]byte{
{1, 1, 1, 1, 1, 1, 1, 1}, // collides with the pre-created file
{1, 1, 1, 1, 1, 1, 1, 1}, // and again, so the retry must keep trying
{2, 2, 2, 2, 2, 2, 2, 2}, // finally a free name
}
previous := randomBytes
t.Cleanup(func() { randomBytes = previous })
call := 0
randomBytes = func(b []byte) (int, error) {
copy(b, draws[min(call, len(draws)-1)])
call++
return len(b), nil
}
taken := filepath.Join(base, "note."+hex.EncodeToString(draws[0])+".tmp")
if err := os.WriteFile(taken, []byte("do not clobber me"), 0o600); err != nil {
t.Fatal(err)
}
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if filepath.Base(name) == filepath.Base(taken) {
t.Fatalf("CreateTemp returned the name that was already taken (%q), so O_EXCL did not protect it", name)
}
kept, err := os.ReadFile(taken)
if err != nil {
t.Fatal(err)
}
if string(kept) != "do not clobber me" {
t.Errorf("the pre-existing file was overwritten: %q", kept)
}
if call < 2 {
t.Errorf("the random source was drawn %d time(s); the collision path never ran", call)
}
}
// On unix a backslash is an ordinary filename character, not a separator, so a
// link genuinely named `linked\` must still be refused. Trimming backslashes
// unconditionally would inspect "linked" instead and report "not a link" for one
// that is.
func TestRefuseReparseKeepsABackslashInAUnixName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip(`backslash is a separator on Windows, so this name is not expressible there`)
}
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, `linked\`))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, `linked\`)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse — the backslash is part of the name here, not a separator", name, err)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("windows access token has no default owner")
return nil, errors.New("Windows access token has no default owner")
}
return owner.Copy()
}
-4
View File
@@ -184,10 +184,6 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -241,7 +241,6 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
@@ -139,68 +139,6 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
@@ -0,0 +1,14 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providerio
import (
"bufio"
"io"
)
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
-22
View File
@@ -242,7 +242,6 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -316,27 +315,6 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+2 -30
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range applyPatchPaths(patch) {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range applyPatchPaths(patch) {
for _, path := range patchHeaderPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -296,34 +296,6 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+2 -5
View File
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,9 +56,6 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
+8
View File
@@ -162,3 +162,11 @@ func ValidateCapabilities(name string, caps ToolCapabilities) []string {
}
return problems
}
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
func ValidateToolCapabilities(tool Tool) []string {
if tool == nil {
return []string{"<nil tool>"}
}
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
}
-19
View File
@@ -231,25 +231,6 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
-106
View File
@@ -221,112 +221,6 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+2 -4
View File
@@ -78,16 +78,14 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
if !ok {
return writtenContent
}
binaryPath, err := exec.LookPath(command[0])
if err != nil {
if _, err := exec.LookPath(command[0]); err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter.Dir = filepath.Dir(absolutePath)
formatter.Stdin = strings.NewReader("")
if err := formatter.Run(); err != nil {
return writtenContent
}
-14
View File
@@ -102,17 +102,3 @@ func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Fatalf("unknown extension must pass through: %q", content)
}
}
func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
t.Setenv("PATH", t.TempDir())
targetPath := filepath.Join(t.TempDir(), "a.go")
uglyContent := "package a\n\nfunc A( ) { }\n"
if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil {
t.Fatal(err)
}
content := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
if content != uglyContent {
t.Fatalf("missing formatter must return written content, got %q", content)
}
}
-9
View File
@@ -1,9 +0,0 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
}
-18
View File
@@ -1,18 +0,0 @@
package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
}
}
-34
View File
@@ -1,7 +1,5 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -38,19 +36,6 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -66,22 +51,3 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
-11
View File
@@ -17,7 +17,6 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -63,10 +62,6 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -87,12 +82,6 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+1 -12
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromApplyPatch(patch) {
for _, p := range pathsFromUnifiedDiff(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,17 +191,6 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
-763
View File
@@ -1,763 +0,0 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
}
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
}
return err
}
}
return nil
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
-6
View File
@@ -130,9 +130,6 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -161,9 +158,6 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
-537
View File
@@ -2,23 +2,12 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -524,532 +513,6 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
return m.appendSystemNotice("write access added: " + root + " (this session only)")
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+4 -39
View File
@@ -18,8 +18,6 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -27,7 +25,6 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -265,8 +262,6 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -287,16 +282,6 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -328,13 +313,6 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -347,7 +325,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd {
return true
}
index = end
@@ -356,9 +334,6 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -1005,7 +980,6 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1054,7 +1028,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1089,14 +1063,8 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
}
}
return words
@@ -1110,9 +1078,6 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1142,7 +1107,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
builder.Reset()
}
-110
View File
@@ -1,110 +0,0 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
-168
View File
@@ -1,168 +0,0 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
-1
View File
@@ -136,7 +136,6 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
+1
View File
@@ -107,6 +107,7 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+7 -28
View File
@@ -15,7 +15,6 @@ import (
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -732,16 +731,14 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
@@ -751,24 +748,6 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
return modelSwitchTarget{}, false
}
// providerModelSwitchCandidates preserves provider-owned model IDs while
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
// The ChatGPT backend itself publishes bare model IDs (for example,
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
// as part of their model ID, so the alias is deliberately provider-specific.
func providerModelSwitchCandidates(providerID string, input string) []string {
requested := strings.TrimSpace(input)
candidates := []string{requested}
const openAIPrefix = "openai/"
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
return candidates
}
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
candidates = append(candidates, bare)
}
return candidates
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
+4 -1
View File
@@ -269,7 +269,10 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
contentY := localY - 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY--
}
if contentY < 0 {
return 0, false
}
+13 -38
View File
@@ -61,7 +61,6 @@ type dictationController struct {
browseVariants []dictation.ModelVariant
// in-flight session state
sessionID int64
streaming bool
recorder Recorder
transcriber Transcriber
@@ -102,17 +101,13 @@ type dictationController struct {
}
// dictationStartedMsg reports the outcome of Recorder.Start().
// sessionID must match the dictation controller's current session so a late
// start result after cancel/reset cannot reset a newer recording.
type dictationStartedMsg struct {
sessionID int64
err error
err error
}
// dictationTranscribedMsg carries the final transcript of a batch (or a
// completed streaming) recording.
type dictationTranscribedMsg struct {
sessionID int64
text string
err error
submit bool
@@ -130,7 +125,6 @@ func newDictationController(opts Options) dictationController {
platform: dictation.DetectPlatform(),
downloadRoot: opts.STTDownloadRoot,
userConfigPath: opts.UserConfigPath,
sessionID: 1,
}
}
@@ -201,13 +195,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
next := m.appendSystemNotice("Voice mode off.")
if next.dictation.active() {
return next, nil
}
@@ -268,7 +262,7 @@ func (m model) startDictation() (model, tea.Cmd) {
if streaming {
return m.startStreamingDictation()
}
return m, startBatchRecordingCmd(m.dictation.sessionID, rec)
return m, startBatchRecordingCmd(rec)
}
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
@@ -282,7 +276,7 @@ func (m model) stopDictation() (model, tea.Cmd) {
}
return m, nil // final text arrives via the streaming command already running
}
return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
}
// cancelDictation aborts an in-flight recording without transcribing. Bound to
@@ -299,7 +293,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
return m.appendSystemNotice("Dictation cancelled."), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
@@ -317,7 +311,6 @@ func (d dictationController) maxDuration() time.Duration {
}
func (d *dictationController) reset() {
d.sessionID++
d.phase = dictIdle
d.recorder = nil
d.transcriber = nil
@@ -333,36 +326,31 @@ func (d *dictationController) reset() {
// startBatchRecordingCmd starts the record-to-file capture off the UI goroutine
// (Start spawns a subprocess and may briefly block or fail on a missing tool).
func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd {
func startBatchRecordingCmd(rec Recorder) tea.Cmd {
return func() tea.Msg {
return dictationStartedMsg{sessionID: sessionID, err: rec.Start()}
return dictationStartedMsg{err: rec.Start()}
}
}
// transcribeBatchCmd stops the recording, transcribes the audio, and reports the
// final text. Runs off the UI goroutine — Stop waits on the capture tool and
// Transcribe does a network round-trip or a local exec.
func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
if ctx == nil {
ctx = context.Background()
}
return func() tea.Msg {
audio, err := rec.Stop()
if err != nil {
return dictationTranscribedMsg{sessionID: sessionID, err: err}
return dictationTranscribedMsg{err: err}
}
text, err := transcriber.Transcribe(ctx, audio)
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit}
return dictationTranscribedMsg{text: text, err: err, submit: submit}
}
}
// handleDictationStarted transitions to recording (or reports a start failure).
func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
// Stale startup from a cancelled session must not reset or re-arm a
// newer recording that already owns the controller.
return m, nil
}
if msg.err != nil {
m = m.discardDictationRegion()
m.dictation.reset()
@@ -387,9 +375,6 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
// handleDictationTranscribed inserts the final transcript into the composer (or
// submits it when stt.autoSubmit is on), then returns to idle.
func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
return m, nil
}
streaming := m.dictation.streaming || msg.streaming
m = m.commitDictationRegion()
// A streaming session can end via a transcriber error (not just a user stop), so
@@ -407,17 +392,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
}
m.dictation.reset()
if errors.Is(msg.err, context.Canceled) {
// cancelDictation already discarded the live region, reset the
// session, and posted "Dictation cancelled." A streaming transcriber
// can still race a buffered event past that cancel and report back a
// nonempty compose()+context.Canceled; treat that as terminal here,
// before the auto-submit branch below, so Esc can never fall through
// to msg.submit and fire the composer's restored pre-existing text.
return m, nil
}
if msg.err != nil {
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
// A cloud auth failure (missing/invalid key) is fixable in place: reopen the
// API-key prompt for the current provider so the user can paste a key and
// retry, instead of hitting a dead-end "run zero auth" line.
@@ -438,7 +413,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
return m.appendSystemNotice("No speech detected."), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
+3 -3
View File
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
+2 -3
View File
@@ -2,7 +2,6 @@ package tui
import (
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -80,7 +79,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !strings.Contains(got.transientNotice.text, "ready") {
if !transcriptHasText(got, "ready") {
t.Error("expected a ready notice")
}
}
@@ -159,7 +158,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
if cmd == nil {
t.Error("expected a download command")
}
if !strings.Contains(next.transientNotice.text, variant.Label) {
if !transcriptHasText(next, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
+4 -9
View File
@@ -14,9 +14,8 @@ import (
// mechanism agent text deltas use (§6). text is the cumulative best transcript
// so far (not a delta); final marks a settled segment.
type sttPartialMsg struct {
sessionID int64
text string
final bool
text string
final bool
}
// startStreamingDictation begins continuous capture and launches the streaming
@@ -39,7 +38,6 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
}
transcriber := m.dictation.transcriber
submit := m.dictation.cfg.AutoSubmitEnabled()
sessionID := m.dictation.sessionID
// Tap the audio: compute a mic level per chunk for the live waveform, then
// forward the chunk to the transcriber. A small buffer keeps the tap from
@@ -65,11 +63,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
streamCmd := func() tea.Msg {
onPartial := func(text string, final bool) {
if sink != nil {
sink(sttPartialMsg{sessionID: sessionID, text: text, final: final})
sink(sttPartialMsg{text: text, final: final})
}
}
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true}
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
}
// Streaming drives the waveform from real levels (no synthetic tick needed).
return m, streamCmd
@@ -79,9 +77,6 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
// composer, replacing the previously-rendered live region wholesale so the text
// builds up in place as the user keeps talking.
func (m model) handleDictationPartial(msg sttPartialMsg) model {
if msg.sessionID != m.dictation.sessionID {
return m
}
// Ignore stragglers that arrive after the session ended (cancel/final).
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
return m
+4 -186
View File
@@ -33,11 +33,8 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if got.transientNotice.text != "No speech detected." {
t.Errorf("no-speech notice = %q", got.transientNotice.text)
}
if transcriptHasText(got, "No speech detected") {
t.Error("no-speech confirmation should not be persisted in the transcript")
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
}
}
@@ -89,10 +86,9 @@ func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
func TestDictationStartedFailureResets(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
m.dictation.streaming = false
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")})
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
if got.dictation.active() {
t.Error("a start failure should reset to idle")
}
@@ -103,48 +99,13 @@ func TestDictationStartedFailureResets(t *testing.T) {
func TestDictationStartedArmsRecording(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1})
got, _ := m.handleDictationStarted(dictationStartedMsg{})
if got.dictation.phase != dictRecording {
t.Error("a successful start should advance to recording")
}
}
// TestStaleDictationStartedIgnoredWhileLive pins the session gate on batch
// startup messages. A late Start() result after cancel must not reset() a
// newer recording (success would leave dictStarting with no recorder; failure
// would kill the live session).
func TestStaleDictationStartedIgnoredWhileLive(t *testing.T) {
// Active recording on session 2.
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "live session text", cursor: len("live session text")})
// Stale failure from session 1 must not reset the live controller.
afterFail, _ := m.handleDictationStarted(dictationStartedMsg{
sessionID: 1,
err: errors.New("mic busy from previous start"),
})
if afterFail.dictation.phase != dictRecording || afterFail.dictation.sessionID != 2 {
t.Fatalf("stale start failure: phase=%v session=%d, want recording/2", afterFail.dictation.phase, afterFail.dictation.sessionID)
}
if transcriptHasText(afterFail, "mic busy from previous start") {
t.Fatal("stale start failure must not post an error notice")
}
// Stale success from session 1 must not leave the live session stuck mid-start.
m2 := model{}
m2.dictation.sessionID = 2
m2.dictation.phase = dictRecording
afterOK, _ := m2.handleDictationStarted(dictationStartedMsg{sessionID: 1})
if afterOK.dictation.phase != dictRecording || afterOK.dictation.sessionID != 2 {
t.Fatalf("stale start success: phase=%v session=%d, want recording/2", afterOK.dictation.phase, afterOK.dictation.sessionID)
}
}
func TestDictationUnavailableShowsHint(t *testing.T) {
m := model{} // no build factory
next, _ := m.toggleDictation()
@@ -175,69 +136,6 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
}
}
func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
// Esc can race an already-buffered realtime event: cancelDictation discards
// the live region and resets state synchronously, but the streaming
// goroutine's dictationTranscribedMsg (a nonempty compose() alongside
// context.Canceled) can still arrive afterward. With stt.autoSubmit on,
// that must never fall through to msg.submit and fire the composer's
// restored pre-existing text.
// Matching sessionID is required so this hits the context.Canceled branch
// rather than only the stale-session gate (session 0 after cancel would be
// dropped before that branch runs).
m := model{}
m.dictation.sessionID = 1
m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{sessionID: 1, text: "half-formed transcript"})
if m.composer.text != "existing prompt half-formed transcript" {
t.Fatalf("partial did not render into composer: %q", m.composer.text)
}
m, _ = m.cancelDictation()
if m.composer.text != "existing prompt" {
t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text)
}
// The already-buffered event's message arrives after the cancel above.
next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{
sessionID: m.dictation.sessionID,
text: "half-formed transcript",
err: context.Canceled,
submit: true,
streaming: true,
})
got := next.(model)
if got.composer.text != "existing prompt" {
t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text)
}
if cmd != nil {
t.Error("a raced cancellation must not return a submit command")
}
}
// TestStaleDictationPartialIgnoredWhileLive pins the session-ID gate on
// handleDictationPartial while a new session is still recording. Cancel-then-
// stale cases are already covered by the phase guard; this case needs a live
// phase so only the session check drops the ghost partial.
func TestStaleDictationPartialIgnoredWhileLive(t *testing.T) {
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "user typed this", cursor: len("user typed this")})
got := m.handleDictationPartial(sttPartialMsg{
sessionID: 1,
text: "ghost text from session 1",
})
if got.composer.text != "user typed this" {
t.Fatalf("stale partial while live: got %q, want %q", got.composer.text, "user typed this")
}
}
func TestDictationCommitKeepsStreamedText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "", cursor: 0})
@@ -404,83 +302,3 @@ func TestCurrentModelLabel(t *testing.T) {
t.Errorf("no-model label = %q", got)
}
}
func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "original composer text", cursor: 22})
m.dictation.sessionID = 1
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
if m.composer.text != "original composer text" {
t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text)
}
// Stale partial message from session 1 must be ignored
stalePartial := sttPartialMsg{
sessionID: 1,
text: "stale partial that should be ignored",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text)
}
staleMsg := dictationTranscribedMsg{
sessionID: 1,
text: "stale text that should be ignored",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale completion, got %v", cmd)
}
}
func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "initial composer text", cursor: 21})
m.dictation.sessionID = 0
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
// Session 0 was reset to session 1
if m.dictation.sessionID != 1 {
t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID)
}
// Stale partial from session 0 must be ignored
stalePartial := sttPartialMsg{
sessionID: 0,
text: "stale partial from session 0",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text)
}
// Stale completion from session 0 must be ignored
staleMsg := dictationTranscribedMsg{
sessionID: 0,
text: "stale completion from session 0",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale session 0 completion, got %v", cmd)
}
}
+2 -5
View File
@@ -36,11 +36,8 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
+14 -22
View File
@@ -6,12 +6,10 @@ import (
"fmt"
"os"
"strings"
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tools"
)
func renderMarkdownInline(text string) string {
@@ -52,26 +50,6 @@ func formatGroupedCommandHelpLines() []string {
return lines
}
// runningPlanModel supplies plan state for tests that exercise contextual
// details without reviving the former persistent plan presentation.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
@@ -112,6 +90,20 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
// height returns the number of terminal lines renderPlanPanel will occupy at
// the given width (0 when the panel is not visible). The step list is shown
// when the panel is expanded or still running; a collapsed, finished plan is
// just the header and progress bar.
func (s planPanelState) height(width int, now time.Time) int {
if !s.visible(now) {
return 0
}
if s.expanded || !s.isComplete() {
return 2 + len(s.steps)
}
return 2
}
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
+9 -33
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -162,9 +161,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
}
}
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
// coordinates cannot unexpectedly replace the full-width conversation view.
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
@@ -186,14 +185,14 @@ func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if handled {
t.Fatal("invisible rail coordinates must not handle clicks")
if !handled {
t.Fatal("agent row click should be handled")
}
if !updated.fileView.active {
t.Fatal("an ignored rail click must leave the active file view alone")
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
}
if updated.subchat.active {
t.Fatal("an ignored rail click must not enter a subchat")
if !updated.subchat.active {
t.Fatal("subchat should be active")
}
}
@@ -218,29 +217,6 @@ func TestChangedFilesRehydration(t *testing.T) {
}
}
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
// user saw while the run was live. The provider-facing output is intentionally
// a short confirmation, so it cannot substitute for the card-only preview.
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected one restored row, got %d", len(rows))
}
if rows[0].detail != preview {
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
}
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
+33 -2
View File
@@ -6,8 +6,10 @@
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// The same compact rows feed the run-details overlay; selecting a file from a
// visible transcript card opens the file drill-in (file_view.go).
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
package tui
import (
@@ -15,6 +17,8 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -308,6 +312,33 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
+31 -9
View File
@@ -161,16 +161,38 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
}
}
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
// permanent column beside the conversation.
func TestRunDetailsListsTouchedFiles(t *testing.T) {
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
m := filesPanelTestModel()
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, file := range m.touchedFiles() {
if !strings.Contains(plain, file.path) {
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
}
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
}
}
+4 -2
View File
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
_ = viewString(m.View())
selectedView := viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
@@ -126,7 +126,9 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
_ = viewString(m.View())
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
+14 -3
View File
@@ -51,10 +51,21 @@ func mouseHover(msg tea.MouseMsg) bool {
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves visible transcript targets for a hover motion.
// Context data lives in the run-details overlay; the former sidebar rail is not
// rendered and therefore cannot own a hover target.
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+69 -48
View File
@@ -1,12 +1,12 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func TestMouseHoverPredicate(t *testing.T) {
@@ -78,71 +78,70 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
}
}
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowToolResult,
id: "diff",
tool: "write_file",
status: tools.StatusOK,
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
})
rowIndex := len(m.transcript) - 1
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.rowIndex == rowIndex {
target = line
break
}
}
if target.text == "" {
t.Fatalf("expected a selectable diff header, got %#v", selectable)
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
}
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.transcript[rowIndex].expanded {
t.Fatal("clicking an always-visible diff header must not toggle it")
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
t.Fatal("expected at least one clickable sidebar agent row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
t.Fatal("expected at least one clickable plan step row")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
}
}
@@ -208,6 +207,28 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
-56
View File
@@ -1,12 +1,7 @@
package tui
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
@@ -14,13 +9,9 @@ import (
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
_ "golang.org/x/image/webp"
)
const attachmentThumbnailMaxPixels = 16 << 20
// droppableImageExts are the image extensions a dragged-and-dropped file may
// carry (matched case-insensitively); PDFs are recognized separately.
var droppableImageExts = map[string]bool{
@@ -117,14 +108,6 @@ func (m model) modelSupportsVisionTUI() bool {
for _, models := range m.modelPickerLiveByProvider {
for _, dm := range models {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
// A provider's authenticated listing may only establish which models
// are available, without repeating modalities. Treat an empty list as
// unknown rather than as an explicit image-input denial, so the
// curated registry/name capability fallback remains available while
// models.dev metadata is temporarily unavailable.
if len(dm.InputModalities) == 0 {
continue
}
for _, modality := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
@@ -158,7 +141,6 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
Data: data,
})
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
m.refreshPendingImageThumbnail()
return m
}
@@ -175,7 +157,6 @@ func (m model) handleImageCommand(arg string) model {
case strings.EqualFold(trimmed, "clear"):
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
return m.appendImageNotice("Cleared pending attachments.")
}
@@ -204,7 +185,6 @@ func (m model) handleImageCommand(arg string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
m.refreshPendingImageThumbnail()
// No "attached" system message: the composer attachment chip ([Image #N]) is
// the confirmation, matching the compact attach UX.
return m
@@ -239,7 +219,6 @@ func (m model) handleDocumentAttach(path string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, label)
}
m.refreshPendingImageThumbnail()
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
// "attached" system message.
return m
@@ -290,46 +269,11 @@ func (m model) removeLastAttachment() (model, bool) {
if len(m.pendingImages) > 0 {
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
}
m.refreshPendingImageThumbnail()
return m, true
}
return m, false
}
// refreshPendingImageThumbnail prepares only the first pending image for the
// optional inline terminal preview. Decode failures deliberately leave the
// attachment intact: the text chip remains the portable fallback and the model
// still receives the original bytes.
func (m *model) refreshPendingImageThumbnail() {
m.pendingImageThumbnails = nil
if len(m.pendingImages) == 0 {
return
}
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
for index := 0; index < count; index++ {
preview, err := attachmentThumbnail(m.pendingImages[index])
if err == nil {
m.pendingImageThumbnails[index] = preview
}
}
}
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
return nil, fmt.Errorf("image is too large for an inline thumbnail")
}
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
return terminalpet.ThumbnailAnimation(decoded)
}
// renderAttachmentChips builds the pending-attachment row from both staged images
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
// nothing is staged. Numbered (not named) so a long screenshot path never shows
-16
View File
@@ -188,22 +188,6 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
t.Fatalf("submit should append a user row, got %#v", next.transcript)
}
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
}
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) == 0 {
t.Fatal("submit should persist the user event")
}
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
t.Fatalf("persisted attachment summary = %#v, want one image", got)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
+6 -9
View File
@@ -1,5 +1,5 @@
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
// by reading the source. A single-key `?` overlay (opened on an empty composer)
// lists them grouped, so the keymap is discoverable the way the reference TUIs
@@ -7,15 +7,11 @@
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// toggleSidebar) pull their key label from m.keyBindings so a user
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import (
"strings"
"charm.land/lipgloss/v2"
)
import "strings"
// keybinding is one row in the help overlay: the key chord and what it does.
type keybinding struct {
@@ -57,6 +53,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -66,7 +63,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
@@ -147,7 +144,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
return centerRenderedBlock(block, width)
}
+19 -21
View File
@@ -162,24 +162,22 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
}
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.altScreen = true
m.width = 120
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
}
initialDetails := m.runDetailsOpen
initialSidebar := m.sidebarHidden
updated, _ := m.Update(testKeyCtrl('b'))
next := updated.(model)
if next.runDetailsOpen == initialDetails {
t.Fatal("Ctrl+B on empty composer should toggle run details")
if next.sidebarHidden == initialSidebar {
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
}
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
initialMouse := next.mouseReleased
updated, _ = next.Update(testKeyCtrl('e'))
@@ -200,11 +198,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
}
initialDetails2 := m2.runDetailsOpen
initialSidebar2 := m2.sidebarHidden
updated, _ = m2.Update(testKeyCtrl('b'))
next2 := updated.(model)
if next2.runDetailsOpen != initialDetails2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
if next2.sidebarHidden != initialSidebar2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
}
initialMouse2 := next2.mouseReleased
@@ -230,8 +228,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -249,11 +247,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
}
initialDetails := next.runDetailsOpen
initialSidebar := next.sidebarHidden
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.runDetailsOpen == initialDetails {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
@@ -275,8 +273,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -291,10 +289,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
}
initialDetails := next.runDetailsOpen
initialSidebar := next.sidebarHidden
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
if next.runDetailsOpen != initialDetails {
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
if next.sidebarHidden != initialSidebar {
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
}
}
+3 -4
View File
@@ -5,7 +5,6 @@ import (
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
@@ -137,12 +136,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
// terminal's own canvas remains visible without jagged panel-fill padding.
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
return centerRenderedBlock(block, width)
}
+1 -17
View File
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same transparent, titled frame builder.
// Both overlays must use the same frame builder (titled box + panel fill).
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
@@ -267,22 +267,6 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
}
}
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
panelFill := zeroTheme.panel.Render(" ")
for _, overlay := range []struct {
name string
view string
}{
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
{"leader help", m.renderLeaderHelpOverlay(100)},
} {
if strings.Contains(overlay.view, panelFill) {
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
}
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
+32 -1
View File
@@ -91,12 +91,32 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
}
}
func TestCtrlPMovesPickerSelection(t *testing.T) {
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
@@ -105,6 +125,9 @@ func TestCtrlPMovesPickerSelection(t *testing.T) {
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
@@ -112,11 +135,19 @@ func TestCtrlPMovesPickerSelection(t *testing.T) {
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
+333 -342
View File
File diff suppressed because it is too large. Load diff
-17
View File
@@ -46,23 +46,6 @@ func TestModelSupportsVisionTUIChecksDiscoveredBeforeHeuristic(t *testing.T) {
}
}
func TestModelSupportsVisionTUIFallsBackWhenLiveMetadataOmitsModalities(t *testing.T) {
registry := mustTestModelRegistry(t, testModelEntry("custom-known", 12345, []modelregistry.ModelCapability{
modelregistry.ModelCapabilityChat,
}))
m := model{
modelName: "gpt-5.6-sol",
modelCatalog: registry,
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
},
}
if !m.modelSupportsVisionTUI() {
t.Fatal("live discovery without modality metadata must fall back to the model capability heuristic")
}
}
func BenchmarkModelContextWindowLookup(b *testing.B) {
cachedRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
+28 -119
View File
@@ -3,7 +3,6 @@ package tui
import (
"bytes"
"context"
"encoding/json"
"errors"
"path/filepath"
"reflect"
@@ -20,7 +19,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -560,79 +558,13 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
if rebuilt.Model != "gpt-4.1-mini" {
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
}
for _, want := range []string{"Model:", "gpt-4.1-mini · openai"} {
if !strings.Contains(next.transientNotice.text, want) {
t.Fatalf("expected model notice to contain %q, got %q", want, next.transientNotice.text)
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
var rebuilt config.ProviderProfile
nextProvider := &fakeProvider{}
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-terra",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://chatgpt.com/backend-api/codex",
Model: "gpt-5.6-terra",
},
Provider: &fakeProvider{},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilt = profile
return nextProvider, nil
},
})
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
}
m.input.SetValue("/model openai/gpt-5.6-sol")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
t.Fatalf("expected ChatGPT model switch to use bare catalog ID, got model=%q provider=%#v", next.modelName, next.provider)
}
if rebuilt.Model != "gpt-5.6-sol" {
t.Fatalf("expected provider rebuild with bare ChatGPT model ID, got %#v", rebuilt)
}
}
func TestProviderModelSwitchCandidatesPreserveGatewayModelIDs(t *testing.T) {
for _, test := range []struct {
name string
provider string
input string
want []string
}{
{
name: "ChatGPT accepts the models.dev OpenAI namespace",
provider: "chatgpt",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol", "gpt-5.6-sol"},
},
{
name: "gateway keeps qualified model ID",
provider: "openrouter",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol"},
},
} {
t.Run(test.name, func(t *testing.T) {
if got := providerModelSwitchCandidates(test.provider, test.input); !reflect.DeepEqual(got, test.want) {
t.Fatalf("providerModelSwitchCandidates(%q, %q) = %#v, want %#v", test.provider, test.input, got, test.want)
}
})
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
@@ -679,8 +611,8 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
if !transcriptContains(next.transcript, "· saved") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
}
}
@@ -1536,28 +1468,6 @@ func TestToolResultDetailPrefersPreview(t *testing.T) {
}
}
func TestToolResultSessionPayloadKeepsPreviewForResume(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
payload := toolResultSessionPayload(agent.ToolResult{
ToolCallID: "edit-1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "Successfully edited calculator.go (replaced 1 occurrence).",
Display: tools.Display{Preview: preview},
})
if got, want := payload["displayPreview"], preview; got != want {
t.Fatalf("persisted display preview = %#v, want %q", got, want)
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal session payload: %v", err)
}
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: encoded}})
if len(rows) != 1 || rows[0].detail != preview {
t.Fatalf("resumed row lost the display preview: %#v", rows)
}
}
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
func TestReasoningRefreshesActivityClock(t *testing.T) {
@@ -1592,15 +1502,16 @@ func TestStaleExplanationDropped(t *testing.T) {
}
}
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
// conversation surface and advances the explanation generation.
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
// and the explanation generation advances.
func TestBeginRunResetsSidebarHidden(t *testing.T) {
m := newModel(context.Background(), Options{})
m.runDetailsOpen = true
m.sidebarHidden = true
gen := m.planDetailGen
m = m.beginRun(nil)
if m.runDetailsOpen {
t.Error("beginRun should close run details for the new turn")
if m.sidebarHidden {
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
}
if m.planDetailGen <= gen {
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
@@ -2650,10 +2561,14 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
}
withDetails := idle
withDetails.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withDetails.composerIdleHint()); !strings.Contains(hint, "Ctrl+B details") {
t.Fatalf("available run details should be advertised, got %q", hint)
withSidebar := idle
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("available sidebar should be advertised, got %q", hint)
}
withSidebar.sidebarHidden = true
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
}
})
}
@@ -3031,27 +2946,21 @@ func TestScrimViewportLine(t *testing.T) {
if got := scrimViewportLine(" ", 10); got != " " {
t.Fatalf("blank line should be untouched, got %q", got)
}
// Non-blank plain lines keep their text and become faint, so the backdrop stays
// readable behind the overlay without competing with its focus surface.
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
// stays readable behind the overlay.
got := scrimViewportLine("transcript content", 40)
if ansi.Strip(got) != "transcript content" {
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
}
// Styled transcript content must retain its semantic foreground and background
// colors. The scrim adds faintness around resets rather than flattening a diff
// or syntax-highlighted line to a single grey style.
styled := "\x1b[38;2;235;80;110mremoved\x1b[0m \x1b[48;2;35;80;58madded\x1b[0m"
// A pre-styled line must have its OWN styling stripped (so the dim wins), while
// the text content survives. This fails if scrim stops re-styling the backdrop.
styled := "\x1b[31mred backdrop\x1b[0m text"
scrimmed := scrimViewportLine(styled, 40)
if ansi.Strip(scrimmed) != "removed added" {
if ansi.Strip(scrimmed) != "red backdrop text" {
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
}
for _, sequence := range []string{"\x1b[38;2;235;80;110m", "\x1b[48;2;35;80;58m"} {
if !strings.Contains(scrimmed, sequence) {
t.Fatalf("scrim must preserve semantic ANSI sequence %q, got %q", sequence, scrimmed)
}
}
if !strings.Contains(scrimmed, "\x1b[2m") {
t.Fatalf("scrim must apply faint styling around semantic colors, got %q", scrimmed)
if strings.Contains(scrimmed, "\x1b[31m") {
t.Fatalf("scrim must strip the line's original styling, got %q", scrimmed)
}
}
+35 -14
View File
@@ -287,6 +287,38 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
// sidebarLineAtMouse maps a left-click in the AGENTS sidebar column to the swarm
// member whose row was clicked, when that member's session is known. Geometry:
// twoColumnTranscriptView is the full screen (sidebarActive ⇒ alt-screen), and
// joinColumns lays out [chat(chatColumnWidth)][" │ " 3-cell divider][sidebar],
// zipping row-by-row — so a sidebar line's screen Y equals its sidebar index and
// the sidebar starts at screen X = chatColumnWidth + 3. Recomputed on demand
// (View can't persist a registry on the value-receiver model), like
// transcriptLineAtMouse.
func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) {
if !m.sidebarActive() {
return sidebarAgentHit{}, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return sidebarAgentHit{}, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return sidebarAgentHit{}, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return sidebarAgentHit{}, false
}
for _, hit := range m.sidebarAgentSelectables(sidebarW) {
if hit.lineOffset == y && hit.sessionID != "" {
return hit, true
}
}
return sidebarAgentHit{}, false
}
func (m model) repeatMouseSelection(target mouseSelectionTarget) bool {
return target.Scope != "" && m.lastMouseSelection == target
}
@@ -499,20 +531,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if !ok {
return mouseSelectionTarget{}, false
}
// The theme picker has a right-hand visual preview on wider terminals. It is
// deliberately non-interactive: clicking a color sample must not select a
// list item merely because it shares that row's y-coordinate.
if m.picker.kind == pickerTheme {
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
}
listWidth, _, showPreview := themePickerColumnWidths(maxInt(1, overlayWidth-4))
// styledBlockFillTitle contributes a left border and one-cell inset.
if showPreview && hit.x >= listWidth+2 {
return mouseSelectionTarget{}, false
}
}
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
@@ -531,6 +549,9 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+4 -24
View File
@@ -9,9 +9,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
@@ -253,27 +251,6 @@ func TestComposerMouseClickMovesCursor(t *testing.T) {
}
}
func TestComposerMouseClickMovesCursorBelowImageThumbnail(t *testing.T) {
m := mouseTestModel()
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.refreshPendingImageThumbnail()
m.input.SetValue("hello world")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + 5
y := frame.composerRect.y + 1 + attachmentPreviewRows
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, x, y))
next := updated.(model)
if cmd != nil {
t.Fatal("composer click should not return a command")
}
if got := next.currentComposerState().cursor; got != 5 {
t.Fatalf("composer cursor below thumbnail = %d, want 5", got)
}
}
func TestComposerMouseDragSelectsCopiesAndClears(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("hello world")
@@ -1008,7 +985,10 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
contentY := 1 + m.attachmentComposerPrefixRows(width)
contentY := 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY++
}
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
y := frame.composerRect.y + contentY
return x, y
+22 -55
View File
@@ -22,19 +22,13 @@ type terminalOutputFile interface {
type petImageOutput struct {
output terminalOutputFile
renderers []*terminalpet.ImageRenderer
renderDisabled []bool
renderer *terminalpet.ImageRenderer
renderDisabled bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderers ...*terminalpet.ImageRenderer) *petImageOutput {
active := make([]*terminalpet.ImageRenderer, 0, len(renderers))
for _, renderer := range renderers {
if renderer != nil {
active = append(active, renderer)
}
}
return &petImageOutput{output: output, renderers: active, renderDisabled: make([]bool, len(active))}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
@@ -45,13 +39,11 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
if leavingAltScreen {
for _, renderer := range o.renderers {
if !rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return 0, err
}
}
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
}
}
if leavingAltScreen {
@@ -59,36 +51,26 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
if err != nil {
return written, err
}
for _, renderer := range o.renderers {
if rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
for _, renderer := range o.renderers {
renderer.Invalidate()
}
o.renderer.Invalidate()
}
var imageUpdate bytes.Buffer
for index, renderer := range o.renderers {
if o.renderDisabled[index] {
continue
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
}
var rendererUpdate bytes.Buffer
if err := renderer.Render(&rendererUpdate); err != nil {
// Terminal images are decorative. Disable only the failing renderer for
// this output session so an attachment issue cannot hide a companion
// (or vice versa) or terminate the interactive shell.
o.renderDisabled[index] = true
continue
}
imageUpdate.Write(rendererUpdate.Bytes())
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
@@ -138,15 +120,6 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
return written, endErr
}
func rendererUsesKitty(renderer *terminalpet.ImageRenderer) bool {
switch renderer.Support().Protocol {
case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile:
return true
default:
return false
}
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
@@ -207,15 +180,9 @@ func (o *petImageOutput) clearImage() error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
for _, renderer := range o.renderers {
if err := renderer.Clear(writer); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
}
if len(o.renderers) == 0 {
return nil
}
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
return o.renderers[0].DeleteImages(writer, ids...)
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
})
}
+10 -4
View File
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
m.petPreview = nil
m.picker = nil
m.petTickSeq++
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
}
m.petRequestedSlug = argument
localEntries, _ := m.petClient.InstalledEntries()
@@ -242,13 +242,13 @@ func (m model) installPet(slug string) (tea.Model, tea.Cmd) {
m.petName = ""
m.petAnimation = nil
m.petTickSeq++
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
}
entry, ok := m.petEntries[slug]
if !ok {
return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil
}
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
m = m.appendSystemNotice("Installing " + entry.Label() + "…")
return m, func() tea.Msg {
animation, err := m.petClient.Install(m.ctx, entry)
return petInstalledMsg{entry: entry, animation: animation, err: err}
@@ -270,7 +270,7 @@ func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) {
m.petPlaybackState = terminalpet.Idle
m.petClickAnimationIndex = 0
m.petOutcome = terminalpet.Idle
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
if m.reducedMotion {
return m, nil
}
@@ -396,6 +396,9 @@ func (m model) petLayoutActive() bool {
return false
}
layoutWidth := m.width
if m.sidebarActive() {
layoutWidth = m.chatColumnWidth()
}
if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed {
return false
}
@@ -612,6 +615,9 @@ func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int {
}
func (m model) petHomePosition(width, height int) (int, int) {
if m.sidebarActive() {
width = m.chatColumnWidth()
}
maxX := maxInt(0, width-petImageColumns)
maxY := maxInt(0, height-petImageRows)
return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY)
+4 -4
View File
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
}
}
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
frame := image.NewNRGBA(image.Rect(0, 0, 12, 12))
@@ -639,12 +639,12 @@ func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
}
runes := []rune(line)
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
if len(runes) < m.width || runes[dockEdge] != '╮' {
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
}
return
}
t.Fatal("composer top not found")
t.Fatal("sidebar composer top not found")
}
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
+23 -51
View File
@@ -522,43 +522,9 @@ func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
pairs = append(pairs, m.recentModels...)
for index := range pairs {
pairs[index] = m.canonicalRecentModelPair(pairs[index])
}
return config.NormalizeRecentModels(pairs)
}
// canonicalRecentModelPair collapses legacy OpenAI-prefixed model IDs for a
// ChatGPT profile. ChatGPT's Codex endpoint uses bare model IDs, while older
// hand-entered configuration could retain the equivalent openai/<id> spelling.
// Keep this picker-only so OpenAI-compatible gateway IDs remain untouched.
func (m model) canonicalRecentModelPair(pair config.RecentModelEntry) config.RecentModelEntry {
pair.Provider = strings.TrimSpace(pair.Provider)
pair.Model = strings.TrimSpace(pair.Model)
if !m.recentPairUsesChatGPT(pair.Provider) {
return pair
}
if len(pair.Model) >= len("openai/") && strings.EqualFold(pair.Model[:len("openai/")], "openai/") {
pair.Model = strings.TrimSpace(pair.Model[len("openai/"):])
}
return pair
}
func (m model) recentPairUsesChatGPT(providerName string) bool {
if profile, ok := m.savedProviderByName(providerName); ok {
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
}
if !strings.EqualFold(strings.TrimSpace(providerName), strings.TrimSpace(m.providerName)) {
return false
}
if descriptor, ok := m.activeProviderDescriptor(); ok {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
return providercatalog.NormalizeID(m.providerProfile.CatalogID) == "chatgpt"
}
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
// tagging it with OwnerProvider so choosing it can switch providers (like any
// other cross-provider picker row) even when it isn't the active provider.
@@ -1042,39 +1008,45 @@ func (m model) newEffortPicker() *commandPicker {
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists the terminal-native system theme followed by named palettes
// in one flat list. Old dark/light preferences migrate to System rather than
// appearing as choices: they would imply a terminal-canvas change that Zero
// deliberately does not make.
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
for _, name := range themeModes {
item := pickerItem{Label: name, Value: name}
if name == string(themeSystem) {
item.Label = "System"
} else if entry, ok := lookupTheme(name); ok {
item.Label = entry.Label
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
}
items = append(items, item)
if name == string(m.themeMode) {
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta. Theme candidates render
// only in the picker preview; their active palette is applied only after Enter.
// Safe to call with no picker open. Callers mutate through m.picker (a pointer),
// so the value receiver is fine.
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+5 -29
View File
@@ -532,8 +532,8 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
if next.modelName != "openai/gpt-4.1" {
t.Fatalf("active model = %q, want raw OpenRouter model ID", next.modelName)
}
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
t.Fatalf("model switch notice = %q", next.transientNotice.text)
if !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
t.Fatalf("expected model switch status, got %#v", next.transcript)
}
}
@@ -570,30 +570,6 @@ func TestRecentModelPairsForPickerPinsActiveDedupesAndCaps(t *testing.T) {
}
}
func TestRecentModelPairsForPickerCanonicalizesChatGPTOpenAIModelAliases(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-sol",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
},
RecentModels: []config.RecentModelEntry{
{Provider: "chatgpt", Model: "openai/gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
},
})
got := m.recentModelPairsForPicker()
want := []config.RecentModelEntry{
{Provider: "chatgpt", Model: "gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("recentModelPairsForPicker() = %#v, want %#v", got, want)
}
}
// The picker's cross-group de-dup must key on provider+model, not model id
// alone: the same model id can be offered by two different providers, and
// those are distinct, independently selectable rows (issue: recents should
@@ -954,8 +930,8 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) {
if m.modelName != "claude-haiku-4.5" {
t.Fatalf("expected model switched to claude-haiku-4.5 via handler, got %q", m.modelName)
}
if !strings.Contains(m.transientNotice.text, "Model:") {
t.Fatalf("model picker notice = %q", m.transientNotice.text)
if !transcriptContains(m.transcript, "Model") {
t.Fatal("choosing should append the model handler's status text")
}
}
@@ -987,7 +963,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (contained preview on move, apply on Enter),
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
+208 -7
View File
@@ -1,10 +1,15 @@
// plan_panel.go maintains the current task-plan state for run details and
// step drill-in. The update_plan result itself renders as a transcript
// checklist, while planPanelState preserves per-step timestamps across the
// tool's full-replacement updates for the contextual detail view.
// plan_panel.go renders the sticky plan panel for the Zero TUI. The panel
// surfaces the in-progress task plan produced by the update_plan tool: a
// one-line header with a live spinner and progress count, a text progress
// bar, and (while running or expanded) the per-step list with status icons
// and timings. planPanelState tracks per-step start/completion timestamps
// across the tool's full-replacement updates so durations stay stable as
// steps transition between pending, in_progress, completed, and failed.
package tui
import (
"fmt"
"strings"
"time"
"github.com/Gitlawb/zero/internal/tools"
@@ -22,10 +27,11 @@ type planStep struct {
completedAt time.Time
}
// planPanelState holds the latest plan state. It lives on the model and is
// synced from the update_plan tool's CurrentPlan() output.
// planPanelState holds the sticky plan panel's view state. It lives on the
// model and is synced from the update_plan tool's CurrentPlan() output.
type planPanelState struct {
steps []planStep
expanded bool
completedAt time.Time // set once all steps reach a terminal status
startedAt time.Time // set on the first non-empty update
// frozenAt freezes the live plan clock while the agent is idle (between
@@ -35,6 +41,10 @@ type planPanelState struct {
frozenAt time.Time
}
// completedHideAfter is how long a finished plan stays pinned before the
// collapsed panel hides itself to reclaim screen space.
const completedHideAfter = 30 * time.Second
// updateFromItems syncs the planStep slice from the update_plan tool. The
// tool replaces the entire plan each call, so steps are matched by content
// to preserve start/completion timestamps. Timestamps are filled in for
@@ -118,9 +128,10 @@ func (s *planPanelState) updateFromItems(items []tools.PlanItem, now time.Time)
}
}
// clear resets all plan state and timestamps.
// clear resets all plan panel state (steps, expansion, timestamps).
func (s *planPanelState) clear() {
s.steps = nil
s.expanded = false
s.completedAt = time.Time{}
s.startedAt = time.Time{}
s.frozenAt = time.Time{}
@@ -183,6 +194,18 @@ func (s *planPanelState) completeRemaining(now time.Time) {
}
}
// visible reports whether renderPlanPanel should emit anything. A finished
// plan hides itself once completedHideAfter has elapsed, unless expanded.
func (s planPanelState) visible(now time.Time) bool {
if s.isEmpty() {
return false
}
if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter {
return false
}
return true
}
// planNow returns the clock used for live plan durations. While the agent is
// idle (no run in flight, activeRunID == 0) it freezes at the moment the last
// run ended, so an in_progress step left mid-plan when the agent yields (e.g.
@@ -195,6 +218,128 @@ func (m model) planNow() time.Time {
return m.now()
}
// renderPlanPanel renders the full sticky plan panel. It returns an empty
// string when the plan is empty or when a finished plan has been collapsed
// past completedHideAfter without being expanded.
func (m model) renderPlanPanel(width int) string {
if width < 20 {
width = 20
}
state := m.plan
now := m.planNow()
if !state.visible(now) {
return ""
}
total := len(state.steps)
done := 0
for _, step := range state.steps {
if step.status == "completed" || step.status == "failed" {
done++
}
}
elapsed := time.Duration(0)
if !state.startedAt.IsZero() {
elapsed = now.Sub(state.startedAt)
}
// The header already carries done/total and the per-step ✓/•/✗ icons convey
// progress; a separate filled █/░ bar restates it and reads heavier than the
// reference agents, so it's dropped.
header := renderPlanHeader(state, m.spinnerGlyph(), done, total, elapsed)
lines := []string{header}
showSteps := state.expanded || !state.isComplete()
if showSteps {
maxContent := width - 15
if maxContent < 4 {
maxContent = 4
}
for _, step := range state.steps {
lines = append(lines, renderPlanStepLine(step, now, maxContent))
}
}
return strings.Join(lines, "\n")
}
// renderPinnedPlanPanel renders the plan for the pinned slot above the
// composer. It returns the full panel when it fits within maxHeight, otherwise
// a one-line summary (so a long plan can't crowd out the transcript or the
// input). maxHeight <= 0 means "no budget" and always uses the summary line.
// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the
// full list via m.plan.expanded, but the height budget always wins to keep the
// composer on screen.
func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {
// The pinned panel is only for terminals where the context sidebar CANNOT
// host the plan (too narrow / inline mode). Whenever the two-column layout is
// available — whether the sidebar is shown OR collapsed with Ctrl+B — the
// plan's home is the sidebar, so suppress the pinned copy: a Ctrl+B hide
// should hide the plan entirely, not resurrect it above the composer. Gating
// on sidebarAvailable (not sidebarActive) covers the hidden case too. The
// hidePinnedPlan flag additionally suppresses it in the two-column chat-column
// copy. Both the view and the mouse-geometry frame call footerView, so this
// stays consistent.
if m.hidePinnedPlan || m.sidebarAvailable() {
return ""
}
if !m.plan.visible(m.now()) {
return ""
}
full := m.renderPlanPanel(width)
if full == "" {
return ""
}
if maxHeight > 0 && strings.Count(full, "\n")+1 <= maxHeight {
return full
}
return m.renderPlanSummaryLine(width)
}
// renderPlanSummaryLine renders the collapsed one-line plan summary used when
// the full panel won't fit the pinned slot: a spinner/check, the done/total
// count, and the current (or first incomplete) step, truncated to width.
func (m model) renderPlanSummaryLine(width int) string {
if width < 20 {
width = 20
}
state := m.plan
total := len(state.steps)
done := 0
current := ""
for _, step := range state.steps {
switch step.status {
case "completed", "failed":
done++
case "in_progress":
if current == "" {
current = step.content
}
}
}
if current == "" {
// No in-progress step: show the first not-yet-done step, else the first.
for _, step := range state.steps {
if step.status != "completed" && step.status != "failed" {
current = step.content
break
}
}
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN · %d/%d complete", done, total))
}
label := fmt.Sprintf("%s PLAN · %d/%d · ", m.spinnerGlyph(), done, total)
room := width - len([]rune(label)) - 1
if room < 4 {
room = 4
}
return zeroTheme.accent.Render(label + truncateStep(current, room))
}
// currentStepContent returns the content of the step the plan is "on": the
// in_progress step, else the first step not yet in a terminal status, else the
// first step. Used by the header so the title names the step actually being
@@ -216,6 +361,62 @@ func currentStepContent(steps []planStep) string {
return ""
}
// renderPlanHeader builds the single header line. While running it shows the
// live spinner, the truncated first step, the done/total count, and the
// elapsed time in the accent color; once complete it shows a green check and
// "PLAN COMPLETE".
func renderPlanHeader(state planPanelState, spinnerView string, done, total int, elapsed time.Duration) string {
// Show the step the plan is currently ON (in_progress, else first incomplete),
// not always step 1 — otherwise the header text never advances and a running
// plan reads as stuck even while the done/total count climbs.
current := ""
if total > 0 {
current = truncateStep(currentStepContent(state.steps), 40)
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN COMPLETE · %d/%d · %s", done, total, formatElapsedSeconds(elapsed)))
}
return zeroTheme.accent.Render(fmt.Sprintf("%s PLAN · %s · %d/%d · %s", spinnerView, current, done, total, formatElapsedSeconds(elapsed)))
}
// renderPlanStepLine renders one step row: an indent, a status icon, the
// (truncated) content styled by status, and a duration where applicable.
// Completed/failed steps show the startedAt→completedAt span; an in_progress
// step shows the elapsed time since it started; pending steps show no time.
func renderPlanStepLine(step planStep, now time.Time, maxContent int) string {
content := truncateStep(step.content, maxContent)
var icon, body, timeStr string
switch step.status {
case "completed":
icon = zeroTheme.green.Render("✓")
// Quiet the completed body (muted, not saturated green) so the single
// in-progress accent step is the obvious focus: past=quiet, now=bright,
// future=faint. The green ✓ icon still marks success.
body = zeroTheme.muted.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
case "in_progress":
icon = zeroTheme.accent.Render("•")
body = zeroTheme.accent.Render(content)
started := step.startedAt
if started.IsZero() {
started = now
}
timeStr = formatElapsedSeconds(now.Sub(started))
case "failed":
icon = zeroTheme.red.Render("✗")
body = zeroTheme.red.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
default: // pending
icon = zeroTheme.faint.Render("○")
body = zeroTheme.faint.Render(content)
}
line := " " + icon + " " + body
if timeStr != "" {
line += " " + zeroTheme.faint.Render(timeStr)
}
return line
}
// truncateStep caps s to max runes, appending an ellipsis when truncated.
func truncateStep(s string, max int) string {
if max <= 0 {
+186
View File
@@ -1,6 +1,8 @@
package tui
import (
"fmt"
"strings"
"testing"
"time"
@@ -110,6 +112,92 @@ func TestUpdateFromItemsDuplicateContentKeepsDistinctTimestamps(t *testing.T) {
}
}
func TestPlanPanelHeight(t *testing.T) {
var s planPanelState
now := time.Now()
// Empty plan: height 0
if h := s.height(80, now); h != 0 {
t.Errorf("empty plan height = %d, want 0", h)
}
// Running plan with 3 steps: 2 (header+bar) + 3 (steps) = 5
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "in_progress"},
{Content: "C", Status: "pending"},
}, now)
if h := s.height(80, now); h != 5 {
t.Errorf("running plan height = %d, want 5", h)
}
// Completed plan collapsed: 2 (header+bar only)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "completed"},
{Content: "C", Status: "completed"},
}, now)
if h := s.height(80, now); h != 2 {
t.Errorf("completed collapsed plan height = %d, want 2", h)
}
// Expanded: 2 + 3 = 5
s.expanded = true
if h := s.height(80, now); h != 5 {
t.Errorf("expanded plan height = %d, want 5", h)
}
}
func TestPlanPanelVisibleHidesAfterTimeout(t *testing.T) {
var s planPanelState
t0 := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
}, t0)
// Visible right after completion
if !s.visible(t0.Add(10 * time.Second)) {
t.Error("plan should be visible within 30s of completion")
}
// Hidden after 30s
if s.visible(t0.Add(31 * time.Second)) {
t.Error("plan should hide after 30s of completion when not expanded")
}
// Expanded keeps it visible
s.expanded = true
if !s.visible(t0.Add(31 * time.Second)) {
t.Error("expanded plan should stay visible")
}
}
func TestPlanPanelRenderEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
if got := m.renderPlanPanel(80); got != "" {
t.Errorf("empty plan should render empty string, got %q", got)
}
}
func TestPlanPanelRenderRunning(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
m.plan.updateFromItems([]tools.PlanItem{
{Content: "Read encrypt.go", Status: "completed"},
{Content: "Fix retry loop", Status: "in_progress"},
{Content: "Run tests", Status: "pending"},
}, base)
got := m.renderPlanPanel(80)
if got == "" {
t.Fatal("expected non-empty plan panel render")
}
}
// TestPlanNowFreezeAndResume covers the idle-freeze fix: while the agent is idle
// (activeRunID == 0) and a freeze time is stamped, the plan clock is frozen so an
// in_progress step left mid-plan stops ticking; during a run it tracks live time.
@@ -140,3 +228,101 @@ func TestPlanPanelClearResetsFrozenAt(t *testing.T) {
t.Fatal("clear() must reset frozenAt so the next run starts with a live clock")
}
}
// runningPlanModel builds a model with an n-step running plan for the pinned
// panel tests.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func TestPinnedPlanFullWhenItFits(t *testing.T) {
m := runningPlanModel(t, 3)
// 3 steps => header + 3 step lines = 4; budget 10 fits.
got := m.renderPinnedPlanPanel(80, 10)
if strings.Count(got, "\n")+1 < 4 {
t.Fatalf("expected full multi-line panel within budget, got:\n%s", got)
}
if !strings.Contains(got, "Step number 1") || !strings.Contains(got, "Step number 3") {
t.Fatalf("full panel should list every step, got:\n%s", got)
}
}
func TestPinnedPlanCollapsesToSummaryWhenTooTall(t *testing.T) {
m := runningPlanModel(t, 12)
// 12 steps would be 14 lines; budget 5 forces the one-line summary.
got := m.renderPinnedPlanPanel(80, 5)
if strings.Contains(got, "\n") {
t.Fatalf("over-budget plan should collapse to one line, got:\n%s", got)
}
if !strings.Contains(got, "PLAN") {
t.Fatalf("summary line should still show PLAN, got:\n%s", got)
}
}
func TestPinnedPlanHiddenWhenEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
if got := m.renderPinnedPlanPanel(80, 10); got != "" {
t.Fatalf("no plan => empty pinned render, got %q", got)
}
}
// TestPinnedPlanHiddenWhenSidebarToggledOff: Ctrl+B (sidebarHidden) on a wide
// alt-screen terminal hides the plan entirely — it must NOT fall back to the
// pinned panel above the composer, since the sidebar is the plan's home there.
func TestPinnedPlanHiddenWhenSidebarToggledOff(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
// Real conversation so the sidebar is available (not gated by the home screen).
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// Sidebar shown: the plan lives in the sidebar, no pinned panel.
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("sidebar shown: pinned plan should be suppressed, got:\n%s", got)
}
// Ctrl+B collapses the sidebar -> the plan must be hidden entirely, not pinned.
m.sidebarHidden = true
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("Ctrl+B should hide the plan entirely, but the pinned panel showed:\n%s", got)
}
// Sidebar unavailable (no alt-screen): the pinned panel is the plan's only
// home and must still show.
if got := runningPlanModel(t, 3).renderPinnedPlanPanel(80, 10); got == "" {
t.Fatal("sidebar unavailable: the pinned plan must still show")
}
}
func TestFooterIncludesPinnedPlanAboveComposer(t *testing.T) {
m := runningPlanModel(t, 3)
m.height = 40
footer := plainRender(t, m.footerView(m.chatColumnWidth()))
planIdx := strings.Index(footer, "PLAN")
composerIdx := strings.Index(footer, "describe a task")
if planIdx < 0 {
t.Fatalf("footer should contain the pinned plan, got:\n%s", footer)
}
if composerIdx >= 0 && planIdx > composerIdx {
t.Fatalf("pinned plan should render ABOVE the composer, got:\n%s", footer)
}
}
+26
View File
@@ -120,6 +120,32 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit {
return hits
}
// planStepAtMouse maps a left-click in the context sidebar to a plan step index,
// mirroring sidebarLineAtMouse's column/x gate.
func (m model) planStepAtMouse(msg tea.MouseMsg) (int, bool) {
if !m.sidebarActive() {
return 0, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return 0, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return 0, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return 0, false
}
for _, hit := range m.sidebarPlanSelectables(sidebarW) {
if hit.lineOffset == y {
return hit.stepIndex, true
}
}
return 0, false
}
// planStepDetailRowID is the stable transcript id for the single plan-step
// detail card, so re-clicking toggles it instead of stacking duplicates.
const planStepDetailRowID = "plan/step-detail"
+14 -52
View File
@@ -3,66 +3,28 @@ package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestRenderPlanUpdateCardUsesTranscriptChecklist(t *testing.T) {
func TestPlanSummaryCardBodyCollapses(t *testing.T) {
detail := "Current Plan:\n1. [completed] Explore workspace\n2. [in_progress] Create pages\n3. [pending] Build CSS\n4. [pending] Add JS\n5. [failed] Verify"
got, ok := renderPlanUpdateCard(detail, 80)
if !ok {
t.Fatal("well-formed plan output should render as a checklist")
body := planSummaryCardBody(toolBodyRequest{name: "update_plan", detail: detail})
if len(body.lines) != 1 {
t.Fatalf("expected one summary line, got %d: %#v", len(body.lines), body.lines)
}
plain := plainRender(t, got)
for _, want := range []string{"Updated Plan", "✔ Explore workspace", "□ Create pages", "□ Build CSS", "✗ Verify"} {
if !strings.Contains(plain, want) {
t.Errorf("checklist missing %q: %q", want, plain)
}
}
if strings.Contains(plain, "Current Plan:") || strings.Contains(plain, "[in_progress]") {
t.Errorf("tool-wire format must not leak into the transcript: %q", plain)
}
}
func TestRenderPlanUpdateCardLeavesMalformedOutputToGenericCard(t *testing.T) {
if _, ok := renderPlanUpdateCard("unexpected error text", 80); ok {
t.Error("malformed plan output should fall back to the generic tool card")
}
if _, ok := renderPlanUpdateCard("1. [pending] disguised diagnostic", 80); ok {
t.Error("numbered status lines without a Current Plan header must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Current Plan:\n1. [invalid] Step", 80); ok {
t.Error("unknown plan statuses must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Current Plan:\n1. [pending] Step\nError: persistence failed", 80); ok {
t.Error("trailing diagnostics must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Error: persistence failed\nCurrent Plan:\n1. [pending] Step", 80); ok {
t.Error("diagnostics before Current Plan must fall back to the generic card")
}
}
func TestUpdatePlanResultRendersChecklistInTranscript(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
kind: rowToolResult,
tool: "update_plan",
status: tools.StatusOK,
detail: "Current Plan:\n1. [completed] Inspect the workspace\n2. [in_progress] Add the feature\n3. [pending] Run the tests",
}
got := plainRender(t, m.renderRow(row, 72, buildRowContext([]transcriptRow{row})))
for _, want := range []string{"Updated Plan", "✔ Inspect the workspace", "□ Add the feature", "□ Run the tests"} {
got := body.lines[0]
for _, want := range []string{"5 steps", "1 done", "1 in progress", "1 failed"} {
if !strings.Contains(got, want) {
t.Errorf("transcript plan missing %q: %q", want, got)
t.Errorf("summary missing %q: %q", want, got)
}
}
if strings.Contains(got, "Explore") || strings.Contains(got, "Build CSS") {
t.Errorf("summary must not re-dump the full plan body: %q", got)
}
}
func TestFooterDoesNotPinActivePlanAboveComposer(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.plan.steps = []planStep{{content: "Keep the composer clear", status: "in_progress"}}
footer := plainRender(t, m.footerView(80))
if strings.Contains(footer, "Keep the composer clear") || strings.Contains(footer, "Ctrl+P details") {
t.Fatalf("active plan must live in the transcript, not the footer: %q", footer)
func TestPlanSummaryFallsBackOnNonPlan(t *testing.T) {
body := planSummaryCardBody(toolBodyRequest{name: "update_plan", detail: "unexpected error text"})
if len(body.lines) == 0 {
t.Error("non-plan detail should fall back to a generic body, not collapse to nothing")
}
}
-126
View File
@@ -1,126 +0,0 @@
package tui
import (
"regexp"
"strings"
)
// planUpdateItem is the persisted, transcript-facing form of one update_plan
// item. It is parsed from the tool result rather than the live plan state so a
// resumed session reflows exactly like the original one.
type planUpdateItem struct {
status string
content string
notes string
}
var planUpdateLinePattern = regexp.MustCompile(`^\s*\d+\.\s+\[([^\]]+)\]\s*(.+?)\s*$`)
// parsePlanUpdateItems understands the stable output contract of update_plan:
// a Current Plan header, numbered status lines, and optional indented Notes.
// It returns false for malformed output so the caller can retain the ordinary
// tool-result card instead of dropping an unexpected diagnostic.
func parsePlanUpdateItems(detail string) ([]planUpdateItem, bool) {
lines := strings.Split(strings.ReplaceAll(detail, "\r\n", "\n"), "\n")
items := make([]planUpdateItem, 0, len(lines))
foundHeader := false
for _, line := range lines {
if !foundHeader {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == "Current Plan:" {
foundHeader = true
continue
}
return nil, false
}
if match := planUpdateLinePattern.FindStringSubmatch(line); len(match) == 3 {
status := strings.ToLower(strings.TrimSpace(match[1]))
switch status {
case "pending", "in_progress", "completed", "failed":
default:
return nil, false
}
items = append(items, planUpdateItem{
status: status,
content: strings.TrimSpace(match[2]),
})
continue
}
if strings.TrimSpace(line) == "" {
continue
}
trimmed := strings.TrimSpace(line)
if len(items) > 0 &&
(strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) &&
strings.HasPrefix(trimmed, "Notes:") {
items[len(items)-1].notes = strings.TrimSpace(strings.TrimPrefix(trimmed, "Notes:"))
continue
}
return nil, false
}
return items, foundHeader && len(items) > 0
}
// renderPlanUpdateCard mirrors the transcript-first plan layout: a quiet
// heading followed by a compact, wrapped checklist. Plans therefore remain
// where they happened in the work history instead of occupying a permanent
// footer slot above the composer.
func renderPlanUpdateCard(detail string, width int) (string, bool) {
items, ok := parsePlanUpdateItems(detail)
if !ok {
return "", false
}
if width < 8 {
width = 8
}
lines := []string{zeroTheme.faint.Render("• ") + zeroTheme.ink.Bold(true).Render("Updated Plan")}
for index, item := range items {
firstPrefix := " "
if index == 0 {
firstPrefix = " └ "
}
lines = append(lines, renderPlanUpdateItem(item, firstPrefix, width)...)
}
return strings.Join(lines, "\n"), true
}
func renderPlanUpdateItem(item planUpdateItem, prefix string, width int) []string {
marker, style := planUpdateItemStyle(item.status)
contentWidth := maxInt(1, width-len([]rune(prefix))-len([]rune(marker)))
wrapped := wrapPlainText(item.content, contentWidth)
if len(wrapped) == 0 {
wrapped = []string{"(unnamed step)"}
}
lines := make([]string, 0, len(wrapped)+1)
lines = append(lines, prefix+style.Render(marker+wrapped[0]))
continuation := strings.Repeat(" ", len([]rune(prefix))+len([]rune(marker)))
for _, line := range wrapped[1:] {
lines = append(lines, continuation+style.Render(line))
}
if note := strings.TrimSpace(item.notes); note != "" {
notePrefix := strings.Repeat(" ", len([]rune(prefix))) + " "
noteWidth := maxInt(1, width-len([]rune(notePrefix)))
for _, line := range wrapPlainText(note, noteWidth) {
lines = append(lines, notePrefix+zeroTheme.faint.Italic(true).Render(line))
}
}
return lines
}
func planUpdateItemStyle(status string) (string, interface{ Render(...string) string }) {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed":
return "✔ ", zeroTheme.faint.Strikethrough(true)
case "in_progress":
return "□ ", zeroTheme.accent.Bold(true)
case "failed":
return "✗ ", zeroTheme.red
default:
return "□ ", zeroTheme.faint
}
}
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more