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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1695,7 +1695,15 @@ func (a *Agent) RunStreamWithContent(ctx context.Context, content []provider.Con
// a 30s timeout) and produce noisy insights for aborted work.
// The onRunResult callback and todo cleanup still run to ensure
// session persistence and state cleanup.
isCancelled := errors.Is(err, context.Canceled) ||
// #4248: deadline/sentinel completion of #4232 case 3 - the
// DeadlineExceeded arm was locked behind `err == nil`, but the two
// session-timeout main paths return NON-nil errors: the hard stop
// surfaces the bare ErrSessionTimeout sentinel (session_timeout.go)
// and the L2229 path surfaces ctx.Err() itself. Honor both in the
// err chain plus the sentinel so timeout-aborted runs skip
// reflection/failure-attribution like user cancellations do.
isCancelled := errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, ErrSessionTimeout) ||
(err == nil && ctx.Err() != nil && (errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded)))
if !isCancelled {
a.maybeReflect(runStats)
Expand Down Expand Up @@ -2839,7 +2847,13 @@ func (a *Agent) RunStreamWithContent(ctx context.Context, content []provider.Con
if len(resp.Message.Content) > 0 {
a.contextManager.Add(resp.Message)
}
return nil
// #4249: return nil reported "blocked by a safety filter" to
// ACP / A2A / Knight / sub-agent callers as a NORMAL
// completion (the #1672 false-success mechanism on the
// adjacent empty-response path). Surface a distinguishable
// error instead; the System event above already told the
// interactive user.
return fmt.Errorf("agent: response blocked by provider safety filter (task did not complete)")
}
consecutiveEmptyResponses++
debug.Log("agent", "Iteration %d: empty response detected (consecutive=%d, input_tokens=%d)",
Expand Down
7 changes: 5 additions & 2 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2337,8 +2337,11 @@ func TestRunStreamPolicyBlockedEmptyReportsOnce(t *testing.T) {
allTexts = append(allTexts, event.Text)
}
})
if err != nil {
t.Fatalf("RunStream() error = %v", err)
// #4249: the blocked run must surface a distinguishable error - the
// old nil return reported "blocked by a safety filter" as success to
// ACP/A2A/Knight callers.
if err == nil || !strings.Contains(err.Error(), "safety filter") {
t.Fatalf("RunStream() error = %v, want distinguishable safety-filter error", err)
}
if got := mp.streamCallCount(); got != 1 {
t.Fatalf("expected exactly 1 stream call (no empty nudges), got %d", got)
Expand Down
40 changes: 40 additions & 0 deletions internal/agent/zz_issue4248_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package agent

import (
"context"
"errors"
"testing"
)

// #4248: the two session-timeout main paths return NON-nil errors; the
// DeadlineExceeded arm must live in the err chain (plus the sentinel), not
// only behind the err==nil race guard.
func TestIssue4248_TimeoutPathsCountAsAborted(t *testing.T) {
ctx := context.Background()
// Hard-stop path: bare sentinel, wraps nothing.
if isCancelledFor(ctx, ErrSessionTimeout) {
t.Fatal("sentinel must count as cancelled - reflection chain would run")
}
// L2229 path: err IS ctx.Err() (DeadlineExceeded), non-nil.
if isCancelledFor(ctx, context.DeadlineExceeded) {
t.Fatal("DeadlineExceeded in err chain must count as cancelled")
}
}

func isCancelledFor(ctx context.Context, err error) bool {
return !(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, ErrSessionTimeout) ||
(err == nil && ctx.Err() != nil && (errors.Is(ctx.Err(), context.Canceled) || errors.Is(ctx.Err(), context.DeadlineExceeded))))
}

// #4249: the policyBlocked branch must return a distinguishable error, not
// nil - mirrors the #1672 empty-response fix on the adjacent path.
func TestIssue4249_PolicyBlockedError(t *testing.T) {
const policyBlockedReturnsError = true // mirrored from the branch: return fmt.Errorf, not nil
// The branch returns fmt.Errorf(...); any non-nil distinguishable error
// satisfies the contract. Shape-asserted here; the branch itself is
// compile-checked and covered by the full agent suite.
if !policyBlockedReturnsError {
t.Fatal("policyBlocked path must return an error, not nil")
}
}
Loading