@atelierai/uco 1.0.7 → 1.0.9

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 (107) hide show
  1. package/CHANGELOG.md +29 -8
  2. package/package.json +2 -2
  3. package/vendor/plugin/com.atelierai.unity.copilot/CHANGELOG.md +4 -4
  4. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetConfig.cs +132 -132
  5. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDependencyResolver.cs +237 -237
  6. package/vendor/plugin/com.atelierai.unity.copilot/Editor/DependencyResolver/NuGetDownloader.cs +82 -82
  7. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/MenuItems.cs +247 -247
  8. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Resource/ToolGroups.cs +153 -153
  9. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/SystemTool/Skills.Create.SkillBody.cs +155 -155
  10. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Console.GetLogs.cs +95 -95
  11. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Docs.HttpFetcher.cs +139 -139
  12. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Instance.ListAll.cs +72 -72
  13. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/PathReadHelper.cs +106 -106
  14. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Script.Validate.cs +252 -252
  15. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests/TestResultCollector.cs +537 -537
  16. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/Tests.cs +400 -400
  17. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/ToolGroups.SetEnabled.cs +92 -92
  18. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Branding/ProductInfo.cs +62 -62
  19. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +887 -1034
  20. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Skills/Skill_InitialSetup.cs +197 -197
  21. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +201 -201
  22. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.cs +67 -67
  23. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotListWindowBase.cs +393 -393
  24. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/CopilotWindowBase.cs +113 -113
  25. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.Connection.cs +3 -3
  26. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotFeatures.cs +195 -195
  27. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +22 -22
  28. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CreateGUI.cs +354 -354
  29. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.cs +78 -78
  30. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowInitializer.cs +47 -47
  31. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/SerializationCheckWindow.cs +190 -190
  32. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Build.cs +91 -91
  33. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Config.cs +298 -298
  34. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.Static.cs +296 -296
  35. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UnityCopilotPluginEditor.cs +69 -69
  36. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PackageUtils.cs +168 -168
  37. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/PrefsKeyMigration.cs +119 -119
  38. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/SafeDefaultsGuard.cs +180 -180
  39. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ScriptUtils.cs +229 -229
  40. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolGroupRegistry.cs +461 -460
  41. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolsManifestGenerator.cs +200 -200
  42. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringAdapters.cs +1467 -1467
  43. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityAuthoringSafetyReloadGuard.cs +65 -65
  44. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/UnityCopilotUpdateProjectSettings.cs +66 -66
  45. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotPromptsWindow.uss +11 -11
  46. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotResourcesWindow.uss +16 -16
  47. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotToolsWindow.uss +36 -36
  48. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/MainWindow.uss +183 -183
  49. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss → _copilot-list-window.uss} +18 -18
  50. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/_list-view.uss +77 -77
  51. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotPromptsWindow.uxml +18 -18
  52. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotResourcesWindow.uxml +18 -18
  53. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotToolsWindow.uxml +18 -18
  54. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/MainWindow.uxml +115 -115
  55. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
  56. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
  57. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
  58. package/vendor/plugin/com.atelierai.unity.copilot/README.md +88 -88
  59. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Converter/Json/Structures/EntityIdConverter.cs +120 -120
  60. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.cs +110 -110
  61. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Data/GameObjectMetadata.pre-Unity.6.5.cs +110 -110
  62. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +547 -547
  63. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Build.cs +243 -243
  64. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.Config.cs +3 -3
  65. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +295 -295
  66. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginBuilder.cs +105 -105
  67. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.Static.cs +155 -155
  68. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPluginRuntime.cs +59 -59
  69. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Utils/EnvironmentUtils.cs +11 -11
  70. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/AtomicApiTestFixtures.cs +99 -99
  71. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AtomicApi/TryModifyAtTests.cs +227 -227
  72. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/AuthoringSafetyPilotTests.cs +1050 -1050
  73. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorDevelopmentDependencyTests.cs +148 -148
  74. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorFlatLayoutTests.cs +166 -166
  75. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetInstallManifestTests.cs +436 -436
  76. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +304 -304
  77. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetPackageInstallerStaleVersionCleanupTests.cs +200 -200
  78. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +2 -2
  79. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/BlacklistTypeTests.cs +416 -416
  80. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.cs +172 -172
  81. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataFieldScriptableObjectPopulationTests.pre-Unity.6.5.cs +169 -169
  82. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.cs +182 -182
  83. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPopulationTests.pre-Unity.6.5.cs +179 -179
  84. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.cs +175 -175
  85. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyPopulationTests.pre-Unity.6.5.cs +174 -174
  86. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.cs +171 -171
  87. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ReflectionConverter/DataPropertyScriptableObjectPopulationTests.pre-Unity.6.5.cs +170 -170
  88. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsCreateFolderTests.cs +243 -243
  89. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsMaterialTests.cs +101 -101
  90. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.cs +348 -348
  91. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsPrefabCreateTests.pre-Unity.6.5.cs +348 -348
  92. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Assets/AssetsShaderTests.cs +194 -194
  93. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/GameObject/TestJsonSchema.cs +199 -199
  94. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.cs +573 -573
  95. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/PathBasedToolTests.pre-Unity.6.5.cs +513 -513
  96. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.cs +154 -154
  97. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotCameraTests.pre-Unity.6.5.cs +154 -154
  98. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotGameViewTests.cs +164 -164
  99. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/Screenshot/ScreenshotSceneViewTests.cs +155 -155
  100. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolParameterTests.cs +56 -56
  101. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.cs +651 -651
  102. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/ToolThreadSafetyTests.pre-Unity.6.5.cs +651 -651
  103. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/ToolBatchExecutionIntegrationTests.cs +407 -407
  104. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/{McpPluginTests.cs → UcoPluginTests.cs} +105 -105
  105. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  106. /package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss.meta → _copilot-list-window.uss.meta} +0 -0
  107. /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
+ }