mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
780d47f31b | ||
|
|
ac50a5a840 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 |
No files matched your search
@@ -77,7 +77,13 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte,
|
||||
proc.Terminate()
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout
|
||||
// Wrap rather than replace. A descendant holding the inherited pipes
|
||||
// open and the command never finishing at all are different failures
|
||||
// that both collapsed into the same bare sentinel, which left callers
|
||||
// and tests unable to tell which one happened. Callers that only ask
|
||||
// errors.Is(err, errProviderCommandTimeout) are unaffected, so the
|
||||
// message LoadProviderCommand reports is unchanged.
|
||||
return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("%w: %w", errProviderCommandTimeout, err)
|
||||
}
|
||||
return stdout.Bytes(), stderr.Bytes(), err
|
||||
case <-timer.C:
|
||||
|
||||
+141
-26
@@ -1,7 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -87,9 +89,32 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// providerCommandTestBudget bounds the two background-child tests below, and is
|
||||
// deliberately NOT providerCommandTimeout. Those tests assert which error each
|
||||
// cmd.Wait path yields and that the leftover descendant is killed; neither is a
|
||||
// claim about how fast Windows can start a process. Driving them through
|
||||
// LoadProviderCommand billed the fixture's own startup to the production 5s
|
||||
// budget, so on a loaded runner the outer timer won the select in
|
||||
// runProviderCommand: the nonzero-exit test saw a timeout instead of the exit
|
||||
// status it asserts, and the timer's Terminate killed the background child
|
||||
// before it recorded its PID, putting the PID file permanently out of reach no
|
||||
// matter how long the assertion polled for it.
|
||||
//
|
||||
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
|
||||
// it, since both tests return about one WaitDelay after the fixture exits.
|
||||
const providerCommandTestBudget = 60 * time.Second
|
||||
|
||||
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
|
||||
// dying of old age is unobservable: any run where runProviderCommand has not
|
||||
// returned yet trips the budget first. That is what lets both tests drop their
|
||||
// old elapsed-versus-lifetime bounds. "Still alive when the call returned" is
|
||||
// now true by construction, so a child found dead afterwards can only have been
|
||||
// killed by proc.Terminate.
|
||||
const backgroundChildLifetime = 120 * time.Second
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
// exits immediately but leaves a detached child holding the inherited
|
||||
// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only
|
||||
@@ -99,21 +124,20 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: 10,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
t.Fatal("runProviderCommand() error = nil, want error from WaitDelay-bounded wait")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 5s") {
|
||||
t.Fatalf("error = %q, want timeout", err.Error())
|
||||
}
|
||||
if elapsed > 4*time.Second {
|
||||
t.Fatalf("returned after %s, want well under the 5s provider-command timeout since WaitDelay (1s) should trigger termination", elapsed)
|
||||
// Name the exact path. The outer-timer path reports the bare sentinel, so
|
||||
// matching only that, or the "timed out after 5s" text this test used to
|
||||
// match, would also be satisfied by a slow runner taking precisely the path
|
||||
// this test exists to rule out.
|
||||
if !errors.Is(err, errProviderCommandTimeout) || !errors.Is(err, exec.ErrWaitDelay) {
|
||||
t.Fatalf("error = %v, want errProviderCommandTimeout wrapping exec.ErrWaitDelay", err)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
@@ -129,21 +153,62 @@ func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: 10,
|
||||
BackgroundSleepSeconds: int(backgroundChildLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_, err := LoadProviderCommand(command)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit")
|
||||
_, _, err := runProviderCommand(command, providerCommandTestBudget)
|
||||
// The shell's own exit status has to survive. A timeout here means the
|
||||
// budget swallowed it, which is exactly what this test kept reporting on CI.
|
||||
if errors.Is(err, errProviderCommandTimeout) {
|
||||
t.Fatalf("error = %v, want the shell's exit status rather than a timeout", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want exit status failure", err.Error())
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error = %v, want *exec.ExitError from the nonzero exit", err)
|
||||
}
|
||||
if elapsed > 4*time.Second {
|
||||
t.Fatalf("returned after %s, want well under the 5s provider-command timeout since WaitDelay (1s) should trigger termination", elapsed)
|
||||
if exitErr.ExitCode() != 7 {
|
||||
t.Fatalf("exit code = %d, want 7", exitErr.ExitCode())
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
|
||||
func TestAssertProcessTerminatedWaitsForDelayedPIDFile(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "delayed.pid")
|
||||
firstMissing := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
go func() {
|
||||
<-firstMissing
|
||||
tempFile := pidFile + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte("2147483647\n"), 0o600); err != nil {
|
||||
writeDone <- err
|
||||
return
|
||||
}
|
||||
writeDone <- os.Rename(tempFile, pidFile)
|
||||
}()
|
||||
|
||||
missingObserved := false
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, func() {
|
||||
missingObserved = true
|
||||
close(firstMissing)
|
||||
})
|
||||
if !missingObserved {
|
||||
t.Fatal("PID file existed before the missing-file polling path was exercised")
|
||||
}
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("write delayed pid file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertProcessTerminatedIfStarted(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
// The basic timeout fixture is intentionally unsynchronized. On a loaded
|
||||
// Windows runner, the cold PowerShell child can be terminated before it
|
||||
// records its PID; the synchronized background-child tests below retain
|
||||
// the strict PID and liveness checks.
|
||||
if _, err := os.Stat(pidFile); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
@@ -151,16 +216,41 @@ func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
func assertProcessTerminated(t *testing.T, pidFile string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
assertProcessTerminatedAfterMissing(t, pidFile, nil)
|
||||
}
|
||||
|
||||
func assertProcessTerminatedAfterMissing(t *testing.T, pidFile string, onMissing func()) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var data []byte
|
||||
var err error
|
||||
missingObserved := false
|
||||
for time.Now().Before(deadline) {
|
||||
data, err = os.ReadFile(pidFile)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
}
|
||||
if !missingObserved {
|
||||
missingObserved = true
|
||||
if onMissing != nil {
|
||||
onMissing()
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read sleeper pid file: %v", err)
|
||||
t.Fatalf("read sleeper pid file before timeout: %v", err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sleeper pid %q: %v", data, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processAlive(pid) {
|
||||
return
|
||||
@@ -237,8 +327,33 @@ func writeCommand(t *testing.T, script commandScript) string {
|
||||
"Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " +
|
||||
"Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds)
|
||||
lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"")
|
||||
waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }"
|
||||
lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"")
|
||||
// Wait for the child to record its PID before exiting, so the PID is
|
||||
// on disk while the tree is still alive: runProviderCommand kills the
|
||||
// tree one WaitDelay after the script exits, and a child that has not
|
||||
// written by then never will.
|
||||
//
|
||||
// This waits in cmd itself. Spawning a second PowerShell just to poll
|
||||
// cost another interpreter cold start, and that start is pure overhead
|
||||
// added AFTER the PID is already written. On a loaded runner it pushed
|
||||
// the script's exit past the 5s provider-command timeout, so the
|
||||
// nonzero-exit test saw a timeout instead of the exit status it
|
||||
// asserts. The wait is also bounded now: giving up and letting the PID
|
||||
// assertion fail is far more diagnosable than blocking until the
|
||||
// timeout fires and reporting an unrelated error. Each iteration is
|
||||
// about a second, since `ping -n 2` sleeps between its two echoes,
|
||||
// so the ceiling is roughly 15s: far longer than any cold PowerShell
|
||||
// start seen on a runner, and far shorter than
|
||||
// providerCommandTestBudget, so giving up surfaces a legible
|
||||
// PID-file failure rather than a timeout that hides the real cause.
|
||||
lines = append(lines,
|
||||
":zeroWaitReady",
|
||||
"if exist \""+readyFile+"\" goto zeroReady",
|
||||
"set /a ZERO_READY_TRIES+=1",
|
||||
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
|
||||
"ping -n 2 127.0.0.1 >nul",
|
||||
"goto zeroWaitReady",
|
||||
":zeroReady",
|
||||
)
|
||||
}
|
||||
lines = append(lines, "exit /b "+itoa(script.ExitCode))
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -485,6 +486,28 @@ 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")
|
||||
|
||||
@@ -203,11 +203,24 @@ 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 s.validateAgainstRoots(requestedPath, s.Roots())
|
||||
return withOutsideWorkspaceAccessReason(s.validateAgainstRoots(requestedPath, s.Roots()), requestedPath, SideEffectWrite)
|
||||
}
|
||||
|
||||
func (s *Scope) validateRead(requestedPath string) *pathBlock {
|
||||
return s.validateAgainstRoots(requestedPath, s.ReadRoots())
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Scope) validateAgainstRoots(requestedPath string, roots []string) *pathBlock {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -279,6 +280,45 @@ 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")
|
||||
|
||||
+11
-1
@@ -2212,7 +2212,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
|
||||
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.runID = msg.runID
|
||||
m.transcript = appendTranscriptRow(m.transcript, promptRow)
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
@@ -4807,6 +4816,7 @@ 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
|
||||
|
||||
@@ -1606,6 +1606,9 @@ 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) {
|
||||
@@ -1720,6 +1723,34 @@ 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",
|
||||
|
||||
@@ -126,6 +126,29 @@ 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",
|
||||
|
||||
@@ -1131,7 +1131,13 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
|
||||
}
|
||||
lines := []string{top, body}
|
||||
if reason := permissionDisplayReason(request.Reason); reason != "" {
|
||||
lines = append(lines, fill(zeroTheme.muted).Render(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))
|
||||
}
|
||||
}
|
||||
// Surface exactly what the grant covers (file/dir/host) so "always" is a
|
||||
// clear, bounded choice rather than a blind tool-wide yes.
|
||||
|
||||
@@ -398,8 +398,10 @@ func permissionDetailText(event agent.PermissionEvent) string {
|
||||
if event.GrantMatched {
|
||||
parts = append(parts, "approved by saved permission")
|
||||
}
|
||||
if event.Reason != "" {
|
||||
parts = append(parts, permissionDisplayReason(event.Reason))
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
if reason := permissionDisplayReason(event.Reason); reason != "" {
|
||||
parts = append(parts, reason)
|
||||
}
|
||||
}
|
||||
if event.Block != nil {
|
||||
parts = append(parts, permissionBlockDetail(event))
|
||||
@@ -438,7 +440,9 @@ func permissionBlockDetail(event agent.PermissionEvent) string {
|
||||
if path := strings.TrimSpace(event.Block.Path); path != "" {
|
||||
parts = append(parts, "path: "+path)
|
||||
}
|
||||
if reason := permissionDisplayReason(event.Block.Reason); reason != "" {
|
||||
reason := permissionDisplayReason(event.Block.Reason)
|
||||
eventReason := permissionDisplayReason(event.Reason)
|
||||
if reason != "" && reason != eventReason {
|
||||
parts = append(parts, reason)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
|
||||
@@ -106,6 +106,20 @@ 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
|
||||
|
||||
Reference in new issue
Block a user