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
4 changes: 4 additions & 0 deletions .agents/skills/uloop-compile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ Returns JSON:
- `ErrorCount`: number or null
- `WarningCount`: number or null
- `Message`: string

## Troubleshooting

If compile times out or Unity stops responding to uloop while the Editor looks idle, check whether Unity is showing **API Update Required** / **Script Updating Consent**. Ask the user to choose Go Ahead or No — never auto-dismiss that modal. Interactive Editors have no public uloop/Unity API to suppress it.
4 changes: 4 additions & 0 deletions .claude/skills/uloop-compile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ Returns JSON:
- `ErrorCount`: number or null
- `WarningCount`: number or null
- `Message`: string

## Troubleshooting

If compile times out or Unity stops responding to uloop while the Editor looks idle, check whether Unity is showing **API Update Required** / **Script Updating Consent**. Ask the user to choose Go Ahead or No — never auto-dismiss that modal. Interactive Editors have no public uloop/Unity API to suppress it.
172 changes: 170 additions & 2 deletions Assets/Tests/Editor/ExternalSceneChangeResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,53 @@ public void ResolveExternalSceneChanges_WhenSceneUnchanged_DoesNotSaveOrReload()
Assert.That(reloadWasCalled, Is.False);
}

[Test]
public void FocusReturnService_WhenHoldSucceeds_EmitsHoldArmedVibeLog()
{
// Verifies successful Disallow arms held and emits the observability vibe event once.
bool autoRefreshHeld = false;
List<string> vibeOperations = new List<string>();
ExternalAssetFocusReturnService service = new ExternalAssetFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => false,
() => { },
() => { },
() => { },
logWarning: null,
logVibeInfo: (operation, message, context) => vibeOperations.Add(operation),
logVibeWarning: null);

service.HoldAutoRefreshIfNeeded();
service.HoldAutoRefreshIfNeeded();

Assert.That(autoRefreshHeld, Is.True);
Assert.That(vibeOperations, Is.EqualTo(new[] { "external_scene_hold_armed" }));
}

[Test]
public void FocusReturnService_WhenDisallowThrows_EmitsHoldFailedVibeLog()
{
// Verifies Disallow failures leave SessionState unheld and emit hold_failed vibe warning.
bool autoRefreshHeld = false;
List<string> vibeOperations = new List<string>();
ExternalAssetFocusReturnService service = new ExternalAssetFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => false,
() => throw new InvalidOperationException("kCodeReload"),
() => { },
() => { },
logWarning: _ => { },
logVibeInfo: null,
logVibeWarning: (operation, message, context) => vibeOperations.Add(operation));

service.HoldAutoRefreshIfNeeded();

Assert.That(autoRefreshHeld, Is.False);
Assert.That(vibeOperations, Is.EqualTo(new[] { "external_scene_hold_failed" }));
}

[Test]
public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce()
{
Expand All @@ -215,6 +262,125 @@ public void FocusReturnService_WhenFocusIsLost_HoldsAutoRefreshOnce()
Assert.That(allowCallCount, Is.EqualTo(0));
}

[Test]
public void FocusReturnService_WhenHoldIfCurrentlyUnfocusedTwice_HoldsAutoRefreshOnce()
{
// Verifies Initialize-style unfocused Hold is idempotent (disallow once).
bool autoRefreshHeld = false;
int disallowCallCount = 0;
ExternalAssetFocusReturnService service = CreateFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => false,
() => disallowCallCount++,
() => { },
() => { });

service.HoldIfCurrentlyUnfocused();
service.HoldIfCurrentlyUnfocused();

Assert.That(autoRefreshHeld, Is.True);
Assert.That(disallowCallCount, Is.EqualTo(1));
}

[Test]
public void FocusReturnService_WhenDisallowThrows_DoesNotSetHeldFlag()
{
// Verifies kCodeReload Disallow failures leave SessionState unheld for later reconcile.
bool autoRefreshHeld = false;
List<string> warnings = new List<string>();
ExternalAssetFocusReturnService service = CreateFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => false,
() => throw new InvalidOperationException("kCodeReload"),
() => { },
() => { },
warning => warnings.Add(warning));

service.HoldAutoRefreshIfNeeded();

Assert.That(autoRefreshHeld, Is.False);
Assert.That(warnings.Count, Is.EqualTo(1));
Assert.That(warnings[0], Does.Contain("DisallowAutoRefresh"));
Assert.That(warnings[0], Does.Contain("InvalidOperationException"));
}

[Test]
public void FocusReturnService_WhenDisallowStopsFailing_ReconcileHoldsAutoRefresh()
{
// Verifies update reconcile arms Hold after transient Disallow failures without delayCall chains.
bool autoRefreshHeld = false;
bool disallowShouldThrow = true;
int disallowCallCount = 0;
ExternalAssetFocusReturnService service = CreateFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => false,
() =>
{
disallowCallCount++;
if (disallowShouldThrow)
{
throw new InvalidOperationException("kCodeReload");
}
},
() => { },
() => { },
_ => { });

service.ReconcileAutoRefreshHoldWithFocus();
Assert.That(autoRefreshHeld, Is.False);

disallowShouldThrow = false;
service.ReconcileAutoRefreshHoldWithFocus();

Assert.That(autoRefreshHeld, Is.True);
Assert.That(disallowCallCount, Is.EqualTo(2));
}

[Test]
public void FocusReturnService_WhenFocusedAndHeld_ReconcileReleasesAfterPreflight()
{
// Verifies focused reconcile resolves external changes then releases a surviving Hold.
bool autoRefreshHeld = true;
List<string> events = new List<string>();
ExternalAssetFocusReturnService service = CreateFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => true,
() => events.Add("disallow"),
() => events.Add("allow"),
() => events.Add("preflight"));

service.ReconcileAutoRefreshHoldWithFocus();

Assert.That(autoRefreshHeld, Is.False);
Assert.That(events, Is.EqualTo(new[] { "preflight", "allow" }));
}

[Test]
public void FocusReturnService_WhenAllowThrows_KeepsHeldFlag()
{
// Verifies failed Allow leaves SessionState held so reconcile can retry without counter desync.
bool autoRefreshHeld = true;
List<string> warnings = new List<string>();
ExternalAssetFocusReturnService service = CreateFocusReturnService(
() => autoRefreshHeld,
isHeld => autoRefreshHeld = isHeld,
() => true,
() => { },
() => throw new InvalidOperationException("kCodeReload"),
() => { },
warning => warnings.Add(warning));

service.HandleFocusChanged(true);

Assert.That(autoRefreshHeld, Is.True);
Assert.That(warnings.Count, Is.EqualTo(1));
Assert.That(warnings[0], Does.Contain("AllowAutoRefresh"));
}

[Test]
public void FocusReturnService_WhenFocusReturns_RunsPreflightBeforeReleasingAutoRefresh()
{
Expand Down Expand Up @@ -404,15 +570,17 @@ private static ExternalAssetFocusReturnService CreateFocusReturnService(
Func<bool> isEditorFocused,
Action disallowAutoRefresh,
Action allowAutoRefresh,
Action resolveFocusReturnChanges)
Action resolveFocusReturnChanges,
Action<string> logWarning = null)
{
return new ExternalAssetFocusReturnService(
getAutoRefreshHeld,
setAutoRefreshHeld,
isEditorFocused,
disallowAutoRefresh,
allowAutoRefresh,
resolveFocusReturnChanges);
resolveFocusReturnChanges,
logWarning);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -331,17 +331,36 @@ public void GetCompileResult_WhenLegacyCompletedTicksAreOutOfRange_ClearsLegacyS
}

[Test]
public void ClearExpiredCompileResult_WhenResultIsStale_ClearsSessionValue()
public void ClearExpiredCompileResult_WhenResultIsJustUnderLifetime_KeepsSessionValue()
{
// Verifies stale compile results do not survive indefinitely across commands.
DateTime now = new DateTime(2026, 5, 30, 0, 32, 1, DateTimeKind.Utc);
// Verifies compile results stay retrievable until the 20-minute TTL elapses.
DateTime completedAt = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc);
DateTime nowJustUnderLifetime = completedAt.AddMinutes(20).AddSeconds(-1);
_compileResultSessionRepository.StoreCompileResult(
"compile_test_request",
forceRecompile: false,
resultJson: "{\"Success\":true}",
completedAtUtc: completedAt);

bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(nowJustUnderLifetime);

Assert.That(cleared, Is.False);
Assert.That(_compileResultSessionRepository.GetStoredCompileResult().HasResult, Is.True);
}

[Test]
public void ClearExpiredCompileResult_WhenResultIsJustOverLifetime_ClearsSessionValue()
{
// Verifies compile results expire immediately after the 20-minute TTL.
DateTime completedAt = new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc);
DateTime nowJustOverLifetime = completedAt.AddMinutes(20).AddSeconds(1);
_compileResultSessionRepository.StoreCompileResult(
"compile_test_request",
forceRecompile: false,
resultJson: "{\"Success\":true}",
completedAtUtc: new DateTime(2026, 5, 30, 0, 0, 0, DateTimeKind.Utc));
completedAtUtc: completedAt);

bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(now);
bool cleared = _compileSessionLifecycleService.ClearExpiredCompileResult(nowJustOverLifetime);

Assert.That(cleared, Is.True);
Assert.That(_compileResultSessionRepository.GetStoredCompileResult().HasResult, Is.False);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ namespace io.github.hatayama.UnityCliLoop.Domain
/// </summary>
public sealed class UnityCliLoopCompileSessionLifecycleService
{
private static readonly TimeSpan CompileResultLifetime = TimeSpan.FromMinutes(32);
// Why 20m: Go compileWaitTimeout is 10m; TTL must stay longer (wait ≤ TTL) so a
// timed-out client can still retrieve the result by retrying uloop compile for
// about 10 more minutes. Why not keep 32m: shrink session-state leak window.
private static readonly TimeSpan CompileResultLifetime = TimeSpan.FromMinutes(20);
private readonly ISessionFlagsRepository _sessionFlagsRepository;
private readonly ICompileResultSessionRepository _compileResultSessionRepository;
private readonly IPendingCompileSessionRepository _pendingCompileSessionRepository;
Expand Down
Loading
Loading