Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions internal/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providers"
Expand Down Expand Up @@ -462,10 +463,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
defer stopSignals()
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with the workspace test plan (the LSP half stays nil here since
// headless exec does not run a language-server manager); the autonomy gate
// inside the corrector still decides whether failures auto-fix or just report.
selfCorrector := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
// on we verify with both the workspace test plan and LSP diagnostics over the
// changed files; the autonomy gate inside the corrector still decides whether
// failures auto-fix or just report. lspShutdown tears down any language-server
// sessions the LSP half spawned (no-op when self-correct is off).
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
Expand Down Expand Up @@ -616,24 +619,31 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// population here keeps registration and activation in agreement: tool_search is
// registered iff the partition will actually go active. Built-ins never implement
// the Deferred interface, so they never count.
// newExecSelfCorrector builds the post-edit self-corrector for a headless run,
// or nil when --self-correct is off. nil is deliberately the disabled state the
// agent loop treats as a no-op, so a run without the flag stays byte-identical.
//
// The headless wiring verifies with the workspace test plan only (IncludeLSP is
// false: exec does not spin up a language-server manager, and NewSelfCorrector
// accepts a nil checker). Whether a failure auto-fixes or is merely reported is
// decided by the corrector's autonomy gate from the run's --auto level.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) *agent.SelfCorrector {
// newExecSelfCorrector builds the post-edit corrector for a headless run and a
// cleanup func the caller must defer. When enabled it wires both halves: the
// workspace test plan AND an LSP diagnostics checker backed by a per-run
// lsp.Manager. The manager is lazy — Manager.Check degrades a missing/unsupported
// language server to (nil, nil), so enabling the LSP half never spawns a server
// unless a changed file's language actually has one installed on PATH. The
// returned cleanup shuts that manager down (terminating any spawned server
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil
return nil, func() {}
}
return agent.NewSelfCorrector(workspaceRoot, nil, agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
manager := lsp.NewManager(workspaceRoot)
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: false,
IncludeLSP: true,
Autonomy: autonomy,
})
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
}

func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
Expand Down
10 changes: 0 additions & 10 deletions internal/lsp/documents.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,6 @@ func (s *session) fileLock(uri string) *sync.Mutex {
return lock
}

func (s *session) didClose(ctx context.Context, absPath string) error {
uri := PathToURI(absPath)
s.mu.Lock()
delete(s.open, uri)
s.mu.Unlock()
return s.client.Notify(ctx, "textDocument/didClose", map[string]any{
"textDocument": map[string]any{"uri": uri},
})
}

func (s *session) diagnosticsFor(uri string) []Diagnostic {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down
51 changes: 51 additions & 0 deletions internal/lsp/real_gopls_manual_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package lsp

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

// TestManagerCheckRealGopls drives the exact path the self-correct LSP wiring
// uses (NewManager -> Check) against a REAL gopls and asserts a real diagnostic
// comes back. Skips when gopls is not installed. Temporary manual-verification
// test (the rest of the suite uses a fake server).
func TestManagerCheckRealGopls(t *testing.T) {
if _, err := exec.LookPath("gopls"); err != nil {
t.Skip("gopls not on PATH; skipping real-server check")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module m\n\ngo 1.24\n"), 0o644); err != nil {
t.Fatal(err)
}
src := "package main\n\nfunc main() {\n\tvar count int = \"three\"\n\t_ = count\n}\n"
path := filepath.Join(root, "main.go")
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
t.Fatal(err)
}

m := NewManager(root)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = m.Shutdown(ctx)
})

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
diags, err := m.Check(ctx, path, src)
if err != nil {
t.Fatalf("Manager.Check returned error: %v", err)
}
errs := FilterBySeverity(diags, SeverityError)
if len(errs) == 0 {
t.Fatalf("expected gopls to report the type error, got %d diagnostics: %+v", len(diags), diags)
}
t.Logf("real gopls returned %d error diagnostic(s):", len(errs))
for _, d := range errs {
t.Logf(" [source=%s] %s", d.Source, d.Message)
}
}
9 changes: 9 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
commandBash
commandImage
commandAddDir
commandSelfCorrect
commandUnknown
)

Expand Down Expand Up @@ -201,6 +202,14 @@ var commandDefinitions = []commandDefinition{
description: "Show or set the response style preference.",
kind: commandStyle,
},
{
name: "/selfcorrect",
aliases: []string{"/sc"},
usage: "/selfcorrect [status|on|off|tests|full|lsp]",
group: commandGroupSession,
description: "Show or set post-edit self-correction depth (LSP-only default; on/tests/full add the project test plan; off/lsp disable tests, LSP-only).",
kind: commandSelfCorrect,
},
{
name: "/doctor",
usage: "/doctor",
Expand Down
45 changes: 45 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/lsp"
internalmcp "github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
Expand Down Expand Up @@ -62,6 +63,7 @@ type model struct {
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
compactRequests int
Expand Down Expand Up @@ -1874,6 +1876,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleStyleCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandSelfCorrect:
text := ""
m, text = m.handleSelfCorrectCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
Expand Down Expand Up @@ -2093,6 +2100,20 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
return m.runAgentWithOptions(runID, runCtx, prompt, images, tuiAgentRunOptions{})
}

// selfCorrectAutonomyForMode maps the active permission mode to the self-correct
// autonomy gate: more autonomous modes auto-fix after a failed verification,
// while restrictive modes only surface the failure. Mirrors exec's --auto levels.
func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
switch mode {
case agent.PermissionModeUnsafe:
return "high"
case agent.PermissionModeAuto:
return "medium"
default: // ask, etc. — report the failure without starting an auto-fix round
return "low"
}
}

func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
started := m.now()
Expand Down Expand Up @@ -2127,6 +2148,30 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// window. An unknown/custom model resolves to 0, leaving compaction off.
options.ContextWindow = modelContextWindow(m.modelName)

// Post-edit self-correction is on by default in the TUI but kept FAST: it
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
// and a no-op when no language server is installed. The project test plan
// (`go test ./...`, whole-repo) is NOT run per edit by default — that would
// add the full suite's latency to every turn and let a pre-existing failure
// hijack the agent — so the test half is opt-in via `/selfcorrect on`
// (m.selfCorrectTests). The spec-draft (planning) path never wires it,
// matching exec; the per-turn lsp.Manager is torn down when this run
// returns; auto-fix vs report-only follows the active permission mode.
if !runOptions.specDraft && options.Cwd != "" {
lspManager := lsp.NewManager(options.Cwd)
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = lspManager.Shutdown(shutdownCtx)
}()
options.SelfCorrect = agent.NewSelfCorrector(options.Cwd, agent.NewLSPDiagnosticsChecker(lspManager), agent.NewProjectVerifier(options.Cwd), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: m.selfCorrectTests,
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
}

onText := options.OnText
options.OnText = func(delta string) {
m.sendAgentText(runID, delta)
Expand Down
34 changes: 34 additions & 0 deletions internal/tui/session_controls.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,40 @@ func defaultedResponseStyle(value string) string {
return defaultResponseStyle
}

func (m model) handleSelfCorrectCommand(args string) (model, string) {
args = strings.TrimSpace(strings.ToLower(args))
switch args {
case "", "status":
return m, m.selfCorrectText()
case "on", "tests", "full":
m.selfCorrectTests = true
case "off", "lsp":
m.selfCorrectTests = false
default:
return m, "Self-correct\nUsage: /selfcorrect [status|on|off|tests|full|lsp]"
}
return m, m.selfCorrectText()
}

func (m model) selfCorrectText() string {
depth := "LSP diagnostics only — fast, scoped to changed files"
if m.selfCorrectTests {
depth = "LSP diagnostics + project test plan (e.g. go test ./...) — slower, whole-repo"
}
return renderCommandOutput(commandOutput{
Title: "Self-correct",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "State",
Lines: []string{
"post-edit verification: " + depth,
"auto-fix vs report-only: follows the active permission mode",
},
}},
Hints: []string{"/selfcorrect on adds the full test suite; /selfcorrect off returns to LSP-only (default)"},
})
}

func responseStyleAllowed(value string) bool {
for _, style := range responseStyles {
if value == style {
Expand Down
Loading