diff --git a/reflect_ai.csx b/reflect_ai.csx
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.cs b/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.cs
new file mode 100644
index 00000000000000..97c9c8631b2622
--- /dev/null
+++ b/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.cs
@@ -0,0 +1,46 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+
+// This assembly opts OUT of wrapping non-Exception throws, in contrast to the
+// C# default (WrapNonExceptionThrows = true) used by the main test assembly and
+// by CoreLib. The async methods below are otherwise identical to the ones in the
+// main test assembly; keeping them in a separate assembly is the only way to
+// exercise the WrapNonExceptionThrows = false configuration, since the setting is
+// assembly-scoped and decided by the frame that catches the exception.
+[assembly: RuntimeCompatibility(WrapNonExceptionThrows = false)]
+
+public static class NoWrapThrowers
+{
+ // Runtime async (async2) throwing a non-Exception after suspending.
+ public static async Task ThrowAfterYieldAsync2()
+ {
+ await Task.Yield();
+ NonExceptionThrower.ThrowNonException();
+ }
+
+ // Compiler state machine (async1) throwing a non-Exception after suspending.
+ [RuntimeAsyncMethodGeneration(false)]
+ public static async Task ThrowAfterYieldAsync1()
+ {
+ await Task.Yield();
+ NonExceptionThrower.ThrowNonException();
+ }
+
+ // Runtime async (async2) throwing a non-Exception before suspending.
+ public static async Task ThrowBeforeYieldAsync2()
+ {
+ NonExceptionThrower.ThrowNonException();
+ await Task.Yield();
+ }
+
+ // Compiler state machine (async1) throwing a non-Exception before suspending.
+ [RuntimeAsyncMethodGeneration(false)]
+ public static async Task ThrowBeforeYieldAsync1()
+ {
+ NonExceptionThrower.ThrowNonException();
+ await Task.Yield();
+ }
+}
diff --git a/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.csproj b/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.csproj
new file mode 100644
index 00000000000000..6981b089475cc1
--- /dev/null
+++ b/src/tests/async/runtime-wrapped-exception/NoWrapThrowers.csproj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.il b/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.il
new file mode 100644
index 00000000000000..3a5cfd42de722c
--- /dev/null
+++ b/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.il
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+// Helper that throws an object which does not derive from System.Exception.
+// This is only expressible in IL, so it lives in its own IL assembly. The thrown
+// object (a string) is what a catch handler in a WrapNonExceptionThrows=true
+// assembly observes as RuntimeWrappedException.WrappedException.
+
+.assembly extern System.Runtime { .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A) .ver 4:0:0:0 }
+
+.assembly NonExceptionThrower
+{
+}
+
+.class public auto ansi abstract sealed beforefieldinit NonExceptionThrower
+ extends [System.Runtime]System.Object
+{
+ .method public hidebysig static void ThrowNonException() cil managed noinlining
+ {
+ .maxstack 8
+ ldstr "A non-Exception object thrown from IL"
+ throw
+ }
+}
diff --git a/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.ilproj b/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.ilproj
new file mode 100644
index 00000000000000..da615aeb006351
--- /dev/null
+++ b/src/tests/async/runtime-wrapped-exception/NonExceptionThrower.ilproj
@@ -0,0 +1,10 @@
+
+
+ Library
+
+
+
+
+
+
+
diff --git a/src/tests/async/runtime-wrapped-exception/runtime-wrapped-exception.cs b/src/tests/async/runtime-wrapped-exception/runtime-wrapped-exception.cs
new file mode 100644
index 00000000000000..1de34df3001157
--- /dev/null
+++ b/src/tests/async/runtime-wrapped-exception/runtime-wrapped-exception.cs
@@ -0,0 +1,125 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Xunit;
+
+// Regression tests for https://github.com/dotnet/runtime/issues/123194.
+//
+// IL allows throwing an object that does not derive from System.Exception. When
+// such an object propagates into a catch handler, it is (or is not) wrapped in a
+// RuntimeWrappedException depending on the RuntimeCompatibilityAttribute
+// WrapNonExceptionThrows setting of the assembly that owns the catch handler.
+//
+// The goal of runtime async (async2) is to match the traditional compiler
+// generated state machine (async1) behavior. These tests exercise both async
+// forms (via [RuntimeAsyncMethodGeneration]) throwing a non-Exception object,
+// once from an assembly with WrapNonExceptionThrows = true (this assembly, which
+// matches the C# default and CoreLib) and once from an assembly with
+// WrapNonExceptionThrows = false (NoWrapThrowers), for throws both before and
+// after a suspension point.
+public class RuntimeAsyncNonExceptionThrows
+{
+ private const string ThrownObject = "A non-Exception object thrown from IL";
+
+ // WrapNonExceptionThrows = true (this assembly): the non-Exception is observed
+ // as a RuntimeWrappedException by the awaiting caller for both async forms.
+
+ [Fact]
+ public static void CatchAfterYield_Async2()
+ => AssertNonExceptionWrapped(ThrowAfterYieldAsync2);
+
+ [Fact]
+ public static void CatchAfterYield_Async1()
+ => AssertNonExceptionWrapped(ThrowAfterYieldAsync1);
+
+ [Fact]
+ public static void CatchBeforeYield_Async2()
+ => AssertNonExceptionWrapped(ThrowBeforeYieldAsync2);
+
+ [Fact]
+ public static void CatchBeforeYield_Async1()
+ => AssertNonExceptionWrapped(ThrowBeforeYieldAsync1);
+
+ // WrapNonExceptionThrows = false (NoWrapThrowers assembly).
+
+ [Fact]
+ public static void CatchAfterYield_NoWrap_Async2()
+ => AssertNonExceptionWrapped(NoWrapThrowers.ThrowAfterYieldAsync2);
+
+ // async2 and async1 behave differently for a non-Exception thrown after a
+ // suspension point in a WrapNonExceptionThrows = false assembly:
+ // * async2 faults the returned Task with a RuntimeWrappedException, which the
+ // caller observes just like the wrap=true case above.
+ // * async1 lets the raw non-Exception escape the resumed state machine onto
+ // the thread pool where it becomes an unhandled exception and crashes the
+ // process, so it can never be observed by the caller.
+ // async1 behavior here is terminal to the process, thus we will not test it:
+ //
+ // [Fact]
+ // public static void CatchAfterYield_NoWrap_Async1()
+ // => AssertNonExceptionWrapped(NoWrapThrowers.ThrowAfterYieldAsync1);
+
+ [Fact]
+ public static void CatchBeforeYield_NoWrap_Async2()
+ => AssertNonExceptionWrapped(NoWrapThrowers.ThrowBeforeYieldAsync2);
+
+ [Fact]
+ public static void CatchBeforeYield_NoWrap_Async1()
+ => AssertNonExceptionWrapped(NoWrapThrowers.ThrowBeforeYieldAsync1);
+
+ private static void AssertNonExceptionWrapped(Func thrower)
+ {
+ object wrapped = ObserveNonException(thrower).GetAwaiter().GetResult();
+ Assert.Equal(ThrownObject, wrapped);
+ }
+
+ // Awaits the throwing method and returns the wrapped non-Exception object.
+ // The catch handler lives in this WrapNonExceptionThrows = true assembly, so a
+ // propagating non-Exception is caught as a RuntimeWrappedException. Returns null
+ // if nothing was thrown so the assertion above fails with a clear diff.
+ private static async Task