Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 780d47f31b fix(config): stop billing provider-command fixtures to the 5s timeout
Both background-child tests fail intermittently on the Windows runner, and
have since #690. #800 and #802 each relaxed an assertion, which is why neither
held: the deadline itself was the problem.

The tests drove LoadProviderCommand, so the fixture's own startup was billed to
the production 5s budget. On a loaded runner the outer timer won the select in
runProviderCommand, and both symptoms follow from that. The nonzero-exit test
saw a timeout instead of the exit status it asserts, because the timer
preempted the shell's exit. The other test lost its PID file, because the
timer's Terminate killed the background child before it could record a PID,
putting the file permanently out of reach however long the assertion polled.

Neither test is a claim about the 5s budget. They assert which error each
cmd.Wait path yields and that the leftover descendant is killed, so they now
call runProviderCommand directly with a budget of their own and the production
deadline stops competing with process startup. Making the child outlive that
budget lets both drop their elapsed-versus-lifetime bounds as well, so no
wall-clock assertion is left in either test.

That is only worth doing if the two timeout paths can be told apart, and they
could not: the WaitDelay path and the outer-timer path both returned the same
bare sentinel, so asserting on it would have been satisfied by the very timeout
the tests exist to rule out. The WaitDelay path now wraps rather than replaces.
Callers asking errors.Is for the sentinel are unaffected, so no user-visible
message changes.

The Windows fixture also waited for its ready marker by launching a second
PowerShell. That cold start is pure overhead added after the PID is already
written, so the wait now runs in cmd itself and is bounded.

Verified by looping both tests under CPU contention: 7 failures in 16
iterations before, none in 32 after, with per-iteration time going from a
spread of 2.3s to 66s down to a steady 2.1s. Removing the wrap makes the
WaitDelay assertion fail, so it is load-bearing rather than decorative.
2026-07-27 01:31:43 +05:30
Anandan ac50a5a840 test(config): wait for background PID file (#802)
* test(config): wait for background PID file

* test(config): make delayed PID check deterministic
2026-07-25 23:02:07 +05:30
Anandan a4a805a6c8 fix(tui): reset quiet clock for new runs (#799)
New turns inherited lastStreamActivity from the prior turn, so a fresh request could immediately look stalled. Initialize stream activity from the same timestamp as turnStartedAt and cover the stale-clock case with a deterministic regression test.
2026-07-24 17:38:10 +05:30
Anandan 9fe02ae52e fix(sandbox): clarify scoped permission prompts (#798) 2026-07-24 17:37:32 +05:30
Anandan 9d5c82d441 test(config): remove Windows startup timing assumptions (#800)
Cold PowerShell startup under concurrent Windows CI can consume most of the provider timeout, so an unsynchronized PID file may never be written and a fixed four-second bound can fail despite correct process-tree cleanup. Treat an absent PID as a helper that never reached startup, retain strict liveness checks for synchronized children, and bound completion by the child's natural lifetime instead.
2026-07-24 15:43:51 +08:00
11 changed files with 319 additions and 34 deletions

No files matched your search

+7 -1
View File
@@ -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
View File
@@ -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 {
+23
View File
@@ -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")
+15 -2
View File
@@ -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 {
+40
View File
@@ -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
View File
@@ -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
+31
View File
@@ -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",
+23
View File
@@ -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",
+7 -1
View File
@@ -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.
+7 -3
View File
@@ -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, " ")
+14
View File
@@ -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