@atelierai/uco 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +1 -1
  3. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
  4. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
  5. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
  6. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
  7. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
  8. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
  9. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
  10. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
  11. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
  12. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
  13. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
  14. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
  15. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
  16. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
  17. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +1034 -1034
  18. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
  19. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
  20. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
  21. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
  22. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
  23. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
  24. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
  25. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +20 -20
  26. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
  27. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
  28. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
  29. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
  30. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
  31. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
  32. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
  33. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
  34. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
  35. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
  36. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
  37. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
  38. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
  39. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
  40. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
  41. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
  42. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
  43. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
  44. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
  45. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
  46. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
  47. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
  48. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
  49. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
  50. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +3 -3
  51. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
  52. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
  53. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
  54. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
  55. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +11 -11
  56. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
  57. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
  58. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
  59. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
  60. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +1 -1
  61. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
  62. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
  63. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
  64. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
  65. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
  66. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
  67. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
  68. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
  69. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
  70. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
  71. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
  72. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
  73. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
  74. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
  75. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
  76. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
  77. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
  78. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
  79. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
  80. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
  81. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
  82. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
  83. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
  84. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
  85. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
  86. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  87. /package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs.meta → UcoPluginTests.cs.meta} +0 -0
@@ -1,1050 +1,1050 @@
1
- #nullable enable
2
-
3
- using System;
4
- using System.Collections;
5
- using System.Collections.Generic;
6
- using System.IO;
7
- using System.Linq;
8
- using System.Reflection;
9
- using System.Text.Json;
10
- using System.Text.Json.Nodes;
11
- using System.Text.RegularExpressions;
12
- using System.Threading;
13
- using System.Threading.Tasks;
14
- using com.AtelierAI.Unity.Copilot.Editor.API;
15
- using com.AtelierAI.Unity.Copilot.Editor.Utils;
16
- using com.AtelierAI.Uco.Framework;
17
- using com.AtelierAI.Uco.Framework.Common.Model;
18
- using com.IvanMurzak.ReflectorNet;
19
- using com.IvanMurzak.ReflectorNet.Utils;
20
- using Microsoft.Extensions.Logging;
21
- using NUnit.Framework;
22
- using UnityEditor;
23
- using UnityEditor.SceneManagement;
24
- using UnityEngine;
25
- using UnityEngine.SceneManagement;
26
- using UnityEngine.TestTools;
27
- using JsonSerializer = System.Text.Json.JsonSerializer;
28
-
29
- namespace com.AtelierAI.Unity.Copilot.Editor.Tests
30
- {
31
- /// <summary>
32
- /// g-005 pilot verification against the real Editor: dry-run invariants
33
- /// (4.5), one-step Undo and truthful reporting (5.5), failure/abort
34
- /// behaviour (5.6), the production batch entry point (6.4), plan
35
- /// staleness/reload invalidation, and no-bypass for direct runner calls.
36
- /// Every call goes through the production tool manager unless the test
37
- /// is explicitly proving that a bypass attempt fails.
38
- /// </summary>
39
- [TestFixture]
40
- public sealed class AuthoringSafetyPilotTests : BaseTest
41
- {
42
- private const string TmpFolderName = "AuthoringSafetyTests_TMP";
43
- private const string TmpFolder = "Assets/" + TmpFolderName;
44
- private static readonly BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static;
45
-
46
- private bool _ignoreFailingMessages;
47
- private static UnityCopilotPluginEditor _sessionOriginalEditor = null!;
48
-
49
- [OneTimeSetUp]
50
- public void CaptureSessionSingleton()
51
- {
52
- _sessionOriginalEditor = UnityCopilotPluginEditor.Instance;
53
- _sessionOriginalEditor.BuildMcpPluginIfNeeded();
54
- }
55
-
56
- [OneTimeTearDown]
57
- public void RestoreSessionSingleton()
58
- {
59
- // An abandoned coroutine (the runner fails the test on an
60
- // unexpected error log, e.g. the replacement's hub handshake
61
- // failing ~10 s after build) never reaches the replacing tests'
62
- // `finally { RestoreEditorSingleton }`, so a broken double stays
63
- // installed and every later fixture resolves it instead of the
64
- // real editor. Fixture-level teardown runs after every test of
65
- // this fixture regardless of failure or abandonment — restore
66
- // the captured session singleton whenever one of this fixture's
67
- // doubles is still installed.
68
- if (UnityCopilotPluginEditor.Instance is TestUnityCopilotPluginEditor leaked)
69
- RestoreEditorSingleton(
70
- _sessionOriginalEditor, _sessionOriginalEditor.UcoPluginInstance, leaked);
71
- }
72
-
73
- public override IEnumerator SetUp()
74
- {
75
- yield return base.SetUp();
76
- UnityCopilotPluginEditor.Instance.BuildMcpPluginIfNeeded();
77
- // Rejections are logged as errors by the manager ("Error Response
78
- // to AI"); the assertions here are on responses and Editor state.
79
- _ignoreFailingMessages = LogAssert.ignoreFailingMessages;
80
- LogAssert.ignoreFailingMessages = true;
81
- }
82
-
83
- public override IEnumerator TearDown()
84
- {
85
- LogAssert.ignoreFailingMessages = _ignoreFailingMessages;
86
- if (AssetDatabase.IsValidFolder(TmpFolder))
87
- AssetDatabase.DeleteAsset(TmpFolder);
88
- yield return base.TearDown();
89
- }
90
-
91
- // ------------------------------------------------------------------
92
- // 4.5 dry-run invariants
93
- // ------------------------------------------------------------------
94
-
95
- [UnityTest]
96
- public IEnumerator DryRun_ValidateAndPlan_LeaveEditorUntouched()
97
- {
98
- EnsureTmpFolder();
99
- var host = new GameObject("dry-run-host");
100
- host.AddComponent<BoxCollider>();
101
- var parent = new GameObject("dry-run-parent");
102
- var hostId = ObjectIdJsonValue(host);
103
- var parentId = ObjectIdJsonValue(parent);
104
-
105
- var calls = new (string Tool, string Json)[]
106
- {
107
- (Tool_GameObject.GameObjectCreateToolId, @"{""name"":""dry-run-created""}"),
108
- (Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
109
- (Tool_GameObject.GameObjectComponentAddToolId, $@"{{""componentNames"":[""UnityEngine.SphereCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
110
- (Tool_GameObject.GameObjectComponentDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}"),
111
- (Tool_GameObject.GameObjectSetParentToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{parentId}}}}}"),
112
- (Tool_GameObject.GameObjectDuplicateToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}]}}"),
113
- (Tool_Scene.SceneCreateToolId, $@"{{""path"":""{TmpFolder}/dry-run.unity""}}"),
114
- (Tool_Scene.SceneSaveToolId, $@"{{""path"":""{TmpFolder}/dry-run-save.unity""}}"),
115
- };
116
-
117
- var before = EditorSnapshot.Capture(TmpFolder);
118
- foreach (var (tool, json) in calls)
119
- {
120
- foreach (var mode in new[] { "validate", "plan" })
121
- {
122
- ResponseData<ResponseCallTool>? response = null;
123
- yield return Call(tool, json, Control(tool + "-" + mode, dryRun: mode), r => response = r);
124
- Assert.IsNotNull(response, tool);
125
- Assert.AreEqual(ResponseStatus.Success, response!.Status, $"{tool} {mode}: {response.Message}");
126
- var result = response.Value!.StructuredContent!["result"]!;
127
- Assert.AreEqual(mode, result["dryRun"]!.GetValue<string>(), tool);
128
- Assert.IsTrue(result["valid"]!.GetValue<bool>(), $"{tool} {mode} should validate");
129
- Assert.IsNull(response.Value.Transaction, $"{tool} {mode} must not open a transaction");
130
- if (mode == "plan")
131
- {
132
- var plan = result["confirmationPlan"]!;
133
- StringAssert.StartsWith("plan-", plan["planId"]!.GetValue<string>());
134
- StringAssert.StartsWith("sha256-", plan["planHash"]!.GetValue<string>());
135
- var targets = plan["targets"]!.AsArray();
136
- Assert.Greater(targets.Count, 0, $"{tool} plan should list targets");
137
- foreach (var target in targets)
138
- StringAssert.StartsWith("sha256-", target!["fingerprint"]!.GetValue<string>(), tool);
139
- Assert.Greater(plan["predictedEffects"]!.AsArray().Count, 0, tool);
140
- }
141
- else
142
- {
143
- Assert.IsNull(result["confirmationPlan"], $"{tool} validate must not issue a plan");
144
- }
145
- EditorSnapshot.Capture(TmpFolder).AssertEquals(before, $"{tool} {mode}");
146
- }
147
- }
148
- }
149
-
150
- [UnityTest]
151
- public IEnumerator Validate_ReportsMissingTargetWithoutMutation()
152
- {
153
- var before = EditorSnapshot.Capture(null);
154
- ResponseData<ResponseCallTool>? response = null;
155
- yield return Call(
156
- Tool_GameObject.GameObjectDestroyToolId,
157
- @"{""gameObjectRef"":{""instanceID"":123456789}}",
158
- Control("missing-target", dryRun: "validate"),
159
- r => response = r);
160
-
161
- Assert.AreEqual(ResponseStatus.Error, response!.Status);
162
- var error = response.StructuredError ?? response.Value?.StructuredError;
163
- Assert.AreEqual("validation_failed", error!.Code);
164
- Assert.AreEqual("target_not_found", error.Details!["reason"]!.GetValue<string>());
165
- EditorSnapshot.Capture(null).AssertEquals(before, "invalid validate");
166
- }
167
-
168
- // ------------------------------------------------------------------
169
- // Confirmation lifecycle against real targets
170
- // ------------------------------------------------------------------
171
-
172
- [UnityTest]
173
- public IEnumerator Destroy_LegacyIsRejected_PlanIsStaleAfterTargetChange_ThenConfirmedDestroyIsUndoable()
174
- {
175
- var victim = new GameObject("victim");
176
- var id = ObjectIdJsonValue(victim);
177
- var json = $@"{{""gameObjectRef"":{{""instanceID"":{id}}}}}";
178
-
179
- // Legacy destructive call: no mutation, copyable retry instruction.
180
- ResponseData<ResponseCallTool>? legacy = null;
181
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, null, r => legacy = r);
182
- Assert.AreEqual(ResponseStatus.Error, legacy!.Status);
183
- StringAssert.Contains("[" + ToolCallErrorCodes.ConfirmationRequired + "]", legacy.Value!.GetMessage());
184
- StringAssert.Contains("confirmationPlan", legacy.Value.GetMessage());
185
- StringAssert.Contains("retryWith", legacy.Value.GetMessage());
186
- Assert.IsNotNull(GameObject.Find("victim"), "a rejected legacy destroy must not mutate");
187
-
188
- // Plan, then change the target: the approval is stale.
189
- ResponseData<ResponseCallTool>? planned = null;
190
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", dryRun: "plan"), r => planned = r);
191
- var token = ReadPlan(planned!);
192
- victim.name = "victim-renamed";
193
- ResponseData<ResponseCallTool>? stale = null;
194
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", confirmation: token), r => stale = r);
195
- Assert.AreEqual(ToolCallErrorCodes.ConfirmationStale, ErrorCode(stale!));
196
- Assert.IsNotNull(GameObject.Find("victim-renamed"));
197
-
198
- // Fresh plan on the current target executes once and is undoable.
199
- ResponseData<ResponseCallTool>? replanned = null;
200
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", dryRun: "plan"), r => replanned = r);
201
- var freshToken = ReadPlan(replanned!);
202
- ResponseData<ResponseCallTool>? executed = null;
203
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", confirmation: freshToken), r => executed = r);
204
- Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
205
- Assert.IsNull(GameObject.Find("victim-renamed"), "confirmed destroy must destroy");
206
- var transaction = executed.Value!.Transaction!;
207
- Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
208
- Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
209
- Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
210
- Assert.AreEqual("MCP: " + Tool_GameObject.GameObjectDestroyToolId, transaction["groupLabel"]!.GetValue<string>());
211
-
212
- // A replayed token is not a standing capability.
213
- var again = new GameObject("victim-again");
214
- ResponseData<ResponseCallTool>? replay = null;
215
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(again)}}}}}", Control("destroy-2", confirmation: freshToken), r => replay = r);
216
- Assert.AreEqual(ResponseStatus.Error, replay!.Status);
217
- Assert.IsNotNull(GameObject.Find("victim-again"));
218
-
219
- Undo.PerformUndo();
220
- Assert.IsNotNull(GameObject.Find("victim-renamed"), "one Undo step must restore the destroyed object");
221
- }
222
-
223
- [UnityTest]
224
- public IEnumerator ReloadGuard_InvalidatesIssuedPlans()
225
- {
226
- var victim = new GameObject("reload-victim");
227
- var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(victim)}}}}}";
228
- ResponseData<ResponseCallTool>? planned = null;
229
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", dryRun: "plan"), r => planned = r);
230
- var token = ReadPlan(planned!);
231
-
232
- Assert.GreaterOrEqual(UnityAuthoringSafetyReloadGuard.InvalidateConfirmationPlans(), 1,
233
- "the live pipeline must expose at least one confirmation store");
234
-
235
- ResponseData<ResponseCallTool>? afterReload = null;
236
- yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", confirmation: token), r => afterReload = r);
237
- Assert.AreEqual(ToolCallErrorCodes.ConfirmationInvalid, ErrorCode(afterReload!));
238
- Assert.IsNotNull(GameObject.Find("reload-victim"));
239
- }
240
-
241
- // ------------------------------------------------------------------
242
- // 5.5 one-step Undo, dirty state, full versus partial
243
- // ------------------------------------------------------------------
244
-
245
- [UnityTest]
246
- public IEnumerator Create_IsOneUndoStepWithFullReport()
247
- {
248
- var scene = SceneManager.GetActiveScene();
249
- var groupBefore = Undo.GetCurrentGroup();
250
- ResponseData<ResponseCallTool>? response = null;
251
- yield return Call(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""undo-created""}", Control("create"), r => response = r);
252
-
253
- Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
254
- var created = GameObject.Find("undo-created");
255
- Assert.IsNotNull(created);
256
- Assert.IsTrue(scene.isDirty || !scene.IsValid() || string.IsNullOrEmpty(scene.path),
257
- "an executing create marks the active scene dirty");
258
- var transaction = response.Value!.Transaction!;
259
- Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
260
- Assert.AreEqual("MCP: " + Tool_GameObject.GameObjectCreateToolId, transaction["groupLabel"]!.GetValue<string>());
261
- Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
262
- Assert.AreEqual("none", transaction["rollback"]!.GetValue<string>());
263
- Assert.Greater(transaction["affectedObjects"]!.AsArray().Count, 0);
264
- Assert.Greater(Undo.GetCurrentGroup(), groupBefore, "the call must own a new Undo group");
265
-
266
- Undo.PerformUndo();
267
- Assert.IsNull(GameObject.Find("undo-created"), "one Undo step removes the created object");
268
- }
269
-
270
- [UnityTest]
271
- public IEnumerator ComponentAddAndSetParent_AreOneUndoStepEach()
272
- {
273
- var host = new GameObject("undo-host");
274
- var parent = new GameObject("undo-parent");
275
- var hostId = ObjectIdJsonValue(host);
276
-
277
- ResponseData<ResponseCallTool>? add = null;
278
- yield return Call(
279
- Tool_GameObject.GameObjectComponentAddToolId,
280
- $@"{{""componentNames"":[""UnityEngine.BoxCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}",
281
- Control("add"),
282
- r => add = r);
283
- Assert.AreEqual(ResponseStatus.Success, add!.Status, add.Message);
284
- Assert.IsNotNull(host.GetComponent<BoxCollider>());
285
- Assert.AreEqual("full", add.Value!.Transaction!["undo"]!.GetValue<string>());
286
- Undo.PerformUndo();
287
- Assert.IsNull(host.GetComponent<BoxCollider>(), "one Undo step removes the added component");
288
-
289
- ResponseData<ResponseCallTool>? reparent = null;
290
- yield return Call(
291
- Tool_GameObject.GameObjectSetParentToolId,
292
- $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(parent)}}}}}",
293
- Control("parent"),
294
- r => reparent = r);
295
- Assert.AreEqual(ResponseStatus.Success, reparent!.Status, reparent.Message);
296
- Assert.AreEqual(parent.transform, host.transform.parent);
297
- Assert.AreEqual("full", reparent.Value!.Transaction!["undo"]!.GetValue<string>());
298
- Undo.PerformUndo();
299
- Assert.IsNull(host.transform.parent, "one Undo step restores the previous parent");
300
- }
301
-
302
- [UnityTest]
303
- public IEnumerator ComponentDestroy_UsesUndoAwareDestroyAndIsUndoable()
304
- {
305
- var host = new GameObject("component-destroy-host");
306
- host.AddComponent<BoxCollider>();
307
- ResponseData<ResponseCallTool>? planned = null;
308
- var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(host)}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}";
309
- yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", dryRun: "plan"), r => planned = r);
310
- ResponseData<ResponseCallTool>? executed = null;
311
- yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", confirmation: ReadPlan(planned!)), r => executed = r);
312
-
313
- Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
314
- Assert.IsNull(host.GetComponent<BoxCollider>());
315
- Assert.AreEqual("full", executed.Value!.Transaction!["undo"]!.GetValue<string>());
316
- Undo.PerformUndo();
317
- Assert.IsNotNull(host.GetComponent<BoxCollider>(), "Undo.DestroyObjectImmediate makes the deletion undoable");
318
- }
319
-
320
- [UnityTest]
321
- public IEnumerator Modify_ReportsPartialUndoTruthfully()
322
- {
323
- var target = new GameObject("modify-me");
324
- var arguments = new Dictionary<string, JsonElement>
325
- {
326
- ["gameObjectRefs"] = JsonSerializer.SerializeToElement(new[] { new { instanceID = ObjectIdWireValue(target) } }),
327
- ["jsonPatchesPerGameObject"] = JsonSerializer.SerializeToElement(new[] { "{\"name\":\"modified-name\"}" }),
328
- };
329
- ResponseData<ResponseCallTool>? response = null;
330
- yield return Call(Tool_GameObject.GameObjectModifyToolId, arguments, Control("modify"), r => response = r);
331
-
332
- Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
333
- Assert.AreEqual("modified-name", target.name);
334
- var transaction = response.Value!.Transaction!;
335
- Assert.AreEqual("partial", transaction["undo"]!.GetValue<string>(), "reflected patches are advertised as partial");
336
- Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
337
- Undo.PerformUndo();
338
- Assert.AreEqual("modify-me", target.name, "the recorded complete snapshot restores the name");
339
- }
340
-
341
- // ------------------------------------------------------------------
342
- // 5.6 failure/abort and no-bypass
343
- // ------------------------------------------------------------------
344
-
345
- [UnityTest]
346
- public IEnumerator DirectRunnerInvocation_FailsClosedWithoutMutation()
347
- {
348
- var manager = UnityCopilotPluginEditor.Instance.Tools!;
349
- var runner = manager.GetAllTools().First(tool => tool.Name == Tool_GameObject.GameObjectCreateToolId);
350
- var before = EditorSnapshot.Capture(null);
351
-
352
- LogAssert.ignoreFailingMessages = true;
353
- var direct = runner.Run("direct-bypass", new Dictionary<string, JsonElement>
354
- {
355
- ["name"] = JsonSerializer.SerializeToElement("bypass-created"),
356
- });
357
- yield return WaitForTask(direct);
358
-
359
- Assert.AreEqual(ResponseStatus.Error, direct.Result.Status, "a direct runner call must fail closed");
360
- Assert.IsNull(GameObject.Find("bypass-created"), "no object may be created outside the policy pipeline");
361
- EditorSnapshot.Capture(null).AssertEquals(before, "direct bypass");
362
- }
363
-
364
- [UnityTest]
365
- public IEnumerator RunnerFailureAfterMutation_RevertsOnlyItsGroup()
366
- {
367
- var originalInstance = UnityCopilotPluginEditor.Instance;
368
- originalInstance.BuildMcpPluginIfNeeded();
369
- var originalPlugin = originalInstance.UcoPluginInstance;
370
- var failing = new FailingCreateRunner();
371
- var replacement = new TestUnityCopilotPluginEditor(failing);
372
- SetEditorSingleton(replacement);
373
- try
374
- {
375
- replacement.BuildMcpPluginIfNeeded();
376
- var manager = replacement.Tools!;
377
-
378
- // Unrelated earlier history that must survive the abort.
379
- var unrelated = new GameObject("unrelated-history");
380
- Undo.RegisterCreatedObjectUndo(unrelated, "unrelated");
381
- Undo.IncrementCurrentGroup();
382
-
383
- var request = new RequestCallTool("failing-request", failing.Name, new Dictionary<string, JsonElement>(), Control("failing"));
384
- LogAssert.ignoreFailingMessages = true;
385
- var execution = manager.RunCallTool(request);
386
- yield return WaitForTask(execution);
387
- var response = execution.Result;
388
-
389
- Assert.AreEqual(ResponseStatus.Error, response.Status);
390
- Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName), "the aborted group reverts the created object");
391
- Assert.IsNotNull(GameObject.Find("unrelated-history"), "unrelated Undo history is preserved");
392
- var transaction = response.Value!.Transaction!;
393
- Assert.IsTrue(transaction["aborted"]!.GetValue<bool>());
394
- Assert.IsFalse(transaction["completed"]!.GetValue<bool>());
395
- Assert.AreEqual("complete", transaction["rollback"]!.GetValue<string>());
396
- Assert.AreEqual(1, failing.Invocations);
397
- }
398
- finally
399
- {
400
- RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
401
- }
402
- }
403
-
404
- // ------------------------------------------------------------------
405
- // 6.4 production batch with pilot children
406
- // ------------------------------------------------------------------
407
-
408
- [UnityTest]
409
- public IEnumerator Batch_PilotChildren_CollapseToOneUndoStep()
410
- {
411
- var commands = new[]
412
- {
413
- new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-a")) },
414
- new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-b")) },
415
- };
416
- var arguments = new Dictionary<string, JsonElement>
417
- {
418
- ["commands"] = JsonSerializer.SerializeToElement(commands),
419
- ["failFast"] = JsonSerializer.SerializeToElement(true),
420
- };
421
-
422
- ResponseData<ResponseCallTool>? planned = null;
423
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", dryRun: "plan"), r => planned = r);
424
- Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
425
- Assert.IsNull(GameObject.Find("batch-a"), "planning a batch must not create children");
426
- var plan = planned.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
427
- Assert.GreaterOrEqual(plan["targets"]!.AsArray().Count, 2);
428
-
429
- ResponseData<ResponseCallTool>? executed = null;
430
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", confirmation: ReadPlan(planned)), r => executed = r);
431
- Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
432
- var batchResult = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
433
- executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
434
- Assert.AreEqual(2, batchResult.Succeeded, string.Join("; ", batchResult.Results.Select(r => r.Error)));
435
- Assert.IsNotNull(GameObject.Find("batch-a"));
436
- Assert.IsNotNull(GameObject.Find("batch-b"));
437
- var transaction = executed.Value.Transaction!;
438
- Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
439
- Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
440
- Assert.AreEqual("full", transaction["undo"]!.GetValue<string>(), "two fully recorded children keep aggregate full Undo");
441
- Assert.AreEqual(2, transaction["affectedObjects"]!.AsArray().Count, "both children attach to the parent group");
442
- for (var index = 0; index < batchResult.Results.Length; index++)
443
- {
444
- var child = batchResult.Results[index];
445
- Assert.IsTrue(child.Ok);
446
- Assert.IsNotNull(child.Transaction, "each shared child reports its own transaction contribution");
447
- Assert.IsTrue(child.Transaction!["shared"]!.GetValue<bool>());
448
- Assert.IsFalse(child.Transaction["pending"]!.GetValue<bool>());
449
- Assert.IsTrue(child.Transaction["completed"]!.GetValue<bool>());
450
- Assert.IsFalse(child.Transaction["aborted"]!.GetValue<bool>());
451
- Assert.AreEqual("none", child.Transaction["rollback"]!.GetValue<string>());
452
- Assert.AreEqual("full", child.Transaction["undo"]!.GetValue<string>());
453
- Assert.AreEqual(transaction["groupId"]!.GetValue<int>(), child.Transaction["groupId"]!.GetValue<int>());
454
- Assert.AreEqual(transaction["groupLabel"]!.GetValue<string>(), child.Transaction["groupLabel"]!.GetValue<string>());
455
- var affectedNames = child.Transaction["affectedObjects"]!.AsArray()
456
- .Select(value => value!["name"]!.GetValue<string>())
457
- .Distinct(StringComparer.Ordinal)
458
- .ToArray();
459
- CollectionAssert.AreEqual(new[] { index == 0 ? "batch-a" : "batch-b" }, affectedNames,
460
- "a child report may retain multiple recording events, but only for that child's object");
461
- }
462
-
463
- Undo.PerformUndo();
464
- Assert.IsNull(GameObject.Find("batch-a"), "one Undo step reverts the whole batch");
465
- Assert.IsNull(GameObject.Find("batch-b"));
466
- }
467
-
468
- [UnityTest]
469
- public IEnumerator Batch_SharedChildFailure_RevertsPriorChildAndFinalizesTruthfulResults()
470
- {
471
- var originalInstance = UnityCopilotPluginEditor.Instance;
472
- originalInstance.BuildMcpPluginIfNeeded();
473
- var originalPlugin = originalInstance.UcoPluginInstance;
474
- var failing = new FailingCreateRunner();
475
- var replacement = new TestUnityCopilotPluginEditor(failing);
476
- SetEditorSingleton(replacement);
477
- try
478
- {
479
- replacement.BuildMcpPluginIfNeeded();
480
- var unrelated = new GameObject("batch-unrelated-history");
481
- Undo.RegisterCreatedObjectUndo(unrelated, "batch unrelated");
482
- Undo.IncrementCurrentGroup();
483
-
484
- var commands = new[]
485
- {
486
- new Tool_Batch.BatchCommand
487
- {
488
- Tool = Tool_GameObject.GameObjectCreateToolId,
489
- Params = Params(("name", "batch-shared-restored")),
490
- },
491
- new Tool_Batch.BatchCommand
492
- {
493
- Tool = failing.Name,
494
- Params = new Dictionary<string, JsonElement>(),
495
- },
496
- new Tool_Batch.BatchCommand
497
- {
498
- Tool = Tool_GameObject.GameObjectCreateToolId,
499
- Params = Params(("name", "batch-shared-skipped")),
500
- },
501
- };
502
- var arguments = new Dictionary<string, JsonElement>
503
- {
504
- ["commands"] = JsonSerializer.SerializeToElement(commands),
505
- ["failFast"] = JsonSerializer.SerializeToElement(true),
506
- };
507
-
508
- ResponseData<ResponseCallTool>? planned = null;
509
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
510
- Control("batch-shared-failure", dryRun: "plan"), r => planned = r);
511
- Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
512
-
513
- ResponseData<ResponseCallTool>? executed = null;
514
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
515
- Control("batch-shared-failure", confirmation: ReadPlan(planned)), r => executed = r);
516
- Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
517
- var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
518
- executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
519
-
520
- Assert.AreEqual(0, result.Succeeded);
521
- Assert.AreEqual(2, result.Failed);
522
- Assert.IsTrue(result.Aborted);
523
- Assert.IsNull(GameObject.Find("batch-shared-restored"),
524
- "the parent abort restores a prior successful shared child");
525
- Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName),
526
- "the failing child's mutation is restored by the same parent group");
527
- Assert.IsNull(GameObject.Find("batch-shared-skipped"));
528
- Assert.IsNotNull(GameObject.Find("batch-unrelated-history"),
529
- "aborting the batch must preserve unrelated Undo history");
530
-
531
- var prior = result.Results[0];
532
- Assert.IsFalse(prior.Ok, "a rolled-back child must not remain successful");
533
- Assert.IsFalse(prior.Skipped);
534
- Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
535
- Assert.IsNull(prior.Data);
536
- Assert.IsNotNull(prior.Transaction);
537
- Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
538
- Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
539
- Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
540
- Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
541
- Assert.AreEqual("complete", prior.Transaction["rollback"]!.GetValue<string>());
542
- Assert.AreEqual("full", prior.Transaction["undo"]!.GetValue<string>());
543
- var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
544
- .Select(value => value!["name"]!.GetValue<string>())
545
- .Distinct(StringComparer.Ordinal)
546
- .ToArray();
547
- CollectionAssert.AreEqual(new[] { "batch-shared-restored" }, priorAffectedNames,
548
- "the rolled-back child report may contain multiple record events, but only for its object");
549
-
550
- var failed = result.Results[1];
551
- Assert.IsFalse(failed.Ok);
552
- Assert.IsFalse(failed.Skipped);
553
- Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
554
- "the failing child retains its original runtime error");
555
- Assert.IsNotNull(failed.Transaction);
556
- Assert.IsTrue(failed.Transaction!["aborted"]!.GetValue<bool>());
557
- Assert.AreEqual("complete", failed.Transaction["rollback"]!.GetValue<string>());
558
- Assert.IsTrue(result.Results[2].Skipped);
559
- Assert.IsFalse(result.Results[2].Ok);
560
- }
561
- finally
562
- {
563
- RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
564
- }
565
- }
566
-
567
- [UnityTest]
568
- public IEnumerator Batch_PartialSharedChildFailure_ReportsUncertainFinalStateAndRetainsContext()
569
- {
570
- var originalInstance = UnityCopilotPluginEditor.Instance;
571
- originalInstance.BuildMcpPluginIfNeeded();
572
- var originalPlugin = originalInstance.UcoPluginInstance;
573
- var failing = new FailingCreateRunner();
574
- var replacement = new TestUnityCopilotPluginEditor(failing);
575
- SetEditorSingleton(replacement);
576
- try
577
- {
578
- replacement.BuildMcpPluginIfNeeded();
579
- var target = new GameObject("batch-shared-partial");
580
- var unrelated = new GameObject("batch-partial-unrelated-history");
581
- Undo.RegisterCreatedObjectUndo(unrelated, "batch partial unrelated");
582
- Undo.IncrementCurrentGroup();
583
-
584
- var commands = new[]
585
- {
586
- new Tool_Batch.BatchCommand
587
- {
588
- Tool = Tool_GameObject.GameObjectModifyToolId,
589
- Params = Params(
590
- ("gameObjectRefs", new[] { new { instanceID = ObjectIdWireValue(target) } }),
591
- ("jsonPatchesPerGameObject", new[] { "{\"name\":\"batch-shared-partial-modified\"}" })),
592
- },
593
- new Tool_Batch.BatchCommand
594
- {
595
- Tool = failing.Name,
596
- Params = new Dictionary<string, JsonElement>(),
597
- },
598
- new Tool_Batch.BatchCommand
599
- {
600
- Tool = Tool_GameObject.GameObjectCreateToolId,
601
- Params = Params(("name", "batch-partial-skipped")),
602
- },
603
- };
604
- var arguments = new Dictionary<string, JsonElement>
605
- {
606
- ["commands"] = JsonSerializer.SerializeToElement(commands),
607
- ["failFast"] = JsonSerializer.SerializeToElement(true),
608
- };
609
-
610
- ResponseData<ResponseCallTool>? planned = null;
611
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
612
- Control("batch-partial-failure", dryRun: "plan"), r => planned = r);
613
- Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
614
-
615
- ResponseData<ResponseCallTool>? executed = null;
616
- yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
617
- Control("batch-partial-failure", confirmation: ReadPlan(planned)), r => executed = r);
618
- Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
619
- var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
620
- executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
621
-
622
- Assert.AreEqual(0, result.Succeeded);
623
- Assert.AreEqual(2, result.Failed);
624
- Assert.IsTrue(result.Aborted);
625
- Assert.IsNotNull(GameObject.Find("batch-partial-unrelated-history"),
626
- "aborting a partial batch must preserve unrelated Undo history");
627
- Assert.IsNull(GameObject.Find("batch-partial-skipped"));
628
-
629
- var parentTransaction = executed.Value.Transaction!;
630
- Assert.IsTrue(parentTransaction["aborted"]!.GetValue<bool>());
631
- Assert.IsFalse(parentTransaction["completed"]!.GetValue<bool>());
632
- Assert.AreEqual("partial", parentTransaction["rollback"]!.GetValue<string>());
633
-
634
- var prior = result.Results[0];
635
- Assert.IsFalse(prior.Ok, "an aborted prior mutation must not remain successful");
636
- Assert.IsFalse(prior.Skipped);
637
- Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
638
- StringAssert.Contains("final mutation state is uncertain", prior.Error);
639
- StringAssert.DoesNotContain("did not remain applied", prior.Error);
640
- Assert.IsNotNull(prior.Data, "uncertain rollback retains bounded child diagnostics");
641
- Assert.LessOrEqual(prior.Data!.ToString()!.Length, 1024);
642
- Assert.IsNotNull(prior.Transaction);
643
- Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
644
- Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
645
- Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
646
- Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
647
- Assert.AreEqual("partial", prior.Transaction["rollback"]!.GetValue<string>());
648
- Assert.AreEqual("partial", prior.Transaction["undo"]!.GetValue<string>());
649
- var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
650
- .Select(value => value!["name"]!.GetValue<string>())
651
- .Distinct(StringComparer.Ordinal)
652
- .ToArray();
653
- CollectionAssert.Contains(priorAffectedNames, "batch-shared-partial");
654
-
655
- var failed = result.Results[1];
656
- Assert.IsFalse(failed.Ok);
657
- Assert.IsFalse(failed.Skipped);
658
- Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
659
- "the failing child retains its original runtime error");
660
- Assert.AreEqual("Tool execution failed.", failed.Error,
661
- "parent finalization must not replace the failing child's original safe error");
662
- Assert.IsNotNull(failed.Transaction);
663
- Assert.IsTrue(failed.Transaction!["shared"]!.GetValue<bool>());
664
- Assert.IsFalse(failed.Transaction["pending"]!.GetValue<bool>());
665
- Assert.IsTrue(failed.Transaction["aborted"]!.GetValue<bool>());
666
- Assert.AreEqual("partial", failed.Transaction["rollback"]!.GetValue<string>());
667
-
668
- Assert.IsTrue(result.Results[2].Skipped);
669
- Assert.IsFalse(result.Results[2].Ok);
670
- }
671
- finally
672
- {
673
- RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
674
- }
675
- }
676
-
677
- [UnityTest]
678
- public IEnumerator Batch_AllowsUndoNoneAndRejectsInvalidChildBeforeAnyMutation()
679
- {
680
- EnsureTmpFolder();
681
- var undoNone = new Dictionary<string, JsonElement>
682
- {
683
- ["commands"] = JsonSerializer.SerializeToElement(new[]
684
- {
685
- new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-undo-none")) },
686
- new Tool_Batch.BatchCommand { Tool = Tool_Scene.SceneSaveToolId, Params = Params(("path", $"{TmpFolder}/batch-undo-none.unity")) },
687
- }),
688
- };
689
- ResponseData<ResponseCallTool>? undoNonePlan = null;
690
- yield return Call(Tool_Batch.BatchExecuteToolId, undoNone, Control("batch-undo-none", dryRun: "plan"), r => undoNonePlan = r);
691
- Assert.AreEqual(ResponseStatus.Success, undoNonePlan!.Status, undoNonePlan.Message);
692
- var aggregatePlan = undoNonePlan.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
693
- Assert.AreEqual("none", aggregatePlan["undo"]!.GetValue<string>());
694
- Assert.IsNull(GameObject.Find("batch-undo-none"), "planning must not execute the first child");
695
-
696
- var before = EditorSnapshot.Capture(null);
697
- var missingTarget = new Dictionary<string, JsonElement>
698
- {
699
- ["commands"] = JsonSerializer.SerializeToElement(new[]
700
- {
701
- new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-2")) },
702
- new Tool_Batch.BatchCommand
703
- {
704
- Tool = Tool_GameObject.GameObjectDestroyToolId,
705
- Params = new Dictionary<string, JsonElement>
706
- {
707
- ["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = 123456789 }),
708
- },
709
- },
710
- }),
711
- };
712
- ResponseData<ResponseCallTool>? missingPlan = null;
713
- yield return Call(Tool_Batch.BatchExecuteToolId, missingTarget, Control("batch-missing", dryRun: "plan"), r => missingPlan = r);
714
- Assert.AreEqual(ResponseStatus.Error, missingPlan!.Status);
715
- Assert.IsNull(GameObject.Find("batch-rejected-2"));
716
-
717
- // A confirmed batch is still rejected by preflight when a child
718
- // cannot be planned at execution time (its target vanished).
719
- var victim = new GameObject("batch-victim");
720
- var vanishing = new Dictionary<string, JsonElement>
721
- {
722
- ["commands"] = JsonSerializer.SerializeToElement(new[]
723
- {
724
- new Tool_Batch.BatchCommand
725
- {
726
- Tool = Tool_GameObject.GameObjectDestroyToolId,
727
- Params = new Dictionary<string, JsonElement>
728
- {
729
- ["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = ObjectIdWireValue(victim) }),
730
- },
731
- },
732
- new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-3")) },
733
- }),
734
- };
735
- ResponseData<ResponseCallTool>? vanishingPlan = null;
736
- yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", dryRun: "plan"), r => vanishingPlan = r);
737
- Assert.AreEqual(ResponseStatus.Success, vanishingPlan!.Status, vanishingPlan.Message);
738
- UnityEngine.Object.DestroyImmediate(victim);
739
- ResponseData<ResponseCallTool>? vanishingExec = null;
740
- yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", confirmation: ReadPlan(vanishingPlan)), r => vanishingExec = r);
741
- Assert.AreEqual(ResponseStatus.Error, vanishingExec!.Status, "the parent plan is stale once a child target vanished");
742
- Assert.IsNull(GameObject.Find("batch-rejected-3"), "no child may run when the aggregate approval fails");
743
-
744
- EditorSnapshot.Capture(null).AssertEquals(before, "rejected batches");
745
- }
746
-
747
- // ------------------------------------------------------------------
748
- // 5.5 static regressions: direct DestroyImmediate / mutation-before-recording
749
- // ------------------------------------------------------------------
750
-
751
- [Test]
752
- public void PilotSources_NeverUseDirectDestroyImmediateOrMutateBeforeRecording()
753
- {
754
- var toolDirectory = Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "API", "Tool"));
755
- Assert.IsTrue(Directory.Exists(toolDirectory), toolDirectory);
756
- var pilotFiles = Directory.GetFiles(toolDirectory, "*.cs")
757
- .Where(file =>
758
- {
759
- var name = Path.GetFileName(file);
760
- return name.StartsWith("GameObject.", StringComparison.Ordinal)
761
- || name.StartsWith("Scene.", StringComparison.Ordinal);
762
- })
763
- .Where(file => File.ReadAllText(file).Contains("[AuthoringCapability("))
764
- .ToArray();
765
- Assert.GreaterOrEqual(pilotFiles.Length, 13, "expected the Scene/GameObject/Component pilot files");
766
-
767
- var directDestroy = new Regex(@"(?<!Undo\.)\bDestroyImmediate\s*\(");
768
- foreach (var file in pilotFiles)
769
- {
770
- var source = File.ReadAllText(file);
771
- Assert.IsFalse(directDestroy.IsMatch(source), $"{Path.GetFileName(file)} must not call a non-Undo DestroyImmediate");
772
- StringAssert.Contains("UnityAuthoringUndo.RequireAuthoringScope()", source, Path.GetFileName(file));
773
- var scopeIndex = source.IndexOf("UnityAuthoringUndo.RequireAuthoringScope()", StringComparison.Ordinal);
774
- var markIndex = source.IndexOf("UnityAuthoringUndo.MarkMutated()", StringComparison.Ordinal);
775
- if (markIndex >= 0)
776
- {
777
- Assert.Less(scopeIndex, markIndex, $"{Path.GetFileName(file)}: scope check must precede the mutation mark");
778
- var recordIndex = new[]
779
- {
780
- source.IndexOf("UnityAuthoringUndo.RecordCreated(", StringComparison.Ordinal),
781
- source.IndexOf("UnityAuthoringUndo.RecordModified(", StringComparison.Ordinal),
782
- source.IndexOf("UnityAuthoringUndo.RecordHostRegistered(", StringComparison.Ordinal),
783
- source.IndexOf("UnityAuthoringUndo.AddComponent(", StringComparison.Ordinal),
784
- source.IndexOf("UnityAuthoringUndo.SetParent(", StringComparison.Ordinal),
785
- source.IndexOf("UnityAuthoringUndo.Destroy(", StringComparison.Ordinal),
786
- }.Where(index => index >= 0).DefaultIfEmpty(-1).Min();
787
- var declaresUndo = source.Contains("UndoLevel = AuthoringUndoLevel.Full") || source.Contains("UndoLevel = AuthoringUndoLevel.Partial");
788
- if (declaresUndo)
789
- Assert.IsTrue(recordIndex >= 0 && recordIndex < markIndex, $"{Path.GetFileName(file)}: an undoable pilot tool must record before marking a mutation");
790
- }
791
- }
792
-
793
- // The read-only inspection region must not reach a side-effecting Unity API.
794
- var adapters = File.ReadAllText(Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "Utils", "UnityAuthoringAdapters.cs")));
795
- var start = adapters.IndexOf("internal static class UnityPilotAuthoringInspection", StringComparison.Ordinal);
796
- var end = adapters.IndexOf("public sealed class UnityBatchAuthoringValidator", StringComparison.Ordinal);
797
- Assert.IsTrue(start >= 0 && end > start, "inspection region not found");
798
- var inspection = adapters.Substring(start, end - start);
799
- foreach (var forbidden in new[] { "SaveAssets", "CreateAsset", "DestroyImmediate", "new GameObject(", "AddComponent<", "SetDirty", "MarkSceneDirty", "SaveScene", "NewScene(", "OpenScene(", "Undo." })
800
- Assert.IsFalse(inspection.Contains(forbidden), $"read-only inspection must not use {forbidden}");
801
- }
802
-
803
- // ------------------------------------------------------------------
804
- // helpers
805
- // ------------------------------------------------------------------
806
-
807
- private static void EnsureTmpFolder()
808
- {
809
- if (!AssetDatabase.IsValidFolder(TmpFolder))
810
- AssetDatabase.CreateFolder("Assets", TmpFolderName);
811
- }
812
-
813
- private static Dictionary<string, JsonElement> Params(params (string Name, object Value)[] values)
814
- {
815
- var result = new Dictionary<string, JsonElement>();
816
- foreach (var (name, value) in values)
817
- result[name] = JsonSerializer.SerializeToElement(value);
818
- return result;
819
- }
820
-
821
- private static object ObjectIdWireValue(UnityEngine.Object value)
822
- {
823
- #if UNITY_6000_5_OR_NEWER
824
- return UnityEngine.EntityId.ToULong(value.GetEntityId())
825
- .ToString(System.Globalization.CultureInfo.InvariantCulture);
826
- #else
827
- return value.GetInstanceID();
828
- #endif
829
- }
830
-
831
- private static string ObjectIdJsonValue(UnityEngine.Object value)
832
- => JsonSerializer.Serialize(ObjectIdWireValue(value));
833
-
834
- private static string ObjectIdText(UnityEngine.Object value)
835
- => Convert.ToString(ObjectIdWireValue(value), System.Globalization.CultureInfo.InvariantCulture)
836
- ?? "0";
837
-
838
- private static ToolCallControl Control(string callId, string? dryRun = null, ToolCallConfirmation? confirmation = null)
839
- => new ToolCallControl
840
- {
841
- Version = ToolCallControl.CurrentVersion,
842
- CallId = callId,
843
- CorrelationId = callId + "-trace",
844
- DryRun = dryRun,
845
- Confirm = confirmation == null ? (bool?)null : true,
846
- Confirmation = confirmation,
847
- };
848
-
849
- private static ToolCallConfirmation ReadPlan(ResponseData<ResponseCallTool> response)
850
- {
851
- Assert.AreEqual(ResponseStatus.Success, response.Status, "plan: " + response.Message);
852
- var plan = response.Value!.StructuredContent!["result"]!["confirmationPlan"]!.AsObject();
853
- return new ToolCallConfirmation
854
- {
855
- PlanId = plan["planId"]!.GetValue<string>(),
856
- PlanHash = plan["planHash"]!.GetValue<string>(),
857
- ExpiresAtUnixMs = plan["expiresAtUnixMs"]!.GetValue<long>(),
858
- };
859
- }
860
-
861
- private static string? ErrorCode(ResponseData<ResponseCallTool> response)
862
- => response.StructuredError?.Code ?? response.Value?.StructuredError?.Code;
863
-
864
- private IEnumerator Call(string tool, string json, ToolCallControl? control, Action<ResponseData<ResponseCallTool>> onDone)
865
- => Call(tool, JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!, control, onDone);
866
-
867
- private IEnumerator Call(
868
- string tool,
869
- Dictionary<string, JsonElement> arguments,
870
- ToolCallControl? control,
871
- Action<ResponseData<ResponseCallTool>> onDone)
872
- {
873
- // requestID is part of the confirmation binding: derive it from the
874
- // call id so plan and execution share one logical identity.
875
- var requestId = (control?.CallId ?? "legacy-" + Guid.NewGuid().ToString("N")) + "-request";
876
- var request = control == null
877
- ? new RequestCallTool(requestId, tool, arguments)
878
- : new RequestCallTool(requestId, tool, arguments, control);
879
- // Unity resets this between SetUp and the test body; rejections
880
- // are logged as errors by the manager and asserted on the response.
881
- LogAssert.ignoreFailingMessages = true;
882
- var task = UnityCopilotPluginEditor.Instance.Tools!.RunCallTool(request);
883
- yield return WaitForTask(task);
884
- onDone(task.Result);
885
- }
886
-
887
- private static void SetEditorSingleton(UnityCopilotPluginEditor replacement)
888
- {
889
- var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
890
- Assert.IsNotNull(instanceField, "Editor singleton field was not found.");
891
- instanceField!.SetValue(null, replacement);
892
- }
893
-
894
- private static void RestoreEditorSingleton(
895
- UnityCopilotPluginEditor original,
896
- IUcoPlugin? originalPlugin,
897
- TestUnityCopilotPluginEditor replacement)
898
- {
899
- replacement.DisposeMcpPluginInstance();
900
- replacement.Dispose();
901
- var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
902
- instanceField!.SetValue(null, original);
903
- var setCurrentPlugin = typeof(UnityCopilotPluginEditor).GetMethod("SetCurrentPlugin", PrivateStatic);
904
- setCurrentPlugin!.Invoke(null, new object?[] { originalPlugin });
905
- }
906
-
907
- /// <summary>Bounded view of Editor state that a dry-run must not change.</summary>
908
- private sealed class EditorSnapshot
909
- {
910
- public string Objects = string.Empty;
911
- public string Dirty = string.Empty;
912
- public int UndoGroup;
913
- public string Files = string.Empty;
914
-
915
- public static EditorSnapshot Capture(string? watchFolder)
916
- {
917
- var ids = new List<string>();
918
- var dirty = new List<string>();
919
- for (var index = 0; index < SceneManager.sceneCount; index++)
920
- {
921
- var scene = SceneManager.GetSceneAt(index);
922
- if (!scene.IsValid() || !scene.isLoaded)
923
- continue;
924
- dirty.Add(scene.name + "=" + scene.isDirty);
925
- foreach (var root in scene.GetRootGameObjects())
926
- {
927
- foreach (var transform in root.GetComponentsInChildren<Transform>(true))
928
- {
929
- var go = transform.gameObject;
930
- ids.Add(ObjectIdText(go) + ":" + go.name + ":" + go.GetComponents<Component>().Length + ":" + (go.transform.parent == null ? "0" : ObjectIdText(go.transform.parent)));
931
- }
932
- }
933
- }
934
- ids.Sort(StringComparer.Ordinal);
935
-
936
- var files = string.Empty;
937
- if (watchFolder != null)
938
- {
939
- var full = Path.GetFullPath(watchFolder);
940
- files = Directory.Exists(full)
941
- ? string.Join("|", Directory.GetFiles(full, "*", SearchOption.AllDirectories)
942
- .OrderBy(path => path, StringComparer.Ordinal)
943
- .Select(path => Path.GetFileName(path) + ":" + new FileInfo(path).Length))
944
- : "<missing>";
945
- }
946
-
947
- return new EditorSnapshot
948
- {
949
- Objects = string.Join("|", ids),
950
- Dirty = string.Join("|", dirty),
951
- UndoGroup = Undo.GetCurrentGroup(),
952
- Files = files,
953
- };
954
- }
955
-
956
- public void AssertEquals(EditorSnapshot expected, string context)
957
- {
958
- Assert.AreEqual(expected.Objects, Objects, context + ": object set/state changed");
959
- Assert.AreEqual(expected.Dirty, Dirty, context + ": scene dirty flags changed");
960
- Assert.AreEqual(expected.UndoGroup, UndoGroup, context + ": Undo group changed");
961
- Assert.AreEqual(expected.Files, Files, context + ": project files changed");
962
- }
963
- }
964
-
965
- private sealed class TestUnityCopilotPluginEditor : UnityCopilotPluginEditor
966
- {
967
- private readonly IReadOnlyList<IRunTool> _runners;
968
-
969
- public TestUnityCopilotPluginEditor(params IRunTool[] runners)
970
- {
971
- _runners = runners;
972
- ConnectionConfigForTests.KeepConnected = false;
973
- }
974
-
975
- protected override IUcoPlugin BuildMcpPlugin(
976
- com.AtelierAI.Uco.Framework.Common.Version version,
977
- Reflector reflector,
978
- ILoggerProvider? loggerProvider = null,
979
- Action<IMcpPluginBuilder>? configure = null)
980
- {
981
- return base.BuildMcpPlugin(
982
- version,
983
- reflector,
984
- loggerProvider,
985
- builder =>
986
- {
987
- configure?.Invoke(builder);
988
- foreach (var runner in _runners)
989
- builder.AddTool(runner.Name, runner);
990
- });
991
- }
992
- }
993
-
994
- /// <summary>
995
- /// Test-only mutating runner: creates a real GameObject inside the
996
- /// ambient transaction and then throws, modelling a tool that fails
997
- /// after Unity accepted a mutation.
998
- /// </summary>
999
- private sealed class FailingCreateRunner : IRunTool
1000
- {
1001
- public const string VictimName = "tx-victim";
1002
-
1003
- public string Name => "test-failing-create";
1004
- public bool Enabled { get; set; } = true;
1005
- public string? Title => Name;
1006
- public string? Description => "Test-only failing authoring runner.";
1007
- public MethodInfo? Method => null;
1008
- public string? SkillDescription => null;
1009
- public string? SkillBody => null;
1010
- public JsonNode? InputSchema => new JsonObject();
1011
- public JsonNode? OutputSchema => null;
1012
- public UcoToolType ToolType => UcoToolType.Standard;
1013
- public bool? ReadOnlyHint => false;
1014
- public bool? DestructiveHint => null;
1015
- public bool? IdempotentHint => null;
1016
- public bool? OpenWorldHint => null;
1017
- public AuthoringCapabilityDescriptor? AuthoringCapability { get; } = new AuthoringCapabilityDescriptor
1018
- {
1019
- MutationKind = AuthoringMutationKind.Create,
1020
- UndoLevel = AuthoringUndoLevel.Full,
1021
- SupportsValidation = true,
1022
- SupportsPlanning = true,
1023
- Validator = new UnityPilotAuthoringValidator(),
1024
- Planner = new UnityPilotAuthoringPlanner(),
1025
- TransactionFactory = new UnityAuthoringTransactionFactory(),
1026
- };
1027
- public int TokenCount => 0;
1028
- public int Invocations { get; private set; }
1029
-
1030
- public Task<ResponseCallTool> Run(
1031
- string requestId,
1032
- IReadOnlyDictionary<string, JsonElement>? namedParameters,
1033
- CancellationToken cancellationToken = default)
1034
- {
1035
- Invocations++;
1036
- return Task.FromResult(MainThread.Instance.Run(() =>
1037
- {
1038
- UnityAuthoringUndo.RequireAuthoringScope();
1039
- var victim = new GameObject(VictimName);
1040
- UnityAuthoringUndo.RecordCreated(victim);
1041
- UnityAuthoringUndo.MarkMutated();
1042
- throw new InvalidOperationException("failure after mutation");
1043
- #pragma warning disable CS0162
1044
- return ResponseCallTool.Success().SetRequestID(requestId);
1045
- #pragma warning restore CS0162
1046
- }));
1047
- }
1048
- }
1049
- }
1050
- }
1
+ #nullable enable
2
+
3
+ using System;
4
+ using System.Collections;
5
+ using System.Collections.Generic;
6
+ using System.IO;
7
+ using System.Linq;
8
+ using System.Reflection;
9
+ using System.Text.Json;
10
+ using System.Text.Json.Nodes;
11
+ using System.Text.RegularExpressions;
12
+ using System.Threading;
13
+ using System.Threading.Tasks;
14
+ using com.AtelierAI.Unity.Copilot.Editor.API;
15
+ using com.AtelierAI.Unity.Copilot.Editor.Utils;
16
+ using com.AtelierAI.Uco.Framework;
17
+ using com.AtelierAI.Uco.Framework.Common.Model;
18
+ using com.IvanMurzak.ReflectorNet;
19
+ using com.IvanMurzak.ReflectorNet.Utils;
20
+ using Microsoft.Extensions.Logging;
21
+ using NUnit.Framework;
22
+ using UnityEditor;
23
+ using UnityEditor.SceneManagement;
24
+ using UnityEngine;
25
+ using UnityEngine.SceneManagement;
26
+ using UnityEngine.TestTools;
27
+ using JsonSerializer = System.Text.Json.JsonSerializer;
28
+
29
+ namespace com.AtelierAI.Unity.Copilot.Editor.Tests
30
+ {
31
+ /// <summary>
32
+ /// g-005 pilot verification against the real Editor: dry-run invariants
33
+ /// (4.5), one-step Undo and truthful reporting (5.5), failure/abort
34
+ /// behaviour (5.6), the production batch entry point (6.4), plan
35
+ /// staleness/reload invalidation, and no-bypass for direct runner calls.
36
+ /// Every call goes through the production tool manager unless the test
37
+ /// is explicitly proving that a bypass attempt fails.
38
+ /// </summary>
39
+ [TestFixture]
40
+ public sealed class AuthoringSafetyPilotTests : BaseTest
41
+ {
42
+ private const string TmpFolderName = "AuthoringSafetyTests_TMP";
43
+ private const string TmpFolder = "Assets/" + TmpFolderName;
44
+ private static readonly BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static;
45
+
46
+ private bool _ignoreFailingMessages;
47
+ private static UnityCopilotPluginEditor _sessionOriginalEditor = null!;
48
+
49
+ [OneTimeSetUp]
50
+ public void CaptureSessionSingleton()
51
+ {
52
+ _sessionOriginalEditor = UnityCopilotPluginEditor.Instance;
53
+ _sessionOriginalEditor.BuildUcoPluginIfNeeded();
54
+ }
55
+
56
+ [OneTimeTearDown]
57
+ public void RestoreSessionSingleton()
58
+ {
59
+ // An abandoned coroutine (the runner fails the test on an
60
+ // unexpected error log, e.g. the replacement's hub handshake
61
+ // failing ~10 s after build) never reaches the replacing tests'
62
+ // `finally { RestoreEditorSingleton }`, so a broken double stays
63
+ // installed and every later fixture resolves it instead of the
64
+ // real editor. Fixture-level teardown runs after every test of
65
+ // this fixture regardless of failure or abandonment — restore
66
+ // the captured session singleton whenever one of this fixture's
67
+ // doubles is still installed.
68
+ if (UnityCopilotPluginEditor.Instance is TestUnityCopilotPluginEditor leaked)
69
+ RestoreEditorSingleton(
70
+ _sessionOriginalEditor, _sessionOriginalEditor.UcoPluginInstance, leaked);
71
+ }
72
+
73
+ public override IEnumerator SetUp()
74
+ {
75
+ yield return base.SetUp();
76
+ UnityCopilotPluginEditor.Instance.BuildUcoPluginIfNeeded();
77
+ // Rejections are logged as errors by the manager ("Error Response
78
+ // to AI"); the assertions here are on responses and Editor state.
79
+ _ignoreFailingMessages = LogAssert.ignoreFailingMessages;
80
+ LogAssert.ignoreFailingMessages = true;
81
+ }
82
+
83
+ public override IEnumerator TearDown()
84
+ {
85
+ LogAssert.ignoreFailingMessages = _ignoreFailingMessages;
86
+ if (AssetDatabase.IsValidFolder(TmpFolder))
87
+ AssetDatabase.DeleteAsset(TmpFolder);
88
+ yield return base.TearDown();
89
+ }
90
+
91
+ // ------------------------------------------------------------------
92
+ // 4.5 dry-run invariants
93
+ // ------------------------------------------------------------------
94
+
95
+ [UnityTest]
96
+ public IEnumerator DryRun_ValidateAndPlan_LeaveEditorUntouched()
97
+ {
98
+ EnsureTmpFolder();
99
+ var host = new GameObject("dry-run-host");
100
+ host.AddComponent<BoxCollider>();
101
+ var parent = new GameObject("dry-run-parent");
102
+ var hostId = ObjectIdJsonValue(host);
103
+ var parentId = ObjectIdJsonValue(parent);
104
+
105
+ var calls = new (string Tool, string Json)[]
106
+ {
107
+ (Tool_GameObject.GameObjectCreateToolId, @"{""name"":""dry-run-created""}"),
108
+ (Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
109
+ (Tool_GameObject.GameObjectComponentAddToolId, $@"{{""componentNames"":[""UnityEngine.SphereCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}"),
110
+ (Tool_GameObject.GameObjectComponentDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{hostId}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}"),
111
+ (Tool_GameObject.GameObjectSetParentToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{parentId}}}}}"),
112
+ (Tool_GameObject.GameObjectDuplicateToolId, $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}]}}"),
113
+ (Tool_Scene.SceneCreateToolId, $@"{{""path"":""{TmpFolder}/dry-run.unity""}}"),
114
+ (Tool_Scene.SceneSaveToolId, $@"{{""path"":""{TmpFolder}/dry-run-save.unity""}}"),
115
+ };
116
+
117
+ var before = EditorSnapshot.Capture(TmpFolder);
118
+ foreach (var (tool, json) in calls)
119
+ {
120
+ foreach (var mode in new[] { "validate", "plan" })
121
+ {
122
+ ResponseData<ResponseCallTool>? response = null;
123
+ yield return Call(tool, json, Control(tool + "-" + mode, dryRun: mode), r => response = r);
124
+ Assert.IsNotNull(response, tool);
125
+ Assert.AreEqual(ResponseStatus.Success, response!.Status, $"{tool} {mode}: {response.Message}");
126
+ var result = response.Value!.StructuredContent!["result"]!;
127
+ Assert.AreEqual(mode, result["dryRun"]!.GetValue<string>(), tool);
128
+ Assert.IsTrue(result["valid"]!.GetValue<bool>(), $"{tool} {mode} should validate");
129
+ Assert.IsNull(response.Value.Transaction, $"{tool} {mode} must not open a transaction");
130
+ if (mode == "plan")
131
+ {
132
+ var plan = result["confirmationPlan"]!;
133
+ StringAssert.StartsWith("plan-", plan["planId"]!.GetValue<string>());
134
+ StringAssert.StartsWith("sha256-", plan["planHash"]!.GetValue<string>());
135
+ var targets = plan["targets"]!.AsArray();
136
+ Assert.Greater(targets.Count, 0, $"{tool} plan should list targets");
137
+ foreach (var target in targets)
138
+ StringAssert.StartsWith("sha256-", target!["fingerprint"]!.GetValue<string>(), tool);
139
+ Assert.Greater(plan["predictedEffects"]!.AsArray().Count, 0, tool);
140
+ }
141
+ else
142
+ {
143
+ Assert.IsNull(result["confirmationPlan"], $"{tool} validate must not issue a plan");
144
+ }
145
+ EditorSnapshot.Capture(TmpFolder).AssertEquals(before, $"{tool} {mode}");
146
+ }
147
+ }
148
+ }
149
+
150
+ [UnityTest]
151
+ public IEnumerator Validate_ReportsMissingTargetWithoutMutation()
152
+ {
153
+ var before = EditorSnapshot.Capture(null);
154
+ ResponseData<ResponseCallTool>? response = null;
155
+ yield return Call(
156
+ Tool_GameObject.GameObjectDestroyToolId,
157
+ @"{""gameObjectRef"":{""instanceID"":123456789}}",
158
+ Control("missing-target", dryRun: "validate"),
159
+ r => response = r);
160
+
161
+ Assert.AreEqual(ResponseStatus.Error, response!.Status);
162
+ var error = response.StructuredError ?? response.Value?.StructuredError;
163
+ Assert.AreEqual("validation_failed", error!.Code);
164
+ Assert.AreEqual("target_not_found", error.Details!["reason"]!.GetValue<string>());
165
+ EditorSnapshot.Capture(null).AssertEquals(before, "invalid validate");
166
+ }
167
+
168
+ // ------------------------------------------------------------------
169
+ // Confirmation lifecycle against real targets
170
+ // ------------------------------------------------------------------
171
+
172
+ [UnityTest]
173
+ public IEnumerator Destroy_LegacyIsRejected_PlanIsStaleAfterTargetChange_ThenConfirmedDestroyIsUndoable()
174
+ {
175
+ var victim = new GameObject("victim");
176
+ var id = ObjectIdJsonValue(victim);
177
+ var json = $@"{{""gameObjectRef"":{{""instanceID"":{id}}}}}";
178
+
179
+ // Legacy destructive call: no mutation, copyable retry instruction.
180
+ ResponseData<ResponseCallTool>? legacy = null;
181
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, null, r => legacy = r);
182
+ Assert.AreEqual(ResponseStatus.Error, legacy!.Status);
183
+ StringAssert.Contains("[" + ToolCallErrorCodes.ConfirmationRequired + "]", legacy.Value!.GetMessage());
184
+ StringAssert.Contains("confirmationPlan", legacy.Value.GetMessage());
185
+ StringAssert.Contains("retryWith", legacy.Value.GetMessage());
186
+ Assert.IsNotNull(GameObject.Find("victim"), "a rejected legacy destroy must not mutate");
187
+
188
+ // Plan, then change the target: the approval is stale.
189
+ ResponseData<ResponseCallTool>? planned = null;
190
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", dryRun: "plan"), r => planned = r);
191
+ var token = ReadPlan(planned!);
192
+ victim.name = "victim-renamed";
193
+ ResponseData<ResponseCallTool>? stale = null;
194
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy", confirmation: token), r => stale = r);
195
+ Assert.AreEqual(ToolCallErrorCodes.ConfirmationStale, ErrorCode(stale!));
196
+ Assert.IsNotNull(GameObject.Find("victim-renamed"));
197
+
198
+ // Fresh plan on the current target executes once and is undoable.
199
+ ResponseData<ResponseCallTool>? replanned = null;
200
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", dryRun: "plan"), r => replanned = r);
201
+ var freshToken = ReadPlan(replanned!);
202
+ ResponseData<ResponseCallTool>? executed = null;
203
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("destroy-2", confirmation: freshToken), r => executed = r);
204
+ Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
205
+ Assert.IsNull(GameObject.Find("victim-renamed"), "confirmed destroy must destroy");
206
+ var transaction = executed.Value!.Transaction!;
207
+ Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
208
+ Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
209
+ Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
210
+ Assert.AreEqual("Uco: " + Tool_GameObject.GameObjectDestroyToolId, transaction["groupLabel"]!.GetValue<string>());
211
+
212
+ // A replayed token is not a standing capability.
213
+ var again = new GameObject("victim-again");
214
+ ResponseData<ResponseCallTool>? replay = null;
215
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(again)}}}}}", Control("destroy-2", confirmation: freshToken), r => replay = r);
216
+ Assert.AreEqual(ResponseStatus.Error, replay!.Status);
217
+ Assert.IsNotNull(GameObject.Find("victim-again"));
218
+
219
+ Undo.PerformUndo();
220
+ Assert.IsNotNull(GameObject.Find("victim-renamed"), "one Undo step must restore the destroyed object");
221
+ }
222
+
223
+ [UnityTest]
224
+ public IEnumerator ReloadGuard_InvalidatesIssuedPlans()
225
+ {
226
+ var victim = new GameObject("reload-victim");
227
+ var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(victim)}}}}}";
228
+ ResponseData<ResponseCallTool>? planned = null;
229
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", dryRun: "plan"), r => planned = r);
230
+ var token = ReadPlan(planned!);
231
+
232
+ Assert.GreaterOrEqual(UnityAuthoringSafetyReloadGuard.InvalidateConfirmationPlans(), 1,
233
+ "the live pipeline must expose at least one confirmation store");
234
+
235
+ ResponseData<ResponseCallTool>? afterReload = null;
236
+ yield return Call(Tool_GameObject.GameObjectDestroyToolId, json, Control("reload", confirmation: token), r => afterReload = r);
237
+ Assert.AreEqual(ToolCallErrorCodes.ConfirmationInvalid, ErrorCode(afterReload!));
238
+ Assert.IsNotNull(GameObject.Find("reload-victim"));
239
+ }
240
+
241
+ // ------------------------------------------------------------------
242
+ // 5.5 one-step Undo, dirty state, full versus partial
243
+ // ------------------------------------------------------------------
244
+
245
+ [UnityTest]
246
+ public IEnumerator Create_IsOneUndoStepWithFullReport()
247
+ {
248
+ var scene = SceneManager.GetActiveScene();
249
+ var groupBefore = Undo.GetCurrentGroup();
250
+ ResponseData<ResponseCallTool>? response = null;
251
+ yield return Call(Tool_GameObject.GameObjectCreateToolId, @"{""name"":""undo-created""}", Control("create"), r => response = r);
252
+
253
+ Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
254
+ var created = GameObject.Find("undo-created");
255
+ Assert.IsNotNull(created);
256
+ Assert.IsTrue(scene.isDirty || !scene.IsValid() || string.IsNullOrEmpty(scene.path),
257
+ "an executing create marks the active scene dirty");
258
+ var transaction = response.Value!.Transaction!;
259
+ Assert.AreEqual("full", transaction["undo"]!.GetValue<string>());
260
+ Assert.AreEqual("Uco: " + Tool_GameObject.GameObjectCreateToolId, transaction["groupLabel"]!.GetValue<string>());
261
+ Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
262
+ Assert.AreEqual("none", transaction["rollback"]!.GetValue<string>());
263
+ Assert.Greater(transaction["affectedObjects"]!.AsArray().Count, 0);
264
+ Assert.Greater(Undo.GetCurrentGroup(), groupBefore, "the call must own a new Undo group");
265
+
266
+ Undo.PerformUndo();
267
+ Assert.IsNull(GameObject.Find("undo-created"), "one Undo step removes the created object");
268
+ }
269
+
270
+ [UnityTest]
271
+ public IEnumerator ComponentAddAndSetParent_AreOneUndoStepEach()
272
+ {
273
+ var host = new GameObject("undo-host");
274
+ var parent = new GameObject("undo-parent");
275
+ var hostId = ObjectIdJsonValue(host);
276
+
277
+ ResponseData<ResponseCallTool>? add = null;
278
+ yield return Call(
279
+ Tool_GameObject.GameObjectComponentAddToolId,
280
+ $@"{{""componentNames"":[""UnityEngine.BoxCollider""],""gameObjectRef"":{{""instanceID"":{hostId}}}}}",
281
+ Control("add"),
282
+ r => add = r);
283
+ Assert.AreEqual(ResponseStatus.Success, add!.Status, add.Message);
284
+ Assert.IsNotNull(host.GetComponent<BoxCollider>());
285
+ Assert.AreEqual("full", add.Value!.Transaction!["undo"]!.GetValue<string>());
286
+ Undo.PerformUndo();
287
+ Assert.IsNull(host.GetComponent<BoxCollider>(), "one Undo step removes the added component");
288
+
289
+ ResponseData<ResponseCallTool>? reparent = null;
290
+ yield return Call(
291
+ Tool_GameObject.GameObjectSetParentToolId,
292
+ $@"{{""gameObjectRefs"":[{{""instanceID"":{hostId}}}],""parentGameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(parent)}}}}}",
293
+ Control("parent"),
294
+ r => reparent = r);
295
+ Assert.AreEqual(ResponseStatus.Success, reparent!.Status, reparent.Message);
296
+ Assert.AreEqual(parent.transform, host.transform.parent);
297
+ Assert.AreEqual("full", reparent.Value!.Transaction!["undo"]!.GetValue<string>());
298
+ Undo.PerformUndo();
299
+ Assert.IsNull(host.transform.parent, "one Undo step restores the previous parent");
300
+ }
301
+
302
+ [UnityTest]
303
+ public IEnumerator ComponentDestroy_UsesUndoAwareDestroyAndIsUndoable()
304
+ {
305
+ var host = new GameObject("component-destroy-host");
306
+ host.AddComponent<BoxCollider>();
307
+ ResponseData<ResponseCallTool>? planned = null;
308
+ var json = $@"{{""gameObjectRef"":{{""instanceID"":{ObjectIdJsonValue(host)}}},""destroyComponentRefs"":[{{""typeName"":""UnityEngine.BoxCollider""}}]}}";
309
+ yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", dryRun: "plan"), r => planned = r);
310
+ ResponseData<ResponseCallTool>? executed = null;
311
+ yield return Call(Tool_GameObject.GameObjectComponentDestroyToolId, json, Control("cdestroy", confirmation: ReadPlan(planned!)), r => executed = r);
312
+
313
+ Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
314
+ Assert.IsNull(host.GetComponent<BoxCollider>());
315
+ Assert.AreEqual("full", executed.Value!.Transaction!["undo"]!.GetValue<string>());
316
+ Undo.PerformUndo();
317
+ Assert.IsNotNull(host.GetComponent<BoxCollider>(), "Undo.DestroyObjectImmediate makes the deletion undoable");
318
+ }
319
+
320
+ [UnityTest]
321
+ public IEnumerator Modify_ReportsPartialUndoTruthfully()
322
+ {
323
+ var target = new GameObject("modify-me");
324
+ var arguments = new Dictionary<string, JsonElement>
325
+ {
326
+ ["gameObjectRefs"] = JsonSerializer.SerializeToElement(new[] { new { instanceID = ObjectIdWireValue(target) } }),
327
+ ["jsonPatchesPerGameObject"] = JsonSerializer.SerializeToElement(new[] { "{\"name\":\"modified-name\"}" }),
328
+ };
329
+ ResponseData<ResponseCallTool>? response = null;
330
+ yield return Call(Tool_GameObject.GameObjectModifyToolId, arguments, Control("modify"), r => response = r);
331
+
332
+ Assert.AreEqual(ResponseStatus.Success, response!.Status, response.Message);
333
+ Assert.AreEqual("modified-name", target.name);
334
+ var transaction = response.Value!.Transaction!;
335
+ Assert.AreEqual("partial", transaction["undo"]!.GetValue<string>(), "reflected patches are advertised as partial");
336
+ Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
337
+ Undo.PerformUndo();
338
+ Assert.AreEqual("modify-me", target.name, "the recorded complete snapshot restores the name");
339
+ }
340
+
341
+ // ------------------------------------------------------------------
342
+ // 5.6 failure/abort and no-bypass
343
+ // ------------------------------------------------------------------
344
+
345
+ [UnityTest]
346
+ public IEnumerator DirectRunnerInvocation_FailsClosedWithoutMutation()
347
+ {
348
+ var manager = UnityCopilotPluginEditor.Instance.Tools!;
349
+ var runner = manager.GetAllTools().First(tool => tool.Name == Tool_GameObject.GameObjectCreateToolId);
350
+ var before = EditorSnapshot.Capture(null);
351
+
352
+ LogAssert.ignoreFailingMessages = true;
353
+ var direct = runner.Run("direct-bypass", new Dictionary<string, JsonElement>
354
+ {
355
+ ["name"] = JsonSerializer.SerializeToElement("bypass-created"),
356
+ });
357
+ yield return WaitForTask(direct);
358
+
359
+ Assert.AreEqual(ResponseStatus.Error, direct.Result.Status, "a direct runner call must fail closed");
360
+ Assert.IsNull(GameObject.Find("bypass-created"), "no object may be created outside the policy pipeline");
361
+ EditorSnapshot.Capture(null).AssertEquals(before, "direct bypass");
362
+ }
363
+
364
+ [UnityTest]
365
+ public IEnumerator RunnerFailureAfterMutation_RevertsOnlyItsGroup()
366
+ {
367
+ var originalInstance = UnityCopilotPluginEditor.Instance;
368
+ originalInstance.BuildUcoPluginIfNeeded();
369
+ var originalPlugin = originalInstance.UcoPluginInstance;
370
+ var failing = new FailingCreateRunner();
371
+ var replacement = new TestUnityCopilotPluginEditor(failing);
372
+ SetEditorSingleton(replacement);
373
+ try
374
+ {
375
+ replacement.BuildUcoPluginIfNeeded();
376
+ var manager = replacement.Tools!;
377
+
378
+ // Unrelated earlier history that must survive the abort.
379
+ var unrelated = new GameObject("unrelated-history");
380
+ Undo.RegisterCreatedObjectUndo(unrelated, "unrelated");
381
+ Undo.IncrementCurrentGroup();
382
+
383
+ var request = new RequestCallTool("failing-request", failing.Name, new Dictionary<string, JsonElement>(), Control("failing"));
384
+ LogAssert.ignoreFailingMessages = true;
385
+ var execution = manager.RunCallTool(request);
386
+ yield return WaitForTask(execution);
387
+ var response = execution.Result;
388
+
389
+ Assert.AreEqual(ResponseStatus.Error, response.Status);
390
+ Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName), "the aborted group reverts the created object");
391
+ Assert.IsNotNull(GameObject.Find("unrelated-history"), "unrelated Undo history is preserved");
392
+ var transaction = response.Value!.Transaction!;
393
+ Assert.IsTrue(transaction["aborted"]!.GetValue<bool>());
394
+ Assert.IsFalse(transaction["completed"]!.GetValue<bool>());
395
+ Assert.AreEqual("complete", transaction["rollback"]!.GetValue<string>());
396
+ Assert.AreEqual(1, failing.Invocations);
397
+ }
398
+ finally
399
+ {
400
+ RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
401
+ }
402
+ }
403
+
404
+ // ------------------------------------------------------------------
405
+ // 6.4 production batch with pilot children
406
+ // ------------------------------------------------------------------
407
+
408
+ [UnityTest]
409
+ public IEnumerator Batch_PilotChildren_CollapseToOneUndoStep()
410
+ {
411
+ var commands = new[]
412
+ {
413
+ new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-a")) },
414
+ new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-b")) },
415
+ };
416
+ var arguments = new Dictionary<string, JsonElement>
417
+ {
418
+ ["commands"] = JsonSerializer.SerializeToElement(commands),
419
+ ["failFast"] = JsonSerializer.SerializeToElement(true),
420
+ };
421
+
422
+ ResponseData<ResponseCallTool>? planned = null;
423
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", dryRun: "plan"), r => planned = r);
424
+ Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
425
+ Assert.IsNull(GameObject.Find("batch-a"), "planning a batch must not create children");
426
+ var plan = planned.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
427
+ Assert.GreaterOrEqual(plan["targets"]!.AsArray().Count, 2);
428
+
429
+ ResponseData<ResponseCallTool>? executed = null;
430
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments, Control("batch", confirmation: ReadPlan(planned)), r => executed = r);
431
+ Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
432
+ var batchResult = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
433
+ executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
434
+ Assert.AreEqual(2, batchResult.Succeeded, string.Join("; ", batchResult.Results.Select(r => r.Error)));
435
+ Assert.IsNotNull(GameObject.Find("batch-a"));
436
+ Assert.IsNotNull(GameObject.Find("batch-b"));
437
+ var transaction = executed.Value.Transaction!;
438
+ Assert.IsTrue(transaction["completed"]!.GetValue<bool>());
439
+ Assert.IsTrue(transaction["mutated"]!.GetValue<bool>());
440
+ Assert.AreEqual("full", transaction["undo"]!.GetValue<string>(), "two fully recorded children keep aggregate full Undo");
441
+ Assert.AreEqual(2, transaction["affectedObjects"]!.AsArray().Count, "both children attach to the parent group");
442
+ for (var index = 0; index < batchResult.Results.Length; index++)
443
+ {
444
+ var child = batchResult.Results[index];
445
+ Assert.IsTrue(child.Ok);
446
+ Assert.IsNotNull(child.Transaction, "each shared child reports its own transaction contribution");
447
+ Assert.IsTrue(child.Transaction!["shared"]!.GetValue<bool>());
448
+ Assert.IsFalse(child.Transaction["pending"]!.GetValue<bool>());
449
+ Assert.IsTrue(child.Transaction["completed"]!.GetValue<bool>());
450
+ Assert.IsFalse(child.Transaction["aborted"]!.GetValue<bool>());
451
+ Assert.AreEqual("none", child.Transaction["rollback"]!.GetValue<string>());
452
+ Assert.AreEqual("full", child.Transaction["undo"]!.GetValue<string>());
453
+ Assert.AreEqual(transaction["groupId"]!.GetValue<int>(), child.Transaction["groupId"]!.GetValue<int>());
454
+ Assert.AreEqual(transaction["groupLabel"]!.GetValue<string>(), child.Transaction["groupLabel"]!.GetValue<string>());
455
+ var affectedNames = child.Transaction["affectedObjects"]!.AsArray()
456
+ .Select(value => value!["name"]!.GetValue<string>())
457
+ .Distinct(StringComparer.Ordinal)
458
+ .ToArray();
459
+ CollectionAssert.AreEqual(new[] { index == 0 ? "batch-a" : "batch-b" }, affectedNames,
460
+ "a child report may retain multiple recording events, but only for that child's object");
461
+ }
462
+
463
+ Undo.PerformUndo();
464
+ Assert.IsNull(GameObject.Find("batch-a"), "one Undo step reverts the whole batch");
465
+ Assert.IsNull(GameObject.Find("batch-b"));
466
+ }
467
+
468
+ [UnityTest]
469
+ public IEnumerator Batch_SharedChildFailure_RevertsPriorChildAndFinalizesTruthfulResults()
470
+ {
471
+ var originalInstance = UnityCopilotPluginEditor.Instance;
472
+ originalInstance.BuildUcoPluginIfNeeded();
473
+ var originalPlugin = originalInstance.UcoPluginInstance;
474
+ var failing = new FailingCreateRunner();
475
+ var replacement = new TestUnityCopilotPluginEditor(failing);
476
+ SetEditorSingleton(replacement);
477
+ try
478
+ {
479
+ replacement.BuildUcoPluginIfNeeded();
480
+ var unrelated = new GameObject("batch-unrelated-history");
481
+ Undo.RegisterCreatedObjectUndo(unrelated, "batch unrelated");
482
+ Undo.IncrementCurrentGroup();
483
+
484
+ var commands = new[]
485
+ {
486
+ new Tool_Batch.BatchCommand
487
+ {
488
+ Tool = Tool_GameObject.GameObjectCreateToolId,
489
+ Params = Params(("name", "batch-shared-restored")),
490
+ },
491
+ new Tool_Batch.BatchCommand
492
+ {
493
+ Tool = failing.Name,
494
+ Params = new Dictionary<string, JsonElement>(),
495
+ },
496
+ new Tool_Batch.BatchCommand
497
+ {
498
+ Tool = Tool_GameObject.GameObjectCreateToolId,
499
+ Params = Params(("name", "batch-shared-skipped")),
500
+ },
501
+ };
502
+ var arguments = new Dictionary<string, JsonElement>
503
+ {
504
+ ["commands"] = JsonSerializer.SerializeToElement(commands),
505
+ ["failFast"] = JsonSerializer.SerializeToElement(true),
506
+ };
507
+
508
+ ResponseData<ResponseCallTool>? planned = null;
509
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
510
+ Control("batch-shared-failure", dryRun: "plan"), r => planned = r);
511
+ Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
512
+
513
+ ResponseData<ResponseCallTool>? executed = null;
514
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
515
+ Control("batch-shared-failure", confirmation: ReadPlan(planned)), r => executed = r);
516
+ Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
517
+ var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
518
+ executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
519
+
520
+ Assert.AreEqual(0, result.Succeeded);
521
+ Assert.AreEqual(2, result.Failed);
522
+ Assert.IsTrue(result.Aborted);
523
+ Assert.IsNull(GameObject.Find("batch-shared-restored"),
524
+ "the parent abort restores a prior successful shared child");
525
+ Assert.IsNull(GameObject.Find(FailingCreateRunner.VictimName),
526
+ "the failing child's mutation is restored by the same parent group");
527
+ Assert.IsNull(GameObject.Find("batch-shared-skipped"));
528
+ Assert.IsNotNull(GameObject.Find("batch-unrelated-history"),
529
+ "aborting the batch must preserve unrelated Undo history");
530
+
531
+ var prior = result.Results[0];
532
+ Assert.IsFalse(prior.Ok, "a rolled-back child must not remain successful");
533
+ Assert.IsFalse(prior.Skipped);
534
+ Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
535
+ Assert.IsNull(prior.Data);
536
+ Assert.IsNotNull(prior.Transaction);
537
+ Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
538
+ Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
539
+ Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
540
+ Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
541
+ Assert.AreEqual("complete", prior.Transaction["rollback"]!.GetValue<string>());
542
+ Assert.AreEqual("full", prior.Transaction["undo"]!.GetValue<string>());
543
+ var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
544
+ .Select(value => value!["name"]!.GetValue<string>())
545
+ .Distinct(StringComparer.Ordinal)
546
+ .ToArray();
547
+ CollectionAssert.AreEqual(new[] { "batch-shared-restored" }, priorAffectedNames,
548
+ "the rolled-back child report may contain multiple record events, but only for its object");
549
+
550
+ var failed = result.Results[1];
551
+ Assert.IsFalse(failed.Ok);
552
+ Assert.IsFalse(failed.Skipped);
553
+ Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
554
+ "the failing child retains its original runtime error");
555
+ Assert.IsNotNull(failed.Transaction);
556
+ Assert.IsTrue(failed.Transaction!["aborted"]!.GetValue<bool>());
557
+ Assert.AreEqual("complete", failed.Transaction["rollback"]!.GetValue<string>());
558
+ Assert.IsTrue(result.Results[2].Skipped);
559
+ Assert.IsFalse(result.Results[2].Ok);
560
+ }
561
+ finally
562
+ {
563
+ RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
564
+ }
565
+ }
566
+
567
+ [UnityTest]
568
+ public IEnumerator Batch_PartialSharedChildFailure_ReportsUncertainFinalStateAndRetainsContext()
569
+ {
570
+ var originalInstance = UnityCopilotPluginEditor.Instance;
571
+ originalInstance.BuildUcoPluginIfNeeded();
572
+ var originalPlugin = originalInstance.UcoPluginInstance;
573
+ var failing = new FailingCreateRunner();
574
+ var replacement = new TestUnityCopilotPluginEditor(failing);
575
+ SetEditorSingleton(replacement);
576
+ try
577
+ {
578
+ replacement.BuildUcoPluginIfNeeded();
579
+ var target = new GameObject("batch-shared-partial");
580
+ var unrelated = new GameObject("batch-partial-unrelated-history");
581
+ Undo.RegisterCreatedObjectUndo(unrelated, "batch partial unrelated");
582
+ Undo.IncrementCurrentGroup();
583
+
584
+ var commands = new[]
585
+ {
586
+ new Tool_Batch.BatchCommand
587
+ {
588
+ Tool = Tool_GameObject.GameObjectModifyToolId,
589
+ Params = Params(
590
+ ("gameObjectRefs", new[] { new { instanceID = ObjectIdWireValue(target) } }),
591
+ ("jsonPatchesPerGameObject", new[] { "{\"name\":\"batch-shared-partial-modified\"}" })),
592
+ },
593
+ new Tool_Batch.BatchCommand
594
+ {
595
+ Tool = failing.Name,
596
+ Params = new Dictionary<string, JsonElement>(),
597
+ },
598
+ new Tool_Batch.BatchCommand
599
+ {
600
+ Tool = Tool_GameObject.GameObjectCreateToolId,
601
+ Params = Params(("name", "batch-partial-skipped")),
602
+ },
603
+ };
604
+ var arguments = new Dictionary<string, JsonElement>
605
+ {
606
+ ["commands"] = JsonSerializer.SerializeToElement(commands),
607
+ ["failFast"] = JsonSerializer.SerializeToElement(true),
608
+ };
609
+
610
+ ResponseData<ResponseCallTool>? planned = null;
611
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
612
+ Control("batch-partial-failure", dryRun: "plan"), r => planned = r);
613
+ Assert.AreEqual(ResponseStatus.Success, planned!.Status, planned.Message);
614
+
615
+ ResponseData<ResponseCallTool>? executed = null;
616
+ yield return Call(Tool_Batch.BatchExecuteToolId, arguments,
617
+ Control("batch-partial-failure", confirmation: ReadPlan(planned)), r => executed = r);
618
+ Assert.AreEqual(ResponseStatus.Success, executed!.Status, executed.Message);
619
+ var result = JsonSerializer.Deserialize<Tool_Batch.BatchResult>(
620
+ executed.Value!.StructuredContent!["result"]!.ToJsonString())!;
621
+
622
+ Assert.AreEqual(0, result.Succeeded);
623
+ Assert.AreEqual(2, result.Failed);
624
+ Assert.IsTrue(result.Aborted);
625
+ Assert.IsNotNull(GameObject.Find("batch-partial-unrelated-history"),
626
+ "aborting a partial batch must preserve unrelated Undo history");
627
+ Assert.IsNull(GameObject.Find("batch-partial-skipped"));
628
+
629
+ var parentTransaction = executed.Value.Transaction!;
630
+ Assert.IsTrue(parentTransaction["aborted"]!.GetValue<bool>());
631
+ Assert.IsFalse(parentTransaction["completed"]!.GetValue<bool>());
632
+ Assert.AreEqual("partial", parentTransaction["rollback"]!.GetValue<string>());
633
+
634
+ var prior = result.Results[0];
635
+ Assert.IsFalse(prior.Ok, "an aborted prior mutation must not remain successful");
636
+ Assert.IsFalse(prior.Skipped);
637
+ Assert.AreEqual(ToolCallErrorCodes.AuthoringTransactionFailed, prior.ErrorCode);
638
+ StringAssert.Contains("final mutation state is uncertain", prior.Error);
639
+ StringAssert.DoesNotContain("did not remain applied", prior.Error);
640
+ Assert.IsNotNull(prior.Data, "uncertain rollback retains bounded child diagnostics");
641
+ Assert.LessOrEqual(prior.Data!.ToString()!.Length, 1024);
642
+ Assert.IsNotNull(prior.Transaction);
643
+ Assert.IsTrue(prior.Transaction!["shared"]!.GetValue<bool>());
644
+ Assert.IsFalse(prior.Transaction["pending"]!.GetValue<bool>());
645
+ Assert.IsTrue(prior.Transaction["aborted"]!.GetValue<bool>());
646
+ Assert.IsFalse(prior.Transaction["completed"]!.GetValue<bool>());
647
+ Assert.AreEqual("partial", prior.Transaction["rollback"]!.GetValue<string>());
648
+ Assert.AreEqual("partial", prior.Transaction["undo"]!.GetValue<string>());
649
+ var priorAffectedNames = prior.Transaction["affectedObjects"]!.AsArray()
650
+ .Select(value => value!["name"]!.GetValue<string>())
651
+ .Distinct(StringComparer.Ordinal)
652
+ .ToArray();
653
+ CollectionAssert.Contains(priorAffectedNames, "batch-shared-partial");
654
+
655
+ var failed = result.Results[1];
656
+ Assert.IsFalse(failed.Ok);
657
+ Assert.IsFalse(failed.Skipped);
658
+ Assert.AreEqual(ToolCallErrorCodes.ToolExecutionFailed, failed.ErrorCode,
659
+ "the failing child retains its original runtime error");
660
+ Assert.AreEqual("Tool execution failed.", failed.Error,
661
+ "parent finalization must not replace the failing child's original safe error");
662
+ Assert.IsNotNull(failed.Transaction);
663
+ Assert.IsTrue(failed.Transaction!["shared"]!.GetValue<bool>());
664
+ Assert.IsFalse(failed.Transaction["pending"]!.GetValue<bool>());
665
+ Assert.IsTrue(failed.Transaction["aborted"]!.GetValue<bool>());
666
+ Assert.AreEqual("partial", failed.Transaction["rollback"]!.GetValue<string>());
667
+
668
+ Assert.IsTrue(result.Results[2].Skipped);
669
+ Assert.IsFalse(result.Results[2].Ok);
670
+ }
671
+ finally
672
+ {
673
+ RestoreEditorSingleton(originalInstance, originalPlugin, replacement);
674
+ }
675
+ }
676
+
677
+ [UnityTest]
678
+ public IEnumerator Batch_AllowsUndoNoneAndRejectsInvalidChildBeforeAnyMutation()
679
+ {
680
+ EnsureTmpFolder();
681
+ var undoNone = new Dictionary<string, JsonElement>
682
+ {
683
+ ["commands"] = JsonSerializer.SerializeToElement(new[]
684
+ {
685
+ new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-undo-none")) },
686
+ new Tool_Batch.BatchCommand { Tool = Tool_Scene.SceneSaveToolId, Params = Params(("path", $"{TmpFolder}/batch-undo-none.unity")) },
687
+ }),
688
+ };
689
+ ResponseData<ResponseCallTool>? undoNonePlan = null;
690
+ yield return Call(Tool_Batch.BatchExecuteToolId, undoNone, Control("batch-undo-none", dryRun: "plan"), r => undoNonePlan = r);
691
+ Assert.AreEqual(ResponseStatus.Success, undoNonePlan!.Status, undoNonePlan.Message);
692
+ var aggregatePlan = undoNonePlan.Value!.StructuredContent!["result"]!["confirmationPlan"]!;
693
+ Assert.AreEqual("none", aggregatePlan["undo"]!.GetValue<string>());
694
+ Assert.IsNull(GameObject.Find("batch-undo-none"), "planning must not execute the first child");
695
+
696
+ var before = EditorSnapshot.Capture(null);
697
+ var missingTarget = new Dictionary<string, JsonElement>
698
+ {
699
+ ["commands"] = JsonSerializer.SerializeToElement(new[]
700
+ {
701
+ new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-2")) },
702
+ new Tool_Batch.BatchCommand
703
+ {
704
+ Tool = Tool_GameObject.GameObjectDestroyToolId,
705
+ Params = new Dictionary<string, JsonElement>
706
+ {
707
+ ["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = 123456789 }),
708
+ },
709
+ },
710
+ }),
711
+ };
712
+ ResponseData<ResponseCallTool>? missingPlan = null;
713
+ yield return Call(Tool_Batch.BatchExecuteToolId, missingTarget, Control("batch-missing", dryRun: "plan"), r => missingPlan = r);
714
+ Assert.AreEqual(ResponseStatus.Error, missingPlan!.Status);
715
+ Assert.IsNull(GameObject.Find("batch-rejected-2"));
716
+
717
+ // A confirmed batch is still rejected by preflight when a child
718
+ // cannot be planned at execution time (its target vanished).
719
+ var victim = new GameObject("batch-victim");
720
+ var vanishing = new Dictionary<string, JsonElement>
721
+ {
722
+ ["commands"] = JsonSerializer.SerializeToElement(new[]
723
+ {
724
+ new Tool_Batch.BatchCommand
725
+ {
726
+ Tool = Tool_GameObject.GameObjectDestroyToolId,
727
+ Params = new Dictionary<string, JsonElement>
728
+ {
729
+ ["gameObjectRef"] = JsonSerializer.SerializeToElement(new { instanceID = ObjectIdWireValue(victim) }),
730
+ },
731
+ },
732
+ new Tool_Batch.BatchCommand { Tool = Tool_GameObject.GameObjectCreateToolId, Params = Params(("name", "batch-rejected-3")) },
733
+ }),
734
+ };
735
+ ResponseData<ResponseCallTool>? vanishingPlan = null;
736
+ yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", dryRun: "plan"), r => vanishingPlan = r);
737
+ Assert.AreEqual(ResponseStatus.Success, vanishingPlan!.Status, vanishingPlan.Message);
738
+ UnityEngine.Object.DestroyImmediate(victim);
739
+ ResponseData<ResponseCallTool>? vanishingExec = null;
740
+ yield return Call(Tool_Batch.BatchExecuteToolId, vanishing, Control("batch-vanish", confirmation: ReadPlan(vanishingPlan)), r => vanishingExec = r);
741
+ Assert.AreEqual(ResponseStatus.Error, vanishingExec!.Status, "the parent plan is stale once a child target vanished");
742
+ Assert.IsNull(GameObject.Find("batch-rejected-3"), "no child may run when the aggregate approval fails");
743
+
744
+ EditorSnapshot.Capture(null).AssertEquals(before, "rejected batches");
745
+ }
746
+
747
+ // ------------------------------------------------------------------
748
+ // 5.5 static regressions: direct DestroyImmediate / mutation-before-recording
749
+ // ------------------------------------------------------------------
750
+
751
+ [Test]
752
+ public void PilotSources_NeverUseDirectDestroyImmediateOrMutateBeforeRecording()
753
+ {
754
+ var toolDirectory = Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "API", "Tool"));
755
+ Assert.IsTrue(Directory.Exists(toolDirectory), toolDirectory);
756
+ var pilotFiles = Directory.GetFiles(toolDirectory, "*.cs")
757
+ .Where(file =>
758
+ {
759
+ var name = Path.GetFileName(file);
760
+ return name.StartsWith("GameObject.", StringComparison.Ordinal)
761
+ || name.StartsWith("Scene.", StringComparison.Ordinal);
762
+ })
763
+ .Where(file => File.ReadAllText(file).Contains("[AuthoringCapability("))
764
+ .ToArray();
765
+ Assert.GreaterOrEqual(pilotFiles.Length, 13, "expected the Scene/GameObject/Component pilot files");
766
+
767
+ var directDestroy = new Regex(@"(?<!Undo\.)\bDestroyImmediate\s*\(");
768
+ foreach (var file in pilotFiles)
769
+ {
770
+ var source = File.ReadAllText(file);
771
+ Assert.IsFalse(directDestroy.IsMatch(source), $"{Path.GetFileName(file)} must not call a non-Undo DestroyImmediate");
772
+ StringAssert.Contains("UnityAuthoringUndo.RequireAuthoringScope()", source, Path.GetFileName(file));
773
+ var scopeIndex = source.IndexOf("UnityAuthoringUndo.RequireAuthoringScope()", StringComparison.Ordinal);
774
+ var markIndex = source.IndexOf("UnityAuthoringUndo.MarkMutated()", StringComparison.Ordinal);
775
+ if (markIndex >= 0)
776
+ {
777
+ Assert.Less(scopeIndex, markIndex, $"{Path.GetFileName(file)}: scope check must precede the mutation mark");
778
+ var recordIndex = new[]
779
+ {
780
+ source.IndexOf("UnityAuthoringUndo.RecordCreated(", StringComparison.Ordinal),
781
+ source.IndexOf("UnityAuthoringUndo.RecordModified(", StringComparison.Ordinal),
782
+ source.IndexOf("UnityAuthoringUndo.RecordHostRegistered(", StringComparison.Ordinal),
783
+ source.IndexOf("UnityAuthoringUndo.AddComponent(", StringComparison.Ordinal),
784
+ source.IndexOf("UnityAuthoringUndo.SetParent(", StringComparison.Ordinal),
785
+ source.IndexOf("UnityAuthoringUndo.Destroy(", StringComparison.Ordinal),
786
+ }.Where(index => index >= 0).DefaultIfEmpty(-1).Min();
787
+ var declaresUndo = source.Contains("UndoLevel = AuthoringUndoLevel.Full") || source.Contains("UndoLevel = AuthoringUndoLevel.Partial");
788
+ if (declaresUndo)
789
+ Assert.IsTrue(recordIndex >= 0 && recordIndex < markIndex, $"{Path.GetFileName(file)}: an undoable pilot tool must record before marking a mutation");
790
+ }
791
+ }
792
+
793
+ // The read-only inspection region must not reach a side-effecting Unity API.
794
+ var adapters = File.ReadAllText(Path.GetFullPath(Path.Combine("Packages", "com.atelierai.unity.copilot", "Editor", "Scripts", "Utils", "UnityAuthoringAdapters.cs")));
795
+ var start = adapters.IndexOf("internal static class UnityPilotAuthoringInspection", StringComparison.Ordinal);
796
+ var end = adapters.IndexOf("public sealed class UnityBatchAuthoringValidator", StringComparison.Ordinal);
797
+ Assert.IsTrue(start >= 0 && end > start, "inspection region not found");
798
+ var inspection = adapters.Substring(start, end - start);
799
+ foreach (var forbidden in new[] { "SaveAssets", "CreateAsset", "DestroyImmediate", "new GameObject(", "AddComponent<", "SetDirty", "MarkSceneDirty", "SaveScene", "NewScene(", "OpenScene(", "Undo." })
800
+ Assert.IsFalse(inspection.Contains(forbidden), $"read-only inspection must not use {forbidden}");
801
+ }
802
+
803
+ // ------------------------------------------------------------------
804
+ // helpers
805
+ // ------------------------------------------------------------------
806
+
807
+ private static void EnsureTmpFolder()
808
+ {
809
+ if (!AssetDatabase.IsValidFolder(TmpFolder))
810
+ AssetDatabase.CreateFolder("Assets", TmpFolderName);
811
+ }
812
+
813
+ private static Dictionary<string, JsonElement> Params(params (string Name, object Value)[] values)
814
+ {
815
+ var result = new Dictionary<string, JsonElement>();
816
+ foreach (var (name, value) in values)
817
+ result[name] = JsonSerializer.SerializeToElement(value);
818
+ return result;
819
+ }
820
+
821
+ private static object ObjectIdWireValue(UnityEngine.Object value)
822
+ {
823
+ #if UNITY_6000_5_OR_NEWER
824
+ return UnityEngine.EntityId.ToULong(value.GetEntityId())
825
+ .ToString(System.Globalization.CultureInfo.InvariantCulture);
826
+ #else
827
+ return value.GetInstanceID();
828
+ #endif
829
+ }
830
+
831
+ private static string ObjectIdJsonValue(UnityEngine.Object value)
832
+ => JsonSerializer.Serialize(ObjectIdWireValue(value));
833
+
834
+ private static string ObjectIdText(UnityEngine.Object value)
835
+ => Convert.ToString(ObjectIdWireValue(value), System.Globalization.CultureInfo.InvariantCulture)
836
+ ?? "0";
837
+
838
+ private static ToolCallControl Control(string callId, string? dryRun = null, ToolCallConfirmation? confirmation = null)
839
+ => new ToolCallControl
840
+ {
841
+ Version = ToolCallControl.CurrentVersion,
842
+ CallId = callId,
843
+ CorrelationId = callId + "-trace",
844
+ DryRun = dryRun,
845
+ Confirm = confirmation == null ? (bool?)null : true,
846
+ Confirmation = confirmation,
847
+ };
848
+
849
+ private static ToolCallConfirmation ReadPlan(ResponseData<ResponseCallTool> response)
850
+ {
851
+ Assert.AreEqual(ResponseStatus.Success, response.Status, "plan: " + response.Message);
852
+ var plan = response.Value!.StructuredContent!["result"]!["confirmationPlan"]!.AsObject();
853
+ return new ToolCallConfirmation
854
+ {
855
+ PlanId = plan["planId"]!.GetValue<string>(),
856
+ PlanHash = plan["planHash"]!.GetValue<string>(),
857
+ ExpiresAtUnixMs = plan["expiresAtUnixMs"]!.GetValue<long>(),
858
+ };
859
+ }
860
+
861
+ private static string? ErrorCode(ResponseData<ResponseCallTool> response)
862
+ => response.StructuredError?.Code ?? response.Value?.StructuredError?.Code;
863
+
864
+ private IEnumerator Call(string tool, string json, ToolCallControl? control, Action<ResponseData<ResponseCallTool>> onDone)
865
+ => Call(tool, JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!, control, onDone);
866
+
867
+ private IEnumerator Call(
868
+ string tool,
869
+ Dictionary<string, JsonElement> arguments,
870
+ ToolCallControl? control,
871
+ Action<ResponseData<ResponseCallTool>> onDone)
872
+ {
873
+ // requestID is part of the confirmation binding: derive it from the
874
+ // call id so plan and execution share one logical identity.
875
+ var requestId = (control?.CallId ?? "legacy-" + Guid.NewGuid().ToString("N")) + "-request";
876
+ var request = control == null
877
+ ? new RequestCallTool(requestId, tool, arguments)
878
+ : new RequestCallTool(requestId, tool, arguments, control);
879
+ // Unity resets this between SetUp and the test body; rejections
880
+ // are logged as errors by the manager and asserted on the response.
881
+ LogAssert.ignoreFailingMessages = true;
882
+ var task = UnityCopilotPluginEditor.Instance.Tools!.RunCallTool(request);
883
+ yield return WaitForTask(task);
884
+ onDone(task.Result);
885
+ }
886
+
887
+ private static void SetEditorSingleton(UnityCopilotPluginEditor replacement)
888
+ {
889
+ var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
890
+ Assert.IsNotNull(instanceField, "Editor singleton field was not found.");
891
+ instanceField!.SetValue(null, replacement);
892
+ }
893
+
894
+ private static void RestoreEditorSingleton(
895
+ UnityCopilotPluginEditor original,
896
+ IUcoPlugin? originalPlugin,
897
+ TestUnityCopilotPluginEditor replacement)
898
+ {
899
+ replacement.DisposeUcoPluginInstance();
900
+ replacement.Dispose();
901
+ var instanceField = typeof(UnityCopilotPluginEditor).GetField("instance", PrivateStatic);
902
+ instanceField!.SetValue(null, original);
903
+ var setCurrentPlugin = typeof(UnityCopilotPluginEditor).GetMethod("SetCurrentPlugin", PrivateStatic);
904
+ setCurrentPlugin!.Invoke(null, new object?[] { originalPlugin });
905
+ }
906
+
907
+ /// <summary>Bounded view of Editor state that a dry-run must not change.</summary>
908
+ private sealed class EditorSnapshot
909
+ {
910
+ public string Objects = string.Empty;
911
+ public string Dirty = string.Empty;
912
+ public int UndoGroup;
913
+ public string Files = string.Empty;
914
+
915
+ public static EditorSnapshot Capture(string? watchFolder)
916
+ {
917
+ var ids = new List<string>();
918
+ var dirty = new List<string>();
919
+ for (var index = 0; index < SceneManager.sceneCount; index++)
920
+ {
921
+ var scene = SceneManager.GetSceneAt(index);
922
+ if (!scene.IsValid() || !scene.isLoaded)
923
+ continue;
924
+ dirty.Add(scene.name + "=" + scene.isDirty);
925
+ foreach (var root in scene.GetRootGameObjects())
926
+ {
927
+ foreach (var transform in root.GetComponentsInChildren<Transform>(true))
928
+ {
929
+ var go = transform.gameObject;
930
+ ids.Add(ObjectIdText(go) + ":" + go.name + ":" + go.GetComponents<Component>().Length + ":" + (go.transform.parent == null ? "0" : ObjectIdText(go.transform.parent)));
931
+ }
932
+ }
933
+ }
934
+ ids.Sort(StringComparer.Ordinal);
935
+
936
+ var files = string.Empty;
937
+ if (watchFolder != null)
938
+ {
939
+ var full = Path.GetFullPath(watchFolder);
940
+ files = Directory.Exists(full)
941
+ ? string.Join("|", Directory.GetFiles(full, "*", SearchOption.AllDirectories)
942
+ .OrderBy(path => path, StringComparer.Ordinal)
943
+ .Select(path => Path.GetFileName(path) + ":" + new FileInfo(path).Length))
944
+ : "<missing>";
945
+ }
946
+
947
+ return new EditorSnapshot
948
+ {
949
+ Objects = string.Join("|", ids),
950
+ Dirty = string.Join("|", dirty),
951
+ UndoGroup = Undo.GetCurrentGroup(),
952
+ Files = files,
953
+ };
954
+ }
955
+
956
+ public void AssertEquals(EditorSnapshot expected, string context)
957
+ {
958
+ Assert.AreEqual(expected.Objects, Objects, context + ": object set/state changed");
959
+ Assert.AreEqual(expected.Dirty, Dirty, context + ": scene dirty flags changed");
960
+ Assert.AreEqual(expected.UndoGroup, UndoGroup, context + ": Undo group changed");
961
+ Assert.AreEqual(expected.Files, Files, context + ": project files changed");
962
+ }
963
+ }
964
+
965
+ private sealed class TestUnityCopilotPluginEditor : UnityCopilotPluginEditor
966
+ {
967
+ private readonly IReadOnlyList<IRunTool> _runners;
968
+
969
+ public TestUnityCopilotPluginEditor(params IRunTool[] runners)
970
+ {
971
+ _runners = runners;
972
+ ConnectionConfigForTests.KeepConnected = false;
973
+ }
974
+
975
+ protected override IUcoPlugin BuildUcoPlugin(
976
+ com.AtelierAI.Uco.Framework.Common.Version version,
977
+ Reflector reflector,
978
+ ILoggerProvider? loggerProvider = null,
979
+ Action<IUcoPluginBuilder>? configure = null)
980
+ {
981
+ return base.BuildUcoPlugin(
982
+ version,
983
+ reflector,
984
+ loggerProvider,
985
+ builder =>
986
+ {
987
+ configure?.Invoke(builder);
988
+ foreach (var runner in _runners)
989
+ builder.AddTool(runner.Name, runner);
990
+ });
991
+ }
992
+ }
993
+
994
+ /// <summary>
995
+ /// Test-only mutating runner: creates a real GameObject inside the
996
+ /// ambient transaction and then throws, modelling a tool that fails
997
+ /// after Unity accepted a mutation.
998
+ /// </summary>
999
+ private sealed class FailingCreateRunner : IRunTool
1000
+ {
1001
+ public const string VictimName = "tx-victim";
1002
+
1003
+ public string Name => "test-failing-create";
1004
+ public bool Enabled { get; set; } = true;
1005
+ public string? Title => Name;
1006
+ public string? Description => "Test-only failing authoring runner.";
1007
+ public MethodInfo? Method => null;
1008
+ public string? SkillDescription => null;
1009
+ public string? SkillBody => null;
1010
+ public JsonNode? InputSchema => new JsonObject();
1011
+ public JsonNode? OutputSchema => null;
1012
+ public UcoToolType ToolType => UcoToolType.Standard;
1013
+ public bool? ReadOnlyHint => false;
1014
+ public bool? DestructiveHint => null;
1015
+ public bool? IdempotentHint => null;
1016
+ public bool? OpenWorldHint => null;
1017
+ public AuthoringCapabilityDescriptor? AuthoringCapability { get; } = new AuthoringCapabilityDescriptor
1018
+ {
1019
+ MutationKind = AuthoringMutationKind.Create,
1020
+ UndoLevel = AuthoringUndoLevel.Full,
1021
+ SupportsValidation = true,
1022
+ SupportsPlanning = true,
1023
+ Validator = new UnityPilotAuthoringValidator(),
1024
+ Planner = new UnityPilotAuthoringPlanner(),
1025
+ TransactionFactory = new UnityAuthoringTransactionFactory(),
1026
+ };
1027
+ public int TokenCount => 0;
1028
+ public int Invocations { get; private set; }
1029
+
1030
+ public Task<ResponseCallTool> Run(
1031
+ string requestId,
1032
+ IReadOnlyDictionary<string, JsonElement>? namedParameters,
1033
+ CancellationToken cancellationToken = default)
1034
+ {
1035
+ Invocations++;
1036
+ return Task.FromResult(MainThread.Instance.Run(() =>
1037
+ {
1038
+ UnityAuthoringUndo.RequireAuthoringScope();
1039
+ var victim = new GameObject(VictimName);
1040
+ UnityAuthoringUndo.RecordCreated(victim);
1041
+ UnityAuthoringUndo.MarkMutated();
1042
+ throw new InvalidOperationException("failure after mutation");
1043
+ #pragma warning disable CS0162
1044
+ return ResponseCallTool.Success().SetRequestID(requestId);
1045
+ #pragma warning restore CS0162
1046
+ }));
1047
+ }
1048
+ }
1049
+ }
1050
+ }