Compare commits

..
Author SHA1 Message Date
anandh8x e4e4fa20c1 fix(goal): resume continuations after btw 2026-07-26 23:54:59 +05:30
anandh8x c6f77b4f46 fix(goal): bound automatic continuations
Persist a provider-independent continuation limit, require approval for model-created goals, and keep resume, BTW, and loop runs from starting or charging unrelated goal work.

Also reject trailing lifecycle arguments and bound persisted objective length.
2026-07-26 14:53:06 +05:30
anandh8x b44189dc86 Cover goal edit state transitions 2026-07-24 18:32:28 +05:30
anandh8x f4ca3296c4 Strengthen loop goal isolation test 2026-07-24 18:19:04 +05:30
anandh8x de8b4bd810 Fix goal run isolation and guards 2026-07-24 18:08:49 +05:30
anandh8x 7f87eb1eb4 feat: add persistent session goals 2026-07-24 17:32:18 +05:30
40 changed files with 2124 additions and 1006 deletions

No files matched your search

+15 -24
View File
@@ -2404,11 +2404,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
root := t.TempDir()
command := "PATH=.:$PATH curl https://example.com"
if runtime.GOOS == "windows" {
if windowsTestUsesPowerShell() {
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
} else {
command = "set PATH=.;%PATH% && curl https://example.com"
}
command = "set PATH=.;%PATH% && curl https://example.com"
fakeCurl := filepath.Join(root, "curl.cmd")
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
t.Fatal(err)
@@ -2529,17 +2525,13 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
root := t.TempDir()
command := "echo rm -rf /"
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
command = `Write-Output 'rm -rf /'`
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedBashTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2635,12 +2627,6 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
}
}
func windowsTestUsesPowerShell() bool {
executable, _ := tools.HostShellCommand("")
name := strings.ToLower(filepath.Base(executable))
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
}
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
for _, decision := range decisions {
if decision == want {
@@ -3174,17 +3160,22 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
wants := []string{"workdir/cwd", "MSYS binaries"}
if windowsTestUsesPowerShell() {
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
} else {
wants = append(wants, "cmd.exe", "double quotes")
}
for _, want := range wants {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
}
+5 -2
View File
@@ -11,7 +11,6 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -311,7 +310,11 @@ func workspaceContext(cwd string) string {
b.WriteString("<environment>\n")
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
if branch := gitBranchForPrompt(cwd); branch != "" {
b.WriteString("Git branch: " + branch + "\n")
}
+2 -29
View File
@@ -27,22 +27,11 @@ var destructivePrograms = map[string]bool{
"mkfs": true, "fdisk": true, "shred": true, "dd": true, "parted": true,
}
var powerShellRemoveItemPrograms = map[string]bool{
"remove-item": true,
"ri": true,
"rd": true,
"rmdir": true,
"del": true,
"erase": true,
"rm": true,
}
// networkPrograms are commands that perform network egress/ingress.
var networkPrograms = map[string]bool{
"curl": true, "wget": true, "ssh": true, "scp": true, "sftp": true,
"rsync": true, "nc": true, "ncat": true, "netcat": true, "telnet": true,
"ftp": true, "iwr": true, "irm": true, "invoke-webrequest": true,
"invoke-restmethod": true,
"ftp": true,
}
var localServerPrograms = map[string]bool{
@@ -183,10 +172,7 @@ func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, de
if commandUsesNetwork(prog, rest) {
result.Network = true
}
if destructivePrograms[prog] ||
(prog == "rm" && hasRecursiveForce(rest)) ||
(powerShellRemoveItemPrograms[prog] && hasPowerShellRecursiveForce(rest)) ||
(prog == "find" && hasFindDelete(rest)) {
if destructivePrograms[prog] || (prog == "rm" && hasRecursiveForce(rest)) || (prog == "find" && hasFindDelete(rest)) {
result.Destructive = true
}
return true
@@ -493,19 +479,6 @@ func hasRecursiveForce(args []*syntax.Word) bool {
return recursive && force
}
func hasPowerShellRecursiveForce(args []*syntax.Word) bool {
recursive, force := false, false
for _, arg := range args {
switch strings.ToLower(wordText(arg)) {
case "-recurse", "-r":
recursive = true
case "-force":
force = true
}
}
return recursive && force
}
func hasFindDelete(args []*syntax.Word) bool {
for _, arg := range args {
if wordText(arg) == "-delete" {
-21
View File
@@ -45,27 +45,6 @@ func TestAnalyzeCommand(t *testing.T) {
{name: "sudo dynamic flag then rm -rf", script: `sudo "$maybe" rm -rf /tmp/x`, destructive: true},
{name: "curl", script: "curl https://example.com", network: true},
{name: "PowerShell iwr alias", script: "iwr https://example.com", network: true},
{name: "PowerShell irm alias", script: "irm https://example.com", network: true},
{name: "PowerShell Invoke-WebRequest", script: "Invoke-WebRequest https://example.com", network: true},
{name: "PowerShell Invoke-RestMethod", script: "Invoke-RestMethod https://example.com", network: true},
{name: "PowerShell Remove-Item recursive force", script: `Remove-Item -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rm recursive force", script: `rm -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ri recursive force", script: `ri -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rd recursive force", script: `rd -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell rmdir recursive force", script: `rmdir -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell del recursive force", script: `del -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell erase recursive force", script: `erase -Recurse -Force 'C:\temp\x'`, destructive: true},
{name: "PowerShell ambiguous force abbreviation", script: `Remove-Item -Recurse -f 'C:\temp\x'`, destructive: false},
{name: "PowerShell Remove-Item without recurse", script: `Remove-Item -Force 'C:\temp\x'`, destructive: false},
{name: "Windows curl cmd", script: "curl.cmd https://example.com", network: true},
{name: "Windows curl exe", script: "curl.exe https://example.com", network: true},
{name: "Windows drive path curl exe", script: `'C:\tools\curl.exe' https://example.com`, network: true},
{name: "Windows UNC path curl exe", script: `'\\server\share\curl.exe' https://example.com`, network: true},
{name: "Windows dot relative path curl exe", script: `'.\curl.exe' https://example.com`, network: true},
{name: "Windows relative path curl exe", script: `'tools\curl.exe' https://example.com`, network: true},
{name: "Windows drive relative path curl exe", script: `'C:curl.exe' https://example.com`, network: true},
{name: "Windows npm cmd", script: "npm.cmd install", network: true},
{name: "wget piped to shell", script: "wget -qO- https://x.test | sh", network: true},
{name: "python http server", script: "python3 -m http.server 8000", network: true},
{name: "python pip install", script: "python3 -m pip install requests", network: true},
-42
View File
@@ -2,7 +2,6 @@ package sandbox
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
@@ -74,25 +73,6 @@ func TestEngineBashAllowGrantDoesNotBypassNetworkPrompt(t *testing.T) {
}
}
func TestEngineClassifiesPowerShellCurlCommandAsNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{
WorkspaceRoot: t.TempDir(),
Policy: DefaultPolicy(),
})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "bash",
SideEffect: SideEffectShell,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{
"command": `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`,
},
})
if decision.Action != ActionPrompt || decision.Reason != ReasonNetworkBlocked || !HasRiskCategory(decision.Risk, "network") {
t.Fatalf("PowerShell curl command decision = %#v, want network prompt", decision)
}
}
func TestUnsandboxedExecutionAllowedPreservesDeniedReads(t *testing.T) {
root := t.TempDir()
policy := DefaultPolicy()
@@ -505,28 +485,6 @@ func TestEngineDeniesOutOfWorkspacePaths(t *testing.T) {
}
}
func TestEnginePromptsForOutOfWorkspaceReadWithReadSpecificReason(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "external")
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "list_directory",
SideEffect: SideEffectRead,
Permission: PermissionAllow,
WorkspaceRoot: root,
Args: map[string]any{"path": outside},
})
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if decision.Action != ActionPrompt || decision.Block == nil || !decision.Block.Recoverable {
t.Fatalf("outside read decision = %#v, want recoverable prompt", decision)
}
if decision.Reason != want || decision.Block.Reason != want {
t.Fatalf("outside read reasons = decision %q, block %q; want %q", decision.Reason, decision.Block.Reason, want)
}
}
func TestEnginePrecheckReportsBlocksBeforeExecution(t *testing.T) {
root := t.TempDir()
outside := outsideDefaultTempPath(root, "escape.txt")
@@ -266,74 +266,6 @@ func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
}
}
// TestWindowsRestrictedTokenPowerShell exercises the default Windows shell
// shape end to end under the write-restricted token. It covers PowerShell
// initialization, a native pipeline (which creates IPC objects), UTF-8 output,
// and workspace write enforcement.
func TestWindowsRestrictedTokenPowerShell(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
powerShell := realSmokePowerShell(t)
root := t.TempDir()
outside := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
insideMarker := filepath.Join(root, "powershell-ok.txt")
script := "$ErrorActionPreference='Stop'; " +
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " +
"$values = 1,2,3 | ForEach-Object { $_ * 2 }; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(insideMarker) + ", (($values -join ',') + ' ✓'))"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", script,
}, 0)
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "2,4,6 ✓" {
t.Fatalf("PowerShell sandbox marker = %q, %v; want pipeline output", bytes, err)
}
outsideMarker := filepath.Join(outside, "powershell-denied.txt")
deniedScript := "$ErrorActionPreference='Stop'; " +
"[IO.File]::WriteAllText(" + powershellSingleQuote(outsideMarker) + ", 'leaked')"
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
powerShell, "-NoLogo", "-NoProfile", "-Command", deniedScript,
}, 1)
if _, err := os.Stat(outsideMarker); err == nil {
t.Fatal("sandboxed PowerShell wrote outside every granted root")
} else if !os.IsNotExist(err) {
t.Fatalf("stat outside PowerShell marker: %v", err)
}
}
func realSmokePowerShell(t *testing.T) string {
t.Helper()
for _, candidate := range []string{"pwsh.exe", "pwsh", "powershell.exe", "powershell"} {
if path, err := exec.LookPath(candidate); err == nil {
return path
}
}
t.Skip("PowerShell is unavailable")
return ""
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
+14 -59
View File
@@ -430,70 +430,25 @@ func shellDashCPayload(program string, fields []string) string {
// name: it strips shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token (including embedded ones like `vi\m` or `v"i"m`), strips
// leading command-substitution markers, removes any directory prefix (so
// /usr/bin/vim and C:\tools\vim.exe match "vim"), removes Windows executable
// suffixes, and lowercases. This closes path/quote/substitution evasions of the
// detector and keeps curl.exe/npm.cmd equivalent to curl/npm for risk analysis.
// /usr/bin/vim and C:\tools\vim.exe match "vim"), and lowercases. This closes
// path/quote/substitution evasions of the detector.
func normalizeProgramToken(field string) string {
token := strings.TrimSpace(field)
token = strings.TrimLeft(token, "$(")
token = strings.TrimRight(token, ")")
// PowerShell commonly invokes drive-letter, UNC, and relative paths. Extract
// their basename while backslashes still carry path-separator meaning;
// removing them first would turn C:\tools\curl.exe into c:toolscurl.exe and
// bypass the canonical curl risk entry.
pathToken := stripChars(token, "\"'`")
if windowsPathBasename, ok := windowsExecutablePathBasename(pathToken); ok {
token = windowsPathBasename
} else {
// Strip shell quoting/escaping characters (", ', `, \) wherever they
// appear in the token — surrounding, embedded, or as a mid-word escape
// — so "vim", v"i"m, 'v'im, and vi\m all collapse to the program name.
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename.
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
}
// Strip shell quoting/escaping characters (", ', `, \) wherever they appear
// in the token — surrounding, embedded, or as a mid-word escape — so
// "vim", v"i"m, 'v'im, and vi\m all collapse to the program name. This is
// done BEFORE the directory-prefix trim so an escape can't masquerade as a
// path separator (e.g. vi\m must become vim, not m).
token = stripChars(token, "\"'`\\")
// Strip a directory prefix so /usr/bin/vim reduces to the basename. (A
// Windows-style backslash path separator is already removed above, so only
// the POSIX separator remains to split on.)
if i := strings.LastIndex(token, "/"); i >= 0 {
token = token[i+1:]
}
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return strings.TrimSuffix(token, suffix)
}
}
return token
}
func windowsExecutablePathBasename(token string) (string, bool) {
drivePath := len(token) >= 3 &&
((token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z')) &&
token[1] == ':'
uncPath := strings.HasPrefix(token, `\\`)
explicitRelativePath := strings.HasPrefix(token, `.\`) || strings.HasPrefix(token, `..\`)
backslashRelativePath := strings.ContainsRune(token, '\\') && hasWindowsExecutableSuffix(token)
if !drivePath && !uncPath && !explicitRelativePath && !backslashRelativePath {
return "", false
}
index := strings.LastIndexAny(token, `\/`)
if index >= 0 {
if index+1 >= len(token) {
return "", false
}
return token[index+1:], true
}
if drivePath && len(token) > 2 {
return token[2:], true
}
return "", false
}
func hasWindowsExecutableSuffix(token string) bool {
token = strings.ToLower(token)
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
if strings.HasSuffix(token, suffix) {
return true
}
}
return false
return strings.ToLower(token)
}
// stripChars returns s with every rune in cutset removed.
+2 -15
View File
@@ -203,24 +203,11 @@ func dedupeScopeRoots(roots []string) []string {
// only on outside_workspace results. The returned block always carries
// the caller's original requestedPath.
func (s *Scope) validate(requestedPath string) *pathBlock {
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
return s.validateAgainstRoots(requestedPath, s.Roots())
}
func (s *Scope) validateRead(requestedPath string) *pathBlock {
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.ReadRoots()), requestedPath, SideEffectRead)
}
func withOutsideWorkspaceAccessReason(block *pathBlock, requestedPath string, sideEffect SideEffect) *pathBlock {
if block == nil || block.Code != BlockOutsideWorkspace || !strings.Contains(block.Reason, " is outside the workspace") {
return block
}
switch sideEffect {
case SideEffectRead:
block.Reason = fmt.Sprintf("Reading %s requires access outside the workspace.", requestedPath)
case SideEffectWrite, SideEffectOutOfWorkspace:
block.Reason = fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", requestedPath)
}
return block
return s.validateAgainstRoots(requestedPath, s.ReadRoots())
}
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
-40
View File
@@ -1,7 +1,6 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"runtime"
@@ -280,45 +279,6 @@ func TestScopeValidateAllowsAnyRootButRelativeOnlyWorkspace(t *testing.T) {
}
}
func TestScopeValidateReadExplainsReadAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validateRead(outside)
if block == nil {
t.Fatal("validateRead(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Reading %s requires access outside the workspace.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
if strings.Contains(block.Reason, "--add-dir") || strings.Contains(block.Reason, "writes") {
t.Fatalf("read block reason must not suggest write access: %q", block.Reason)
}
}
func TestScopeValidateWriteExplainsWriteAccessOutsideWorkspace(t *testing.T) {
workspace := t.TempDir()
scope, err := NewScope(workspace, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
outside := outsideDefaultTempPath(workspace, "elsewhere.txt")
block := scope.validate(outside)
if block == nil {
t.Fatal("validate(outside workspace) = nil, want block")
}
want := fmt.Sprintf("Writing to %s requires access outside the workspace. Use /add-dir or --add-dir to allow writes there.", outside)
if block.Reason != want {
t.Fatalf("block.Reason = %q, want %q", block.Reason, want)
}
}
func TestScopeValidateKeepsSymlinkTraversalProtection(t *testing.T) {
workspace := t.TempDir()
outside := outsideDefaultTempPath(workspace, "symlink-target")
+422
View File
@@ -0,0 +1,422 @@
package sessions
import (
"fmt"
"strings"
"unicode/utf8"
)
const (
GoalObjectiveMaxLength = 4_000
GoalMaxConsecutiveContinuations = 20
goalContinuationLimitStatusReason = "automatic continuation limit reached"
)
func validateGoalObjective(objective string) (string, error) {
objective = strings.TrimSpace(objective)
if objective == "" {
return "", fmt.Errorf("goal objective is required")
}
if utf8.RuneCountInString(objective) > GoalObjectiveMaxLength {
return "", fmt.Errorf("goal objective cannot exceed %d characters", GoalObjectiveMaxLength)
}
return objective, nil
}
// CreateGoal persists a new active goal for a session. It refuses to replace an
// existing goal so callers must make replacement an explicit user decision.
func (store *Store) CreateGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal != nil {
return Metadata{}, Event{}, fmt.Errorf("session already has a goal")
}
now := store.timestamp()
session.Goal = &Goal{
Objective: objective,
Status: GoalStatusActive,
TokenBudget: tokenBudget,
ContinuationLimit: GoalMaxConsecutiveContinuations,
CreatedAt: now,
UpdatedAt: now,
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCreated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// UpdateGoal changes a goal's lifecycle state. Terminal goals remain available
// for status/history until the user explicitly clears them.
func (store *Store) UpdateGoal(sessionID string, status GoalStatus, reason string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
if !validGoalStatus(status) {
return Metadata{}, Event{}, fmt.Errorf("invalid goal status %q", status)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Status = status
session.Goal.StatusReason = strings.TrimSpace(reason)
if status == GoalStatusActive {
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// EditGoal replaces the objective and optional budget without resetting usage or
// lifecycle timestamps. User-facing command handling owns confirmation.
func (store *Store) EditGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
var err error
objective, err = validateGoalObjective(objective)
if err != nil {
return Metadata{}, Event{}, err
}
if tokenBudget < 0 {
return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
session.Goal.Objective = objective
session.Goal.TokenBudget = tokenBudget
session.Goal.Status = GoalStatusActive
session.Goal.StatusReason = ""
session.Goal.ContinuationCount = 0
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
}
if tokenBudget > 0 && session.Goal.TokensUsed >= tokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
// ResetGoalContinuations starts a fresh autonomous-run allowance after explicit
// user input. It makes the safety bound consecutive rather than lifetime-wide.
func (store *Store) ResetGoalContinuations(sessionID string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil
}
changed := false
if session.Goal.ContinuationCount != 0 {
session.Goal.ContinuationCount = 0
changed = true
}
if session.Goal.ContinuationLimit <= 0 {
session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations
changed = true
}
if !changed {
return session, nil
}
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
// ReserveGoalContinuation atomically reserves one automatic continuation. Once
// the persisted consecutive-run limit is exhausted it pauses the goal before
// another provider request can start, even when the provider reports no usage.
func (store *Store) ReserveGoalContinuation(sessionID string) (Metadata, *Event, bool, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, false, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, false, nil
}
limit := session.Goal.ContinuationLimit
if limit <= 0 {
limit = GoalMaxConsecutiveContinuations
session.Goal.ContinuationLimit = limit
}
if session.Goal.ContinuationCount < limit {
session.Goal.ContinuationCount++
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
return session, nil, true, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = goalContinuationLimitStatusReason
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, false, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, false, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, false, err
}
return loaded, &event, false, nil
}
// AddGoalUsage accounts tokens consumed while a goal is active. Reaching the
// optional budget pauses the goal before another autonomous turn can start.
func (store *Store) AddGoalUsage(sessionID string, tokens int) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
if tokens < 0 {
return Metadata{}, nil, fmt.Errorf("goal token usage cannot be negative")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || tokens == 0 {
return session, nil, nil
}
session.Goal.TokensUsed += tokens
session.Goal.UpdatedAt = store.timestamp()
budgetReached := false
if session.Goal.Status == GoalStatusActive &&
session.Goal.TokenBudget > 0 &&
session.Goal.TokensUsed >= session.Goal.TokenBudget {
session.Goal.Status = GoalStatusBudgetLimited
session.Goal.StatusReason = "token budget reached"
budgetReached = true
}
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
if !budgetReached {
return session, nil, nil
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// PauseGoalIfActive pauses a goal only while it is still active. It is used by
// cancellation paths where an in-flight agent may have completed or blocked the
// goal immediately before the cancellation reached the runtime.
func (store *Store) PauseGoalIfActive(sessionID, reason string) (Metadata, *Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, nil, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
if session.Goal == nil || session.Goal.Status != GoalStatusActive {
return session, nil, nil
}
session.Goal.Status = GoalStatusPaused
session.Goal.StatusReason = strings.TrimSpace(reason)
session.Goal.UpdatedAt = store.timestamp()
if err := store.writeMetadata(session); err != nil {
return Metadata{}, nil, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalUpdated,
Payload: session.Goal,
})
if err != nil {
return Metadata{}, nil, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, nil, err
}
return loaded, &event, nil
}
// ClearGoal removes the current goal while preserving an audit event.
func (store *Store) ClearGoal(sessionID string) (Metadata, Event, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
if session.Goal == nil {
return Metadata{}, Event{}, fmt.Errorf("session has no goal")
}
cleared := cloneGoal(session.Goal)
session.Goal = nil
if err := store.writeMetadata(session); err != nil {
return Metadata{}, Event{}, err
}
event, err := store.appendEventLocked(sessionID, AppendEventInput{
Type: EventGoalCleared,
Payload: cleared,
})
if err != nil {
return Metadata{}, Event{}, err
}
loaded, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, Event{}, err
}
return loaded, event, nil
}
func validGoalStatus(status GoalStatus) bool {
switch status {
case GoalStatusActive, GoalStatusPaused, GoalStatusBlocked, GoalStatusBudgetLimited, GoalStatusUsageLimited, GoalStatusComplete:
return true
default:
return false
}
}
func cloneGoal(goal *Goal) *Goal {
if goal == nil {
return nil
}
copy := *goal
return &copy
}
+382
View File
@@ -0,0 +1,382 @@
package sessions
import (
"os"
"strings"
"testing"
"time"
)
func TestGoalLifecyclePersistsInSessionMetadata(t *testing.T) {
now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "goal_session"})
if err != nil {
t.Fatal(err)
}
created, event, err := store.CreateGoal(session.SessionID, "Ship the release", 1_000)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCreated {
t.Fatalf("create event = %q, want %q", event.Type, EventGoalCreated)
}
if created.Goal == nil || created.Goal.Objective != "Ship the release" ||
created.Goal.Status != GoalStatusActive || created.Goal.TokenBudget != 1_000 {
t.Fatalf("created goal = %#v", created.Goal)
}
accounted, _, err := store.AddGoalUsage(session.SessionID, 250)
if err != nil {
t.Fatal(err)
}
if accounted.Goal.TokensUsed != 250 || accounted.Goal.Status != GoalStatusActive {
t.Fatalf("accounted goal = %#v", accounted.Goal)
}
paused, event, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user interrupted")
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated || paused.Goal.Status != GoalStatusPaused ||
paused.Goal.StatusReason != "user interrupted" {
t.Fatalf("paused goal/event = %#v / %#v", paused.Goal, event)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Status != GoalStatusPaused {
t.Fatalf("reloaded session = %#v", loaded)
}
cleared, event, err := store.ClearGoal(session.SessionID)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalCleared || cleared.Goal != nil {
t.Fatalf("cleared goal/event = %#v / %#v", cleared.Goal, event)
}
}
func TestGoalBudgetPausesAtLimit(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "budget_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded", 100); err != nil {
t.Fatal(err)
}
updated, event, err := store.AddGoalUsage(session.SessionID, 100)
if err != nil {
t.Fatal(err)
}
if updated.Goal.Status != GoalStatusBudgetLimited || updated.Goal.StatusReason != "token budget reached" {
t.Fatalf("budgeted goal = %#v", updated.Goal)
}
if event == nil || event.Type != EventGoalUpdated {
t.Fatalf("budget transition event = %#v", event)
}
}
func TestGoalContinuationLimitStopsWithoutProviderUsage(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "continuation_limit"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded without usage events", 0); err != nil {
t.Fatal(err)
}
for continuation := 1; continuation <= GoalMaxConsecutiveContinuations; continuation++ {
updated, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if !reserved || event != nil {
t.Fatalf("continuation %d: reserved=%v event=%#v", continuation, reserved, event)
}
if updated.Goal.ContinuationCount != continuation {
t.Fatalf("continuation count = %d, want %d", updated.Goal.ContinuationCount, continuation)
}
if updated.Goal.TokensUsed != 0 {
t.Fatalf("provider-independent guard unexpectedly recorded tokens: %#v", updated.Goal)
}
}
stopped, event, reserved, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reserved || event == nil || stopped.Goal.Status != GoalStatusPaused ||
stopped.Goal.StatusReason != goalContinuationLimitStatusReason {
t.Fatalf("unbounded continuation was not stopped: goal=%#v event=%#v reserved=%v", stopped.Goal, event, reserved)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal.ContinuationCount != GoalMaxConsecutiveContinuations ||
reloaded.Goal.Status != GoalStatusPaused {
t.Fatalf("continuation guard did not persist: %#v", reloaded.Goal)
}
resumed, _, err := store.UpdateGoal(session.SessionID, GoalStatusActive, "")
if err != nil {
t.Fatal(err)
}
if resumed.Goal.ContinuationCount != 0 {
t.Fatalf("explicit resume did not reset consecutive continuations: %#v", resumed.Goal)
}
}
func TestResetGoalContinuationsPersistsOnlyActiveChanges(t *testing.T) {
newStore := func(t *testing.T) (*Store, Metadata) {
t.Helper()
now := time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC)
store := NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
session, err := store.Create(CreateInput{SessionID: "reset_goal"})
if err != nil {
t.Fatal(err)
}
return store, session
}
pinMetadataTime := func(t *testing.T, store *Store, sessionID string) time.Time {
t.Helper()
path := store.metadataPath(sessionID)
sentinel := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
if err := os.Chtimes(path, sentinel, sentinel); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.ModTime()
}
assertMetadataTime := func(t *testing.T, store *Store, sessionID string, want time.Time) {
t.Helper()
info, err := os.Stat(store.metadataPath(sessionID))
if err != nil {
t.Fatal(err)
}
if !info.ModTime().Equal(want) {
t.Fatalf("metadata was rewritten: modtime=%s want=%s", info.ModTime(), want)
}
}
t.Run("no goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal != nil {
t.Fatalf("no-goal reset created a goal: %#v", updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("inactive goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Pause safely", 0); err != nil {
t.Fatal(err)
}
if _, _, reserved, err := store.ReserveGoalContinuation(session.SessionID); err != nil || !reserved {
t.Fatalf("reserve continuation: reserved=%v err=%v", reserved, err)
}
paused, _, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user paused")
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 1 || updated.Goal.UpdatedAt != paused.Goal.UpdatedAt {
t.Fatalf("inactive goal changed during reset: before=%#v after=%#v", paused.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("already reset active goal is a no-op", func(t *testing.T) {
store, session := newStore(t)
created, _, err := store.CreateGoal(session.SessionID, "Stay reset", 0)
if err != nil {
t.Fatal(err)
}
modTime := pinMetadataTime(t, store, session.SessionID)
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt != created.Goal.UpdatedAt {
t.Fatalf("already-reset goal changed: before=%#v after=%#v", created.Goal, updated.Goal)
}
assertMetadataTime(t, store, session.SessionID, modTime)
})
t.Run("active goal reset persists", func(t *testing.T) {
store, session := newStore(t)
if _, _, err := store.CreateGoal(session.SessionID, "Reset progress", 0); err != nil {
t.Fatal(err)
}
reserved, _, ok, err := store.ReserveGoalContinuation(session.SessionID)
if err != nil || !ok {
t.Fatalf("reserve continuation: reserved=%v err=%v", ok, err)
}
updated, err := store.ResetGoalContinuations(session.SessionID)
if err != nil {
t.Fatal(err)
}
if updated.Goal.ContinuationCount != 0 ||
updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations ||
updated.Goal.UpdatedAt == reserved.Goal.UpdatedAt {
t.Fatalf("active reset did not persist: before=%#v after=%#v", reserved.Goal, updated.Goal)
}
reloaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if reloaded.Goal == nil || *reloaded.Goal != *updated.Goal {
t.Fatalf("active reset was not durable: updated=%#v reloaded=%#v", updated.Goal, reloaded.Goal)
}
})
}
func TestGoalObjectiveLengthIsBounded(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "objective_limit"})
if err != nil {
t.Fatal(err)
}
tooLong := strings.Repeat("g", GoalObjectiveMaxLength+1)
if _, _, err := store.CreateGoal(session.SessionID, tooLong, 0); err == nil ||
!strings.Contains(err.Error(), "cannot exceed") {
t.Fatalf("oversized objective error = %v", err)
}
}
func TestEditGoalUpdatesStateAndRejectsInvalidInputWithoutMutation(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "edit_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Original objective", 1_000); err != nil {
t.Fatal(err)
}
if _, _, err := store.AddGoalUsage(session.SessionID, 250); err != nil {
t.Fatal(err)
}
edited, event, err := store.EditGoal(session.SessionID, "Updated objective", 500)
if err != nil {
t.Fatal(err)
}
if event.Type != EventGoalUpdated {
t.Fatalf("edit event = %q, want %q", event.Type, EventGoalUpdated)
}
if edited.Goal == nil ||
edited.Goal.Objective != "Updated objective" ||
edited.Goal.TokenBudget != 500 ||
edited.Goal.TokensUsed != 250 ||
edited.Goal.Status != GoalStatusActive ||
edited.Goal.StatusReason != "" {
t.Fatalf("edited goal = %#v", edited.Goal)
}
limited, _, err := store.EditGoal(session.SessionID, "Stay within budget", 200)
if err != nil {
t.Fatal(err)
}
if limited.Goal == nil ||
limited.Goal.Status != GoalStatusBudgetLimited ||
limited.Goal.StatusReason != "token budget reached" ||
limited.Goal.TokensUsed != 250 {
t.Fatalf("budget-limited goal = %#v", limited.Goal)
}
before := *limited.Goal
eventsBefore, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if _, _, err := store.EditGoal(session.SessionID, " ", 200); err == nil {
t.Fatal("EditGoal should reject an empty objective")
}
if _, _, err := store.EditGoal(session.SessionID, "Invalid budget", -1); err == nil {
t.Fatal("EditGoal should reject a negative token budget")
}
after, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if after == nil || after.Goal == nil || *after.Goal != before {
t.Fatalf("invalid edit mutated goal: before=%#v after=%#v", before, after)
}
eventsAfter, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatal(err)
}
if len(eventsAfter) != len(eventsBefore) {
t.Fatalf("invalid edit appended events: before=%d after=%d", len(eventsBefore), len(eventsAfter))
}
}
func TestCreateGoalRefusesImplicitReplacement(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "replace_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "First", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Second", 0); err == nil {
t.Fatal("CreateGoal should require an explicit clear before replacement")
}
}
func TestPauseGoalIfActiveDoesNotOverwriteTerminalState(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(CreateInput{SessionID: "terminal_session"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish", 0); err != nil {
t.Fatal(err)
}
if _, _, err := store.UpdateGoal(session.SessionID, GoalStatusComplete, ""); err != nil {
t.Fatal(err)
}
updated, event, err := store.PauseGoalIfActive(session.SessionID, "cancelled")
if err != nil {
t.Fatal(err)
}
if event != nil || updated.Goal.Status != GoalStatusComplete {
t.Fatalf("terminal goal changed during cancellation: goal=%#v event=%#v", updated.Goal, event)
}
}
+29
View File
@@ -45,6 +45,9 @@ const (
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
EventGoalCreated EventType = "goal_created"
EventGoalUpdated EventType = "goal_updated"
EventGoalCleared EventType = "goal_cleared"
)
type SessionKind string
@@ -65,6 +68,29 @@ const (
SpecStatusRejected SpecStatus = "rejected"
)
type GoalStatus string
const (
GoalStatusActive GoalStatus = "active"
GoalStatusPaused GoalStatus = "paused"
GoalStatusBlocked GoalStatus = "blocked"
GoalStatusBudgetLimited GoalStatus = "budget_limited"
GoalStatusUsageLimited GoalStatus = "usage_limited"
GoalStatusComplete GoalStatus = "complete"
)
type Goal struct {
Objective string `json:"objective"`
Status GoalStatus `json:"status"`
StatusReason string `json:"statusReason,omitempty"`
TokenBudget int `json:"tokenBudget,omitempty"`
TokensUsed int `json:"tokensUsed,omitempty"`
ContinuationCount int `json:"continuationCount,omitempty"`
ContinuationLimit int `json:"continuationLimit,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Metadata struct {
SessionID string `json:"sessionId"`
SessionKind SessionKind `json:"sessionKind,omitempty"`
@@ -91,6 +117,7 @@ type Metadata struct {
SpecRejectReason string `json:"specRejectReason,omitempty"`
SpecSourceSessionID string `json:"specSourceSessionId,omitempty"`
SpecImplSessionID string `json:"specImplSessionId,omitempty"`
Goal *Goal `json:"goal,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
EventCount int `json:"eventCount"`
@@ -123,6 +150,7 @@ type CreateInput struct {
SpecRejectReason string
SpecSourceSessionID string
SpecImplSessionID string
Goal *Goal
}
type ForkInput struct {
@@ -279,6 +307,7 @@ func (store *Store) Create(input CreateInput) (Metadata, error) {
SpecRejectReason: strings.TrimSpace(input.SpecRejectReason),
SpecSourceSessionID: strings.TrimSpace(input.SpecSourceSessionID),
SpecImplSessionID: strings.TrimSpace(input.SpecImplSessionID),
Goal: cloneGoal(input.Goal),
CreatedAt: timestamp,
UpdatedAt: timestamp,
EventCount: 0,
+27 -9
View File
@@ -106,7 +106,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtime.GOOS)); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssue(commandText, runtime.GOOS); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
@@ -293,19 +293,23 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. PowerShell is preferred on Windows, with cmd.exe retained as
// the fallback. Only cmd.exe needs the raw command-line override.
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// line so commandText reaches cmd.exe unescaped; see
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
// gets the same treatment inside the sandboxed runner process itself
// (internal/sandbox/windows_process_windows.go), since that command line is
// built there, not here.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
hostShell := detectShellRuntime(runtime.GOOS)
spec := zeroSandbox.CommandSpec{
Name: hostShell.Executable,
Args: hostShell.arguments(commandText),
Name: shellExecutable(),
Args: shellArguments(commandText),
Dir: absoluteCwd,
}
if engine != nil {
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
}
return command, plan, err
}
@@ -329,7 +333,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped, hostShell.Kind == shellKindCmd)
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
return command, plan, nil
}
@@ -381,6 +385,20 @@ func interactiveBlockResult(detection zeroSandbox.InteractiveCommandResult) Resu
}
}
func shellExecutable() string {
if runtime.GOOS == "windows" {
return "cmd.exe"
}
return "/bin/sh"
}
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return zeroSandbox.WindowsShellArgs(command)
}
return []string{"-c", command}
}
func commandExitCode(err error) int {
if err == nil {
return 0
@@ -603,7 +621,7 @@ func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int,
func formatBashOutputWithShellHint(stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssueForRuntime(stdout+"\n"+stderr, detectShellRuntime(runtime.GOOS)); issue != nil {
if issue := detectShellOutputIssue(stdout+"\n"+stderr, runtime.GOOS); issue != nil {
meta["shell_issue"] = issue.Kind
output = appendShellIssueHint(output, *issue)
}
+3 -3
View File
@@ -36,6 +36,6 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine is a no-op outside Windows.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
+9 -6
View File
@@ -31,12 +31,15 @@ func hardenProcessLifetime(command *exec.Cmd) {
}
}
// applyWindowsShellCommandLine overrides the cmd.exe fallback's raw child
// command line so commandText reaches cmd.exe unescaped. PowerShell consumes
// ordinary argv quoting and must not take this path. Wrapped commands are
// handled after unwrapping by the Windows sandbox runner.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool, cmdFallback bool) {
if wrapped || !cmdFallback {
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
+12 -31
View File
@@ -124,15 +124,11 @@ func TestBashToolDescribesHostShellSyntax(t *testing.T) {
}
if runtime.GOOS == "windows" {
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in bash description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in bash description, got %q", description)
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in bash description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in bash description, got %q", description)
}
return
}
@@ -459,14 +455,13 @@ func TestBashToolReturnsNonzeroExitAsError(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected error status, got %s", result.Status)
}
wantExitCode := strconv.Itoa(helperFailureExitCode())
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: " + wantExitCode} {
for _, want := range []string{"stdout:\nbefore failure", "stderr:\nfailure details", "exit_code: 7"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
}
}
if result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected exit_code metadata %s, got %q", wantExitCode, result.Meta["exit_code"])
if result.Meta["exit_code"] != "7" {
t.Fatalf("expected exit_code metadata 7, got %q", result.Meta["exit_code"])
}
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
@@ -583,16 +578,10 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
}
registry := NewRegistry()
registry.Register(NewScopedBashTool(root, nil))
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell. Use a name that
// still resolves to an incompatible Git-for-Windows MSYS executable.
msysCommand = "grep pattern somefile.txt"
}
t.Run("default sandboxing still blocks an MSYS-prone command", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": msysCommand,
"command": "cat somefile.txt",
}, RunOptions{
PermissionGranted: true,
Sandbox: newEngine(),
@@ -605,7 +594,7 @@ func TestBashToolRequireEscalatedMsysGuard(t *testing.T) {
t.Run("approved require_escalated bypasses the MSYS guard", func(t *testing.T) {
result := registry.RunWithOptions(context.Background(), "bash", map[string]any{
"command": msysCommand,
"command": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -659,9 +648,8 @@ func TestBashToolIgnoresMsysMarkersInCommandArgumentsAfterFailure(t *testing.T)
"command": command,
})
wantExitCode := strconv.Itoa(helperFailureExitCode())
if result.Status != StatusError || result.Meta["exit_code"] != wantExitCode {
t.Fatalf("expected the helper failure exit %s, got %s: %#v", wantExitCode, result.Status, result)
if result.Status != StatusError || result.Meta["exit_code"] != "7" {
t.Fatalf("expected the helper's real exit 7 failure, got %s: %#v", result.Status, result)
}
if result.Meta["shell_issue"] == "windows_msys_sandbox" {
t.Fatalf("expected the MSYS marker in the command's own argument text to be ignored, got %#v", result)
@@ -872,9 +860,6 @@ func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
}
if detectShellRuntime(runtime.GOOS).Kind != shellKindCmd {
t.Skip("cmd.exe fallback is not selected")
}
root := t.TempDir()
result := NewScopedBashTool(root, nil).Run(context.Background(), map[string]any{
@@ -896,10 +881,6 @@ func helperCommand(name string) string {
return executable + " --zero-bash-helper " + name
}
func helperFailureExitCode() int {
return 7
}
func shellQuote(value string) string {
if runtime.GOOS == "windows" {
return value
+2 -2
View File
@@ -173,7 +173,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
// unsandboxed) can actually bypass the MSYS guard instead of being
// hard-blocked by the same check it was meant to escalate past.
commandEngine := commandEngineForSandboxPermissions(engine, sandboxPermissions)
if issue := detectShellCommandIssueForRuntime(commandText, detectShellRuntime(runtimeGOOS())); issue != nil && !msysGuardBypassed(issue, commandEngine) {
if issue := detectShellCommandIssue(commandText, runtimeGOOS()); issue != nil && !msysGuardBypassed(issue, commandEngine) {
return shellIssueBlockResult(*issue)
}
if interactive := zeroSandbox.DetectInteractiveCommand(commandText, runtimeGOOS()); interactive.Interactive {
@@ -459,7 +459,7 @@ func execToolResultWithBudget(input execToolResultInput, directBudget bool) Resu
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if status == StatusError && input.exited && !input.interrupted {
if issue := detectShellOutputIssueForRuntime(output, detectShellRuntime(runtimeGOOS())); issue != nil {
if issue := detectShellOutputIssue(output, runtimeGOOS()); issue != nil {
meta["shell_issue"] = issue.Kind
body = appendShellIssueHint(body, *issue)
}
+10 -21
View File
@@ -76,15 +76,11 @@ func TestExecCommandToolDescribesHostShellSyntax(t *testing.T) {
description := strings.ToLower(strings.Join(descriptionParts, " "))
if runtime.GOOS == "windows" {
shell := detectShellRuntime(runtime.GOOS)
if shell.Kind == shellKindPowerShell {
for _, want := range []string{"powershell", "cwd", "get-childitem", "select-string", "$env:name"} {
if !strings.Contains(description, want) {
t.Fatalf("expected Windows PowerShell guidance %q in exec_command description, got %q", want, description)
}
}
} else if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe fallback guidance in exec_command description, got %q", description)
if !strings.Contains(description, "cmd.exe") || !strings.Contains(description, "cwd") {
t.Fatalf("expected Windows cmd.exe and cwd guidance in exec_command description, got %q", description)
}
if !strings.Contains(description, "double quotes") || !strings.Contains(description, `--jq ".a | b"`) {
t.Fatalf("expected the double-quote metacharacter rule in exec_command description, got %q", description)
}
}
}
@@ -196,15 +192,9 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
Policy: sandbox.DefaultPolicy(),
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
})
msysCommand := "cat somefile.txt"
if detectShellRuntime(runtime.GOOS).Kind == shellKindPowerShell {
// cat is a native Get-Content alias in PowerShell, so use an executable
// name that still exercises the MSYS guard.
msysCommand = "grep pattern somefile.txt"
}
result := registry.RunWithOptions(context.Background(), ExecCommandToolName, map[string]any{
"cmd": msysCommand,
"cmd": "cat somefile.txt",
"sandbox_permissions": string(SandboxPermissionsRequireEscalated),
}, RunOptions{
PermissionGranted: true,
@@ -214,8 +204,8 @@ func TestExecCommandRequireEscalatedBypassesMsysGuardAfterApproval(t *testing.T)
// Assert on the preflight block sentinel (exit_code "-1", set only by
// shellIssueBlockResult) rather than shell_issue: once the guard is
// bypassed, the command actually runs, and its real, PATH-dependent output
// could otherwise trip the unrelated
// bypassed, "cat somefile.txt" actually runs, and its real,
// PATH-dependent output could otherwise trip the unrelated
// post-execution detectShellOutputIssue heuristic and make this
// assertion flaky for reasons unrelated to the guard under test.
if result.Meta["exit_code"] == "-1" {
@@ -294,9 +284,8 @@ func TestExecCommandApplicationFailureHasTypedOutcome(t *testing.T) {
if result.ExecutionOutcome == nil || result.ExecutionOutcome.State != execution.StateFailed || result.ExecutionOutcome.Kind != execution.OutcomeApplicationFailure {
t.Fatalf("execution outcome = %#v, want failed/application_failure", result.ExecutionOutcome)
}
wantExitCode := helperFailureExitCode()
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != wantExitCode {
t.Fatalf("execution exit = %#v, want code %d", result.ExecutionOutcome.Exit, wantExitCode)
if result.ExecutionOutcome.Exit == nil || result.ExecutionOutcome.Exit.Code != 7 {
t.Fatalf("execution exit = %#v, want code 7", result.ExecutionOutcome.Exit)
}
}
+187
View File
@@ -0,0 +1,187 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/sessions"
)
type goalTool struct {
baseTool
store *sessions.Store
sessionID string
action string
}
// NewGoalTools returns goal lifecycle tools bound to one captured session. The
// binding prevents a late tool call from mutating a different session after a
// resume or session switch.
func NewGoalTools(store *sessions.Store, sessionID string) []Tool {
return []Tool{
newGetGoalTool(store, sessionID),
newCreateGoalTool(store, sessionID),
newUpdateGoalTool(store, sessionID),
}
}
func newGetGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "get_goal",
description: "Get the current persistent goal and its status, token budget, and usage for this session.",
parameters: Schema{
Type: "object",
AdditionalProperties: false,
},
safety: readOnlySafety("Reads goal state for the current session."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
},
store: store,
sessionID: sessionID,
action: "get",
}
}
func newCreateGoalTool(store *sessions.Store, sessionID string) Tool {
minimum := 0
maximum := 1_000_000_000
maxObjectiveLength := sessions.GoalObjectiveMaxLength
return &goalTool{
baseTool: baseTool{
name: "create_goal",
description: "Create one persistent goal for this session when the user explicitly asks to pursue an ongoing objective. " +
"Do not infer a goal from an ordinary one-turn task. This fails while any goal is still stored.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"objective": {
Type: "string",
Description: "The concrete objective to keep pursuing.",
MaxLength: &maxObjectiveLength,
},
"token_budget": {
Type: "integer",
Description: "Optional maximum total tokens. Zero means no goal-specific limit.",
Minimum: &minimum,
Maximum: &maximum,
},
},
Required: []string{"objective"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
Reason: "Creates persistent goal state and enables automatic follow-up runs for the current session.",
AdvertiseInAuto: true,
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "create",
}
}
func newUpdateGoalTool(store *sessions.Store, sessionID string) Tool {
return &goalTool{
baseTool: baseTool{
name: "update_goal",
description: "Finish the current goal or mark it blocked. Use complete only when the objective is achieved and no required work remains. " +
"Use blocked only when progress cannot continue without user input or an external state change.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"status": {
Type: "string",
Description: "The terminal state to apply.",
Enum: []string{string(sessions.GoalStatusComplete), string(sessions.GoalStatusBlocked)},
},
"reason": {
Type: "string",
Description: "Why the goal is blocked. Optional for completed goals.",
},
},
Required: []string{"status"},
AdditionalProperties: false,
},
safety: Safety{
SideEffect: SideEffectNone,
Permission: PermissionAllow,
Reason: "Updates persistent goal state for the current session.",
},
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys},
},
store: store,
sessionID: sessionID,
action: "update",
}
}
func (tool *goalTool) Run(_ context.Context, args map[string]any) Result {
if tool.store == nil || !sessions.ValidSessionID(tool.sessionID) {
return errorResult("Error: Goal state is unavailable for this run.")
}
switch tool.action {
case "get":
session, err := tool.store.Get(tool.sessionID)
if err != nil {
return errorResult("Error: Read goal: " + err.Error())
}
if session == nil || session.Goal == nil {
return okResult("No goal is set for this session.")
}
return goalResult(session.Goal)
case "create":
objective, err := stringArg(args, "objective", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
tokenBudget, err := intArg(args, "token_budget", 0, 0, 1_000_000_000)
if err != nil {
return errorResult("Error: Invalid arguments for create_goal: " + err.Error())
}
session, _, err := tool.store.CreateGoal(tool.sessionID, objective, tokenBudget)
if err != nil {
return errorResult("Error: Create goal: " + err.Error())
}
return goalResult(session.Goal)
case "update":
statusText, err := stringArg(args, "status", "", true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
status := sessions.GoalStatus(strings.ToLower(statusText))
if status != sessions.GoalStatusComplete && status != sessions.GoalStatusBlocked {
return errorResult(`Error: Invalid arguments for update_goal: status must be "complete" or "blocked"`)
}
reason, err := stringArgWithEmpty(args, "reason", "", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for update_goal: " + err.Error())
}
if status == sessions.GoalStatusBlocked && strings.TrimSpace(reason) == "" {
return errorResult("Error: Invalid arguments for update_goal: blocked goals require a reason")
}
session, _, err := tool.store.UpdateGoal(tool.sessionID, status, reason)
if err != nil {
return errorResult("Error: Update goal: " + err.Error())
}
return goalResult(session.Goal)
default:
return errorResult("Error: Unknown goal action.")
}
}
func goalResult(goal *sessions.Goal) Result {
if goal == nil {
return okResult("No goal is set for this session.")
}
data, err := json.MarshalIndent(goal, "", " ")
if err != nil {
return errorResult(fmt.Sprintf("Error: Encode goal: %v", err))
}
return okResult(string(data))
}
+76
View File
@@ -0,0 +1,76 @@
package tools
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
)
func TestGoalToolsAreBoundToTheirSession(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
first, err := store.Create(sessions.CreateInput{SessionID: "first"})
if err != nil {
t.Fatal(err)
}
second, err := store.Create(sessions.CreateInput{SessionID: "second"})
if err != nil {
t.Fatal(err)
}
goalTools := NewGoalTools(store, first.SessionID)
result := goalTools[1].Run(context.Background(), map[string]any{"objective": "Finish first"})
if result.Status != StatusOK {
t.Fatalf("create result = %#v", result)
}
loadedFirst, _ := store.Get(first.SessionID)
loadedSecond, _ := store.Get(second.SessionID)
if loadedFirst.Goal == nil || loadedFirst.Goal.Objective != "Finish first" {
t.Fatalf("first goal = %#v", loadedFirst.Goal)
}
if loadedSecond.Goal != nil {
t.Fatalf("second goal unexpectedly changed = %#v", loadedSecond.Goal)
}
}
func TestCreateGoalToolDeclaresTokenBudgetMaximum(t *testing.T) {
create := NewGoalTools(nil, "goal")[1]
maximum := create.Parameters().Properties["token_budget"].Maximum
if maximum == nil || *maximum != 1_000_000_000 {
t.Fatalf("token_budget maximum = %v, want 1000000000", maximum)
}
maxLength := create.Parameters().Properties["objective"].MaxLength
if maxLength == nil || *maxLength != sessions.GoalObjectiveMaxLength {
t.Fatalf("objective maxLength = %v, want %d", maxLength, sessions.GoalObjectiveMaxLength)
}
safety := create.Safety()
if safety.SideEffect != SideEffectWrite || safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
t.Fatalf("create_goal safety = %#v, want prompted persistent write advertised in auto mode", safety)
}
}
func TestUpdateGoalToolRestrictsAgentTransitions(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
session, err := store.Create(sessions.CreateInput{SessionID: "goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Finish it", 0); err != nil {
t.Fatal(err)
}
update := NewGoalTools(store, session.SessionID)[2]
result := update.Run(context.Background(), map[string]any{"status": "paused"})
if result.Status != StatusError || !strings.Contains(result.Output, "complete") {
t.Fatalf("paused result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "blocked"})
if result.Status != StatusError || !strings.Contains(result.Output, "require a reason") {
t.Fatalf("reasonless blocked result = %#v", result)
}
result = update.Run(context.Background(), map[string]any{"status": "complete"})
if result.Status != StatusOK {
t.Fatalf("complete result = %#v", result)
}
}
+13 -300
View File
@@ -1,33 +1,15 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"unicode"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
type shellKind string
const (
shellKindPOSIX shellKind = "posix"
shellKindPowerShell shellKind = "powershell"
shellKindCmd shellKind = "cmd"
)
type shellRuntime struct {
GOOS string
Executable string
Syntax string
Kind shellKind
}
type shellIssue struct {
@@ -38,18 +20,7 @@ type shellIssue struct {
const windowsMsysSandboxKind = "windows_msys_sandbox"
const windowsMsysSandboxSuggestion = "MSYS/Cygwin executables and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer native PowerShell cmdlets or Zero tools (grep, read_file with offset/limit, list_directory, glob). If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
const windowsPowerShellUTF8Prefix = "try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}\n"
const windowsPowerShellFailurePrefix = "$ErrorActionPreference = 'Stop'\n"
const windowsPowerShellExitSuffix = "\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n"
var (
hostShellOnce sync.Once
hostShell shellRuntime
)
const windowsMsysSandboxSuggestion = "MSYS/Cygwin coreutils and shells (bash, sh) from Git for Windows cannot run under Zero's write-restricted Windows sandbox, and the WSL bash launcher cannot reach the WSL service from it either. This also hits native commands that spawn Git Bash internally: git hooks and git/gh credential helpers can fail this way even though git and gh themselves run fine. Prefer Zero native tools (grep, read_file with offset/limit, list_directory, glob), cmd.exe findstr, or PowerShell Select-Object -First/-Last. If host-level execution is truly required, rerun with sandbox_permissions: \"require_escalated\" and a narrow justification."
// windowsMsysProneNames is the single source of truth for POSIX coreutil and
// shell names that commonly resolve to a Git-for-Windows MSYS/Cygwin binary
@@ -83,139 +54,24 @@ var (
)
func detectShellRuntime(goos string) shellRuntime {
if goos != runtime.GOOS {
return detectShellRuntimeWithLookup(goos, exec.LookPath, os.Getenv)
}
hostShellOnce.Do(func() {
hostShell = detectShellRuntimeWithProbe(goos, exec.LookPath, os.Getenv, powerShellExecutableUsable)
})
return hostShell
}
func detectShellRuntimeWithLookup(goos string, lookPath func(string) (string, error), getenv func(string) string) shellRuntime {
return detectShellRuntimeWithProbe(goos, lookPath, getenv, func(string) bool { return true })
}
func detectShellRuntimeWithProbe(goos string, lookPath func(string) (string, error), getenv func(string) string, usable func(string) bool) shellRuntime {
if goos == "windows" {
for _, candidate := range windowsPowerShellCandidates(getenv) {
if path, err := lookPath(candidate); err == nil && strings.TrimSpace(path) != "" && usable(path) {
return shellRuntime{
GOOS: goos,
Executable: path,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
}
}
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
return shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe"}
}
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
}
func powerShellExecutableUsable(path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
command := exec.CommandContext(ctx, path, "-NoLogo", "-NoProfile", "-Command", "exit 0")
return command.Run() == nil
}
func windowsPowerShellCandidates(getenv func(string) string) []string {
candidates := []string{"pwsh.exe", "pwsh"}
if programFiles := strings.TrimSpace(getenv("ProgramFiles")); programFiles != "" {
candidates = append(candidates, filepath.Join(programFiles, "PowerShell", "7", "pwsh.exe"))
}
candidates = append(candidates, "powershell.exe", "powershell")
if systemRoot := strings.TrimSpace(getenv("SystemRoot")); systemRoot != "" {
candidates = append(candidates, filepath.Join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
}
return candidates
}
func (shell shellRuntime) arguments(command string) []string {
switch shell.Kind {
case shellKindPowerShell:
script := windowsPowerShellUTF8Prefix + windowsPowerShellFailurePrefix + command + windowsPowerShellExitSuffix
return []string{"-NoLogo", "-NoProfile", "-Command", script}
case shellKindCmd:
return zeroSandbox.WindowsShellArgs(command)
default:
return []string{"-c", command}
}
}
// HostShellCommand returns the shell executable and argv used for a command on
// this host. Keeping this in one place makes agent tools and the user-typed TUI
// shell escape use the same Windows shell and fallback behavior.
func HostShellCommand(command string) (string, []string) {
shell := detectShellRuntime(runtime.GOOS)
return shell.Executable, shell.arguments(command)
}
// NewHostShellCommandContext constructs a direct host-shell command. The
// cmd.exe fallback needs a Windows-specific raw command line, while PowerShell
// and POSIX shells use normal argv handling.
func NewHostShellCommandContext(ctx context.Context, commandText string) *exec.Cmd {
shell := detectShellRuntime(runtime.GOOS)
command := exec.CommandContext(ctx, shell.Executable, shell.arguments(commandText)...)
applyWindowsShellCommandLine(command, commandText, false, shell.Kind == shellKindCmd)
return command
return shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh"}
}
func shellGuidanceForGOOS(goos string) string {
return shellGuidanceForRuntime(detectShellRuntime(goos))
}
func shellGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Uses PowerShell syntax on Windows; prefer cwd/workdir over Set-Location when changing directories. Examples: Get-ChildItem -Force; Get-ChildItem -Recurse -Filter *.go; Get-ChildItem -Recurse | Select-String -Pattern 'TODO'; Get-Process | Where-Object { $_.ProcessName -like '*node*' }; $env:NAME='value'; @'\nprint('hello')\n'@ | python -. Do not invoke Git-for-Windows MSYS/Cygwin executables (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; prefer PowerShell cmdlets or native Zero tools."
return guidance + legacyPowerShellChainGuidance(shell)
runtime := detectShellRuntime(goos)
if goos == "windows" {
return "Uses " + runtime.Syntax + " syntax on Windows; prefer cwd over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
if shell.GOOS == "windows" {
return "Uses " + shell.Syntax + " syntax on Windows because PowerShell was unavailable; prefer cwd/workdir over cd when changing directories. To include | & > < or other metacharacters in an argument value, wrap the value in double quotes (e.g. --jq \".a | b\"); single quotes do not protect metacharacters in cmd.exe. MSYS/Cygwin coreutils on PATH (Git for Windows usr\\bin) are not sandbox-compatible; prefer native Zero file tools."
}
guidance := "Uses " + shell.Syntax + " syntax."
if shell.GOOS == "darwin" {
guidance := "Uses " + runtime.Syntax + " syntax."
if goos == "darwin" {
guidance += " To find or stop a process, use `lsof -i :PORT` (or `lsof -nP -iTCP -sTCP:LISTEN`) for the PID then `kill <pid>`; `ps` and `pgrep` do not work under the sandbox."
}
return guidance
}
// HostShellEnvironmentGuidance returns the concise, model-facing shell rule
// for the current host.
func HostShellEnvironmentGuidance() string {
return hostShellEnvironmentGuidanceForRuntime(detectShellRuntime(runtime.GOOS))
}
func hostShellEnvironmentGuidanceForRuntime(shell shellRuntime) string {
if shell.GOOS == "windows" && shell.Kind == shellKindPowerShell {
guidance := "Shell syntax: PowerShell for exec_command/bash tools. Use PowerShell cmdlets and pipelines (Get-ChildItem, Get-Content, Select-String, Select-Object), use $env:NAME='value' for environment variables, and prefer the workdir/cwd argument over Set-Location. Do not invoke Git-for-Windows MSYS binaries (bash, sh, grep.exe, sed.exe, head.exe, and similar) inside the restricted sandbox; use native PowerShell cmdlets or Zero tools instead, or sandbox_permissions require_escalated only when host-level execution is truly required."
return guidance + legacyPowerShellChainGuidance(shell)
}
if shell.GOOS == "windows" {
return "Shell syntax: Windows cmd.exe syntax for exec_command/bash tools because PowerShell is unavailable. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes. Do not invoke Git-for-Windows MSYS binaries inside the restricted sandbox; use native Zero tools instead. Prefer the workdir/cwd argument over cd."
}
return "Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories."
}
func legacyPowerShellChainGuidance(shell shellRuntime) string {
if !shell.isWindowsPowerShell() {
return ""
}
return " This host uses Windows PowerShell 5.1, which does not support && or ||. Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining."
}
func (shell shellRuntime) isWindowsPowerShell() bool {
if shell.Kind != shellKindPowerShell {
return false
}
executable := strings.TrimSpace(shell.Executable)
if index := strings.LastIndexAny(executable, `\/`); index >= 0 {
executable = executable[index+1:]
}
return strings.EqualFold(executable, "powershell") || strings.EqualFold(executable, "powershell.exe")
}
// MsysProneCommandName reports whether a bare command name commonly resolves to
// a Git-for-Windows MSYS binary that fails under the Windows restricted sandbox.
func MsysProneCommandName(name string) bool {
@@ -316,15 +172,7 @@ func msysProneCommandWord(word string) bool {
}
func detectShellCommandIssue(command string, goos string) *shellIssue {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellCommandIssueForRuntime(command, shell)
}
func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
if goos != "windows" {
return nil
}
trimmed := strings.TrimSpace(command)
@@ -336,24 +184,7 @@ func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shel
// cat), so an escaped metachar can't stand in as a fake segment boundary
// either.
unquoted := stripCmdCaretEscapes(stripDoubleQuotedSpans(trimmed))
if shell.Kind == shellKindPowerShell {
unquoted = stripPowerShellQuotedSpans(trimmed)
if shell.isWindowsPowerShell() && (strings.Contains(unquoted, "&&") || strings.Contains(unquoted, "||")) {
return &shellIssue{
Kind: "windows_powershell_version",
Message: "Command uses && or ||, but this host runs Windows PowerShell 5.1, which does not support those operators.",
Suggestion: "Use separate statements when execution is unconditional, or if ($?) { ... } and if (-not $?) { ... } for conditional chaining.",
}
}
}
if windowsBashStyleCDPattern.MatchString(unquoted) {
if shell.Kind == shellKindPowerShell {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs PowerShell commands on this Windows host.",
Suggestion: "Use the cwd/workdir argument instead of cd and use native PowerShell syntax or Zero tools such as list_directory, read_file, grep, and glob.",
}
}
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Command looks like POSIX/Bash syntax, but Zero runs bash tool commands through Windows cmd.exe on this host.",
@@ -367,128 +198,18 @@ func detectShellCommandIssueForRuntime(command string, shell shellRuntime) *shel
// Being segment/word anchored rather than a whole-string regex or scan,
// neither check matches text that only appears inside a quoted argument
// (e.g. a commit message or PR comment body discussing head.exe).
segments := windowsCommandSegments(trimmed)
if shell.Kind == shellKindPowerShell {
segments = powerShellCommandSegments(trimmed)
}
for _, segment := range segments {
for _, segment := range windowsCommandSegments(trimmed) {
word := firstCommandWord(segment)
if windowsMsysBinaryPathPattern.MatchString(word) {
return windowsMsysSandboxIssue("Command invokes an MSYS/Cygwin binary path that cannot run under Zero's Windows sandbox.")
}
if msysProneCommandWord(word) {
if shell.Kind == shellKindPowerShell && powerShellAliasWord(word) {
continue
}
return windowsMsysSandboxIssue("Command uses a POSIX coreutil (head/tail/grep/cat/...) that commonly resolves to Git-for-Windows MSYS binaries incompatible with the Windows sandbox.")
}
}
return nil
}
// powerShellCommandSegments splits the subset of PowerShell syntax needed for
// command-position checks. It respects single/double quoted strings and the
// backtick escape, then splits on pipeline/statement operators. Complex script
// interpretation remains PowerShell's job; this scanner only determines
// whether a known-incompatible MSYS executable is actually invoked.
func powerShellCommandSegments(command string) []string {
var segments []string
var current strings.Builder
var quote rune
escaped := false
for _, c := range command {
if escaped {
current.WriteRune(c)
escaped = false
continue
}
if c == '`' {
current.WriteRune(c)
escaped = true
continue
}
if quote != 0 {
current.WriteRune(c)
if c == quote {
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
current.WriteRune(c)
continue
}
if c == ';' || c == '|' || c == '&' {
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
current.Reset()
continue
}
current.WriteRune(c)
}
if segment := strings.TrimSpace(current.String()); segment != "" {
segments = append(segments, segment)
}
return segments
}
func powerShellAliasWord(word string) bool {
trimmed := strings.Trim(word, `"'`)
if strings.ContainsAny(trimmed, `\/`) || strings.HasSuffix(strings.ToLower(trimmed), ".exe") {
return false
}
switch strings.ToLower(trimmed) {
case "cat", "ls":
return true
default:
return false
}
}
func stripPowerShellQuotedSpans(command string) string {
var b strings.Builder
b.Grow(len(command))
var quote rune
escaped := false
runes := []rune(command)
for index := 0; index < len(runes); index++ {
c := runes[index]
if escaped {
b.WriteRune(' ')
escaped = false
continue
}
if c == '`' {
b.WriteRune(' ')
escaped = true
continue
}
if quote != 0 {
b.WriteRune(' ')
if c == quote {
// PowerShell escapes a single quote inside a single-quoted
// string by doubling it.
if quote == '\'' && index+1 < len(runes) && runes[index+1] == '\'' {
index++
b.WriteRune(' ')
continue
}
quote = 0
}
continue
}
if c == '\'' || c == '"' {
quote = c
b.WriteRune(' ')
continue
}
b.WriteRune(c)
}
return b.String()
}
// stripDoubleQuotedSpans replaces the contents of every double-quoted span
// (quotes included) with spaces, preserving the string's length and the
// position of unquoted text. cmd.exe treats a double-quoted span as a single
@@ -535,15 +256,7 @@ func stripCmdCaretEscapes(command string) string {
// quoted-text false positives the preflight command-position check exists to
// avoid, just after execution instead of before it.
func detectShellOutputIssue(output string, goos string) *shellIssue {
shell := shellRuntime{GOOS: goos, Executable: "/bin/sh", Syntax: "/bin/sh", Kind: shellKindPOSIX}
if goos == "windows" {
shell = shellRuntime{GOOS: goos, Executable: "cmd.exe", Syntax: "Windows cmd.exe", Kind: shellKindCmd}
}
return detectShellOutputIssueForRuntime(output, shell)
}
func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellIssue {
if shell.GOOS != "windows" {
if goos != "windows" {
return nil
}
lower := strings.ToLower(output)
@@ -553,8 +266,8 @@ func detectShellOutputIssueForRuntime(output string, shell shellRuntime) *shellI
if wslServiceDeniedInOutput(lower) {
return windowsMsysSandboxIssue("WSL bash could not connect to the WSL service under Zero's Windows sandbox (Bash/Service/CreateInstance/E_ACCESSDENIED).")
}
if shell.Kind == shellKindCmd && (strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command")) {
if strings.Contains(lower, "the syntax of the command is incorrect") ||
strings.Contains(lower, "is not recognized as an internal or external command") {
return &shellIssue{
Kind: "windows_shell_syntax",
Message: "Windows cmd.exe rejected the command syntax.",
-208
View File
@@ -1,218 +1,10 @@
package tools
import (
"errors"
"os/exec"
"runtime"
"strings"
"testing"
)
func TestDetectShellRuntimePrefersPowerShellSevenOnWindows(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "pwsh.exe" {
return `C:\Program Files\PowerShell\7\pwsh.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\pwsh.exe`) {
t.Fatalf("shell = %#v, want PowerShell 7", shell)
}
}
func TestDetectShellRuntimeFallsBackThroughWindowsPowerShellToCmd(t *testing.T) {
t.Run("windows powershell", func(t *testing.T) {
lookPath := func(name string) (string, error) {
if name == "powershell.exe" {
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
}
return "", errors.New("not found")
}
shell := detectShellRuntimeWithLookup("windows", lookPath, func(string) string { return "" })
if shell.Kind != shellKindPowerShell || !strings.HasSuffix(strings.ToLower(shell.Executable), `\powershell.exe`) {
t.Fatalf("shell = %#v, want Windows PowerShell", shell)
}
})
t.Run("cmd", func(t *testing.T) {
shell := detectShellRuntimeWithLookup(
"windows",
func(string) (string, error) { return "", errors.New("not found") },
func(string) string { return "" },
)
if shell.Kind != shellKindCmd || shell.Executable != "cmd.exe" {
t.Fatalf("shell = %#v, want cmd.exe fallback", shell)
}
})
}
func TestDetectShellRuntimeSkipsUnusablePowerShell(t *testing.T) {
lookPath := func(name string) (string, error) {
switch name {
case "pwsh.exe":
return `C:\broken\pwsh.exe`, nil
case "powershell.exe":
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, nil
default:
return "", errors.New("not found")
}
}
shell := detectShellRuntimeWithProbe(
"windows",
lookPath,
func(string) string { return "" },
func(path string) bool { return !strings.Contains(path, `\broken\`) },
)
if shell.Kind != shellKindPowerShell || strings.Contains(shell.Executable, `\broken\`) {
t.Fatalf("shell = %#v, want usable Windows PowerShell fallback", shell)
}
}
func TestPowerShellArgumentsDisableProfilesAndRequestUTF8(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
args := shell.arguments("Write-Output 'hello'")
joined := strings.Join(args, "\n")
for _, want := range []string{
"-NoLogo",
"-NoProfile",
"-Command",
windowsPowerShellUTF8Prefix,
"$ErrorActionPreference = 'Stop'",
"Write-Output 'hello'",
"exit $LASTEXITCODE",
} {
if !strings.Contains(joined, want) {
t.Fatalf("PowerShell args missing %q: %#v", want, args)
}
}
script := args[len(args)-1]
preferenceIndex := strings.Index(script, "$ErrorActionPreference")
commandIndex := strings.Index(script, "Write-Output 'hello'")
exitIndex := strings.Index(script, "exit $LASTEXITCODE")
if preferenceIndex < 0 || commandIndex <= preferenceIndex || exitIndex <= commandIndex {
t.Fatalf("PowerShell failure handling is not ordered around the command: %q", script)
}
}
func TestWindowsPowerShellArgumentsPropagateFailures(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows PowerShell integration test")
}
executable, err := exec.LookPath("powershell.exe")
if err != nil {
t.Skipf("Windows PowerShell unavailable: %v", err)
}
shell := shellRuntime{
GOOS: "windows",
Executable: executable,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
t.Run("native exit code after cmdlet", func(t *testing.T) {
command := exec.Command(executable, shell.arguments("cmd /c exit 5; Write-Output done")...)
output, err := command.CombinedOutput()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 5 {
t.Fatalf("exit = %v, output = %q; want exact native exit code 5", err, output)
}
if !strings.Contains(string(output), "done") {
t.Fatalf("output = %q, want command output before propagated failure", output)
}
})
t.Run("cmdlet error stops script", func(t *testing.T) {
missing := strings.ReplaceAll(t.TempDir()+`\missing`, `'`, `''`)
commandText := "Get-Item -LiteralPath '" + missing + "'; Write-Output after"
command := exec.Command(executable, shell.arguments(commandText)...)
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("cmdlet failure reported success: %q", output)
}
if strings.Contains(string(output), "after") {
t.Fatalf("script continued after cmdlet failure: %q", output)
}
})
}
func TestWindowsPowerShellGuidanceAvoidsUnsupportedChainOperators(t *testing.T) {
shell := shellRuntime{
GOOS: "windows",
Executable: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`,
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, guidance := range []string{
shellGuidanceForRuntime(shell),
hostShellEnvironmentGuidanceForRuntime(shell),
} {
for _, want := range []string{"Windows PowerShell 5.1", "&&", "||", "if ($?)"} {
if !strings.Contains(guidance, want) {
t.Fatalf("legacy PowerShell guidance missing %q: %q", want, guidance)
}
}
}
}
func TestWindowsPowerShellCommandIssueFlagsUnsupportedChainOperators(t *testing.T) {
legacy := shellRuntime{
GOOS: "windows",
Executable: "powershell.exe",
Syntax: "PowerShell",
Kind: shellKindPowerShell,
}
for _, command := range []string{
"Write-Output one && Write-Output two",
"Write-Output one || Write-Output two",
} {
issue := detectShellCommandIssueForRuntime(command, legacy)
if issue == nil || issue.Kind != "windows_powershell_version" {
t.Fatalf("legacy PowerShell command %q issue = %#v, want version issue", command, issue)
}
}
for _, command := range []string{
`Write-Output "one && two"`,
"Write-Output 'one || two'",
"Write-Output one `&`& Write-Output two",
} {
if issue := detectShellCommandIssueForRuntime(command, legacy); issue != nil {
t.Fatalf("literal legacy PowerShell operators in %q produced issue %#v", command, issue)
}
}
modern := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
if issue := detectShellCommandIssueForRuntime("Write-Output one && Write-Output two", modern); issue != nil {
t.Fatalf("PowerShell 7 chain operator produced issue %#v", issue)
}
}
func TestPowerShellCommandIssueAllowsAliasesAndBlocksMsysExecutables(t *testing.T) {
shell := shellRuntime{GOOS: "windows", Executable: "pwsh.exe", Syntax: "PowerShell", Kind: shellKindPowerShell}
for _, command := range []string{
`ls -Force`,
`cat README.md`,
`Get-ChildItem | Select-Object -First 10`,
`Write-Output 'grep README.md; head file.txt'`,
`Write-Output 'cd /tmp'`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue != nil {
t.Fatalf("PowerShell-native command %q was blocked: %#v", command, issue)
}
}
for _, command := range []string{
`grep README.md`,
`Get-Content README.md | head -10`,
`cat.exe README.md`,
`bash -lc "make test"`,
`Write-Output ok; sed -n '1,5p' README.md`,
} {
if issue := detectShellCommandIssueForRuntime(command, shell); issue == nil || issue.Kind != windowsMsysSandboxKind {
t.Fatalf("MSYS-prone command %q was not blocked: %#v", command, issue)
}
}
}
func TestDetectShellCommandIssueFlagsMsysBinaryPaths(t *testing.T) {
for _, command := range []string{
`for /F %i in ('whoami') do echo %i | "C:\Program Files\Git\usr\bin\head.exe" -1`,
+1
View File
@@ -83,6 +83,7 @@ type PropertySchema struct {
Minimum *int `json:"minimum,omitempty"`
Maximum *int `json:"maximum,omitempty"`
MinLength *int `json:"minLength,omitempty"`
MaxLength *int `json:"maxLength,omitempty"`
MinItems *int `json:"minItems,omitempty"`
// Properties/Required describe nested object fields (for Type "object" or an
// object-typed Items).
+6 -2
View File
@@ -83,6 +83,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
})
}
parent.btw = btwState{}
parent.goalContinuationsSuspended = true
// A scrollback print that was already scheduled may acknowledge after the
// side surface is active. The hidden model does not receive that unscoped
// acknowledgement, so clear its print latch and rebuild on return.
@@ -175,6 +176,7 @@ func (m model) leaveBTW() (model, tea.Cmd) {
}
m, _ = m.clearLoopsForSessionSwitch()
parent := *m.btw.parent
parent.goalContinuationsSuspended = false
parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID)
parent.btw = btwState{}
// A hidden parent completion may have scheduled an unscoped git-sweep result
@@ -196,13 +198,15 @@ func (m model) leaveBTW() (model, tea.Cmd) {
text: "Returned from the isolated BTW conversation. Its messages were not added to this session.",
})
parent.resetFlushFrontier("· returned from btw ·")
return parent, batchCommands(sweepCmd, spinnerCmd)
var goalCmd tea.Cmd
parent, goalCmd = parent.launchGoalContinuationIfReady()
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
}
func btwCommandUnavailable(command parsedCommand) bool {
arg := strings.ToLower(strings.TrimSpace(command.text))
switch command.kind {
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop,
case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal,
commandRewind, commandCompact, commandSTTModel, commandMCP:
return true
case commandModel:
+45
View File
@@ -135,6 +135,51 @@ func TestBTWCanOpenWhileParentRunContinues(t *testing.T) {
}
}
func TestBTWHiddenParentDoesNotLaunchGoalContinuation(t *testing.T) {
m := newBTWTestModel(t)
goalSession, _, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, "Stay visible to the user", 0)
if err != nil {
t.Fatal(err)
}
m.activeSession = goalSession
m.provider = &fakeProvider{}
m.pending = true
m.runID = 7
m.activeRunID = 7
side, _ := m.handleBTWCommand("")
routed, _, ok := side.routeBTWParentMessage(agentResponseMsg{
runID: 7,
goalAware: true,
rows: []transcriptRow{{kind: rowAssistant, text: "main turn finished", final: true}},
})
if !ok || routed.btw.parent == nil {
t.Fatal("parent completion was not routed while BTW was active")
}
parent := routed.btw.parent
if parent.pending {
t.Fatal("hidden parent launched an automatic goal continuation")
}
if parent.activeSession.Goal == nil || parent.activeSession.Goal.ContinuationCount != 0 {
t.Fatalf("hidden parent consumed a continuation: %#v", parent.activeSession.Goal)
}
returned, cmd := routed.leaveBTW()
if cmd == nil || !returned.pending {
t.Fatalf("returning from BTW did not resume the deferred goal: pending=%v cmd=%v", returned.pending, cmd)
}
if returned.goalContinuationsSuspended {
t.Fatal("goal continuations remained suspended after leaving BTW")
}
if returned.activeSession.Goal == nil || returned.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW reserved an unexpected continuation: %#v", returned.activeSession.Goal)
}
again, duplicateCmd := returned.launchGoalContinuationIfReady()
if duplicateCmd != nil || again.activeSession.Goal.ContinuationCount != 1 {
t.Fatalf("returning from BTW launched more than one continuation: goal=%#v cmd=%v", again.activeSession.Goal, duplicateCmd)
}
}
func TestBTWInlineQuestionStartsSideRun(t *testing.T) {
m := newBTWTestModel(t)
m.provider = &fakeProvider{}
+12 -6
View File
@@ -2,11 +2,12 @@ package tui
import (
"context"
"os/exec"
"runtime"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
@@ -29,7 +30,8 @@ func runBashEscape(cwd, command string) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), bashEscapeTimeout)
defer cancel()
cmd := tools.NewHostShellCommandContext(ctx, command)
name, shellArgs := escapeShell(command)
cmd := exec.CommandContext(ctx, name, shellArgs...)
if strings.TrimSpace(cwd) != "" {
cmd.Dir = cwd
}
@@ -49,11 +51,15 @@ func runBashEscape(cwd, command string) tea.Cmd {
}
}
// escapeShell returns the platform shell and arguments for tests and display.
// Execution itself uses tools.NewHostShellCommandContext so the cmd.exe
// fallback receives its required raw Windows command line.
// escapeShell returns the platform shell and arguments for a "!cmd" escape,
// matching the agent bash tool: cmd.exe on Windows, /bin/sh elsewhere. Hardcoding
// "bash" broke the escape on stock Windows (no bash on PATH) and anywhere bash is
// not installed at a predictable path.
func escapeShell(command string) (string, []string) {
return tools.HostShellCommand(command)
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/d", "/s", "/c", command}
}
return "/bin/sh", []string{"-c", command}
}
func appendNote(text, note string) string {
+8
View File
@@ -50,6 +50,7 @@ const (
commandBTW
commandSkills
commandLoop
commandGoal
commandVoice
commandSTTModel
commandUnknown
@@ -338,6 +339,13 @@ var commandDefinitions = []commandDefinition{
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
kind: commandLoop,
},
{
name: "/goal",
usage: "/goal [--tokens N] <objective> | status | pause | resume | edit | clear",
group: commandGroupSession,
description: "Create and pursue one persistent objective for this session.",
kind: commandGoal,
},
{
name: "/help",
usage: "/help",
+358
View File
@@ -0,0 +1,358 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const goalContinuationPrompt = "Continue pursuing the active goal. Review the existing session context, make concrete progress, and use update_goal when the objective is complete or genuinely blocked."
func (m model) handleGoalCommand(args string) (model, tea.Cmd) {
action, rest := splitGoalCommand(args)
if rest != "" && (action == "status" || action == "pause" || action == "resume" || action == "clear") {
return m.appendGoalError("Usage: /goal " + action), nil
}
switch action {
case "status":
return m.appendGoalStatus(), nil
case "pause":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
return m, nil
}
return m.setGoalStatus(sessions.GoalStatusPaused, "paused by user"), nil
case "resume":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.activeSession.Goal.Status == sessions.GoalStatusBudgetLimited &&
m.activeSession.Goal.TokenBudget > 0 &&
m.activeSession.Goal.TokensUsed >= m.activeSession.Goal.TokenBudget {
return m.appendGoalError("The token budget is exhausted. Increase it with /goal edit --tokens N <objective>."), nil
}
m = m.setGoalStatus(sessions.GoalStatusActive, "")
return m.launchGoalContinuationIfReady()
case "clear":
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
m.cancelRun()
}
updated, event, err := m.sessionStore.ClearGoal(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal cleared."})
return m, nil
case "edit":
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal == nil {
return m.appendGoalError("No goal is set for this session."), nil
}
if m.pending {
return m.appendGoalError("Pause the current run before editing its goal."), nil
}
if !goalBudgetSpecified(rest) {
budget = m.activeSession.Goal.TokenBudget
}
updated, event, err := m.sessionStore.EditGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
message := "Goal updated and resumed: " + objective
if updated.Goal.Status == sessions.GoalStatusBudgetLimited {
message = "Goal updated, but its token budget is still exhausted. Increase the budget or use --tokens 0 for no goal-specific limit."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
return m.launchGoalContinuationIfReady()
case "create":
if m.pending {
return m.appendGoalError("A run is already in progress."), nil
}
objective, budget, err := parseGoalObjective(rest)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
if m.activeSession.Goal != nil {
return m.appendGoalError("This session already has a goal. Use /goal edit <objective> or /goal clear first."), nil
}
m, err = m.ensureActiveSession(objective)
if err != nil {
return m.appendGoalError("session create error: " + err.Error()), nil
}
updated, event, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, objective, budget)
if err != nil {
return m.appendGoalError(err.Error()), nil
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
return m.launchPrompt(objective)
default:
return m.appendGoalError("Unknown action. Use /goal, /goal pause, /goal resume, /goal edit <objective>, or /goal clear."), nil
}
}
func splitGoalCommand(args string) (string, string) {
trimmed := strings.TrimSpace(args)
if trimmed == "" || strings.EqualFold(trimmed, "status") {
return "status", ""
}
first, rest, _ := strings.Cut(trimmed, " ")
switch strings.ToLower(first) {
case "status", "pause", "resume", "clear":
return strings.ToLower(first), strings.TrimSpace(rest)
case "edit":
return "edit", strings.TrimSpace(rest)
default:
return "create", trimmed
}
}
func parseGoalObjective(input string) (string, int, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
budget := 0
if strings.HasPrefix(input, "--tokens") {
fields := strings.Fields(input)
if len(fields) < 3 || fields[0] != "--tokens" {
return "", 0, fmt.Errorf("usage: /goal [--tokens N] <objective>")
}
value, err := strconv.Atoi(fields[1])
if err != nil || value < 0 {
return "", 0, fmt.Errorf("goal token budget must be a non-negative integer")
}
budget = value
input = strings.TrimSpace(strings.Join(fields[2:], " "))
}
if input == "" {
return "", 0, fmt.Errorf("goal objective is required")
}
return input, budget, nil
}
func goalBudgetSpecified(input string) bool {
return strings.HasPrefix(strings.TrimSpace(input), "--tokens")
}
func (m model) appendGoalStatus() model {
goal := m.activeSession.Goal
if goal == nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal\nstatus: none\nStart one with /goal <objective>.",
})
return m
}
budget := "unlimited"
if goal.TokenBudget > 0 {
budget = fmt.Sprintf("%d / %d tokens", goal.TokensUsed, goal.TokenBudget)
} else if goal.TokensUsed > 0 {
budget = fmt.Sprintf("%d tokens used", goal.TokensUsed)
}
lines := []string{
"Goal",
"status: " + string(goal.Status),
"objective: " + goal.Objective,
"budget: " + budget,
fmt.Sprintf(
"automatic continuations: %d / %d",
goal.ContinuationCount,
goalContinuationLimit(goal),
),
}
if goal.StatusReason != "" {
lines = append(lines, "reason: "+goal.StatusReason)
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: strings.Join(lines, "\n")})
return m
}
func goalContinuationLimit(goal *sessions.Goal) int {
if goal != nil && goal.ContinuationLimit > 0 {
return goal.ContinuationLimit
}
return sessions.GoalMaxConsecutiveContinuations
}
func (m model) goalFooterSummary() string {
if m.activeSession.Goal == nil {
return ""
}
return "goal " + string(m.activeSession.Goal.Status)
}
func (m model) appendGoalError(message string) model {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: " + message})
return m
}
func (m model) setGoalStatus(status sessions.GoalStatus, reason string) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" || m.activeSession.Goal == nil {
return m.appendGoalError("Goal state is unavailable.")
}
updated, event, err := m.sessionStore.UpdateGoal(m.activeSession.SessionID, status, reason)
if err != nil {
return m.appendGoalError(err.Error())
}
m.activeSession = updated
m.sessionEvents = append(m.sessionEvents, event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Goal " + string(status) + ".",
})
return m
}
func (m model) goalRegistry() *tools.Registry {
registry := cloneToolRegistry(m.registry)
if m.activeSession.SessionID == "" {
return registry
}
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
registry.Register(tool)
}
return registry
}
func (m model) goalSystemPrompt(base string) string {
goal := m.activeSession.Goal
if goal == nil {
return base
}
instruction := fmt.Sprintf(
"Persistent goal for this session:\nObjective: %s\nStatus: %s\n"+
"When the status is active, keep pursuing this objective across turns. "+
"Call update_goal with complete only after the objective is genuinely achieved, "+
"or blocked with a concrete reason when progress requires user input or an external change.",
goal.Objective,
goal.Status,
)
if strings.TrimSpace(base) == "" {
return instruction
}
return base + "\n\n" + instruction
}
func (m model) launchGoalContinuationIfReady() (model, tea.Cmd) {
goal := m.activeSession.Goal
if goal == nil || goal.Status != sessions.GoalStatusActive || m.pending ||
m.compactInFlight || m.exiting || m.provider == nil ||
m.goalContinuationsSuspended {
return m, nil
}
updated, event, reserved, err := m.sessionStore.ReserveGoalContinuation(m.activeSession.SessionID)
if err != nil {
return m.appendGoalError("reserve automatic continuation: " + err.Error()), nil
}
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
}
if !reserved {
if event != nil && updated.Goal != nil && updated.Goal.Status == sessions.GoalStatusPaused {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: fmt.Sprintf(
"Goal paused after %d automatic continuations. Review progress, then use /goal resume to continue.",
goalContinuationLimit(updated.Goal),
),
})
}
return m, nil
}
goal = updated.Goal
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
text: "Continuing goal: " + goal.Objective,
})
prompt := m.sessionPrompt(goalContinuationPrompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "goal",
"content": goalContinuationPrompt,
})
if err != nil {
return m.appendGoalError("session record error: " + err.Error()), nil
}
runCtx, cancel := context.WithCancel(m.ctx)
m = m.beginRun(cancel)
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick)
}
func (m model) reconcileGoalAfterRun(usageEvents []zeroruntime.Usage, runErr error) model {
if m.sessionStore == nil || m.activeSession.SessionID == "" {
return m
}
loaded, err := m.sessionStore.Get(m.activeSession.SessionID)
if err != nil || loaded == nil {
if err != nil {
return m.appendGoalError("reload after run: " + err.Error())
}
return m
}
if loaded.Goal == nil {
return m
}
tokens := 0
for _, event := range usageEvents {
tokens += event.TotalTokens()
}
if tokens > 0 {
updated, _, addErr := m.sessionStore.AddGoalUsage(loaded.SessionID, tokens)
if addErr != nil {
return m.appendGoalError("account usage: " + addErr.Error())
}
loaded = &updated
}
if runErr != nil && loaded.Goal.Status == sessions.GoalStatusActive {
status := sessions.GoalStatusPaused
reason := "run stopped after an error"
if errhint.Classify(runErr) == errhint.RateLimit {
status = sessions.GoalStatusUsageLimited
reason = "provider usage limit reached"
}
updated, _, updateErr := m.sessionStore.UpdateGoal(
loaded.SessionID,
status,
reason,
)
if updateErr != nil {
return m.appendGoalError("pause after error: " + updateErr.Error())
}
loaded = &updated
message := "Goal paused because the run stopped with an error. Use /goal resume to continue."
if status == sessions.GoalStatusUsageLimited {
message = "Goal stopped at the provider usage limit. Use /goal resume when capacity is available."
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message})
}
m.activeSession = *loaded
if events, readErr := m.sessionStore.ReadEvents(loaded.SessionID); readErr == nil {
m.sessionEvents = events
}
return m
}
+362
View File
@@ -0,0 +1,362 @@
package tui
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestGoalCommandCreatesPersistentGoalAndStartsRun(t *testing.T) {
store := testSessionStore(t)
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
next, cmd := m.handleGoalCommand("--tokens 500 Ship the release")
if cmd == nil || !next.pending {
t.Fatal("creating a goal should start its first run")
}
if next.activeSession.Goal == nil ||
next.activeSession.Goal.Objective != "Ship the release" ||
next.activeSession.Goal.TokenBudget != 500 ||
next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal = %#v", next.activeSession.Goal)
}
loaded, err := store.Get(next.activeSession.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.Goal == nil || loaded.Goal.Objective != "Ship the release" {
t.Fatalf("persisted session = %#v", loaded)
}
}
func TestGoalCommandDoesNotCreateGoalDuringActiveRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_pending"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
next, cmd := m.handleGoalCommand("Do not replace the active run")
if cmd != nil {
t.Fatal("goal creation during an active run returned a command")
}
if next.activeSession.Goal != nil {
t.Fatalf("goal was created during an active run: %#v", next.activeSession.Goal)
}
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal != nil {
t.Fatalf("goal was persisted during an active run: %#v", loaded.Goal)
}
if !transcriptContains(next.transcript, "A run is already in progress.") {
t.Fatalf("missing active-run explanation: %#v", next.transcript)
}
}
func TestGoalRunRegistryContainsSessionBoundTools(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_tools"})
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
registry := m.goalRegistry()
for _, name := range []string{"get_goal", "create_goal", "update_goal"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("goal registry missing %q", name)
}
}
if _, ok := m.registry.Get("get_goal"); ok {
t.Fatal("goal tools should not mutate the shared base registry")
}
}
func TestLoopRunExcludesGoalToolsAndInstructions(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep this out of loops", 0)
if err != nil {
t.Fatal(err)
}
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "Loop iteration complete."},
{Type: zeroruntime.StreamEventDone},
}}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.activeLoopID = "loop-1"
_ = execCmd(m.runAgentWithOptions(1, context.Background(), "run loop", nil, tuiAgentRunOptions{}))
if len(provider.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(provider.requests))
}
request := provider.requests[0]
for _, definition := range request.Tools {
switch definition.Name {
case "get_goal", "create_goal", "update_goal":
t.Fatalf("loop request exposed goal tool %q", definition.Name)
}
}
for _, message := range request.Messages {
if strings.Contains(message.Content, "Persistent goal for this session:") {
t.Fatalf("loop request included goal instructions: %q", message.Content)
}
}
}
func TestLoopRunDoesNotConsumeGoalBudgetOrLaunchContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop_usage"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep loop usage separate", 10)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.activeLoopID = "loop-1"
updated, _ := m.Update(agentResponseMsg{
runID: 1,
goalAware: false,
usageEvents: []zeroruntime.Usage{{InputTokens: 8, OutputTokens: 4}},
rows: []transcriptRow{{kind: rowAssistant, text: "loop result", final: true}},
})
next := updated.(model)
loaded, err := store.Get(session.SessionID)
if err != nil {
t.Fatal(err)
}
if loaded.Goal.TokensUsed != 0 || loaded.Goal.ContinuationCount != 0 {
t.Fatalf("loop run mutated goal accounting: %#v", loaded.Goal)
}
if next.pending {
t.Fatal("loop completion launched a goal continuation")
}
}
func TestAgentCanCompleteGoalWithoutAnotherContinuation(t *testing.T) {
store := testSessionStore(t)
provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "goal_done", ToolName: "update_goal"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "goal_done", ArgumentsFragment: `{"status":"complete"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "goal_done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "The goal is complete."},
{Type: zeroruntime.StreamEventDone},
},
}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: store,
})
running, cmd := m.handleGoalCommand("Finish the task")
response := execCmd(cmd)
if response == nil {
t.Fatal("goal run did not return an agent response")
}
updated, nextCmd := running.Update(response)
settled := updated.(model)
if settled.activeSession.Goal == nil || settled.activeSession.Goal.Status != sessions.GoalStatusComplete {
t.Fatalf("completed goal = %#v", settled.activeSession.Goal)
}
if settled.pending {
t.Fatal("completed goal started another continuation")
}
// Background title/recap/sweep commands may still be returned; none should
// have changed the settled goal back to active.
_ = nextCmd
}
func TestGoalBudgetStopsAutomaticContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_budget"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Stay bounded", 20)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
m = m.reconcileGoalAfterRun([]zeroruntime.Usage{{InputTokens: 12, OutputTokens: 8}}, nil)
if m.activeSession.Goal.Status != sessions.GoalStatusBudgetLimited {
t.Fatalf("budgeted goal status = %q", m.activeSession.Goal.Status)
}
next, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || next.pending {
t.Fatal("a budget-paused goal must not launch another run")
}
}
func TestActiveGoalLaunchesContinuation(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_continue"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatal("active goal should launch a continuation while idle")
}
if !transcriptContains(next.transcript, "Continuing goal: Keep going") {
t.Fatalf("continuation was not surfaced: %#v", next.transcript)
}
}
func TestGoalContinuationChainStopsAtPersistedLimit(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_hard_stop"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Never run forever", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.activeSession = session
for continuation := 1; continuation <= sessions.GoalMaxConsecutiveContinuations; continuation++ {
next, cmd := m.launchGoalContinuationIfReady()
if cmd == nil || !next.pending {
t.Fatalf("continuation %d did not launch", continuation)
}
if next.runCancel != nil {
next.runCancel()
}
next.runCancel = nil
next.pending = false
next.activeRunID = 0
m = next
}
stopped, cmd := m.launchGoalContinuationIfReady()
if cmd != nil || stopped.pending {
t.Fatal("goal exceeded its automatic continuation limit")
}
if stopped.activeSession.Goal.Status != sessions.GoalStatusPaused ||
!transcriptContains(stopped.transcript, "Goal paused after") {
t.Fatalf("hard stop was not surfaced: goal=%#v transcript=%#v", stopped.activeSession.Goal, stopped.transcript)
}
}
func TestGoalActionsRejectTrailingArguments(t *testing.T) {
for _, action := range []string{"status extra", "pause extra", "resume extra", "clear extra"} {
m := newModel(context.Background(), Options{})
next, cmd := m.handleGoalCommand(action)
if cmd != nil || !transcriptContains(next.transcript, "Usage: /goal") {
t.Fatalf("%q silently accepted trailing arguments: %#v", action, next.transcript)
}
}
}
func TestCancelRunPausesActiveGoal(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "goal_cancel"})
if err != nil {
t.Fatal(err)
}
session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0)
if err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.activeSession = session
m.pending = true
m.activeRunID = 1
m.cancelRun()
if m.activeSession.Goal.Status != sessions.GoalStatusPaused {
t.Fatalf("cancelled goal status = %q", m.activeSession.Goal.Status)
}
if !transcriptContains(m.transcript, "Goal paused") {
t.Fatalf("cancel did not explain goal pause: %#v", m.transcript)
}
}
func TestParseGoalObjective(t *testing.T) {
objective, budget, err := parseGoalObjective("--tokens 1200 finish the migration")
if err != nil {
t.Fatal(err)
}
if objective != "finish the migration" || budget != 1200 {
t.Fatalf("parsed objective/budget = %q/%d", objective, budget)
}
if _, _, err := parseGoalObjective("--tokens nope task"); err == nil ||
!strings.Contains(err.Error(), "non-negative integer") {
t.Fatalf("invalid budget error = %v", err)
}
}
func TestGoalStatusIsVisibleInNarrowFooter(t *testing.T) {
m := newModel(context.Background(), Options{})
m.activeSession.Goal = &sessions.Goal{Objective: "Ship", Status: sessions.GoalStatusActive}
status := plainRender(t, m.statusLine(51))
if !strings.Contains(status, "goal active") {
t.Fatalf("narrow status omitted active goal: %q", status)
}
}
+61 -22
View File
@@ -245,10 +245,13 @@ type model struct {
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// goalContinuationsSuspended keeps a hidden parent from launching autonomous
// work while the user is in an isolated BTW conversation.
goalContinuationsSuspended bool
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
@@ -585,6 +588,7 @@ type agentResponseMsg struct {
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
goalAware bool
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
@@ -2212,16 +2216,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
promptEvent := permissionEventFromRequest(msg.request)
promptRow := permissionTranscriptRow(promptEvent)
// The focused modal owns the actionable reason while the decision is
// pending. Keep only structured block context in the transcript row so
// the same explanation is not shown immediately above the card.
if promptEvent.Block == nil {
promptRow.detail = ""
} else {
promptRow.detail = permissionBlockDetail(promptEvent)
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
m.pendingPermission = &pendingPermissionPrompt{
@@ -2415,6 +2410,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streamingText = nil
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
if msg.goalAware {
m = m.reconcileGoalAfterRun(msg.usageEvents, msg.err)
}
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
@@ -2469,8 +2467,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
hadQueuedMessage := strings.TrimSpace(m.queuedMessage) != ""
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
var goalCmd tea.Cmd
if msg.goalAware && !hadQueuedMessage && msg.specReview == nil {
next, goalCmd = next.launchGoalContinuationIfReady()
}
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
@@ -4346,6 +4349,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
return m.handleBTWCommand(command.text)
case commandLoop:
return m.handleLoopCommand(command.text)
case commandGoal:
return m.handleGoalCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
@@ -4750,6 +4755,14 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
text: "session create error: " + err.Error(),
})
} else {
if m.activeLoopID == "" && sessions.IsResumableKind(m.activeSession.SessionKind) &&
m.activeSession.Goal != nil && m.activeSession.Goal.Status == sessions.GoalStatusActive {
if updated, resetErr := m.sessionStore.ResetGoalContinuations(m.activeSession.SessionID); resetErr != nil {
m = m.appendGoalError("reset automatic continuation count: " + resetErr.Error())
} else {
m.activeSession = updated
}
}
agentPrompt := m.sessionPrompt(prompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
@@ -4816,7 +4829,6 @@ func (m model) beginRun(cancel context.CancelFunc) model {
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.lastStreamActivity = m.turnStartedAt
m.turnStreamedRunes = 0
m.spinnerTicking = true
return m
@@ -4893,6 +4905,8 @@ func (m *model) rememberInput(value string) {
}
func (m *model) cancelRun() {
goalWasActive := m.pending && m.activeSession.Goal != nil &&
m.activeSession.Goal.Status == sessions.GoalStatusActive
if m.runCancel != nil {
m.runCancel()
}
@@ -4943,6 +4957,18 @@ func (m *model) cancelRun() {
*m = next
}
}
if goalWasActive && m.sessionStore != nil && m.activeSession.SessionID != "" {
updated, event, err := m.sessionStore.PauseGoalIfActive(m.activeSession.SessionID, "run cancelled by user")
if err != nil {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: pause after cancellation: " + err.Error()})
} else {
m.activeSession = updated
if event != nil {
m.sessionEvents = append(m.sessionEvents, *event)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal paused. Use /goal resume to continue."})
}
}
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
@@ -4992,9 +5018,19 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = m.registry
options.Registry = cloneToolRegistry(m.registry)
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
sessions.IsResumableKind(m.activeSession.SessionKind)
if goalAwareRun {
options.Registry = m.goalRegistry()
}
if runOptions.registry != nil {
options.Registry = runOptions.registry
options.Registry = cloneToolRegistry(runOptions.registry)
if goalAwareRun && m.activeSession.SessionID != "" {
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
options.Registry.Register(tool)
}
}
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
@@ -5003,6 +5039,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
if goalAwareRun {
options.SystemPrompt = m.goalSystemPrompt(options.SystemPrompt)
}
options.SessionID = m.activeSession.SessionID
options.ProviderName = m.providerName
options.Model = m.modelName
@@ -5411,7 +5450,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
@@ -5421,10 +5460,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
@@ -5449,7 +5488,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
}
}
-31
View File
@@ -1606,9 +1606,6 @@ func TestPermissionRequestShowsFocusedPrompt(t *testing.T) {
if strings.Contains(view, "risk:") || strings.Contains(view, "risk=") {
t.Fatalf("focused permission prompt must not render risk labels, got %q", view)
}
if count := strings.Count(view, request.Reason); count != 1 {
t.Fatalf("focused permission reason rendered %d times, want once:\n%s", count, view)
}
}
func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
@@ -1723,34 +1720,6 @@ func TestPermissionRowRendersSandboxBlocks(t *testing.T) {
}
}
func TestPermissionRowRendersIdenticalEventAndBlockReasonOnce(t *testing.T) {
const reason = "Reading /home/dev/.nvm requires access outside the workspace."
event := agent.PermissionEvent{
ToolCallID: "call_read",
ToolName: "list_directory",
Action: agent.PermissionActionDeny,
DecisionAction: agent.PermissionDecisionDeny,
SideEffect: "read",
Reason: reason,
Scope: "/home/dev/.nvm",
Block: &sandbox.Block{
Code: sandbox.BlockOutsideWorkspace,
Path: "/home/dev/.nvm",
Reason: reason,
},
}
row := permissionTranscriptRow(event)
if count := strings.Count(event.Reason+"\n"+row.detail, reason); count != 1 {
t.Fatalf("permission reason represented %d times, want once: detail=%q", count, row.detail)
}
for _, want := range []string{"denied by user", "outside workspace", "path: /home/dev/.nvm"} {
if !strings.Contains(row.detail, want) {
t.Fatalf("permission detail missing %q: %q", want, row.detail)
}
}
}
func TestAppendTranscriptRowDedupesRuntimeRowsByID(t *testing.T) {
event := agent.PermissionEvent{
ToolCallID: "call_1",
-23
View File
@@ -126,29 +126,6 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
}
}
func TestPermissionPromptWrapsLongReasonWithoutTruncating(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "list_directory",
SideEffect: "read",
Reason: "Reading /home/dev/projects/a-very-long-directory-name/with/nested/configuration requires access outside the workspace.",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionDeny,
},
}
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 60)
got := plainRender(t, card)
for _, want := range []string{"Reading", "/home/dev/projects", "requires access outside the workspace."} {
if !strings.Contains(got, want) {
t.Fatalf("permission card truncated reason; missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "…") {
t.Fatalf("permission card must wrap the decision reason instead of truncating it:\n%s", got)
}
}
func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
request := agent.PermissionRequest{
ToolName: "bash",
+1 -7
View File
@@ -1131,13 +1131,7 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
}
lines := []string{top, body}
if reason := permissionDisplayReason(request.Reason); reason != "" {
reasonWidth := width
if widthTier(width) != tierTiny {
reasonWidth = maxInt(1, width-4)
}
for _, line := range wrapPlainText(reason, reasonWidth) {
lines = append(lines, fill(zeroTheme.muted).Render(line))
}
lines = append(lines, fill(zeroTheme.muted).Render(reason))
}
// Surface exactly what the grant covers (file/dir/host) so "always" is a
// clear, bounded choice rather than a blind tool-wide yes.
+15 -7
View File
@@ -317,18 +317,26 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st
if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) {
providerLine += " (recorded: " + recorded + ")"
}
lines := []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
}
if session.Goal != nil {
goalLine := "goal: " + string(session.Goal.Status) + " — " + session.Goal.Objective
if session.Goal.Status == sessions.GoalStatusActive {
goalLine += " (run /goal resume to continue)"
}
lines = append(lines, goalLine)
}
return renderCommandOutput(commandOutput{
Title: "Resumed Zero session",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Session",
Lines: []string{
"id: " + session.SessionID,
"title: " + displayValue(session.Title, "untitled"),
modelLine,
providerLine,
fmt.Sprintf("events: %d", eventCount),
},
Lines: lines,
}},
})
}
+29
View File
@@ -1056,6 +1056,35 @@ func TestResumedPromptIncludesSessionContext(t *testing.T) {
}
}
func TestResumeActiveGoalDoesNotStartAutomaticRun(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{SessionID: "resume_active_goal"})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateGoal(session.SessionID, "Wait for explicit resume", 0); err != nil {
t.Fatal(err)
}
m := newModel(context.Background(), Options{
Provider: &scriptedProvider{},
Registry: tools.NewRegistry(),
SessionStore: store,
})
m.input.SetValue("/resume " + session.SessionID)
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil || next.pending {
t.Fatal("resuming a session unexpectedly started a billed goal run")
}
if next.activeSession.Goal == nil || next.activeSession.Goal.Status != sessions.GoalStatusActive {
t.Fatalf("active goal was not restored: %#v", next.activeSession.Goal)
}
if !transcriptContains(next.transcript, "run /goal resume to continue") {
t.Fatalf("resume did not explain how to continue the goal: %#v", next.transcript)
}
}
func TestResumeCommandReportsMissingSession(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
m.input.SetValue("/resume missing_session")
+3 -7
View File
@@ -398,10 +398,8 @@ func permissionDetailText(event agent.PermissionEvent) string {
if event.GrantMatched {
parts = append(parts, "approved by saved permission")
}
if event.Action == agent.PermissionActionPrompt {
if reason := permissionDisplayReason(event.Reason); reason != "" {
parts = append(parts, reason)
}
if event.Reason != "" {
parts = append(parts, permissionDisplayReason(event.Reason))
}
if event.Block != nil {
parts = append(parts, permissionBlockDetail(event))
@@ -440,9 +438,7 @@ func permissionBlockDetail(event agent.PermissionEvent) string {
if path := strings.TrimSpace(event.Block.Path); path != "" {
parts = append(parts, "path: "+path)
}
reason := permissionDisplayReason(event.Block.Reason)
eventReason := permissionDisplayReason(event.Reason)
if reason != "" && reason != eventReason {
if reason := permissionDisplayReason(event.Block.Reason); reason != "" {
parts = append(parts, reason)
}
return strings.Join(parts, " ")
+6 -6
View File
@@ -42,19 +42,19 @@ func TestBashEscapeGatedByPermissionMode(t *testing.T) {
}
}
// The "!" escape must use the same detected host shell as the agent tools.
// The "!" escape must use the platform shell (cmd.exe on Windows, /bin/sh
// elsewhere), not a hardcoded "bash" that is absent on stock Windows.
func TestEscapeShellWrapsCommandForPlatform(t *testing.T) {
name, args := escapeShell("echo hi")
if name == "" {
t.Fatal("escapeShell returned an empty executable")
}
if len(args) == 0 || !strings.Contains(args[len(args)-1], "echo hi") {
t.Fatalf("escapeShell args = %v, want the command in the final arg", args)
if len(args) == 0 || args[len(args)-1] != "echo hi" {
t.Fatalf("escapeShell args = %v, want the command as the final arg", args)
}
if runtime.GOOS == "windows" {
lowerName := strings.ToLower(filepath.Base(name))
if lowerName != "pwsh.exe" && lowerName != "powershell.exe" && lowerName != "cmd.exe" {
t.Fatalf("windows shell = %q %v, want PowerShell or cmd.exe fallback", name, args)
if name != "cmd.exe" || args[0] != "/d" {
t.Fatalf("windows shell = %q %v, want cmd.exe /d /s /c", name, args)
}
} else if name != "/bin/sh" || args[0] != "-c" {
t.Fatalf("unix shell = %q %v, want /bin/sh -c", name, args)
+6
View File
@@ -211,6 +211,9 @@ func (m model) statusLine(width int) string {
if dictation := m.dictationStatusChip(); dictation != "" {
return fitStyledLine(prefix+btwChip+dictation, width)
}
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
return fitStyledLine(left, width)
}
@@ -240,6 +243,9 @@ func (m model) statusLine(width int) string {
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
// running loop is always visible (hidden during an exit/cancel confirm above).
if !m.exitConfirmActive && !m.cancelConfirmActive {
if goalSummary := m.goalFooterSummary(); goalSummary != "" {
left += separator + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary)
}
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
}
-14
View File
@@ -106,20 +106,6 @@ func TestQuietGenerationHint(t *testing.T) {
}
}
func TestBeginRunResetsQuietGenerationClock(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return now },
lastStreamActivity: now.Add(-time.Minute),
}
m = m.beginRun(nil)
if got := m.quietGenerationHint(); got != "" {
t.Fatalf("new run inherited a stale quiet-generation warning: %q", got)
}
}
// TestQuietGenerationHintEscalatesPastHalfIdleTimeout: a heartbeating-but-
// silent stream (chatgpt/gpt-5.x, ollama reasoning models — see
// providerio.ErrStreamStalled) and a genuinely healthy-but-slow one look
+1 -1
View File
@@ -52,7 +52,7 @@ case "$(go env GOOS)" in
ZERO_SANDBOX_REAL_SMOKE=1 \
ZERO_WINDOWS_COMMAND_RUNNER_EXE="$tmpdir/windows-zero-windows-command-runner.exe" \
ZERO_WINDOWS_SANDBOX_SETUP_EXE="$tmpdir/windows-zero-windows-sandbox-setup.exe" \
go test ./internal/sandbox -run 'TestWindowsRestrictedTokenRealSandboxSmoke|TestWindowsRestrictedTokenPowerShell' -count=1
go test ./internal/sandbox -run TestWindowsRestrictedTokenRealSandboxSmoke -count=1
;;
*)
echo "No real sandbox smoke is defined for this host platform."