diff --git a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
index 404fd9fe5d..678fdd0e9c 100644
--- a/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
+++ b/Assets/Tests/Editor/JsonRpcHeartbeatTests.cs
@@ -17,7 +17,7 @@ public class JsonRpcHeartbeatTests
public void CreateDispatchAcceptedResponse_WhenHeartbeatNegotiated_AdvertisesInterval()
{
// Tests that the dispatch ack tells a heartbeat-capable CLI which interval to expect.
- string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 10);
+ string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10);
JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
@@ -29,7 +29,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval()
{
// Tests that older CLIs that did not negotiate heartbeats get the legacy ack shape,
// because they would treat unexpected extra frames as the final response.
- string response = JsonRpcRequestProcessor.CreateDispatchAcceptedResponse(1, 0);
+ string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0);
JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Accepted));
@@ -40,7 +40,7 @@ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_OmitsInterval()
public void CreateHeartbeatResponse_WhenSerialized_CarriesPhaseAndStallSeconds()
{
// Tests the heartbeat frame shape the CLI parses for freeze diagnosis.
- string response = JsonRpcRequestProcessor.CreateHeartbeatResponse(7, 12.5);
+ string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5);
JObject parsed = JObject.Parse(response);
Assert.That(parsed["uloop"]["phase"].ToString(), Is.EqualTo(JsonRpcResponsePhases.Heartbeat));
diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs
new file mode 100644
index 0000000000..1726f83609
--- /dev/null
+++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs
@@ -0,0 +1,178 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using UnityEngine.TestTools;
+
+using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.CompositionRoot;
+using io.github.hatayama.UnityCliLoop.Domain;
+using io.github.hatayama.UnityCliLoop.Infrastructure;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Freezes the exact serialized JSON string for every JSON-RPC response shape
+ /// (success / error / dispatch-accepted / heartbeat / protocol-mismatch) before the
+ /// R3-5 move to JsonRpcResponseFactory, so the move can be verified byte-equal.
+ ///
+ public sealed class JsonRpcResponseFactoryWireShapeCharacterizationTests
+ {
+ [Test]
+ public async Task ProcessRequest_WhenToolSucceeds_ProducesFrozenSuccessJson()
+ {
+ // Verifies the success response wire shape is byte-equal to the frozen baseline.
+ UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools);
+ service.RegisterCustomTool(new DeterministicSuccessTool());
+ JsonRpcRequestProcessor processor = CreateProcessor(service);
+
+ string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"Success\":true}}"));
+ }
+
+ [Test]
+ public async Task ProcessRequest_WhenToolIsDisabled_ProducesFrozenErrorJson()
+ {
+ // Verifies the internal_error response wire shape is byte-equal to the frozen baseline.
+ InMemoryToolSettingsPort toolSettingsPort = new();
+ toolSettingsPort.SetToolEnabled(DeterministicSuccessTool.Name, false);
+ UnityCliLoopToolRegistrarService service = new UnityCliLoopToolRegistrarService(
+ new EmptyInternalToolNameProvider(),
+ toolSettingsPort,
+ new UnityCliLoopToolExecutionService(new NoOpEditorRuntimeStatePort()),
+ UnityCliLoopToolDiscovery.DiscoverTools);
+ service.RegisterCustomTool(new DeterministicSuccessTool());
+ JsonRpcRequestProcessor processor = CreateProcessor(service);
+
+ LogAssert.Expect(UnityEngine.LogType.Error, new System.Text.RegularExpressions.Regex(
+ @"\[JsonRpcRequestProcessor\] Error: Tool 'deterministic-success' is disabled"));
+ string response = await processor.ProcessRequest(BuildToolRequest(DeterministicSuccessTool.Name, 1), CancellationToken.None);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"Tool 'deterministic-success' is disabled. To enable it, go to Window > Unity CLI Loop > Settings\",\"data\":{\"type\":\"internal_error\",\"message\":\"This tool has been disabled in project settings.\"}}}"));
+ }
+
+ [Test]
+ public void CreateDispatchAcceptedResponse_WithHeartbeatNegotiated_ProducesFrozenJson()
+ {
+ // Verifies the dispatch-accepted-with-heartbeat wire shape is byte-equal to the frozen baseline.
+ string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 10);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\",\"heartbeatIntervalSeconds\":10}}"));
+ }
+
+ [Test]
+ public void CreateDispatchAcceptedResponse_WithoutHeartbeat_ProducesFrozenJson()
+ {
+ // Verifies the dispatch-accepted-without-heartbeat wire shape is byte-equal to the frozen baseline.
+ string response = JsonRpcResponseFactory.CreateDispatchAcceptedResponse(1, 0);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"accepted\":true},\"uloop\":{\"phase\":\"accepted\"}}"));
+ }
+
+ [Test]
+ public void CreateHeartbeatResponse_WhenSerialized_ProducesFrozenJson()
+ {
+ // Verifies the heartbeat frame wire shape is byte-equal to the frozen baseline.
+ string response = JsonRpcResponseFactory.CreateHeartbeatResponse(7, 12.5);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"alive\":true},\"uloop\":{\"phase\":\"heartbeat\",\"mainThreadStallSeconds\":12.5}}"));
+ }
+
+ [Test]
+ public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMismatchJson()
+ {
+ // Verifies the CLI-update-required wire shape is byte-equal to the frozen baseline.
+ UnityCliLoopToolRegistrarService service = UnityCliLoopToolRegistrarTestFactory.Create(UnityCliLoopToolDiscovery.DiscoverTools);
+ JsonRpcRequestProcessor processor = CreateProcessor(service);
+
+ string request =
+ "{\"jsonrpc\":\"2.0\",\"method\":\"get-version\",\"params\":{},\"id\":1,\"uloop\":{\"protocolVersion\":" +
+ (CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) +
+ "}}";
+ string response = await processor.ProcessRequest(request, CancellationToken.None);
+
+ Assert.That(response, Is.EqualTo(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32603,\"message\":\"The installed uloop CLI uses an IPC protocol that does not match this Unity package.\",\"data\":{\"type\":\"cli_update_required\",\"currentCliVersion\":null,\"currentProtocolVersion\":" +
+ (CliConstants.REQUIRED_CLI_PROTOCOL_VERSION - 1) +
+ ",\"requiredProtocolVersion\":" +
+ CliConstants.REQUIRED_CLI_PROTOCOL_VERSION +
+ ",\"updateCommand\":\"uloop update\",\"retryableAfterUpdate\":true,\"message\":\"Install matching uloop CLI and Unity package versions, then retry the original command.\"}}}"));
+ }
+
+ private static JsonRpcRequestProcessor CreateProcessor(UnityCliLoopToolRegistrarService service)
+ {
+ UnityCliLoopExecutionRouter executionRouter = new(service);
+ return new JsonRpcRequestProcessor(executionRouter);
+ }
+
+ private static string BuildToolRequest(string toolName, int id)
+ {
+ return
+ "{\"jsonrpc\":\"2.0\",\"method\":\"" +
+ toolName +
+ "\",\"params\":{},\"id\":" +
+ id +
+ ",\"uloop\":{\"protocolVersion\":" +
+ CliConstants.REQUIRED_CLI_PROTOCOL_VERSION +
+ "}}";
+ }
+
+ private sealed class DeterministicSuccessResponse : UnityCliLoopToolResponse
+ {
+ public bool Success { get; set; } = true;
+ }
+
+ private sealed class DeterministicSuccessTool : IUnityCliLoopTool
+ {
+ public const string Name = "deterministic-success";
+
+ public string ToolName => Name;
+
+ public ToolParameterSchema ParameterSchema => new();
+
+ public Task ExecuteAsync(JToken paramsToken, CancellationToken ct)
+ {
+ return Task.FromResult(new DeterministicSuccessResponse());
+ }
+ }
+
+ private sealed class InMemoryToolSettingsPort : IToolSettingsPort
+ {
+ private readonly System.Collections.Generic.HashSet _disabledTools = new();
+
+ public bool IsToolEnabled(string toolName)
+ {
+ return !_disabledTools.Contains(toolName);
+ }
+
+ public void SetToolEnabled(string toolName, bool enabled)
+ {
+ if (enabled)
+ {
+ _disabledTools.Remove(toolName);
+ return;
+ }
+
+ _disabledTools.Add(toolName);
+ }
+
+ public string[] GetDisabledTools()
+ {
+ string[] disabledTools = new string[_disabledTools.Count];
+ _disabledTools.CopyTo(disabledTools);
+ return disabledTools;
+ }
+
+ public void InvalidateCache()
+ {
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta
new file mode 100644
index 0000000000..7793dc1cb9
--- /dev/null
+++ b/Assets/Tests/Editor/JsonRpcResponseFactoryWireShapeCharacterizationTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2cfa7916fff33439a949c8bf4b74c22c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs
new file mode 100644
index 0000000000..541c0abd6e
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs
@@ -0,0 +1,36 @@
+using Newtonsoft.Json;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Carries protocol mismatch details plus optional CLI update instructions.
+ ///
+ public class CliUpdateRequiredErrorData : JsonRpcErrorData
+ {
+ public override string type => JsonRpcErrorTypes.CliUpdateRequired;
+
+ public string currentCliVersion { get; }
+
+ public int? currentProtocolVersion { get; }
+
+ public int requiredProtocolVersion { get; }
+
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public string updateCommand { get; }
+
+ public bool retryableAfterUpdate { get; }
+
+ public CliUpdateRequiredErrorData(
+ string currentCliVersion,
+ int? currentProtocolVersion,
+ int requiredProtocolVersion,
+ string updateCommand) : base("Install matching uloop CLI and Unity package versions, then retry the original command.")
+ {
+ this.currentCliVersion = string.IsNullOrWhiteSpace(currentCliVersion) ? null : currentCliVersion;
+ this.currentProtocolVersion = currentProtocolVersion;
+ this.requiredProtocolVersion = requiredProtocolVersion;
+ this.updateCommand = updateCommand;
+ retryableAfterUpdate = true;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta
new file mode 100644
index 0000000000..1e26320da6
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/CliUpdateRequiredErrorData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3cc2486f961ea48df8310a6e0a6508a8
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs
new file mode 100644
index 0000000000..60cd0fef99
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs
@@ -0,0 +1,14 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Error data for internal errors
+ ///
+ public class InternalErrorData : JsonRpcErrorData
+ {
+ public override string type => JsonRpcErrorTypes.InternalError;
+
+ public InternalErrorData(string message) : base(message)
+ {
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta
new file mode 100644
index 0000000000..8a9592c9ec
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/InternalErrorData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bf9c3c0f066f8433fb6c66f6f7666ae4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs
new file mode 100644
index 0000000000..60558ea589
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs
@@ -0,0 +1,21 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// JSON-RPC error object
+ ///
+ public class JsonRpcError
+ {
+ public int code { get; }
+
+ public string message { get; }
+
+ public JsonRpcErrorData data { get; }
+
+ public JsonRpcError(int code, string message, JsonRpcErrorData data)
+ {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta
new file mode 100644
index 0000000000..2b99f5b12e
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcError.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6642755ac33b843ad8b76cf7d190b473
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs
new file mode 100644
index 0000000000..5d95a0429b
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs
@@ -0,0 +1,17 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Base class for JSON-RPC error data
+ ///
+ public abstract class JsonRpcErrorData
+ {
+ public abstract string type { get; }
+
+ public string message { get; protected set; }
+
+ protected JsonRpcErrorData(string message)
+ {
+ this.message = message;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta
new file mode 100644
index 0000000000..9b72792f1b
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8e947996c8660428fbf126c017e3a21c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs
new file mode 100644
index 0000000000..c19f736761
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs
@@ -0,0 +1,21 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// JSON-RPC error response
+ ///
+ public class JsonRpcErrorResponse
+ {
+ public string jsonrpc { get; }
+
+ public object id { get; }
+
+ public JsonRpcError error { get; }
+
+ public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error)
+ {
+ this.jsonrpc = jsonRpc;
+ this.id = id;
+ this.error = error;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta
new file mode 100644
index 0000000000..3235dbc151
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorResponse.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9fa02e1af45b74779be19677b0effd8f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs
new file mode 100644
index 0000000000..a9796d39f2
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs
@@ -0,0 +1,13 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Constants for JSON-RPC error types
+ ///
+ public static class JsonRpcErrorTypes
+ {
+ public const string SecurityBlocked = "security_blocked";
+ public const string InternalError = "internal_error";
+ public const string CliUpdateRequired = "cli_update_required";
+ public const string ServerBusy = "server_busy";
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta
new file mode 100644
index 0000000000..dac7e1894f
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcErrorTypes.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c51fd9b4f0b1f4afd8044c7aab0f1422
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
index ff855598ac..f165f2788a 100644
--- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs
@@ -33,11 +33,6 @@ internal sealed class JsonRpcRequestProcessor
{
private readonly UnityCliLoopExecutionRouter _executionRouter;
- // Shared by every response path; JsonConvert only reads the settings, so a single
- // instance avoids allocating identical settings per response.
- private static readonly JsonSerializerSettings ResponseSerializerSettings =
- JsonRpcResponseSerializer.Settings;
-
internal delegate Task JsonRpcEarlyResponseWriter(
string responseJson,
bool cancelOnClientDisconnect,
@@ -79,11 +74,11 @@ internal async Task ProcessRequestWithEarlyResponseAsync(
}
catch (JsonReaderException ex)
{
- return CreateErrorResponse(null, ex);
+ return JsonRpcResponseFactory.CreateErrorResponse(null, ex);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
- return CreateErrorResponse(null, ex);
+ return JsonRpcResponseFactory.CreateErrorResponse(null, ex);
}
}
@@ -144,7 +139,7 @@ private async Task ProcessRpcRequest(
ct.ThrowIfCancellationRequested();
if (IsCliProtocolMismatch(request.ClientProtocolVersion))
{
- return CreateCliProtocolMismatchResponse(
+ return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse(
request.Id,
request.ClientProjectRunnerVersion,
request.ClientProtocolVersion);
@@ -156,12 +151,12 @@ private async Task ProcessRpcRequest(
? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS
: 0;
Func createHeartbeatJson = request.AcceptsHeartbeat
- ? () => CreateHeartbeatResponse(
+ ? () => JsonRpcResponseFactory.CreateHeartbeatResponse(
request.Id,
EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick())
: null;
await earlyResponseWriter(
- CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds),
+ JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds),
ShouldCancelAcceptedRequestOnClientDisconnect(request),
createHeartbeatJson);
}
@@ -172,30 +167,30 @@ await earlyResponseWriter(
UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct);
executeMethodStopwatch.Stop();
- AppendTimingIfRequested(
+ JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms");
- AppendTimingIfRequested(
+ JsonRpcResponseFactory.AppendTimingIfRequested(
result,
$"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms");
- string response = CreateSuccessResponse(request.Id, result);
+ string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result);
return response;
}
catch (JsonSerializationException ex)
{
UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}");
- return CreateErrorResponse(request.Id, ex);
+ return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (UnityCliLoopToolParameterValidationException ex)
{
LogUnityCliLoopToolParameterValidationException(ex);
- return CreateErrorResponse(request.Id, ex);
+ return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
catch (Exception ex) when (!(ex is OperationCanceledException))
{
LogRpcExceptionIfNeeded(ex);
- return CreateErrorResponse(request.Id, ex);
+ return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex);
}
}
@@ -230,193 +225,12 @@ private static bool IsCliProtocolMismatch(int? currentProtocolVersion)
return currentProtocolVersion.Value != CliConstants.REQUIRED_CLI_PROTOCOL_VERSION;
}
- private static string CreateCliProtocolMismatchResponse(
- object id,
- string currentCliVersion,
- int? currentProtocolVersion)
- {
- JsonRpcErrorResponse errorResponse = new(
- UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- new JsonRpcError(
- UnityCliLoopServerConfig.INTERNAL_ERROR_CODE,
- "The installed uloop CLI uses an IPC protocol that does not match this Unity package.",
- new CliUpdateRequiredErrorData(
- currentCliVersion,
- currentProtocolVersion,
- CliConstants.REQUIRED_CLI_PROTOCOL_VERSION,
- GetCliUpdateCommandForProtocolMismatch(currentProtocolVersion))));
-
- return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings);
- }
-
- private static string GetCliUpdateCommandForProtocolMismatch(int? currentProtocolVersion)
- {
- if (currentProtocolVersion == null)
- {
- return CreateCliUpdateCommand();
- }
-
- if (currentProtocolVersion.Value < CliConstants.REQUIRED_CLI_PROTOCOL_VERSION)
- {
- return CreateCliUpdateCommand();
- }
-
- return null;
- }
-
- private static string CreateCliUpdateCommand()
- {
- return $"{CliConstants.EXECUTABLE_NAME} update";
- }
-
- internal static string CreateDispatchAcceptedResponse(object id, int heartbeatIntervalSeconds)
- {
- // heartbeatIntervalSeconds is only advertised when the client negotiated
- // heartbeats; older CLIs would treat unexpected extra frames as the final
- // response, so the field doubles as the negotiation answer.
- object uloopMetadata = heartbeatIntervalSeconds > 0
- ? new
- {
- phase = JsonRpcResponsePhases.Accepted,
- heartbeatIntervalSeconds
- }
- : (object)new
- {
- phase = JsonRpcResponsePhases.Accepted
- };
-
- object response = new
- {
- jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- result = new
- {
- accepted = true
- },
- uloop = uloopMetadata
- };
-
- return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
- }
-
- internal static string CreateHeartbeatResponse(object id, double mainThreadStallSeconds)
- {
- object response = new
- {
- jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- result = new
- {
- alive = true
- },
- uloop = new
- {
- phase = JsonRpcResponsePhases.Heartbeat,
- mainThreadStallSeconds
- }
- };
-
- return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
- }
-
- private static void AppendTimingIfRequested(UnityCliLoopToolResponse result, string timing)
- {
- if (result is not IUnityCliLoopTimingResponse timingResponse)
- {
- return;
- }
-
- if (!timingResponse.EmitsTimingsInJsonResponse)
- {
- return;
- }
-
- timingResponse.AddTiming(timing);
- }
-
private static void LogUnityCliLoopToolParameterValidationException(UnityCliLoopToolParameterValidationException exception)
{
UnityEngine.Debug.LogError(
$"[JsonRpcRequestProcessor] Parameter validation error: {exception.Message}\nStack trace: {exception.StackTrace}");
}
- ///
- /// Create JSON-RPC success response
- ///
- /// Request ID - must be same type as received (string/number/null per JSON-RPC spec)
- /// Command execution result
- private static string CreateSuccessResponse(object id, UnityCliLoopToolResponse result)
- {
- try
- {
- JsonRpcSuccessResponse response = new(
- UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- result
- );
- return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
- }
- catch (Exception)
- {
- // Return safe fallback response for any serialization errors
- object fallbackResult = new
- {
- error = "Serialization failed - returning safe fallback",
- commandType = result != null ? result.GetType().Name : "unknown"
- };
-
- JsonRpcSuccessResponse fallbackResponse = new(
- UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- fallbackResult
- );
- return JsonConvert.SerializeObject(fallbackResponse, Formatting.None, ResponseSerializerSettings);
- }
- }
-
- ///
- /// Create JSON-RPC error response
- ///
- /// Request ID - must be same type as received (string/number/null per JSON-RPC spec)
- /// Exception to convert to error response
- private static string CreateErrorResponse(object id, Exception ex)
- {
- // Centralize exception -> user-facing message via UserFriendlyErrorConverter
- UserFriendlyErrorConverter handler = new();
- UserFriendlyErrorDto exceptionResponse = handler.ProcessException(ex);
-
- // Map UserFriendlyErrorDto to JsonRpcError
- string errorMessage = exceptionResponse.FriendlyMessage;
-
- JsonRpcErrorData errorData;
- if (ex is UnityCliLoopSecurityException secEx)
- {
- errorData = new SecurityBlockedErrorData(secEx.ToolName, secEx.SecurityReason, exceptionResponse.Explanation ?? ex.Message);
- }
- else if (ex is UnityCliLoopToolBusyException busyEx)
- {
- errorData = new ServerBusyErrorData(
- busyEx.RunningToolName,
- busyEx.RequestedToolName,
- busyEx.IsPlaying,
- busyEx.IsPaused,
- exceptionResponse.Explanation ?? ex.Message);
- }
- else
- {
- errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message);
- }
-
- JsonRpcErrorResponse errorResponse = new(
- UnityCliLoopServerConfig.JSONRPC_VERSION,
- id,
- new JsonRpcError(UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, errorMessage, errorData)
- );
-
- return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings);
- }
-
///
/// Execute appropriate handler according to method name
/// Use new command-based structure
@@ -429,187 +243,5 @@ private async Task ExecuteMethod(
return await _executionRouter.ExecuteAsync(method, paramsToken, ct);
}
}
+}
- ///
- /// Constants for JSON-RPC error types
- ///
- public static class JsonRpcErrorTypes
- {
- public const string SecurityBlocked = "security_blocked";
- public const string InternalError = "internal_error";
- public const string CliUpdateRequired = "cli_update_required";
- public const string ServerBusy = "server_busy";
- }
-
- public static class JsonRpcResponsePhases
- {
- public const string Accepted = "accepted";
- public const string Heartbeat = "heartbeat";
- }
-
- ///
- /// Base class for JSON-RPC error data
- ///
- public abstract class JsonRpcErrorData
- {
- public abstract string type { get; }
-
- public string message { get; protected set; }
-
- protected JsonRpcErrorData(string message)
- {
- this.message = message;
- }
- }
-
- ///
- /// Error data for security blocked commands
- ///
- public class SecurityBlockedErrorData : JsonRpcErrorData
- {
- public override string type => JsonRpcErrorTypes.SecurityBlocked;
-
- public string command { get; }
-
- public string reason { get; }
-
- public SecurityBlockedErrorData(string command, string reason, string message) : base(message)
- {
- this.command = command;
- this.reason = reason;
- }
- }
-
- ///
- /// Error data for internal errors
- ///
- public class InternalErrorData : JsonRpcErrorData
- {
- public override string type => JsonRpcErrorTypes.InternalError;
-
- public InternalErrorData(string message) : base(message)
- {
- }
- }
-
- ///
- /// Keeps busy responses machine-readable so CLI clients can classify them as retryable.
- ///
- public class ServerBusyErrorData : JsonRpcErrorData
- {
- public override string type => JsonRpcErrorTypes.ServerBusy;
-
- public string runningToolName { get; }
-
- public string requestedToolName { get; }
-
- [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
- public bool? isPlaying { get; }
-
- [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
- public bool? isPaused { get; }
-
- public ServerBusyErrorData(
- string runningToolName,
- string requestedToolName,
- bool? isPlaying,
- bool? isPaused,
- string message)
- : base(message)
- {
- this.runningToolName = runningToolName;
- this.requestedToolName = requestedToolName;
- this.isPlaying = isPlaying;
- this.isPaused = isPaused;
- }
- }
-
- ///
- /// Carries protocol mismatch details plus optional CLI update instructions.
- ///
- public class CliUpdateRequiredErrorData : JsonRpcErrorData
- {
- public override string type => JsonRpcErrorTypes.CliUpdateRequired;
-
- public string currentCliVersion { get; }
-
- public int? currentProtocolVersion { get; }
-
- public int requiredProtocolVersion { get; }
-
- [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
- public string updateCommand { get; }
-
- public bool retryableAfterUpdate { get; }
-
- public CliUpdateRequiredErrorData(
- string currentCliVersion,
- int? currentProtocolVersion,
- int requiredProtocolVersion,
- string updateCommand) : base("Install matching uloop CLI and Unity package versions, then retry the original command.")
- {
- this.currentCliVersion = string.IsNullOrWhiteSpace(currentCliVersion) ? null : currentCliVersion;
- this.currentProtocolVersion = currentProtocolVersion;
- this.requiredProtocolVersion = requiredProtocolVersion;
- this.updateCommand = updateCommand;
- retryableAfterUpdate = true;
- }
- }
-
- ///
- /// JSON-RPC error object
- ///
- public class JsonRpcError
- {
- public int code { get; }
-
- public string message { get; }
-
- public JsonRpcErrorData data { get; }
-
- public JsonRpcError(int code, string message, JsonRpcErrorData data)
- {
- this.code = code;
- this.message = message;
- this.data = data;
- }
- }
-
- ///
- /// JSON-RPC success response
- ///
- public class JsonRpcSuccessResponse
- {
- public string jsonrpc { get; }
-
- public object id { get; }
-
- public object result { get; }
-
- public JsonRpcSuccessResponse(string jsonRpc, object id, object result)
- {
- this.jsonrpc = jsonRpc;
- this.id = id;
- this.result = result;
- }
- }
-
- ///
- /// JSON-RPC error response
- ///
- public class JsonRpcErrorResponse
- {
- public string jsonrpc { get; }
-
- public object id { get; }
-
- public JsonRpcError error { get; }
-
- public JsonRpcErrorResponse(string jsonRpc, object id, JsonRpcError error)
- {
- this.jsonrpc = jsonRpc;
- this.id = id;
- this.error = error;
- }
- }
-}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs
new file mode 100644
index 0000000000..24f692b03c
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs
@@ -0,0 +1,202 @@
+using System;
+using Newtonsoft.Json;
+
+using io.github.hatayama.UnityCliLoop.Application;
+using io.github.hatayama.UnityCliLoop.Domain;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Owns the wire shape of every JSON-RPC response frame: success, error,
+ /// dispatch-accepted, heartbeat, and CLI protocol mismatch.
+ ///
+ internal static class JsonRpcResponseFactory
+ {
+ // Shared by every response path; JsonConvert only reads the settings, so a single
+ // instance avoids allocating identical settings per response.
+ private static readonly JsonSerializerSettings ResponseSerializerSettings =
+ JsonRpcResponseSerializer.Settings;
+
+ ///
+ /// Create JSON-RPC success response
+ ///
+ /// Request ID - must be same type as received (string/number/null per JSON-RPC spec)
+ /// Command execution result
+ internal static string CreateSuccessResponse(object id, UnityCliLoopToolResponse result)
+ {
+ try
+ {
+ JsonRpcSuccessResponse response = new(
+ UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ result
+ );
+ return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
+ }
+ catch (Exception)
+ {
+ // Return safe fallback response for any serialization errors
+ object fallbackResult = new
+ {
+ error = "Serialization failed - returning safe fallback",
+ commandType = result != null ? result.GetType().Name : "unknown"
+ };
+
+ JsonRpcSuccessResponse fallbackResponse = new(
+ UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ fallbackResult
+ );
+ return JsonConvert.SerializeObject(fallbackResponse, Formatting.None, ResponseSerializerSettings);
+ }
+ }
+
+ ///
+ /// Create JSON-RPC error response
+ ///
+ /// Request ID - must be same type as received (string/number/null per JSON-RPC spec)
+ /// Exception to convert to error response
+ internal static string CreateErrorResponse(object id, Exception ex)
+ {
+ // Centralize exception -> user-facing message via UserFriendlyErrorConverter
+ UserFriendlyErrorConverter handler = new();
+ UserFriendlyErrorDto exceptionResponse = handler.ProcessException(ex);
+
+ // Map UserFriendlyErrorDto to JsonRpcError
+ string errorMessage = exceptionResponse.FriendlyMessage;
+
+ JsonRpcErrorData errorData;
+ if (ex is UnityCliLoopSecurityException secEx)
+ {
+ errorData = new SecurityBlockedErrorData(secEx.ToolName, secEx.SecurityReason, exceptionResponse.Explanation ?? ex.Message);
+ }
+ else if (ex is UnityCliLoopToolBusyException busyEx)
+ {
+ errorData = new ServerBusyErrorData(
+ busyEx.RunningToolName,
+ busyEx.RequestedToolName,
+ busyEx.IsPlaying,
+ busyEx.IsPaused,
+ exceptionResponse.Explanation ?? ex.Message);
+ }
+ else
+ {
+ errorData = new InternalErrorData(exceptionResponse.Explanation ?? ex.Message);
+ }
+
+ JsonRpcErrorResponse errorResponse = new(
+ UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ new JsonRpcError(UnityCliLoopServerConfig.INTERNAL_ERROR_CODE, errorMessage, errorData)
+ );
+
+ return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings);
+ }
+
+ internal static string CreateDispatchAcceptedResponse(object id, int heartbeatIntervalSeconds)
+ {
+ // heartbeatIntervalSeconds is only advertised when the client negotiated
+ // heartbeats; older CLIs would treat unexpected extra frames as the final
+ // response, so the field doubles as the negotiation answer.
+ object uloopMetadata = heartbeatIntervalSeconds > 0
+ ? new
+ {
+ phase = JsonRpcResponsePhases.Accepted,
+ heartbeatIntervalSeconds
+ }
+ : (object)new
+ {
+ phase = JsonRpcResponsePhases.Accepted
+ };
+
+ object response = new
+ {
+ jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ result = new
+ {
+ accepted = true
+ },
+ uloop = uloopMetadata
+ };
+
+ return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
+ }
+
+ internal static string CreateHeartbeatResponse(object id, double mainThreadStallSeconds)
+ {
+ object response = new
+ {
+ jsonrpc = UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ result = new
+ {
+ alive = true
+ },
+ uloop = new
+ {
+ phase = JsonRpcResponsePhases.Heartbeat,
+ mainThreadStallSeconds
+ }
+ };
+
+ return JsonConvert.SerializeObject(response, Formatting.None, ResponseSerializerSettings);
+ }
+
+ internal static string CreateCliProtocolMismatchResponse(
+ object id,
+ string currentCliVersion,
+ int? currentProtocolVersion)
+ {
+ JsonRpcErrorResponse errorResponse = new(
+ UnityCliLoopServerConfig.JSONRPC_VERSION,
+ id,
+ new JsonRpcError(
+ UnityCliLoopServerConfig.INTERNAL_ERROR_CODE,
+ "The installed uloop CLI uses an IPC protocol that does not match this Unity package.",
+ new CliUpdateRequiredErrorData(
+ currentCliVersion,
+ currentProtocolVersion,
+ CliConstants.REQUIRED_CLI_PROTOCOL_VERSION,
+ GetCliUpdateCommandForProtocolMismatch(currentProtocolVersion))));
+
+ return JsonConvert.SerializeObject(errorResponse, Formatting.None, ResponseSerializerSettings);
+ }
+
+ private static string GetCliUpdateCommandForProtocolMismatch(int? currentProtocolVersion)
+ {
+ if (currentProtocolVersion == null)
+ {
+ return CreateCliUpdateCommand();
+ }
+
+ if (currentProtocolVersion.Value < CliConstants.REQUIRED_CLI_PROTOCOL_VERSION)
+ {
+ return CreateCliUpdateCommand();
+ }
+
+ return null;
+ }
+
+ private static string CreateCliUpdateCommand()
+ {
+ return $"{CliConstants.EXECUTABLE_NAME} update";
+ }
+
+ internal static void AppendTimingIfRequested(UnityCliLoopToolResponse result, string timing)
+ {
+ if (result is not IUnityCliLoopTimingResponse timingResponse)
+ {
+ return;
+ }
+
+ if (!timingResponse.EmitsTimingsInJsonResponse)
+ {
+ return;
+ }
+
+ timingResponse.AddTiming(timing);
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta
new file mode 100644
index 0000000000..99fc9ab5ad
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponseFactory.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 72860040883c048c78cb67ccb8d5c39f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs
new file mode 100644
index 0000000000..0b1dc1dbbb
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs
@@ -0,0 +1,8 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ public static class JsonRpcResponsePhases
+ {
+ public const string Accepted = "accepted";
+ public const string Heartbeat = "heartbeat";
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta
new file mode 100644
index 0000000000..97faeea3ee
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcResponsePhases.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0526adfb147024f46af583be80987102
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs
new file mode 100644
index 0000000000..cb2d1fdec1
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs
@@ -0,0 +1,21 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// JSON-RPC success response
+ ///
+ public class JsonRpcSuccessResponse
+ {
+ public string jsonrpc { get; }
+
+ public object id { get; }
+
+ public object result { get; }
+
+ public JsonRpcSuccessResponse(string jsonRpc, object id, object result)
+ {
+ this.jsonrpc = jsonRpc;
+ this.id = id;
+ this.result = result;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta
new file mode 100644
index 0000000000..cf54458031
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcSuccessResponse.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d9be8bf4a8ee444efa1adc14ccdf0794
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs
new file mode 100644
index 0000000000..348cdea782
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs
@@ -0,0 +1,20 @@
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Error data for security blocked commands
+ ///
+ public class SecurityBlockedErrorData : JsonRpcErrorData
+ {
+ public override string type => JsonRpcErrorTypes.SecurityBlocked;
+
+ public string command { get; }
+
+ public string reason { get; }
+
+ public SecurityBlockedErrorData(string command, string reason, string message) : base(message)
+ {
+ this.command = command;
+ this.reason = reason;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta
new file mode 100644
index 0000000000..9dfc71b9fe
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/SecurityBlockedErrorData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 431d943a1f3f746279383089fbb7d729
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs
new file mode 100644
index 0000000000..bae0e015d1
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs
@@ -0,0 +1,36 @@
+using Newtonsoft.Json;
+
+namespace io.github.hatayama.UnityCliLoop.Infrastructure
+{
+ ///
+ /// Keeps busy responses machine-readable so CLI clients can classify them as retryable.
+ ///
+ public class ServerBusyErrorData : JsonRpcErrorData
+ {
+ public override string type => JsonRpcErrorTypes.ServerBusy;
+
+ public string runningToolName { get; }
+
+ public string requestedToolName { get; }
+
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public bool? isPlaying { get; }
+
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public bool? isPaused { get; }
+
+ public ServerBusyErrorData(
+ string runningToolName,
+ string requestedToolName,
+ bool? isPlaying,
+ bool? isPaused,
+ string message)
+ : base(message)
+ {
+ this.runningToolName = runningToolName;
+ this.requestedToolName = requestedToolName;
+ this.isPlaying = isPlaying;
+ this.isPaused = isPaused;
+ }
+ }
+}
diff --git a/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta
new file mode 100644
index 0000000000..1ed2503469
--- /dev/null
+++ b/Packages/src/Editor/Infrastructure/Api/ServerBusyErrorData.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cd20357254e574ceabfbf2aa84f2a975
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: