Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/uloop-control-play-mode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uloop control-play-mode [options]

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` |
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) |
| `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state |

## Output
Expand All @@ -37,6 +37,7 @@ Returns JSON with the current play mode state:
- Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true`
- `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything.
- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted.
- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped.
- `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running
- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry.
- Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test.
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/uloop-control-play-mode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uloop control-play-mode [options]

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` |
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) |
| `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state |

## Output
Expand All @@ -37,6 +37,7 @@ Returns JSON with the current play mode state:
- Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true`
- `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything.
- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted.
- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped.
- `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running
- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry.
- Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test.
Expand Down
30 changes: 30 additions & 0 deletions Assets/Tests/Editor/ControlPlayModeUseCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,36 @@ public async Task ExecuteAsync_WhenStatusOnlyPlayBlockedByCompileErrors_ReturnsS
Assert.That(response.Message, Is.EqualTo("Play mode could not start because Unity has compiler errors."));
}

[Test]
public async Task ExecuteAsync_WhenStatusOnlyResumeBlockedByCompileErrors_ReturnsSavedDiagnostics()
{
// Verifies Resume is treated as a Play alias for StatusOnly compile-error blocking.
Assert.That(EditorApplication.isPlaying, Is.False);
ControlPlayModeCompileError[] compileErrors =
{
new ControlPlayModeCompileError
{
Message = "CS1525: invalid expression",
File = "Assets/Scripts/Sample.cs",
Line = 3
}
};
ControlPlayModeUseCase useCase = new ControlPlayModeUseCase(
new StubCompilationFailureProvider(compileErrors),
new StubCompilationFailureGate(true));
ControlPlayModeSchema schema = new ControlPlayModeSchema
{
Action = PlayModeAction.Resume,
StatusOnly = true,
};

ControlPlayModeResponse response = await useCase.ExecuteAsync(schema, CancellationToken.None);

Assert.That(response.BlockedByCompileErrors, Is.True);
Assert.That(response.CompileErrorCount, Is.EqualTo(1));
Assert.That(response.Message, Is.EqualTo("Play mode could not start because Unity has compiler errors."));
}

[Test]
public async Task ExecuteAsync_WhenStatusOnlyStopAndCompileFailed_ReturnsCurrentPlayModeState()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ internal UnityCliLoopApplicationServices Register()
firstPartyServerLifecycle);
UnityCliLoopServerApplicationService applicationService = new(controllerService);
controllerService.InitializeForEditorStartup();
ToolCatalogCachePublisher.Publish(toolRegistrarService);

return new UnityCliLoopApplicationServices(
domainReloadDetectionService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum PlayModeAction
Stop = 1,
Pause = 2,
Step = 3,
Status = 4
Status = 4,
Resume = 5
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ private ControlPlayModeActionResult ExecuteRequestedPlayModeAction(PlayModeActio
switch (action)
{
case PlayModeAction.Play:
case PlayModeAction.Resume:
return ExecutePlayModeStart(wasPaused, wasPlaying);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

case PlayModeAction.Stop:
Expand Down Expand Up @@ -138,7 +139,9 @@ private ControlPlayModeActionResult CreateStatusActionResult()

private bool ShouldBlockPlayForCompileErrors(PlayModeAction action, bool isPlaying)
{
return action == PlayModeAction.Play &&
// Why Resume too: it is a Play alias, including for StatusOnly IPC probes that bypass CLI wait normalization.
bool isPlayOrResume = action == PlayModeAction.Play || action == PlayModeAction.Resume;
return isPlayOrResume &&
!isPlaying &&
_compilationFailureGate.HasScriptCompilationFailed();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uloop control-play-mode [options]

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status` |
| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) |
| `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state |

## Output
Expand All @@ -37,6 +37,7 @@ Returns JSON with the current play mode state:
- Stop on an already-stopped Editor sets `Changed: false`, `WasAlreadyStopped: true`
- `Play` on an Editor that is already playing is a no-op: it sets `Changed: false` and leaves the current session (its accumulated state, spawned objects, progress) untouched instead of restarting it. If you need a clean state for verification, explicitly `Stop` then `Play` rather than relying on `Play` alone to reset anything.
- `Play` while Play Mode is paused resumes the same session: it sets `Changed: true`, `ResumedFromPause: true`, and `Message: "Play mode resumed"` — the session is not restarted.
- `Resume` is an alias of `Play`, accepted because pause-point workflows describe this operation as "resume" (`--resume-play`, "Clearing resumes Play Mode"). It behaves identically to `Play` in every state, including starting Play Mode when stopped.
- `Step` advances exactly one frame and leaves PlayMode paused (the Editor's Next Frame button); it is independent of `Time.timeScale` and requires PlayMode to be running
- The command waits for the requested state before returning. Increase `--timeout-seconds` for projects with slow PlayMode entry.
- Before relying on PlayMode behavior as verification evidence, check `uloop get-logs --log-type Error` for pre-existing errors. An error already present when PlayMode starts can otherwise be mistaken for one caused by the action under test.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.IO;
using System.Text;

using Newtonsoft.Json;
using UnityEngine;

using io.github.hatayama.UnityCliLoop.Application;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Publishes the tool catalog to .uloop/tools.json so package updates refresh the CLI cache without a manual sync.
/// </summary>
internal static class ToolCatalogCachePublisher
{
/// <summary>
/// Writes get-tool-details JSON to the project tools cache when the catalog content changed.
/// </summary>
public static void Publish(UnityCliLoopToolRegistrarService toolRegistrarService)
{
Debug.Assert(toolRegistrarService != null, "toolRegistrarService must not be null");

GetToolDetailsResponse response = GetToolDetailsBridgeCommand.Execute(null, toolRegistrarService);
// Why reuse JsonRpcResponseSerializer: tools.json must match the IPC result bytes that uloop sync writes.
string json = JsonConvert.SerializeObject(
response,
Formatting.None,
JsonRpcResponseSerializer.Settings);

string cachePath = Path.Combine(
UnityCliLoopConstants.ULOOP_DIR,
UnityCliLoopConstants.ULOOP_TOOLS_CACHE_FILE_NAME);

if (File.Exists(cachePath))
{
string existingJson = File.ReadAllText(cachePath);
if (existingJson == json)
{
return;
}
}

string directory = Path.GetDirectoryName(cachePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}

File.WriteAllText(cachePath, json, new UTF8Encoding(false));
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo
// .uloop directory
public const string ULOOP_DIR = ".uloop";
public const string ULOOP_TOOL_SETTINGS_FILE_NAME = "settings.tools.json";
public const string ULOOP_TOOLS_CACHE_FILE_NAME = "tools.json";
public const string ULOOP_PROJECT_RUNNER_PIN_FILE_NAME = "project-runner-pin.json";

// Command name constants
Expand Down
2 changes: 2 additions & 0 deletions cli/common/clicore/command_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const (
InstallCommandName = "install"
UpdateCommandName = "update"
UninstallCommandName = "uninstall"
VersionCommandName = "version"
SkillsCommandName = "skills"
CompileCommandName = "compile"
ExecuteDynamicCodeCommandName = "execute-dynamic-code"
Expand Down Expand Up @@ -39,6 +40,7 @@ var NativeCommands = []NativeCommandEntry{
{Name: InstallCommandName, Description: "Configure the global uloop launcher binary", Owner: DispatcherOwned},
{Name: UpdateCommandName, Description: "Update the global uloop launcher binary", Owner: DispatcherOwned},
{Name: UninstallCommandName, Description: "Remove the global uloop launcher binary", Owner: DispatcherOwned},
{Name: VersionCommandName, Description: "Show the installed uloop version", Owner: DispatcherOwned},
}

// IsDispatcherOwnedCommandName reports whether a native command belongs to the
Expand Down
3 changes: 3 additions & 0 deletions cli/common/clicore/command_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ func TestNativeCommandEntriesDeclareOwners(t *testing.T) {
InstallCommandName: DispatcherOwned,
UpdateCommandName: DispatcherOwned,
UninstallCommandName: DispatcherOwned,
VersionCommandName: DispatcherOwned,
SkillsCommandName: DispatcherOwned,
CompletionCommand: DispatcherOwned,
"list": RunnerOwned,
Expand Down Expand Up @@ -38,6 +39,7 @@ func TestIsDispatcherOwnedCommandName(t *testing.T) {
InstallCommandName,
UpdateCommandName,
UninstallCommandName,
VersionCommandName,
SkillsCommandName,
CompletionCommand,
} {
Expand Down Expand Up @@ -77,6 +79,7 @@ func TestIsRunnerOwnedCommandName(t *testing.T) {
InstallCommandName,
UpdateCommandName,
UninstallCommandName,
VersionCommandName,
SkillsCommandName,
CompletionCommand,
CompileCommandName,
Expand Down
5 changes: 3 additions & 2 deletions cli/common/tools/default-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,14 @@
"properties": {
"Action": {
"type": "string",
"description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode, Step - advance one frame while paused, Status - report current state without changing anything",
"description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode, Step - advance one frame while paused, Status - report current state without changing anything, Resume - alias of Play in every state, including starting Play Mode when stopped",
"enum": [
"Play",
"Stop",
"Pause",
"Step",
"Status"
"Status",
"Resume"
],
"default": "Play"
},
Expand Down
7 changes: 5 additions & 2 deletions cli/dispatcher/internal/dispatcher/dispatcher_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ func tryHandleDispatcherInfoRequest(args []string, stdout io.Writer) (bool, int)
return true, 0
}
if clicore.IsVersionJSONRequest(args) {
writeDispatcherVersionJSON(stdout)
writeDispatcherVersionOutput(stdout, true)
return true, 0
}
if clicore.IsVersionRequest(args) {
clicore.WriteLine(stdout, dispatcherVersion)
writeDispatcherVersionOutput(stdout, false)
return true, 0
}
return false, 0
Expand Down Expand Up @@ -73,6 +73,9 @@ func tryHandlePreConnectionRequestWithDeps(
if handled, code := tryHandleSkillsRequest(remainingArgs, startPath, projectPath, stdout, stderr); handled {
return true, code
}
if handled, code := tryHandleVersionRequest(remainingArgs, stdout, stderr); handled {
return true, code
}
if clicore.ContainsHelpRequest(commandArgs) {
if handled, code := tryHandleCommandHelp(command, startPath, projectPath, stdout, stderr); handled {
return true, code
Expand Down
53 changes: 53 additions & 0 deletions cli/dispatcher/internal/dispatcher/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,59 @@ func TestRunDispatcherVersionUsesDispatcherVersion(t *testing.T) {
}
}

func TestRunDispatcherVersionSubcommandMatchesFlagVersion(t *testing.T) {
// Verifies `uloop version` returns the same text as `uloop --version`.
t.Chdir(t.TempDir())

var flagStdout bytes.Buffer
var subcommandStdout bytes.Buffer
flagCode := RunDispatcher(context.Background(), []string{"--version"}, &flagStdout, io.Discard)
subcommandCode := RunDispatcher(context.Background(), []string{clicore.VersionCommandName}, &subcommandStdout, io.Discard)

if flagCode != 0 || subcommandCode != 0 {
t.Fatalf("version exit codes mismatch: flag=%d subcommand=%d", flagCode, subcommandCode)
}
if flagStdout.String() != subcommandStdout.String() {
t.Fatalf("version output mismatch:\nflag: %q\nsubcommand: %q", flagStdout.String(), subcommandStdout.String())
}
}

func TestRunDispatcherVersionSubcommandJSONMatchesFlagVersionJSON(t *testing.T) {
// Verifies `uloop version --json` returns the same JSON as `uloop --version --json`.
t.Chdir(t.TempDir())

var flagStdout bytes.Buffer
var subcommandStdout bytes.Buffer
flagCode := RunDispatcher(context.Background(), []string{"--version", "--json"}, &flagStdout, io.Discard)
subcommandCode := RunDispatcher(context.Background(), []string{clicore.VersionCommandName, "--json"}, &subcommandStdout, io.Discard)

if flagCode != 0 || subcommandCode != 0 {
t.Fatalf("version --json exit codes mismatch: flag=%d subcommand=%d", flagCode, subcommandCode)
}
if flagStdout.String() != subcommandStdout.String() {
t.Fatalf("version --json output mismatch:\nflag: %q\nsubcommand: %q", flagStdout.String(), subcommandStdout.String())
}
}

func TestRunDispatcherVersionSubcommandReportsTrailingUnknownOption(t *testing.T) {
// Verifies `uloop version --json extra` reports the trailing argument, not --json itself.
t.Chdir(t.TempDir())

var stdout bytes.Buffer
var stderr bytes.Buffer
code := RunDispatcher(context.Background(), []string{clicore.VersionCommandName, "--json", "extra"}, &stdout, &stderr)

if code != 1 {
t.Fatalf("expected failure, got code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "Unknown version option: extra") {
t.Fatalf("stderr should report trailing option extra: %s", stderr.String())
}
if !strings.Contains(stderr.String(), "uloop version --help") {
t.Fatalf("stderr should guide users to version --help: %s", stderr.String())
}
}

func TestResolveDispatcherRealCLIRejectsInvalidProjectRunnerVersion(t *testing.T) {
// Verifies project pins cannot escape the dispatcher cache through projectRunnerVersion path segments.
t.Setenv(nativepath.CacheDirEnvName, t.TempDir())
Expand Down
9 changes: 9 additions & 0 deletions cli/dispatcher/internal/dispatcher/dispatcher_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import (

var dispatcherVersion = dispatchercontract.DispatcherCurrent.DispatcherVersion

// writeDispatcherVersionOutput writes the same payload as --version / --version --json.
func writeDispatcherVersionOutput(stdout io.Writer, asJSON bool) {
if asJSON {
writeDispatcherVersionJSON(stdout)
return
}
clicore.WriteLine(stdout, dispatcherVersion)
}

func writeDispatcherVersionJSON(stdout io.Writer) {
content, err := json.Marshal(map[string]any{
"DispatcherVersion": dispatcherVersion,
Expand Down
2 changes: 2 additions & 0 deletions cli/dispatcher/internal/dispatcher/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestPrintLauncherHelpListsNativeCommandsAndLiveToolGuidance(t *testing.T) {
" list",
" skills",
" uninstall",
" version",
"Unity tool commands are project-specific.",
"does not include the full command list",
"uloop --project-path /path/to/project --help",
Expand Down Expand Up @@ -68,6 +69,7 @@ func TestPrintProjectLocalHelpListsNativeCommandsAndLiveToolGuidance(t *testing.
" list",
" sync",
" uninstall",
" version",
"Unity tool commands are project-specific.",
"does not include the full command list",
"uloop --project-path /path/to/project --help",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ var nativeCommandOptions = map[string][]string{
},
clicore.InstallCommandName: {"--" + installDirFlagName},
clicore.UpdateCommandName: {"--" + updateToVersionFlagName},
clicore.VersionCommandName: {"--json"},
}
Loading
Loading