diff --git a/pkg/agent/agent_tool_test.go b/pkg/agent/agent_tool_test.go index 32bf6e632..463608a57 100644 --- a/pkg/agent/agent_tool_test.go +++ b/pkg/agent/agent_tool_test.go @@ -924,16 +924,22 @@ func TestAgentTool_Execute_SuspendAndRestoreSingleLevel(t *testing.T) { store := newMemoryCheckpointer() toolReady := make(chan struct{}) - toolRelease := make(chan struct{}) var readyOnce sync.Once slowTool := agent.FunctionTool[struct{}]( "slow_inner_work", "Slow inner work", - func(_ context.Context, _ struct{}) (agent.ToolResult, error) { + func(ctx context.Context, _ struct{}) (agent.ToolResult, error) { readyOnce.Do(func() { close(toolReady) }) - <-toolRelease + + // Release only once the graceful-suspend signal has + // reached this inner agent, so the post-tool turn + // boundary deterministically observes cancellation and + // checkpoints instead of completing the run. + if sig := agent.SuspendSignalFrom(ctx); sig != nil { + <-sig.Done() + } return agent.ToolResult{Content: "inner tool done"}, nil }, @@ -996,8 +1002,6 @@ func TestAgentTool_Execute_SuspendAndRestoreSingleLevel(t *testing.T) { } cancel() - time.Sleep(50 * time.Millisecond) - close(toolRelease) select { case err := <-errCh: @@ -1043,16 +1047,22 @@ func TestAgentTool_Execute_SuspendAndRestoreMultiLevel(t *testing.T) { store := newMemoryCheckpointer() toolReady := make(chan struct{}) - toolRelease := make(chan struct{}) var readyOnce sync.Once slowTool := agent.FunctionTool[struct{}]( "slow_grandchild_work", "Slow grandchild work", - func(_ context.Context, _ struct{}) (agent.ToolResult, error) { + func(ctx context.Context, _ struct{}) (agent.ToolResult, error) { readyOnce.Do(func() { close(toolReady) }) - <-toolRelease + + // Release only once the graceful-suspend signal has + // propagated down to this grandchild agent, so the + // post-tool turn boundary deterministically observes + // cancellation and checkpoints instead of completing. + if sig := agent.SuspendSignalFrom(ctx); sig != nil { + <-sig.Done() + } return agent.ToolResult{Content: "grandchild tool done"}, nil }, @@ -1134,8 +1144,6 @@ func TestAgentTool_Execute_SuspendAndRestoreMultiLevel(t *testing.T) { } cancel() - time.Sleep(50 * time.Millisecond) - close(toolRelease) select { case err := <-errCh: diff --git a/pkg/agent/export_test.go b/pkg/agent/export_test.go new file mode 100644 index 000000000..d2da0ab5d --- /dev/null +++ b/pkg/agent/export_test.go @@ -0,0 +1,21 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package agent + +// SuspendSignalFrom exposes the per-run graceful-suspend signal carried in +// ctx to external tests. Tests use it to block in-flight leaf tools until +// the suspend signal has actually reached the running (sub-)agent, making +// suspend/restore assertions deterministic instead of timing-dependent. +var SuspendSignalFrom = suspendSignalFrom diff --git a/pkg/agent/run.go b/pkg/agent/run.go index 2080afecc..3cfc7fe63 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -109,9 +109,12 @@ func withSuspendableToolContext(ctx context.Context, tool Tool) (context.Context } execCtx, cancel := context.WithCancelCause(ctx) - stop := context.AfterFunc(signal, func() { - cancel(context.Cause(signal)) - }) + stop := context.AfterFunc( + signal, + func() { + cancel(context.Cause(signal)) + }, + ) return execCtx, func() { stop()