mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2caa9ab93 | ||
|
|
b9894f0eca | ||
|
|
a6892f2b38 | ||
|
|
de2f071dd2 | ||
|
|
1439b25a34 | ||
|
|
00baeac3ca | ||
|
|
b6cd6c1bab | ||
|
|
99a8b9f588 | ||
|
|
889f109630 | ||
|
|
8b4605b121 | ||
|
|
09c78b527f | ||
|
|
d3d7efdcf8 | ||
|
|
94458fd0d1 | ||
|
|
b94e956893 | ||
|
|
4a289c286a | ||
|
|
6c7a5d8f2a | ||
|
|
c4de188713 | ||
|
|
0d6cc7c5a5 | ||
|
|
76fbe0421e | ||
|
|
93034797ad | ||
|
|
17ed6fd9bb | ||
|
|
f7a4158875 | ||
|
|
7ce81090a2 | ||
|
|
634aa7990e | ||
|
|
5a3a7dd0ca | ||
|
|
7b1180ea0a | ||
|
|
eee66ed0e8 | ||
|
|
e41d2e579c | ||
|
|
d66ad71532 | ||
|
|
d065467cd3 | ||
|
|
0eab63c908 | ||
|
|
64a783b08c | ||
|
|
9c292092a2 | ||
|
|
060d38ce71 | ||
|
|
04fd3c01a7 | ||
|
|
dc15e82298 |
No files matched your search
@@ -91,6 +91,14 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sys v0.47.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
@@ -66,6 +66,8 @@ golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZC
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
|
||||
@@ -3087,13 +3087,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
|
||||
// Deferral may activate only when tool_search is actually runnable; otherwise
|
||||
// the loop would hide deferred tools behind a loader the dispatch gate rejects
|
||||
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
|
||||
// registered, not in DisabledTools, and advertised in the current permission
|
||||
// mode (e.g. not spec-draft, where tool_search is not advertised). The
|
||||
// registered, model-visible, not in DisabledTools, and advertised in the
|
||||
// current permission mode (e.g. not spec-draft, where tool_search is not
|
||||
// advertised). The
|
||||
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
|
||||
// exempt from the allowlist at dispatch, so an allowlist that omits it must
|
||||
// not disable deferral.
|
||||
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
|
||||
loaderUsable := loaderFound &&
|
||||
tools.ModelVisible(loader) &&
|
||||
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
|
||||
ToolAdvertised(loader, permissionMode)
|
||||
|
||||
@@ -3208,7 +3210,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
return tools.ModelVisible(tool) &&
|
||||
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
|
||||
ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
|
||||
|
||||
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func 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")
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ work.
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, edit_file, apply_patch - over shelling out to
|
||||
write_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,10 +61,11 @@ work.
|
||||
- Keep edits focused and reviewable. A single patch may update several related
|
||||
files when they form one coherent change; do not hide unrelated edits in a
|
||||
bulk shell or script rewrite.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- For edits to existing files, 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.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
@@ -104,7 +105,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
with exec_command, or use write_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
|
||||
|
||||
@@ -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, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
write_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, edit_file, apply_patch) over
|
||||
- Prefer the dedicated tools (read_file, grep, glob, 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.
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"prefer edit_file or apply_patch",
|
||||
"for edits to existing files, prefer apply_patch",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
|
||||
@@ -29,10 +29,6 @@ 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) {
|
||||
|
||||
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
|
||||
}
|
||||
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
var migrated bool
|
||||
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
|
||||
if !migrated {
|
||||
return Task{}, false, err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if outputFile != task.OutputFile {
|
||||
changed = true
|
||||
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// migrateLegacyOutputFile recovers task metadata written under a previous home
|
||||
// or XDG data root. It accepts only the canonical per-task output
|
||||
// name in the historic zero/background layout, and only when the corresponding
|
||||
// regular file already exists inside the active manager root.
|
||||
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if !filepath.IsAbs(requested) {
|
||||
return "", false
|
||||
}
|
||||
requested = filepath.Clean(requested)
|
||||
expectedName := taskID + ".ndjson"
|
||||
if filepath.Base(requested) != expectedName {
|
||||
return "", false
|
||||
}
|
||||
legacyRoot := filepath.Dir(requested)
|
||||
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
|
||||
return "", false
|
||||
}
|
||||
canonical := filepath.Join(manager.rootDir, expectedName)
|
||||
info, err := os.Lstat(canonical)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return "", false
|
||||
}
|
||||
return canonical, true
|
||||
}
|
||||
|
||||
func validTaskID(taskID string) bool {
|
||||
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
canonicalOutput := filepath.Join(root, taskID+".ndjson")
|
||||
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: legacyOutput,
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write legacy metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
task, ok := manager.Get(taskID)
|
||||
if !ok {
|
||||
t.Fatal("manager did not load migrated task")
|
||||
}
|
||||
if task.OutputFile != canonicalOutput {
|
||||
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
|
||||
}
|
||||
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
|
||||
t.Fatalf("migration produced load warnings: %#v", warnings)
|
||||
}
|
||||
|
||||
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migrated metadata: %v", err)
|
||||
}
|
||||
var saved Task
|
||||
if err := json.Unmarshal(persisted, &saved); err != nil {
|
||||
t.Fatalf("decode migrated metadata: %v", err)
|
||||
}
|
||||
if saved.OutputFile != canonicalOutput {
|
||||
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const taskID = "legacy_task"
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
|
||||
t.Fatalf("write canonical output: %v", err)
|
||||
}
|
||||
metadata, err := json.Marshal(Task{
|
||||
ID: taskID,
|
||||
Type: "specialist",
|
||||
Status: StatusCompleted,
|
||||
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
|
||||
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal metadata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
|
||||
t.Fatalf("write metadata: %v", err)
|
||||
}
|
||||
|
||||
manager, err := NewManager(root)
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
if _, ok := manager.Get(taskID); ok {
|
||||
t.Fatal("manager loaded task with an unrelated absolute output path")
|
||||
}
|
||||
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
|
||||
t.Fatalf("load warnings = %q, want containment rejection", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager, err := NewManager(root)
|
||||
|
||||
@@ -39,3 +39,21 @@ 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)
|
||||
}
|
||||
@@ -147,11 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
|
||||
t.Fatalf("terminateProcess: %v", err)
|
||||
}
|
||||
|
||||
// The forked child must be gone too (poll until reaped by init).
|
||||
// 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.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
// 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) {
|
||||
// processStopped treats only ESRCH or an observed zombie state as stopped;
|
||||
// other errors (for example EPERM) remain live and keep polling.
|
||||
for !processStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
_ = syscall.Kill(childPID, syscall.SIGKILL)
|
||||
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
|
||||
@@ -159,3 +160,73 @@ 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")
|
||||
}
|
||||
@@ -3,11 +3,20 @@
|
||||
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).
|
||||
@@ -16,3 +25,28 @@ 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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
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
|
||||
@@ -12,3 +21,69 @@ package background
|
||||
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)
|
||||
}
|
||||
}
|
||||
+132
-37
@@ -64,38 +64,53 @@ 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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -198,11 +213,52 @@ func defaultAppDeps() appDeps {
|
||||
commitChanges: zerogit.Commit,
|
||||
pushChanges: zerogit.Push,
|
||||
createPR: zerogit.CreatePR,
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +618,45 @@ 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
|
||||
}
|
||||
|
||||
+52
-10
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -160,20 +161,61 @@ 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 := cmd.Start(); err != nil {
|
||||
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)
|
||||
}
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
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
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
@@ -2,8 +2,15 @@ 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
|
||||
@@ -121,3 +128,84 @@ 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)
|
||||
}
|
||||
}
|
||||
+2059
-15
File diff suppressed because it is too large.
Load diff
+538
-8
@@ -2,9 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -602,18 +605,20 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && options.message != "" {
|
||||
if command != "commit" && options.hasMessage {
|
||||
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`"}
|
||||
}
|
||||
@@ -934,8 +939,7 @@ 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 Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
@@ -958,11 +962,31 @@ 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: options.remote,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Force: options.force,
|
||||
DryRun: options.dryRun,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1010,15 +1034,62 @@ 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: options.remote,
|
||||
Remote: firstNonEmptyString(options.remote, remote),
|
||||
Branch: branch,
|
||||
Force: options.force,
|
||||
RequireNewRemoteBranch: created,
|
||||
AllowPushDefaultBranch: options.yes,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1100,3 +1171,462 @@ 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
|
||||
}
|
||||
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
const (
|
||||
providerCommandTestBudget = 90 * time.Second
|
||||
providerCommandReadyWaitAttempts = 45
|
||||
)
|
||||
|
||||
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
|
||||
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
|
||||
if providerCommandTestBudget < 2*readyWaitCeiling {
|
||||
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
|
||||
// runner while leaving the other half of providerCommandTestBudget for
|
||||
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
|
||||
@@ -151,7 +151,8 @@ type KeyBindingsConfig struct {
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
// TogglePlan is retained for configuration compatibility. Plan updates render
|
||||
// in the transcript and no longer have a persistent panel to toggle.
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -125,14 +126,27 @@ 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) error {
|
||||
func (s *Store) Set(provider, key string) (err 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
|
||||
@@ -142,7 +156,7 @@ func (s *Store) Set(provider, key string) error {
|
||||
}
|
||||
|
||||
// Get returns the API key for provider and whether one is stored.
|
||||
func (s *Store) Get(provider string) (string, bool, error) {
|
||||
func (s *Store) Get(provider string) (value string, found bool, err error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return "", false, nil
|
||||
@@ -150,6 +164,15 @@ func (s *Store) Get(provider string) (string, bool, 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
|
||||
@@ -159,7 +182,7 @@ func (s *Store) Get(provider string) (string, bool, error) {
|
||||
}
|
||||
|
||||
// Delete removes the API key for provider, reporting whether one existed.
|
||||
func (s *Store) Delete(provider string) (bool, error) {
|
||||
func (s *Store) Delete(provider string) (existed bool, err error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return false, nil
|
||||
@@ -167,6 +190,13 @@ func (s *Store) Delete(provider string) (bool, 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
|
||||
@@ -179,17 +209,22 @@ func (s *Store) Delete(provider string) (bool, error) {
|
||||
}
|
||||
|
||||
// Providers lists providers with a stored key (sorted), for display/audit.
|
||||
func (s *Store) Providers() ([]string, error) {
|
||||
func (s *Store) Providers() (names []string, err 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)
|
||||
}
|
||||
@@ -264,6 +299,15 @@ 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))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -60,7 +61,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to Deepgram: %w", err)
|
||||
// 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))
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -68,15 +77,26 @@ 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() {
|
||||
for chunk := range chunks {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
writeErrCh <- err
|
||||
// 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()
|
||||
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
|
||||
@@ -100,7 +120,18 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
|
||||
// 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))
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,10 @@ 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 {
|
||||
@@ -59,7 +64,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
|
||||
// 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))
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
@@ -73,24 +86,53 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
|
||||
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
|
||||
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))
|
||||
}
|
||||
|
||||
writeErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
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
|
||||
// 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()
|
||||
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
|
||||
@@ -113,7 +155,17 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
}
|
||||
default:
|
||||
}
|
||||
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
|
||||
// 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))
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
continue
|
||||
@@ -125,6 +177,12 @@ 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.
|
||||
@@ -138,6 +196,9 @@ 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 {
|
||||
@@ -147,7 +208,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
|
||||
case realtimeCommitted:
|
||||
committed = true
|
||||
case realtimeError:
|
||||
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
|
||||
// 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))
|
||||
}
|
||||
// The writer signals commit completion out of band; observe it so a
|
||||
// stop with no further audio still flips `committed`.
|
||||
@@ -155,6 +224,10 @@ 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:
|
||||
}
|
||||
@@ -201,6 +274,15 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
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,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -38,12 +40,45 @@ 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
|
||||
}
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
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) }
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return nil
|
||||
@@ -77,11 +112,91 @@ func TerminateProcessTree(pid int, grace, poll time.Duration) error {
|
||||
time.Sleep(poll)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
|
||||
}
|
||||
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)
|
||||
@@ -91,10 +206,14 @@ 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
|
||||
} 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.
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//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
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -12,10 +14,34 @@ 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 {
|
||||
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return killProcessTree(pid, runTaskkill, killRootProcess)
|
||||
}
|
||||
|
||||
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
|
||||
treeErr := killTree(pid)
|
||||
if treeErr == 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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
}
|
||||
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("Windows access token has no default owner")
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
|
||||
|
||||
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
switch strings.TrimSpace(provider.ID) {
|
||||
case "chatgpt":
|
||||
// ChatGPT authenticates against the Codex backend, while its public
|
||||
// model metadata is published under the OpenAI provider on models.dev.
|
||||
return "openai"
|
||||
case "dashscope":
|
||||
return "alibaba"
|
||||
case "github":
|
||||
|
||||
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
|
||||
|
||||
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"chatgpt": "openai",
|
||||
"github": "github-models",
|
||||
"moonshot": "moonshotai",
|
||||
"nvidia-nim": "nvidia",
|
||||
|
||||
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api.json":
|
||||
_, _ = w.Write([]byte(`{
|
||||
"openai": {"models": {
|
||||
"gpt-5.6-sol": {
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"description": "Frontier coding model",
|
||||
"tool_call": true,
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["low", "high", "max"],
|
||||
"limit": {"context": 1050000},
|
||||
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
|
||||
}
|
||||
}}
|
||||
}`))
|
||||
case "/backend-api/codex/models":
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected request path %q", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, ok := providercatalog.Get("chatgpt")
|
||||
if !ok {
|
||||
t.Fatal("ChatGPT provider missing from catalog")
|
||||
}
|
||||
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
|
||||
Name: "chatgpt",
|
||||
CatalogID: "chatgpt",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: server.URL + "/backend-api/codex",
|
||||
}, Options{
|
||||
HTTPClient: server.Client(),
|
||||
ModelsDevURL: server.URL + "/api.json",
|
||||
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
|
||||
return "Authorization", "Bearer test", true, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverCatalog returned error: %v", err)
|
||||
}
|
||||
if len(models) != 1 {
|
||||
t.Fatalf("models = %#v, want one ChatGPT live model", models)
|
||||
}
|
||||
model := models[0]
|
||||
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
|
||||
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
|
||||
}
|
||||
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
|
||||
t.Fatalf("input modalities = %q, want text,image,pdf", got)
|
||||
}
|
||||
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
|
||||
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
|
||||
models := mergeLiveModels(
|
||||
providercatalog.Descriptor{ID: "chatgpt"},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
|
||||
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
|
||||
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
|
||||
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
|
||||
|
||||
for _, patch := range []string{
|
||||
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
|
||||
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
|
||||
} {
|
||||
decision := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "apply_patch",
|
||||
SideEffect: SideEffectWrite,
|
||||
Permission: PermissionPrompt,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Args: map[string]any{"patch": patch},
|
||||
})
|
||||
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
|
||||
|
||||
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
|
||||
}
|
||||
cwd := firstArgString(args, "cwd")
|
||||
var paths []string
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
@@ -296,6 +296,34 @@ 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
|
||||
|
||||
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return applyPatchTool{
|
||||
baseTool: baseTool{
|
||||
name: "apply_patch",
|
||||
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
|
||||
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"patch": {Type: "string", Description: "Unified diff patch to apply."},
|
||||
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch 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,6 +56,9 @@ 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())
|
||||
}
|
||||
|
||||
@@ -162,11 +162,3 @@ 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))
|
||||
}
|
||||
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
|
||||
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
|
||||
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
|
||||
want := map[string]bool{
|
||||
ResourceKeyDirectory + "pkg": true,
|
||||
ResourceKeyFile + "pkg/old.go": true,
|
||||
ResourceKeyFile + "pkg/new.go": true,
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !want[key] {
|
||||
t.Fatalf("unexpected key %q in %v", key, keys)
|
||||
}
|
||||
delete(want, key)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing keys %v from %v", want, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
|
||||
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
|
||||
// must still see the raw declaration so miswired constructors fail the gate.
|
||||
|
||||
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: f.txt",
|
||||
"@@",
|
||||
"-beta",
|
||||
"+updated",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
trackedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracker.SeenWhole(trackedPath) {
|
||||
t.Fatal("a partial structured patch credited the unread file as fully seen")
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
|
||||
}, opts)
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
|
||||
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
|
||||
}
|
||||
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
|
||||
t.Fatalf("file = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("read_file failed: %s", result.Output)
|
||||
}
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: f.txt",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+updated",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
trackedPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(trackedPath) {
|
||||
t.Fatal("structured patch discarded an existing whole-file observation")
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
|
||||
}, opts)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registry := safetyRegistry(t, dir)
|
||||
registry.Register(NewScopedApplyPatchTool(dir, nil))
|
||||
tracker := NewFileTracker()
|
||||
opts := grantedOpts(tracker)
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Add File: created.txt",
|
||||
"+supplied",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
|
||||
t.Fatalf("structured patch failed: %s", result.Output)
|
||||
}
|
||||
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(path) {
|
||||
t.Fatal("a fully supplied structured add was not credited as fully seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.txt")
|
||||
|
||||
@@ -78,14 +78,16 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
|
||||
if !ok {
|
||||
return writtenContent
|
||||
}
|
||||
if _, err := exec.LookPath(command[0]); err != nil {
|
||||
binaryPath, err := exec.LookPath(command[0])
|
||||
if err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), command[1:]...), absolutePath)
|
||||
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
|
||||
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
|
||||
formatter.Dir = filepath.Dir(absolutePath)
|
||||
formatter.Stdin = strings.NewReader("")
|
||||
if err := formatter.Run(); err != nil {
|
||||
return writtenContent
|
||||
}
|
||||
|
||||
@@ -102,3 +102,17 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package tools
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// MutationTargets returns the workspace-relative paths a tool call will write to,
|
||||
// so the session layer can snapshot their before-state for safe rewind. It is a
|
||||
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
|
||||
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if isStructuredPatch(patch) {
|
||||
operations, err := parseStructuredPatch(patch)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
paths := structuredPatchOperationPaths(operations)
|
||||
for _, path := range paths {
|
||||
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return prefixPatchPaths(relativeRoot, paths)
|
||||
}
|
||||
// Enforce the same workspace confinement apply_patch applies (against the
|
||||
// resolved apply dir), so a patch with a traversal path (../x) never yields
|
||||
// an out-of-workspace target.
|
||||
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func prefixPatchPaths(relativeRoot string, paths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, path := range paths {
|
||||
workspacePath := path
|
||||
if relativeRoot != "" && relativeRoot != "." {
|
||||
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
|
||||
}
|
||||
if !seen[workspacePath] {
|
||||
seen[workspacePath] = true
|
||||
out = append(out, workspacePath)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
|
||||
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
|
||||
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
|
||||
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
|
||||
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
|
||||
{"bash", map[string]any{"command": "echo hi"}, nil},
|
||||
{"read_file", map[string]any{"path": "e.txt"}, nil},
|
||||
{"grep", map[string]any{"pattern": "x"}, nil},
|
||||
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
|
||||
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
|
||||
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
|
||||
}
|
||||
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
|
||||
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
|
||||
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
|
||||
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
|
||||
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("structured cwd: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// cwd "." (the default) must keep paths unprefixed.
|
||||
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
|
||||
if !reflect.DeepEqual(got, []string{"d.txt"}) {
|
||||
|
||||
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
|
||||
keys = append(keys, multiFileResourceKeys(args)...)
|
||||
cwd := firstStringArg(args, "cwd", "workdir")
|
||||
patch := firstStringArg(args, "patch", "diff")
|
||||
for _, p := range pathsFromUnifiedDiff(patch) {
|
||||
for _, p := range pathsFromApplyPatch(patch) {
|
||||
joined := joinUnderResourceCwd(cwd, p)
|
||||
if n := NormalizeResourcePath(joined); n != "" {
|
||||
keys = append(keys, ResourceKeyFile+n)
|
||||
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func pathsFromApplyPatch(patch string) []string {
|
||||
if !isStructuredPatch(patch) {
|
||||
return pathsFromUnifiedDiff(patch)
|
||||
}
|
||||
operations, err := parseStructuredPatch(patch)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return structuredPatchOperationPaths(operations)
|
||||
}
|
||||
|
||||
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
|
||||
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
|
||||
// Never touches the filesystem.
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
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())
|
||||
}
|
||||
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
|
||||
var deferred []Tool
|
||||
if tool.registry != nil {
|
||||
for _, candidate := range tool.registry.All() {
|
||||
if !ModelVisible(candidate) {
|
||||
continue
|
||||
}
|
||||
if !IsDeferred(candidate) {
|
||||
continue
|
||||
}
|
||||
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
|
||||
return names
|
||||
}
|
||||
for _, candidate := range tool.registry.All() {
|
||||
if !ModelVisible(candidate) {
|
||||
continue
|
||||
}
|
||||
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustReadTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
|
||||
toolset := CoreToolsScoped(t.TempDir(), nil)
|
||||
byName := make(map[string]Tool, len(toolset))
|
||||
@@ -513,6 +524,532 @@ 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).
|
||||
|
||||
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("add-dir: " + err.Error())
|
||||
}
|
||||
return m.appendSystemNotice("write access added: " + root + " (this session only)")
|
||||
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
|
||||
}
|
||||
|
||||
// appendSystemNotice appends a system transcript line and returns the updated
|
||||
|
||||
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
|
||||
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
|
||||
t.Fatalf("expected one additional write root after grant, got %#v", roots)
|
||||
}
|
||||
notice := lastTranscriptText(next)
|
||||
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
|
||||
notice := next.transientNotice.text
|
||||
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
|
||||
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
markdownTableRuleBodyRows = 4
|
||||
markdownBoldStart = "\x1b[1m"
|
||||
markdownBoldEnd = "\x1b[22m"
|
||||
markdownCodeStart = "\x1b[7m"
|
||||
markdownCodeEnd = "\x1b[27m"
|
||||
)
|
||||
|
||||
type markdownDisplayStyle int
|
||||
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
|
||||
const (
|
||||
markdownDisplayNormal markdownDisplayStyle = iota
|
||||
markdownDisplayBold
|
||||
markdownDisplayCode
|
||||
markdownDisplayRule
|
||||
)
|
||||
|
||||
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
case markdownDisplayBold:
|
||||
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
|
||||
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
|
||||
case markdownDisplayCode:
|
||||
builder.WriteString(inlineCodeStyle().Render(text))
|
||||
case markdownDisplayRule:
|
||||
builder.WriteString(zeroTheme.lineStrong.Render(text))
|
||||
default:
|
||||
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
style = markdownDisplayNormal
|
||||
index += len(markdownBoldEnd)
|
||||
continue
|
||||
case strings.HasPrefix(line[index:], markdownCodeStart):
|
||||
flush()
|
||||
style = markdownDisplayCode
|
||||
index += len(markdownCodeStart)
|
||||
continue
|
||||
case strings.HasPrefix(line[index:], markdownCodeEnd):
|
||||
flush()
|
||||
style = markdownDisplayNormal
|
||||
index += len(markdownCodeEnd)
|
||||
continue
|
||||
case line[index] == '\x1b':
|
||||
// Already-styled input (highlighted code, headings, tables) carries real
|
||||
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
|
||||
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func hasMarkdownDisplayControls(line string) bool {
|
||||
return strings.Contains(line, markdownBoldStart) ||
|
||||
strings.Contains(line, markdownBoldEnd) ||
|
||||
strings.Contains(line, markdownCodeStart) ||
|
||||
strings.Contains(line, markdownCodeEnd)
|
||||
}
|
||||
|
||||
func hasExternalANSIStyle(line string) bool {
|
||||
for index := 0; index < len(line); {
|
||||
if line[index] != '\x1b' {
|
||||
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
|
||||
continue
|
||||
}
|
||||
seq := line[index:end]
|
||||
if seq != markdownBoldStart && seq != markdownBoldEnd {
|
||||
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
|
||||
return true
|
||||
}
|
||||
index = end
|
||||
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
|
||||
}
|
||||
|
||||
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
|
||||
if current == markdownDisplayCode {
|
||||
return current
|
||||
}
|
||||
switch r {
|
||||
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
|
||||
return markdownDisplayRule
|
||||
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
|
||||
type markdownInlineSegment struct {
|
||||
text string
|
||||
bold bool
|
||||
code bool
|
||||
}
|
||||
|
||||
func wrapMarkdownInline(text string, measure int) []string {
|
||||
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
|
||||
flush()
|
||||
}
|
||||
head, tail := splitAtWidth(word.text, available)
|
||||
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
|
||||
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
|
||||
word.text = tail
|
||||
prefix = continuationPrefix
|
||||
available = maxInt(1, measure-lipgloss.Width(prefix))
|
||||
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
|
||||
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
|
||||
words := []markdownInlineSegment{}
|
||||
for _, segment := range segments {
|
||||
if segment.code {
|
||||
if segment.text != "" {
|
||||
words = append(words, segment)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, word := range strings.Fields(segment.text) {
|
||||
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
|
||||
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
|
||||
}
|
||||
}
|
||||
return words
|
||||
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
|
||||
if segment.text == "" {
|
||||
return ""
|
||||
}
|
||||
if segment.code {
|
||||
return markdownCodeStart + segment.text + markdownCodeEnd
|
||||
}
|
||||
if segment.bold {
|
||||
return renderMarkdownBoldText(segment.text)
|
||||
}
|
||||
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
|
||||
if builder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
|
||||
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
|
||||
builder.Reset()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
)
|
||||
|
||||
const (
|
||||
attachmentImageID = uint32(0xC0E0)
|
||||
attachmentPreviewCols = 10
|
||||
attachmentPreviewRows = 4
|
||||
attachmentPreviewHeightPixels = 64
|
||||
attachmentPreviewMaxImages = 4
|
||||
)
|
||||
|
||||
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
|
||||
// image is an enhancement to the existing attachment chips, not a reason to
|
||||
// crowd a narrow or modal surface.
|
||||
func (m model) attachmentThumbnailVisible(width int) bool {
|
||||
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
|
||||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
|
||||
return false
|
||||
}
|
||||
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
|
||||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
|
||||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
|
||||
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
|
||||
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailSlots(width int) int {
|
||||
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
|
||||
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
|
||||
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailLines(width int) []string {
|
||||
left := strings.Repeat(" ", attachmentPreviewCols+1)
|
||||
lines := make([]string, attachmentPreviewRows)
|
||||
for index := range lines {
|
||||
lines[index] = left
|
||||
}
|
||||
for index, line := range lines {
|
||||
lines[index] = fitStyledLine(line, width)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m model) attachmentThumbnailSupplementalChips() string {
|
||||
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
|
||||
}
|
||||
|
||||
func (m model) attachmentComposerPrefixRows(width int) int {
|
||||
if m.attachmentThumbnailVisible(width) {
|
||||
rows := attachmentPreviewRows
|
||||
if m.attachmentThumbnailSupplementalChips() != "" {
|
||||
rows++
|
||||
}
|
||||
return rows
|
||||
}
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
|
||||
// of the composer. The position comes from the same footer layout used for mouse
|
||||
// input, so it follows sidebar changes and terminal resizes instead of relying on
|
||||
// a brittle absolute bottom-right calculation.
|
||||
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
|
||||
width := m.chatColumnWidth()
|
||||
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
|
||||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
|
||||
return nil
|
||||
}
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
if frame.composerRect.height < attachmentPreviewRows+2 {
|
||||
return nil
|
||||
}
|
||||
return &terminalpet.ImageDraw{
|
||||
ID: attachmentImageID + uint32(index),
|
||||
Animation: m.pendingImageThumbnails[index],
|
||||
State: terminalpet.Idle,
|
||||
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
|
||||
Y: frame.composerRect.y + 1,
|
||||
Columns: attachmentPreviewCols,
|
||||
Rows: attachmentPreviewRows,
|
||||
HeightPixels: attachmentPreviewHeightPixels,
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentPreviewImageIDs() []uint32 {
|
||||
ids := make([]uint32, attachmentPreviewMaxImages)
|
||||
for index := range ids {
|
||||
ids[index] = attachmentImageID + uint32(index)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
|
||||
t.Helper()
|
||||
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
|
||||
for y := 0; y < 6; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
|
||||
}
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
|
||||
t.Fatal("expected staged image thumbnail")
|
||||
}
|
||||
|
||||
view := m.View()
|
||||
plain := plainRender(t, view.Content)
|
||||
if strings.Contains(plain, "[Image #1]") {
|
||||
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "Image #1 attached") {
|
||||
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
|
||||
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{
|
||||
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
|
||||
}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"first.png", "second.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
|
||||
plain := plainRender(t, m.composerBox(m.width))
|
||||
if !strings.Contains(plain, "[Image #2]") {
|
||||
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
|
||||
}
|
||||
_ = m.View()
|
||||
for index, renderer := range m.attachmentRenderers {
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Render(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
|
||||
if !strings.Contains(output.String(), id) {
|
||||
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
|
||||
plain := plainRender(t, m.composerBox(m.width))
|
||||
if !strings.Contains(plain, "[Image #1]") {
|
||||
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
|
||||
}
|
||||
if strings.Contains(plain, "Image #1 attached") {
|
||||
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
m.altScreen = true
|
||||
m.headerPrinted = true
|
||||
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
|
||||
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
m.refreshPendingImageThumbnail()
|
||||
_ = m.View()
|
||||
var first bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
m.pendingImageThumbnails = nil
|
||||
_ = m.View()
|
||||
var cleared bytes.Buffer
|
||||
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
|
||||
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
|
||||
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
|
||||
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
|
||||
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
|
||||
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sink.Close() })
|
||||
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(sink.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(data)
|
||||
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
|
||||
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
|
||||
}
|
||||
for _, id := range []string{"i=49374", "i=49376"} {
|
||||
if !strings.Contains(got, id) {
|
||||
t.Fatalf("missing image placement %s: %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
if question == "" {
|
||||
side.pendingImages = nil
|
||||
side.pendingImageLabels = nil
|
||||
side.pendingImageThumbnails = nil
|
||||
side.pendingDocuments = nil
|
||||
}
|
||||
side.loops = nil
|
||||
|
||||
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
|
||||
m.previewSelectedTheme()
|
||||
if m.picker.kind == pickerPet {
|
||||
return m.schedulePetPreview()
|
||||
}
|
||||
|
||||
@@ -121,3 +121,59 @@ func TestToolResultRowExposesClickToggle(t *testing.T) {
|
||||
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlwaysExpandedToolResultHeaderIsNotToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "diff",
|
||||
tool: tool,
|
||||
status: tools.StatusOK,
|
||||
detail: "--- a/example.go\n+++ b/example.go\n@@ -1 +1 @@\n-old\n+new",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatalf("%s: expected a visible diff result row", tool)
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("%s header must not advertise a collapse toggle: %#v", tool, selectable[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultOutputCannotCreateToggle(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "custom",
|
||||
tool: "custom_tool",
|
||||
status: tools.StatusOK,
|
||||
detail: "the file literally says: click to expand",
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible tool result row")
|
||||
}
|
||||
if selectable[0].toggle {
|
||||
t.Fatalf("tool-output text must not create a header toggle: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedExploreToolResultHeaderIsToggleable(t *testing.T) {
|
||||
m := transcriptViewTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "read",
|
||||
tool: "read_file",
|
||||
status: tools.StatusOK,
|
||||
detail: numberedLines(cardBodyMaxLines + 5),
|
||||
}
|
||||
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
|
||||
if len(selectable) == 0 {
|
||||
t.Fatal("expected a visible explore result row")
|
||||
}
|
||||
if !selectable[0].toggle {
|
||||
t.Fatalf("collapsed explore card header must remain toggleable: %#v", selectable[0])
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
|
||||
}, true
|
||||
}
|
||||
if provider, ok := m.activeProviderDescriptor(); ok {
|
||||
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
|
||||
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
|
||||
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
|
||||
if strings.EqualFold(model.ID, candidate) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, model := range providermodelcatalog.Models(provider) {
|
||||
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
for _, model := range providermodelcatalog.Models(provider) {
|
||||
if strings.EqualFold(model.ID, candidate) {
|
||||
return modelSwitchTarget{modelID: model.ID}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
|
||||
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
|
||||
return modelSwitchTarget{}, false
|
||||
}
|
||||
|
||||
// providerModelSwitchCandidates preserves provider-owned model IDs while
|
||||
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
|
||||
// The ChatGPT backend itself publishes bare model IDs (for example,
|
||||
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
|
||||
// as part of their model ID, so the alias is deliberately provider-specific.
|
||||
func providerModelSwitchCandidates(providerID string, input string) []string {
|
||||
requested := strings.TrimSpace(input)
|
||||
candidates := []string{requested}
|
||||
const openAIPrefix = "openai/"
|
||||
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
|
||||
return candidates
|
||||
}
|
||||
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
|
||||
candidates = append(candidates, bare)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
|
||||
if modelSwitchCompactionGuard == nil {
|
||||
return m, "", false
|
||||
|
||||
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
if width < 8 {
|
||||
return m.composerPositionAtVisualCell(localX, localY, width)
|
||||
}
|
||||
contentY := localY - 1
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
contentY--
|
||||
}
|
||||
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
|
||||
if contentY < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
+38
-13
@@ -61,6 +61,7 @@ type dictationController struct {
|
||||
browseVariants []dictation.ModelVariant
|
||||
|
||||
// in-flight session state
|
||||
sessionID int64
|
||||
streaming bool
|
||||
recorder Recorder
|
||||
transcriber Transcriber
|
||||
@@ -101,13 +102,17 @@ 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 {
|
||||
err error
|
||||
sessionID int64
|
||||
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
|
||||
@@ -125,6 +130,7 @@ func newDictationController(opts Options) dictationController {
|
||||
platform: dictation.DetectPlatform(),
|
||||
downloadRoot: opts.STTDownloadRoot,
|
||||
userConfigPath: opts.UserConfigPath,
|
||||
sessionID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
|
||||
}
|
||||
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
|
||||
if m.dictation.voiceModeEnabled {
|
||||
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
|
||||
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
|
||||
}
|
||||
// Turning voice off is the "done dictating" signal, so release the warm sherpa
|
||||
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
|
||||
// its port) until the app exits. It respawns lazily on the next streaming
|
||||
// recording. Skip while a recording is still in flight so we don't kill it.
|
||||
next := m.appendSystemNotice("Voice mode off.")
|
||||
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
|
||||
if next.dictation.active() {
|
||||
return next, nil
|
||||
}
|
||||
@@ -262,7 +268,7 @@ func (m model) startDictation() (model, tea.Cmd) {
|
||||
if streaming {
|
||||
return m.startStreamingDictation()
|
||||
}
|
||||
return m, startBatchRecordingCmd(rec)
|
||||
return m, startBatchRecordingCmd(m.dictation.sessionID, rec)
|
||||
}
|
||||
|
||||
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
|
||||
@@ -276,7 +282,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.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
|
||||
return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
|
||||
}
|
||||
|
||||
// cancelDictation aborts an in-flight recording without transcribing. Bound to
|
||||
@@ -293,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
|
||||
}
|
||||
m = m.discardDictationRegion()
|
||||
m.dictation.reset()
|
||||
return m.appendSystemNotice("Dictation cancelled."), nil
|
||||
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
|
||||
}
|
||||
|
||||
// wantStreaming decides whether this recording uses the streaming pipeline:
|
||||
@@ -311,6 +317,7 @@ func (d dictationController) maxDuration() time.Duration {
|
||||
}
|
||||
|
||||
func (d *dictationController) reset() {
|
||||
d.sessionID++
|
||||
d.phase = dictIdle
|
||||
d.recorder = nil
|
||||
d.transcriber = nil
|
||||
@@ -326,31 +333,36 @@ 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(rec Recorder) tea.Cmd {
|
||||
func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return dictationStartedMsg{err: rec.Start()}
|
||||
return dictationStartedMsg{sessionID: sessionID, 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(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
|
||||
func transcribeBatchCmd(sessionID int64, 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{err: err}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, err: err}
|
||||
}
|
||||
text, err := transcriber.Transcribe(ctx, audio)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, 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()
|
||||
@@ -375,6 +387,9 @@ 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
|
||||
@@ -392,7 +407,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
|
||||
}
|
||||
m.dictation.reset()
|
||||
|
||||
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
|
||||
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 {
|
||||
// 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.
|
||||
@@ -413,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
|
||||
if streaming {
|
||||
return m, nil // streaming already rendered live; nothing to add
|
||||
}
|
||||
return m.appendSystemNotice("No speech detected."), nil
|
||||
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
|
||||
}
|
||||
if !streaming {
|
||||
m = m.insertDictatedText(msg.text)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
|
||||
// variant, reporting progress live, then persists and applies the resolved paths.
|
||||
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
|
||||
if m.dictation.downloading {
|
||||
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
|
||||
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
|
||||
}
|
||||
m.dictation.downloading = true
|
||||
sink := m.runtimeMessageSink
|
||||
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
|
||||
base = context.Background()
|
||||
}
|
||||
m.dictation.downloadStatus = "Starting download…"
|
||||
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
|
||||
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
|
||||
return m, func() tea.Msg {
|
||||
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
|
||||
DestRoot: root,
|
||||
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
|
||||
if err != nil {
|
||||
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
|
||||
}
|
||||
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
|
||||
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
|
||||
}
|
||||
|
||||
// applyEngineComponents persists and applies resolved engine/model paths to the
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
|
||||
if got.dictation.cfg.StreamingEnabled() {
|
||||
t.Error("a batch variant should set streaming off")
|
||||
}
|
||||
if !transcriptHasText(got, "ready") {
|
||||
if !strings.Contains(got.transientNotice.text, "ready") {
|
||||
t.Error("expected a ready notice")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
|
||||
if cmd == nil {
|
||||
t.Error("expected a download command")
|
||||
}
|
||||
if !transcriptHasText(next, variant.Label) {
|
||||
if !strings.Contains(next.transientNotice.text, variant.Label) {
|
||||
t.Error("expected the download-starting notice to name the variant")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ 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 {
|
||||
text string
|
||||
final bool
|
||||
sessionID int64
|
||||
text string
|
||||
final bool
|
||||
}
|
||||
|
||||
// startStreamingDictation begins continuous capture and launches the streaming
|
||||
@@ -38,6 +39,7 @@ 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
|
||||
@@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
|
||||
streamCmd := func() tea.Msg {
|
||||
onPartial := func(text string, final bool) {
|
||||
if sink != nil {
|
||||
sink(sttPartialMsg{text: text, final: final})
|
||||
sink(sttPartialMsg{sessionID: sessionID, text: text, final: final})
|
||||
}
|
||||
}
|
||||
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
|
||||
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
|
||||
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true}
|
||||
}
|
||||
// Streaming drives the waveform from real levels (no synthetic tick needed).
|
||||
return m, streamCmd
|
||||
@@ -77,6 +79,9 @@ 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
|
||||
|
||||
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
|
||||
if got.composer.text != "" {
|
||||
t.Errorf("composer should stay empty, got %q", got.composer.text)
|
||||
}
|
||||
if !transcriptHasText(got, "No speech detected") {
|
||||
t.Error("expected a 'no speech' notice")
|
||||
if got.transientNotice.text != "No speech detected." {
|
||||
t.Errorf("no-speech notice = %q", got.transientNotice.text)
|
||||
}
|
||||
if transcriptHasText(got, "No speech detected") {
|
||||
t.Error("no-speech confirmation should not be persisted in the transcript")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +89,10 @@ 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{err: errors.New("mic busy")})
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")})
|
||||
if got.dictation.active() {
|
||||
t.Error("a start failure should reset to idle")
|
||||
}
|
||||
@@ -99,13 +103,48 @@ func TestDictationStartedFailureResets(t *testing.T) {
|
||||
|
||||
func TestDictationStartedArmsRecording(t *testing.T) {
|
||||
m := model{}
|
||||
m.dictation.sessionID = 1
|
||||
m.dictation.phase = dictStarting
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{})
|
||||
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1})
|
||||
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()
|
||||
@@ -136,6 +175,69 @@ 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})
|
||||
@@ -302,3 +404,83 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
|
||||
if !next.dictation.voiceModeEnabled {
|
||||
t.Fatal("first /voice should enable voice mode")
|
||||
}
|
||||
if !transcriptHasText(next, "Voice mode on") {
|
||||
t.Error("expected voice-mode-on notice")
|
||||
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
|
||||
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
|
||||
}
|
||||
if transcriptHasText(next, "Voice mode on") {
|
||||
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
|
||||
}
|
||||
off, _ := next.toggleVoiceMode()
|
||||
if off.dictation.voiceModeEnabled {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
|
||||
// Visible unchanged lines kept at each edge of a collapsed diff section.
|
||||
const diffViewerContextLines = 3
|
||||
|
||||
// diffViewerLine is a source diff line or a synthetic collapsed-context row.
|
||||
// rawIndex keeps rendering anchored to the original diff so line-pair and
|
||||
// syntax-highlighting logic remain correct after context compaction.
|
||||
type diffViewerLine struct {
|
||||
text string
|
||||
rawIndex int
|
||||
hiddenContext int
|
||||
}
|
||||
|
||||
// compactDiffViewerContext returns the display sequence for a unified diff.
|
||||
// Long contiguous unchanged runs keep their leading and trailing context while
|
||||
// one synthetic line accounts for the elided middle. Structural and changed
|
||||
// rows always remain intact.
|
||||
func compactDiffViewerContext(raw []string) []diffViewerLine {
|
||||
lines := make([]diffViewerLine, 0, len(raw))
|
||||
hunk := diffHunkState{}
|
||||
for index := 0; index < len(raw); {
|
||||
if parsed, ok := parseDiffHunkHeader(raw[index]); ok {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
hunk = parsed
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if !hunk.active() || !strings.HasPrefix(raw[index], " ") {
|
||||
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
|
||||
if hunk.active() && isDiffHunkBodyLine(raw[index]) {
|
||||
hunk.consume(raw[index])
|
||||
}
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
start := index
|
||||
for index < len(raw) && hunk.active() && strings.HasPrefix(raw[index], " ") {
|
||||
hunk.consume(raw[index])
|
||||
index++
|
||||
}
|
||||
count := index - start
|
||||
// Collapsing seven lines would replace one readable line with one marker,
|
||||
// leaving the display equally tall. Only collapse when it removes space.
|
||||
if count <= diffViewerContextLines*2+1 {
|
||||
for i := start; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for i := start; i < start+diffViewerContextLines; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
lines = append(lines, diffViewerLine{rawIndex: -1, hiddenContext: count - diffViewerContextLines*2})
|
||||
for i := index - diffViewerContextLines; i < index; i++ {
|
||||
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
func requireDiffViewerTrueColor(t *testing.T) {
|
||||
t.Helper()
|
||||
oldProfile := lipgloss.Writer.Profile
|
||||
lipgloss.Writer.Profile = colorprofile.TrueColor
|
||||
t.Cleanup(func() {
|
||||
lipgloss.Writer.Profile = oldProfile
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiffViewerUsesHunkHeaderForLineNumbers(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/internal/tui/view.go",
|
||||
"+++ b/internal/tui/view.go",
|
||||
"@@ -40,3 +40,4 @@ func renderView() {",
|
||||
" context",
|
||||
"-old line",
|
||||
"+new line",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 90, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "@@") || !strings.Contains(got, " 41 − old line") || !strings.Contains(got, " 41 + new line") {
|
||||
t.Fatalf("diff viewer did not apply the hunk range correctly:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerCollapsesLongUnchangedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,12 +1,12 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
diff := strings.Join(lines, "\n")
|
||||
|
||||
body := diffCardBody(diff, 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "… 6 unchanged lines") {
|
||||
t.Fatalf("diff viewer did not collapse long unchanged context:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context b", "context c", "context j", "context k", "context l"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer should preserve contextual edge %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsSevenUnchangedContextLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,7 +1,7 @@",
|
||||
}
|
||||
for i := 1; i <= diffViewerContextLines*2+1; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if strings.Contains(got, "unchanged lines") {
|
||||
t.Fatalf("seven unchanged context lines should remain visible:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"context a", "context d", "context g"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("diff viewer omitted %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsLineNumbersAfterCollapsedContext(t *testing.T) {
|
||||
lines := []string{
|
||||
"--- a/x.go",
|
||||
"+++ b/x.go",
|
||||
"@@ -1,13 +1,13 @@",
|
||||
}
|
||||
for i := 1; i <= 12; i++ {
|
||||
lines = append(lines, " context "+string(rune('a'+i-1)))
|
||||
}
|
||||
lines = append(lines, "-old value", "+new value")
|
||||
|
||||
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, " 13 − old value") || !strings.Contains(got, " 13 + new value") {
|
||||
t.Fatalf("line numbers drifted after collapsed context:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntheticContextHasNoSourceIndex(t *testing.T) {
|
||||
raw := make([]string, 0, diffViewerContextLines*2+2)
|
||||
for index := 0; index < diffViewerContextLines*2+2; index++ {
|
||||
raw = append(raw, " context")
|
||||
}
|
||||
for _, line := range compactDiffViewerContext(raw) {
|
||||
if line.hiddenContext > 0 && line.rawIndex != -1 {
|
||||
t.Fatalf("synthetic collapsed context rawIndex = %d, want -1", line.rawIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerNearRewriteDeletionSurvives(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/notes.log",
|
||||
"+++ b/notes.log",
|
||||
"@@ -1 +1 @@",
|
||||
"-alpha bravo charlie delta",
|
||||
"+zulu yankee xray whiskey",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "alpha bravo charlie delta") {
|
||||
t.Fatalf("near-rewrite deletion disappeared:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerRendersStyledDeletionOnce(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func value() string { return \"old\" }",
|
||||
"+func value() string { return \"new\" }",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if count := strings.Count(got, "func value() string { return \"old\" }"); count != 1 {
|
||||
t.Fatalf("styled deleted row count = %d, want 1:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerDoesNotFoldPreambleContext(t *testing.T) {
|
||||
raw := []string{
|
||||
"commit deadbeef",
|
||||
"Author: Example",
|
||||
"",
|
||||
}
|
||||
for index := 1; index <= 10; index++ {
|
||||
raw = append(raw, fmt.Sprintf(" commit message line %d", index))
|
||||
}
|
||||
raw = append(raw,
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,1 +1,1 @@",
|
||||
"-old value",
|
||||
"+new value",
|
||||
)
|
||||
|
||||
compacted := compactDiffViewerContext(raw)
|
||||
for _, line := range compacted {
|
||||
if line.hiddenContext > 0 {
|
||||
t.Fatalf("preamble context was folded: %#v", compacted)
|
||||
}
|
||||
}
|
||||
if len(compacted) != len(raw) {
|
||||
t.Fatalf("compacted preamble length = %d, want %d", len(compacted), len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsHeaderLikeHunkContent(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"--- removed",
|
||||
"+++ added",
|
||||
}, "\n")
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
for _, want := range []string{"-- removed", "++ added"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("diff viewer lost header-like hunk content %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
meta := diffCardMetadata(diff)
|
||||
if meta.adds != 1 || meta.dels != 1 {
|
||||
t.Fatalf("diff metadata counts = +%d -%d, want +1 -1", meta.adds, meta.dels)
|
||||
}
|
||||
if _, ok := highlightedDiffLines(strings.Split(diff, "\n"), meta)[3]; !ok {
|
||||
t.Fatal("deleted header-like hunk content was not syntax highlighted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSourcePathStripsOnlyOneDiffPrefix(t *testing.T) {
|
||||
if got, want := diffViewerSourcePath("--- a/b/c.go"), "b/c.go"; got != want {
|
||||
t.Fatalf("diffViewerSourcePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsBothChangedSides(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func greet(name string) string { return \"old\" }",
|
||||
"+func greet(name string) string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
// `func` uses Zero's accent and string literals use its green token style.
|
||||
if !strings.Contains(joined, "202;255;63") || !strings.Contains(joined, "93;209;164") {
|
||||
t.Fatalf("diff viewer did not retain Go syntax colors in changed rows:\n%s", joined)
|
||||
}
|
||||
// The exact changed string literal keeps the brighter word-diff background.
|
||||
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
|
||||
t.Fatalf("diff viewer lost word-level changed-span emphasis:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlightsUnchangedHunkContext(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" func greet() string {",
|
||||
"-\treturn \"old\"",
|
||||
"+\treturn \"new\"",
|
||||
" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "func greet() string {") {
|
||||
if !strings.Contains(line, "202;255;63") {
|
||||
t.Fatalf("unchanged source context was not syntax-highlighted: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted unchanged source context")
|
||||
}
|
||||
|
||||
func TestDiffViewerPreservesSyntaxStateAcrossHunkLines(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" def message():",
|
||||
"- return \"\"\"old\"\"\"",
|
||||
"+ return \"\"\"new",
|
||||
"+continued\"\"\"",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
for _, line := range body.lines {
|
||||
if strings.Contains(plainRender(t, line), "continued") {
|
||||
if !strings.Contains(line, "93;209;164") {
|
||||
t.Fatalf("multiline string lost syntax state across hunk rows: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("diff viewer omitted multiline source row")
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsNonHunkDiffFallback(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"+func greet() string { return \"new\" }",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "func greet() string { return \"new\" }") {
|
||||
t.Fatalf("diff viewer lost content without a hunk header:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsRenameMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/old-name.go b/new-name.go",
|
||||
"similarity index 100%",
|
||||
"rename from old-name.go",
|
||||
"rename to new-name.go",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "rename from old-name.go") || !strings.Contains(got, "rename to new-name.go") {
|
||||
t.Fatalf("rename-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerKeepsModeMetadataWithoutHunks(t *testing.T) {
|
||||
diff := strings.Join([]string{
|
||||
"diff --git a/tool.sh b/tool.sh",
|
||||
"old mode 100644",
|
||||
"new mode 100755",
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
got := plainRender(t, strings.Join(body.lines, "\n"))
|
||||
if !strings.Contains(got, "old mode 100644") || !strings.Contains(got, "new mode 100755") {
|
||||
t.Fatalf("mode-only diff lost its meaningful metadata:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerFallsBackForOversizedSourceLine(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
longLine := strings.Repeat("x", diffViewerHighlightMaxLineBytes)
|
||||
diff := strings.Join([]string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"+" + longLine,
|
||||
}, "\n")
|
||||
|
||||
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
if strings.Contains(joined, "202;255;63") {
|
||||
t.Fatalf("oversized diff line should use the plain diff fallback: %q", joined)
|
||||
}
|
||||
if !strings.Contains(plainRender(t, joined), strings.Repeat("x", 10)) {
|
||||
t.Fatalf("plain fallback lost oversized source line: %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerSyntaxHighlighterKeepsSpans(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
styled, ok := highlightCodeForPathWithSpans(
|
||||
[]string{"func greet(name string) string { return \"new\" }"},
|
||||
"example.go",
|
||||
200,
|
||||
zeroTheme.addLine.GetBackground(),
|
||||
[]highlightSpan{{line: 0, start: 39, end: 44, background: zeroTheme.addLineWord.GetBackground()}},
|
||||
)
|
||||
if !ok || len(styled) != 1 {
|
||||
t.Fatalf("highlightCodeForPathWithSpans = %#v, %v", styled, ok)
|
||||
}
|
||||
if !strings.Contains(styled[0], "46;101;77") {
|
||||
t.Fatalf("highlighted span lost its word-diff background: %q", styled[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightsEachFileWithItsOwnPath(t *testing.T) {
|
||||
requireDiffViewerTrueColor(t)
|
||||
rawLines := []string{
|
||||
"diff --git a/example.go b/example.go",
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
"@@ -1 +1 @@",
|
||||
"-func old() {}",
|
||||
"+func new() {}",
|
||||
"diff --git a/example.py b/example.py",
|
||||
"--- a/example.py",
|
||||
"+++ b/example.py",
|
||||
"@@ -1 +1 @@",
|
||||
"-def old(): pass",
|
||||
"+def new(): pass",
|
||||
}
|
||||
|
||||
highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n")))
|
||||
if len(highlighted) != 4 {
|
||||
t.Fatalf("highlighted line count = %d, want 4: %#v", len(highlighted), highlighted)
|
||||
}
|
||||
for _, index := range []int{4, 5, 10, 11} {
|
||||
if !strings.Contains(highlighted[index], "202;255;63") {
|
||||
t.Fatalf("source row %d lost syntax highlighting: %q", index, highlighted[index])
|
||||
}
|
||||
}
|
||||
for _, header := range []int{2, 3, 8, 9} {
|
||||
if _, ok := highlighted[header]; ok {
|
||||
t.Fatalf("file header at row %d entered syntax highlighter: %q", header, highlighted[header])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffViewerHighlightBudgetSpansHunks(t *testing.T) {
|
||||
hunkLines := diffViewerHighlightMaxLines/2 + 1
|
||||
rawLines := []string{
|
||||
"--- a/example.go",
|
||||
"+++ b/example.go",
|
||||
fmt.Sprintf("@@ -1,%d +1,%d @@", hunkLines, hunkLines),
|
||||
}
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
rawLines = append(rawLines, fmt.Sprintf("@@ -6000,%d +6000,%d @@", hunkLines, hunkLines))
|
||||
for i := 0; i < hunkLines; i++ {
|
||||
rawLines = append(rawLines, " context")
|
||||
}
|
||||
if highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n"))); highlighted != nil {
|
||||
t.Fatalf("diff exceeding the aggregate highlight budget should use the plain fallback: %d highlighted rows", len(highlighted))
|
||||
}
|
||||
}
|
||||
+24
-15
@@ -6,10 +6,12 @@ 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 {
|
||||
@@ -50,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// runningPlanModel supplies plan state for tests that exercise contextual
|
||||
// details without reviving the former persistent plan presentation.
|
||||
func runningPlanModel(t *testing.T, steps int) model {
|
||||
t.Helper()
|
||||
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
|
||||
m.width = 100
|
||||
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
m.now = func() time.Time { return base.Add(15 * time.Second) }
|
||||
items := make([]tools.PlanItem, steps)
|
||||
for i := range items {
|
||||
status := "pending"
|
||||
if i == 0 {
|
||||
status = "in_progress"
|
||||
}
|
||||
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
|
||||
}
|
||||
m.plan.updateFromItems(items, base)
|
||||
return m
|
||||
}
|
||||
|
||||
func listCommandNames() []string {
|
||||
names := make([]string, 0, len(commandDefinitions))
|
||||
for _, command := range commandDefinitions {
|
||||
@@ -90,20 +112,6 @@ 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 {
|
||||
@@ -244,7 +252,8 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
}
|
||||
|
||||
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
|
||||
}
|
||||
|
||||
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
|
||||
// is open closes the file view first (the subchat owns the single-column view).
|
||||
func TestSubchatEntryClosesFileView(t *testing.T) {
|
||||
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
|
||||
// coordinates cannot unexpectedly replace the full-width conversation view.
|
||||
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
|
||||
}
|
||||
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
|
||||
updated, _, handled := m.handleTranscriptSelectionMouse(click)
|
||||
if !handled {
|
||||
t.Fatal("agent row click should be handled")
|
||||
if handled {
|
||||
t.Fatal("invisible rail coordinates must not handle clicks")
|
||||
}
|
||||
if updated.fileView.active {
|
||||
t.Fatal("entering the subchat should close the file view")
|
||||
if !updated.fileView.active {
|
||||
t.Fatal("an ignored rail click must leave the active file view alone")
|
||||
}
|
||||
if !updated.subchat.active {
|
||||
t.Fatal("subchat should be active")
|
||||
if updated.subchat.active {
|
||||
t.Fatal("an ignored rail click must not enter a subchat")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
|
||||
// user saw while the run was live. The provider-facing output is intentionally
|
||||
// a short confirmation, so it cannot substitute for the card-only preview.
|
||||
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
|
||||
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
|
||||
events := []sessions.Event{{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
|
||||
}}
|
||||
|
||||
rows := transcriptRowsFromSessionEvents(events)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected one restored row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].detail != preview {
|
||||
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
|
||||
}
|
||||
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
|
||||
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
|
||||
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
|
||||
// being viewed must not clobber the user's mode or scroll — the old
|
||||
// unconditional openFileView bounced full mode back to diff and reset scroll.
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
// transcript's tool-result rows (their changedFiles), so it survives resume for
|
||||
// free and can never drift from what the chat shows.
|
||||
//
|
||||
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
|
||||
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
|
||||
// to the most recent one; a second click (or a click while the drill-in is
|
||||
// already open) opens the file view (file_view.go). Esc clears the selection.
|
||||
// The same compact rows feed the run-details overlay; selecting a file from a
|
||||
// visible transcript card opens the file drill-in (file_view.go).
|
||||
package tui
|
||||
|
||||
import (
|
||||
@@ -17,8 +15,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
|
||||
return hits
|
||||
}
|
||||
|
||||
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
|
||||
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
|
||||
// sidebar's height budget (or colliding with the token floor line) never match.
|
||||
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
|
||||
if !m.sidebarActive() {
|
||||
return "", false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return "", false
|
||||
}
|
||||
sidebarW := sidebarWidth(m.width)
|
||||
if sidebarW <= 0 {
|
||||
return "", false
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
|
||||
x, y := mouseX(msg), mouseY(msg)
|
||||
if x < x0 || x >= x0+sidebarW {
|
||||
return "", false
|
||||
}
|
||||
for _, hit := range m.sidebarFileSelectables(sidebarW) {
|
||||
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
|
||||
return hit.path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
|
||||
// the currently selected file, so its card renders with the selection tint.
|
||||
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
|
||||
|
||||
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
|
||||
// it (tint + scroll state, no view swap); the second click on the same file
|
||||
// opens the drill-in; clicking another file while the view is open switches it.
|
||||
func TestFileRowClickSelectsThenOpens(t *testing.T) {
|
||||
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
|
||||
// permanent column beside the conversation.
|
||||
func TestRunDetailsListsTouchedFiles(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
width := sidebarWidth(m.width)
|
||||
hits := m.sidebarFileSelectables(width)
|
||||
if len(hits) < 2 {
|
||||
t.Fatal("expected two clickable FILES rows")
|
||||
}
|
||||
x := m.chatColumnWidth() + 3
|
||||
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
|
||||
|
||||
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !handled {
|
||||
t.Fatal("FILES row click should be handled")
|
||||
}
|
||||
if m1.selectedFile != hits[0].path || m1.fileView.active {
|
||||
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
|
||||
}
|
||||
|
||||
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !m2.fileView.active || m2.fileView.path != hits[0].path {
|
||||
t.Fatalf("second click should open the file view: %+v", m2.fileView)
|
||||
}
|
||||
|
||||
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
|
||||
if !m3.fileView.active || m3.fileView.path != hits[1].path {
|
||||
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
|
||||
}
|
||||
if m3.selectedFile != hits[1].path {
|
||||
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
|
||||
m.runDetailsOpen = true
|
||||
plain := plainRender(t, m.runDetailsOverlay(m.width))
|
||||
for _, file := range m.touchedFiles() {
|
||||
if !strings.Contains(plain, file.path) {
|
||||
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
|
||||
}
|
||||
m, _ = m.settleTranscript()
|
||||
selectedView := viewString(m.View())
|
||||
_ = viewString(m.View())
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
|
||||
t.Fatal("clearing a selected file should rebuild the invalidated cache")
|
||||
}
|
||||
if clearedView := viewString(m.View()); clearedView == selectedView {
|
||||
t.Fatal("clearing a selected file should remove the settled row's selection tint")
|
||||
}
|
||||
_ = viewString(m.View())
|
||||
}
|
||||
|
||||
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
|
||||
|
||||
+3
-14
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
|
||||
return mouseEvent(msg).Button == tea.MouseNone
|
||||
}
|
||||
|
||||
// updateHoverTarget resolves what's under the cursor for a hover motion, using
|
||||
// the SAME hit-testers and priority order as the click handlers in
|
||||
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
|
||||
// step) takes priority since it's outside the chat column, then a clickable
|
||||
// transcript line. Clears the hover when nothing clickable is under the cursor.
|
||||
// updateHoverTarget resolves visible transcript targets for a hover motion.
|
||||
// Context data lives in the run-details overlay; the former sidebar rail is not
|
||||
// rendered and therefore cannot own a hover target.
|
||||
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
|
||||
if hit, ok := m.sidebarLineAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
|
||||
}
|
||||
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
|
||||
}
|
||||
if path, ok := m.fileRowAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
|
||||
}
|
||||
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
||||
// A permission option reuses its OWN existing keyboard-cursor highlight
|
||||
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
|
||||
|
||||
+49
-70
@@ -1,12 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestMouseHoverPredicate(t *testing.T) {
|
||||
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
|
||||
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
|
||||
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
|
||||
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
|
||||
// that: real conversation + a mapped swarm member session.
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
|
||||
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "diff",
|
||||
tool: "write_file",
|
||||
status: tools.StatusOK,
|
||||
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
|
||||
})
|
||||
rowIndex := len(m.transcript) - 1
|
||||
width := m.chatColumnWidth()
|
||||
body, selectable := m.transcriptBody(width, "")
|
||||
start, _, top := m.transcriptViewportStart(body, width)
|
||||
var target transcriptSelectableLine
|
||||
for _, line := range selectable {
|
||||
if line.rowIndex == rowIndex {
|
||||
target = line
|
||||
break
|
||||
}
|
||||
}
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
if target.text == "" {
|
||||
t.Fatalf("expected a selectable diff header, got %#v", selectable)
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
|
||||
}
|
||||
if m.hover.sessionID != hits[0].sessionID {
|
||||
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
|
||||
}
|
||||
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
|
||||
// not a raw line offset — resolve it back to a line offset the same way the
|
||||
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
|
||||
// SAME row that was actually hovered, not some other row at that raw index.
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
|
||||
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
if m.transcript[rowIndex].expanded {
|
||||
t.Fatal("clicking an always-visible diff header must not toggle it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
|
||||
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
|
||||
// leaving the transcript at its default (welcome-only, i.e. "empty").
|
||||
m.transcript = appendRow(m.transcript, rowUser, "do something")
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
|
||||
}
|
||||
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable plan step row")
|
||||
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverPlanStep {
|
||||
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
|
||||
}
|
||||
if m.hover.stepIndex != hits[0].stepIndex {
|
||||
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
|
||||
}
|
||||
// The bug this guards: the render path must resolve stepIndex back to the
|
||||
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
|
||||
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
|
||||
// into the rendered sidebar (which would land on the AGENTS header/body).
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
width := m.chatColumnWidth()
|
||||
height := m.height
|
||||
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
}
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
|
||||
}
|
||||
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
if without == with {
|
||||
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverClearsOnWheelScroll(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
for i := 0; i < 80; i++ {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -9,9 +14,13 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/terminalpet"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const attachmentThumbnailMaxPixels = 16 << 20
|
||||
|
||||
// droppableImageExts are the image extensions a dragged-and-dropped file may
|
||||
// carry (matched case-insensitively); PDFs are recognized separately.
|
||||
var droppableImageExts = map[string]bool{
|
||||
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
|
||||
for _, models := range m.modelPickerLiveByProvider {
|
||||
for _, dm := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
|
||||
// A provider's authenticated listing may only establish which models
|
||||
// are available, without repeating modalities. Treat an empty list as
|
||||
// unknown rather than as an explicit image-input denial, so the
|
||||
// curated registry/name capability fallback remains available while
|
||||
// models.dev metadata is temporarily unavailable.
|
||||
if len(dm.InputModalities) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, modality := range dm.InputModalities {
|
||||
if strings.EqualFold(strings.TrimSpace(modality), "image") {
|
||||
return true
|
||||
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
|
||||
Data: data,
|
||||
})
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
|
||||
m.refreshPendingImageThumbnail()
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
|
||||
case strings.EqualFold(trimmed, "clear"):
|
||||
m.pendingImages = nil
|
||||
m.pendingImageLabels = nil
|
||||
m.pendingImageThumbnails = nil
|
||||
m.pendingDocuments = nil
|
||||
return m.appendImageNotice("Cleared pending attachments.")
|
||||
}
|
||||
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
|
||||
|
||||
m.pendingImages = append(m.pendingImages, block)
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
|
||||
m.refreshPendingImageThumbnail()
|
||||
// No "attached" system message: the composer attachment chip ([Image #N]) is
|
||||
// the confirmation, matching the compact attach UX.
|
||||
return m
|
||||
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
|
||||
m.pendingImages = append(m.pendingImages, block)
|
||||
m.pendingImageLabels = append(m.pendingImageLabels, label)
|
||||
}
|
||||
m.refreshPendingImageThumbnail()
|
||||
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
|
||||
// "attached" system message.
|
||||
return m
|
||||
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
|
||||
if len(m.pendingImages) > 0 {
|
||||
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
|
||||
}
|
||||
m.refreshPendingImageThumbnail()
|
||||
return m, true
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// refreshPendingImageThumbnail prepares only the first pending image for the
|
||||
// optional inline terminal preview. Decode failures deliberately leave the
|
||||
// attachment intact: the text chip remains the portable fallback and the model
|
||||
// still receives the original bytes.
|
||||
func (m *model) refreshPendingImageThumbnail() {
|
||||
m.pendingImageThumbnails = nil
|
||||
if len(m.pendingImages) == 0 {
|
||||
return
|
||||
}
|
||||
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
|
||||
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
|
||||
for index := 0; index < count; index++ {
|
||||
preview, err := attachmentThumbnail(m.pendingImages[index])
|
||||
if err == nil {
|
||||
m.pendingImageThumbnails[index] = preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
|
||||
return nil, fmt.Errorf("image is too large for an inline thumbnail")
|
||||
}
|
||||
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return terminalpet.ThumbnailAnimation(decoded)
|
||||
}
|
||||
|
||||
// renderAttachmentChips builds the pending-attachment row from both staged images
|
||||
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
|
||||
// nothing is staged. Numbered (not named) so a long screenshot path never shows
|
||||
|
||||
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
|
||||
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
|
||||
}
|
||||
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
|
||||
t.Fatalf("submit should append a user row, got %#v", next.transcript)
|
||||
}
|
||||
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
|
||||
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
|
||||
}
|
||||
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("submit should persist the user event")
|
||||
}
|
||||
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
|
||||
t.Fatalf("persisted attachment summary = %#v, want one image", got)
|
||||
}
|
||||
|
||||
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
|
||||
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
|
||||
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
|
||||
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
|
||||
// by reading the source. A single-key `?` overlay (opened on an empty composer)
|
||||
// lists them grouped, so the keymap is discoverable the way the reference TUIs
|
||||
@@ -7,11 +7,15 @@
|
||||
// model.go's Update switch; keep them in sync when a binding changes.
|
||||
//
|
||||
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
|
||||
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// who remaps them in config.json sees the actual chords, not the defaults.
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// keybinding is one row in the help overlay: the key chord and what it does.
|
||||
type keybinding struct {
|
||||
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
bindings: []keybinding{
|
||||
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
|
||||
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
|
||||
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
},
|
||||
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
|
||||
func (m model) renderKeybindingHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
|
||||
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
|
||||
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.altScreen = true
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
initialSidebar := m.sidebarHidden
|
||||
initialDetails := m.runDetailsOpen
|
||||
updated, _ := m.Update(testKeyCtrl('b'))
|
||||
next := updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
|
||||
if next.runDetailsOpen == initialDetails {
|
||||
t.Fatal("Ctrl+B on empty composer should toggle run details")
|
||||
}
|
||||
updated, _ = next.Update(testKeyCtrl('b'))
|
||||
next = updated.(model)
|
||||
|
||||
initialMouse := next.mouseReleased
|
||||
updated, _ = next.Update(testKeyCtrl('e'))
|
||||
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
|
||||
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
|
||||
}
|
||||
|
||||
initialSidebar2 := m2.sidebarHidden
|
||||
initialDetails2 := m2.runDetailsOpen
|
||||
updated, _ = m2.Update(testKeyCtrl('b'))
|
||||
next2 := updated.(model)
|
||||
if next2.sidebarHidden != initialSidebar2 {
|
||||
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
|
||||
if next2.runDetailsOpen != initialDetails2 {
|
||||
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
|
||||
}
|
||||
|
||||
initialMouse2 := next2.mouseReleased
|
||||
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
|
||||
// reaches configurable global bindings.
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
m = typeRunes(t, m, "hello")
|
||||
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
|
||||
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
initialDetails := next.runDetailsOpen
|
||||
updated, _ = next.Update(testKeyCtrl('y'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden == initialSidebar {
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
|
||||
if next.runDetailsOpen == initialDetails {
|
||||
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
|
||||
}
|
||||
if next.composerValue() != "hello" {
|
||||
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
|
||||
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
|
||||
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
|
||||
if !m.sidebarToggleAllowed() {
|
||||
t.Fatal("sidebar toggle should be allowed")
|
||||
if !m.runDetailsAllowed() {
|
||||
t.Fatal("run details should be allowed")
|
||||
}
|
||||
|
||||
m = typeRunes(t, m, "hello")
|
||||
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
|
||||
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
|
||||
}
|
||||
|
||||
initialSidebar := next.sidebarHidden
|
||||
initialDetails := next.runDetailsOpen
|
||||
updated, _ = next.Update(testKeyCtrl('b'))
|
||||
next = updated.(model)
|
||||
if next.sidebarHidden != initialSidebar {
|
||||
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
|
||||
if next.runDetailsOpen != initialDetails {
|
||||
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
|
||||
}
|
||||
|
||||
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
|
||||
// general ? keyboard-shortcut overlay: same width helper, border style
|
||||
// (zeroTheme.line), panel fill, and centered block.
|
||||
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
|
||||
// terminal's own canvas remains visible without jagged panel-fill padding.
|
||||
func (m model) renderLeaderHelpOverlay(width int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := renderLeaderHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
|
||||
return centerRenderedBlock(block, width)
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
|
||||
// Both overlays must use the same frame builder (titled box + panel fill).
|
||||
// Both overlays must use the same transparent, titled frame builder.
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
help := m.renderKeybindingHelpOverlay(100)
|
||||
@@ -267,6 +267,22 @@ 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
|
||||
|
||||
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(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) {
|
||||
func TestCtrlPMovesPickerSelection(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)
|
||||
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(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")
|
||||
}
|
||||
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(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())
|
||||
}
|
||||
|
||||
+342
-333
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,23 @@ 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 {
|
||||
|
||||
+119
-28
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -19,6 +20,7 @@ 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"
|
||||
@@ -558,13 +560,79 @@ 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 !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -611,8 +679,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 !transcriptContains(next.transcript, "· saved") {
|
||||
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
|
||||
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
|
||||
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,6 +1536,28 @@ 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) {
|
||||
@@ -1502,16 +1592,15 @@ func TestStaleExplanationDropped(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) {
|
||||
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
|
||||
// conversation surface and advances the explanation generation.
|
||||
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.sidebarHidden = true
|
||||
m.runDetailsOpen = true
|
||||
gen := m.planDetailGen
|
||||
m = m.beginRun(nil)
|
||||
if m.sidebarHidden {
|
||||
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
|
||||
if m.runDetailsOpen {
|
||||
t.Error("beginRun should close run details for the new turn")
|
||||
}
|
||||
if m.planDetailGen <= gen {
|
||||
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
|
||||
@@ -2561,14 +2650,10 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
|
||||
t.Fatalf("empty sidebar should not 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)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2946,21 +3031,27 @@ func TestScrimViewportLine(t *testing.T) {
|
||||
if got := scrimViewportLine(" ", 10); got != " " {
|
||||
t.Fatalf("blank line should be untouched, got %q", got)
|
||||
}
|
||||
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
|
||||
// stays readable behind the overlay.
|
||||
// Non-blank plain lines keep their text and become faint, so the backdrop stays
|
||||
// readable behind the overlay without competing with its focus surface.
|
||||
got := scrimViewportLine("transcript content", 40)
|
||||
if ansi.Strip(got) != "transcript content" {
|
||||
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
|
||||
}
|
||||
// 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"
|
||||
// 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"
|
||||
scrimmed := scrimViewportLine(styled, 40)
|
||||
if ansi.Strip(scrimmed) != "red backdrop text" {
|
||||
if ansi.Strip(scrimmed) != "removed added" {
|
||||
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
|
||||
}
|
||||
if strings.Contains(scrimmed, "\x1b[31m") {
|
||||
t.Fatalf("scrim must strip the line's original styling, got %q", 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-35
@@ -287,38 +287,6 @@ 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
|
||||
}
|
||||
@@ -531,6 +499,20 @@ 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)
|
||||
@@ -549,9 +531,6 @@ 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++
|
||||
|
||||
@@ -9,7 +9,9 @@ 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) {
|
||||
@@ -251,6 +253,27 @@ 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")
|
||||
@@ -985,10 +1008,7 @@ 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
|
||||
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
|
||||
contentY++
|
||||
}
|
||||
contentY := 1 + m.attachmentComposerPrefixRows(width)
|
||||
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
|
||||
y := frame.composerRect.y + contentY
|
||||
return x, y
|
||||
|
||||
+55
-22
@@ -22,13 +22,19 @@ type terminalOutputFile interface {
|
||||
|
||||
type petImageOutput struct {
|
||||
output terminalOutputFile
|
||||
renderer *terminalpet.ImageRenderer
|
||||
renderDisabled bool
|
||||
renderers []*terminalpet.ImageRenderer
|
||||
renderDisabled []bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
|
||||
return &petImageOutput{output: output, renderer: renderer}
|
||||
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 (o *petImageOutput) Read(value []byte) (int, error) {
|
||||
@@ -39,11 +45,13 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
|
||||
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 {
|
||||
for _, renderer := range o.renderers {
|
||||
if !rendererUsesKitty(renderer) {
|
||||
if err := o.writeImageUpdate(renderer.Clear); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if leavingAltScreen {
|
||||
@@ -51,26 +59,36 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
if kittyImage {
|
||||
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
|
||||
return written, err
|
||||
for _, renderer := range o.renderers {
|
||||
if rendererUsesKitty(renderer) {
|
||||
if err := o.writeImageUpdate(renderer.Clear); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
|
||||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
|
||||
o.renderer.Invalidate()
|
||||
for _, renderer := range o.renderers {
|
||||
renderer.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
var imageUpdate bytes.Buffer
|
||||
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()
|
||||
for index, renderer := range o.renderers {
|
||||
if o.renderDisabled[index] {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
@@ -120,6 +138,15 @@ 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) {
|
||||
@@ -180,9 +207,15 @@ func (o *petImageOutput) clearImage() error {
|
||||
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := o.renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
for _, renderer := range o.renderers {
|
||||
if err := renderer.Clear(writer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
|
||||
if len(o.renderers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
|
||||
return o.renderers[0].DeleteImages(writer, ids...)
|
||||
})
|
||||
}
|
||||
+4
-10
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
|
||||
m.petPreview = nil
|
||||
m.picker = nil
|
||||
m.petTickSeq++
|
||||
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
|
||||
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), 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.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
|
||||
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), 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.appendSystemNotice("Installing " + entry.Label() + "…")
|
||||
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
|
||||
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.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
|
||||
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
|
||||
if m.reducedMotion {
|
||||
return m, nil
|
||||
}
|
||||
@@ -396,9 +396,6 @@ 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
|
||||
}
|
||||
@@ -615,9 +612,6 @@ 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)
|
||||
|
||||
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
|
||||
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(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 TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
|
||||
}
|
||||
runes := []rune(line)
|
||||
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
|
||||
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
|
||||
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
|
||||
if len(runes) < m.width || runes[dockEdge] != '╮' {
|
||||
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("sidebar composer top not found")
|
||||
t.Fatal("composer top not found")
|
||||
}
|
||||
|
||||
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
|
||||
|
||||
+51
-23
@@ -522,9 +522,43 @@ 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.
|
||||
@@ -1008,45 +1042,39 @@ func (m model) newEffortPicker() *commandPicker {
|
||||
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (m model) newThemePicker() *commandPicker {
|
||||
items := make([]pickerItem, 0, len(themeModes))
|
||||
selected := 0
|
||||
// `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"
|
||||
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
|
||||
}
|
||||
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
|
||||
if entry.Name == string(m.themeMode) {
|
||||
items = append(items, item)
|
||||
if 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: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
|
||||
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
|
||||
t.Fatalf("expected model switch status, got %#v", next.transcript)
|
||||
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
|
||||
t.Fatalf("model switch notice = %q", next.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,30 @@ 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
|
||||
@@ -930,8 +954,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 !transcriptContains(m.transcript, "Model") {
|
||||
t.Fatal("choosing should append the model handler's status text")
|
||||
if !strings.Contains(m.transientNotice.text, "Model:") {
|
||||
t.Fatalf("model picker notice = %q", m.transientNotice.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,7 +987,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestThemeCommandOpensPicker(t *testing.T) {
|
||||
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
|
||||
// Bare /theme opens the theme popup (contained 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)
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user