mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d427a0d85c | ||
|
|
c98be2ee0a | ||
|
|
ac50a5a840 | ||
|
|
a4a805a6c8 | ||
|
|
9fe02ae52e | ||
|
|
9d5c82d441 |
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,12 +25,47 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
|
||||
var heads, posts atomic.Int64
|
||||
// Closed when the prewarm probe lands, so the turn can be held open until it
|
||||
// does. See the POST handler for why that ordering has to be forced.
|
||||
headSeen := make(chan struct{})
|
||||
var headOnce sync.Once
|
||||
var prewarmWaitTimedOut atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodHead:
|
||||
heads.Add(1)
|
||||
headOnce.Do(func() { close(headSeen) })
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
||||
// Hold the turn open until the prewarm probe arrives. The probe is
|
||||
// issued on the run's context, and runExec cancels that context the
|
||||
// moment it returns, because the stop function signalContext hands
|
||||
// back is a CancelFunc. A probe that has not reached the wire by the
|
||||
// end of the run therefore never reaches it at all. That is why the
|
||||
// poll this replaced could not work at any deadline: once the run is
|
||||
// over the probe is cancelled, not merely slow.
|
||||
//
|
||||
// This widens the window, it does not close it. Once the goroutine is
|
||||
// scheduled it still has only prewarmTimeout to dial loopback and
|
||||
// write the request, and that timeout is armed inside the goroutine
|
||||
// itself, so holding the turn here cannot extend it. If this test
|
||||
// ever fails again, prewarmTimeout is where to look, not this hold.
|
||||
//
|
||||
// No self-deadlock: the pinned transport leaves MaxConnsPerHost at 0,
|
||||
// so the HEAD dials its own connection instead of queueing behind
|
||||
// this held POST. Do not set a per-host connection limit there.
|
||||
//
|
||||
// Bounded, so a probe that genuinely never fires fails the assertion
|
||||
// below with a useful message instead of hanging the test.
|
||||
select {
|
||||
case <-headSeen:
|
||||
case <-time.After(10 * time.Second):
|
||||
// Record it. A probe that shows up after this gave up would still
|
||||
// leave heads at 1, so the count alone proves the probe was
|
||||
// eventually received, not that it was ordered before the turn,
|
||||
// which is the whole claim of this test.
|
||||
prewarmWaitTimedOut.Store(true)
|
||||
}
|
||||
posts.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
|
||||
@@ -75,14 +111,13 @@ func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
|
||||
if got := posts.Load(); got < 1 {
|
||||
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
|
||||
}
|
||||
// The prewarm probe is asynchronous; it fires well before the model turn
|
||||
// finishes, but poll rather than assuming scheduling order. The deadline
|
||||
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
|
||||
// this assertion.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for heads.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Checked before the count, because the count cannot tell the two apart: a
|
||||
// probe that arrived only after the guard expired still leaves heads at 1.
|
||||
if prewarmWaitTimedOut.Load() {
|
||||
t.Fatal("the turn was not held until the prewarm probe arrived; the probe did not precede it")
|
||||
}
|
||||
// No polling: the turn above could not complete until the probe landed, so
|
||||
// the count is settled by this point.
|
||||
if got := heads.Load(); got != 1 {
|
||||
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -87,7 +88,7 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
if elapsed > maxElapsed {
|
||||
t.Fatalf("timeout returned after %s, want roughly 5s", elapsed)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
assertProcessTerminatedIfStarted(t, pidFile)
|
||||
}
|
||||
|
||||
// TestLoadProviderCommandTerminatesBackgroundChild covers a command that
|
||||
@@ -97,9 +98,10 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
|
||||
// leftover child instead of just returning and leaking it.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`,
|
||||
BackgroundSleepSeconds: 10,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
@@ -112,8 +114,8 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
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)
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
}
|
||||
assertProcessTerminated(t, pidFile)
|
||||
}
|
||||
@@ -126,10 +128,11 @@ func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) {
|
||||
// be terminated on that path.
|
||||
func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
pidFile := filepath.Join(t.TempDir(), "bg-fail.pid")
|
||||
childLifetime := 10 * time.Second
|
||||
command := writeCommand(t, commandScript{
|
||||
Stderr: "boom",
|
||||
ExitCode: 7,
|
||||
BackgroundSleepSeconds: 10,
|
||||
BackgroundSleepSeconds: int(childLifetime / time.Second),
|
||||
BackgroundPidFile: pidFile,
|
||||
})
|
||||
|
||||
@@ -142,8 +145,48 @@ func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "exit status") {
|
||||
t.Fatalf("error = %q, want exit status failure", 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)
|
||||
if elapsed >= childLifetime {
|
||||
t.Fatalf("returned after %s, want before the background child finishes naturally after %s", elapsed, childLifetime)
|
||||
}
|
||||
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 +194,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
|
||||
|
||||
@@ -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