-
Notifications
You must be signed in to change notification settings - Fork 632
Feature/action trace #570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feature/action trace #570
Conversation
Fixes CoplayDev#538 The System Requirements panel showed "UV Package Manager: Not Found" even when a valid UV path override was configured in Advanced Settings. Root cause: PlatformDetectorBase.DetectUv() only searched PATH with bare command names ("uvx", "uv") and never consulted PathResolverService which respects the user's override setting. Changes: - Refactor DetectUv() to use PathResolverService.GetUvxPath() which checks override path first, then system PATH, then falls back to "uvx" - Add TryValidateUvExecutable() to verify executables by running --version instead of just checking File.Exists - Prioritize PATH environment variable in EnumerateUvxCandidates() for better compatibility with official uv install scripts - Fix process output read order (ReadToEnd before WaitForExit) to prevent potential deadlocks Co-Authored-By: ChatGLM 4.7 <noreply@zhipuai.com>
- Read both stdout and stderr when validating uv/uvx executables
- Respect WaitForExit timeout return value instead of ignoring it
- Fix version parsing to handle extra tokens like "(Homebrew 2025-01-01)"
- Resolve bare commands ("uv"/"uvx") to absolute paths after validation
- Rename FindExecutableInPath to FindUvxExecutableInPath for clarity
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…s PATH augmentation Replace direct Process.Start calls with ExecPath.TryRun across all platform detectors. This change: - Fixes potential deadlocks by using async output reading - Adds proper timeout handling with process termination - Removes redundant fallback logic and simplifies version parsing - Adds Windows PATH augmentation with common uv, npm, and Python installation paths Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The version extraction logic now properly handles outputs like: - "uvx 0.9.18" -> "0.9.18" - "uvx 0.9.18 (hash date)" -> "0.9.18" - "uvx 0.9.18 extra info" -> "0.9.18" Uses Math.Min to find the first occurrence of either space or parenthesis. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add absolute path resolution in TryValidatePython and TryValidateUvWithPath for better UI display - Fix BuildAugmentedPath to avoid PATH duplication - Add comprehensive comments for version parsing logic - Ensure cross-platform consistency across all three detectors - Fix override path validation logic with clear state handling - Fix platform detector path resolution and Python version detection - Use UserProfile consistently in GetClaudeCliPath instead of Personal - All platforms now use protected BuildAugmentedPath method This change improves user experience by displaying full paths in the UI while maintaining robust fallback behavior if path resolution fails. Co-Authored-By: GLM4.7 <noreply@zhipuai.com>
- Rename TryValidateUvExecutable -> TryValidateUvxExecutable for consistency - Add cross-platform FindInPath() helper in ExecPath.cs - Remove platform-specific where/which implementations in favor of unified helper - Add Windows-specific DetectUv() override with enhanced uv/uvx detection - Add WinGet shim path support for Windows uvx installation - Update UI labels: "UV Path" -> "UVX Path" - Only show uvx path status when override is configured Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ides and system paths
…ethods across platform detectors
… WindowsPlatformDetector
Implement a comprehensive ActionTrace system that captures, stores, and queries Unity editor events for debugging, analysis, and undo/replay capabilities. **Core Features:** - Event capture layer with hooks for Unity editor events - Context tracking with stack and timeline support - Event store with in-memory persistence and query capabilities - Semantic analysis (categorization, scoring, intent inference) - VCS integration for version control context - Editor window with UI for visualizing events - MCP tools for remote query and control **Components Added:** - Capture: ActionTraceEventEmitter, EventFilter, PropertyChangeTracker, UnityEventHooks - Context: ContextStack, ContextTimeline, OperationContext, ContextMapping - Core: EventStore, EditorEvent, EventTypes, ActionTraceSettings, GlobalIdHelper - Query: ActionTraceQuery, EventSummarizer, TransactionAggregator - Semantics: DefaultCategorizer, DefaultEventScorer, DefaultIntentInferrer - UI: ActionTraceEditorWindow with UXML/USS styling - MCP Tools: get_action_trace, get_action_trace_settings, add_action_trace_note, undo_to_sequence **Server-side:** - Python models and resources for ActionTrace - MCP tools for querying events, managing settings, and undoing to sequence Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ility - Extract EventStore.Context.cs for context mapping management - Extract EventStore.Diagnostics.cs for memory diagnostics and dehydration - Extract EventStore.Merging.cs for event deduplication logic - Extract EventStore.Persistence.cs for save/load and domain reload survival - Add PropertyEventPayloadBuilder helper for consistent payload structure - Add PropertyFormatter helper to eliminate code duplication - Adjust ActionTraceSettings defaults (MaxEvents: 800, HotEventCount: 150) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ctionTraceEditorWindow
… clarity and consistency
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry @whatevertogo, your pull request is larger than the review limit of 300000 diff characters
📝 WalkthroughWalkthroughThis PR introduces a comprehensive ActionTrace system for MCPForUnity that captures, filters, stores, and queries editor events (component changes, asset operations, scene management, builds, script compilation) with configurable sampling, event merging, semantic analysis, and a Unity Editor window for visualization. It includes event capture hooks, context tracking, undo group management, Python tools for remote querying, and multi-layered settings with presets. Changes
Sequence Diagram(s)sequenceDiagram
participant UnityEditor as Unity Editor
participant CapturePoints as Capture Points<br/>(Hooks, Postprocessor,<br/>Property Tracker)
participant EventFilter as EventFilter<br/>(Junk Detection)
participant SamplingMiddleware as SamplingMiddleware<br/>(Rate Limiting)
participant EventStore as EventStore<br/>(Storage)
participant UI as ActionTrace<br/>Editor Window
participant Query as Query/Semantics<br/>(Scorer, Summarizer)
UnityEditor->>CapturePoints: Event triggered<br/>(Asset/Component/Scene)
CapturePoints->>EventFilter: Check ShouldTrackAsset?
alt Filtered (Junk)
EventFilter-->>CapturePoints: Block
else Allowed
CapturePoints->>SamplingMiddleware: ShouldRecord?
alt Sampled/Throttled
SamplingMiddleware-->>CapturePoints: Block/Queue
else Record
SamplingMiddleware-->>CapturePoints: Allow
CapturePoints->>EventStore: Record(EditorEvent)
EventStore->>EventStore: Validate, Merge if enabled,<br/>Assign sequence, Dehydrate old
EventStore-->>CapturePoints: Return sequence
EventStore->>EventStore: Persist (deferred)
EventStore-->>UI: Emit EventRecorded callback
end
end
UI->>EventStore: Query(limit, since)
EventStore-->>UI: Return events (snapshot)
UI->>Query: Project(events)
Query->>Query: Score, Categorize,<br/>Infer Intent
Query-->>UI: ActionTraceViewItem[]
UI->>UI: Render ListView,<br/>Display Summary
sequenceDiagram
participant AIAgent as AI Agent (Python)
participant Tool as get_action_trace Tool
participant Unity as Unity Editor
participant EventStore as EventStore
participant Query as ActionTraceQuery
participant Response as Response
AIAgent->>Tool: get_action_trace(limit, min_importance, filters)
Tool->>Unity: async_send_command(limit, min_importance)
Unity->>EventStore: Query(limit, sinceSequence)
EventStore-->>Unity: IReadOnlyList<EditorEvent>
Unity->>Query: Project(events)
Query->>Query: Compute scores, categories,<br/>context/intent
Query-->>Unity: ActionTraceViewItem[]
Unity-->>Tool: EventQueryResult(events, total_count)
Tool->>Response: Normalize to MCPResponse
Response-->>AIAgent: {success, events, metadata}
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes This is a substantial, multi-faceted system addition with high complexity across capture, filtering, storage, querying, and UI layers. Review requires understanding event flow, filtering logic, context tracking, semantic analysis, Python/C# integration, and UI binding patterns. Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs (1)
76-121: Claude Code stdio path leavestypestale or missing.When switching from HTTP to stdio for Claude Code,
typeis never updated to"stdio"and the removal guard preserves any old"http"value, which can misconfigure the client.🔧 Proposed fix (set type for Claude Code in both branches)
private static void PopulateUnityNode(JObject unity, string uvPath, McpClient client, bool isVSCode) { + bool isClaudeCode = string.Equals(client?.name, "Claude Code", StringComparison.OrdinalIgnoreCase); // Get transport preference (default to HTTP) bool prefValue = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); bool clientSupportsHttp = client?.SupportsHttpTransport != false; bool useHttpTransport = clientSupportsHttp && prefValue; @@ - if (isVSCode) + if (isVSCode || isClaudeCode) { unity["type"] = "http"; } - // Also add type for Claude Code (uses mcpServers layout but needs type field) - else if (client?.name == "Claude Code") - { - unity["type"] = "http"; - } } else { @@ - if (isVSCode) + if (isVSCode || isClaudeCode) { unity["type"] = "stdio"; } } // Remove type for non-VSCode clients (except Claude Code which needs it) - if (!isVSCode && client?.name != "Claude Code" && unity["type"] != null) + if (!isVSCode && !isClaudeCode && unity["type"] != null) { unity.Remove("type"); }MCPForUnity/Editor/Helpers/ExecPath.cs (1)
245-273: Avoid adding current directory to PATH whenprependPathis empty.When
prependPathis empty, the constructed PATH becomes:<existing>, which implicitly adds the current directory to PATH. That can causewhichto resolve a project-local executable unintentionally.🛠️ Safer PATH handling
string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + if (!string.IsNullOrEmpty(prependPath)) + { + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) + ? prependPath + : (prependPath + Path.PathSeparator + path); + }
🤖 Fix all issues with AI agents
In `@MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs`:
- Around line 44-69: The PropertyChangeTracker class is never initialized
because it lacks the Unity Editor initialization attribute; add
[InitializeOnLoad] above the PropertyChangeTracker class declaration so its
static constructor runs and subscribes Undo.postprocessModifications and calls
ScheduleNextFlush(); ensure the attribute is placed directly before the class
definition to mirror SelectionPropertyTracker's initialization behavior so the
static constructor, Undo.postprocessModifications += mods =>
ProcessModifications(mods), and ScheduleNextFlush() execute on editor startup.
In `@MCPForUnity/Editor/ActionTrace/Capture/SamplingMiddleware.cs`:
- Around line 47-148: ShouldRecord currently stores the latest event for
SamplingMode.Debounce/DebounceByKey but never emits it; add a trailing-edge
flush: create a scheduled flush (e.g., using
UnityEditor.EditorApplication.delayCall or EditorApplication.update) that calls
a new FlushExpiredPendingSamples method which iterates _pendingSamples, finds
entries whose TimestampMs + strategy.WindowMs <= now, removes them and
emits/records their PendingSample.Event (reuse existing CleanupExpiredSamples as
a template or extend it to return expired items), and ensure ScheduleNextFlush
is called whenever you add/update a PendingSample so a flush will run after the
earliest window expires; also add the UnityEditor using and update references to
PendingSample, _pendingSamples, ShouldRecord, SamplingMode.Debounce and
SamplingMode.DebounceByKey accordingly.
In `@MCPForUnity/Editor/ActionTrace/Capture/SelectionPropertyTracker.cs`:
- Around line 104-165: The event currently uses _currentSelectionGlobalId as the
EditorEvent targetId which hides the actual modified object's identity; change
the call to RecordSelectionPropertyModified to pass the targetGlobalId (add a
string targetGlobalId parameter), update the RecordSelectionPropertyModified
signature to accept that id, and use that passed targetGlobalId as the
EditorEvent targetId when constructing evt; keep the existing selection_context
payload entries unchanged so selection info remains in the payload while the
event targetId preserves the real modified-object id.
In `@MCPForUnity/Editor/ActionTrace/Core/EventStore.cs`:
- Around line 226-235: The Clear() method currently only clears
_events/_contextMappings and resets _sequenceCounter but must also reset merge
tracking and pending notifications to avoid dropped events; update Clear() to
set _lastRecordedEvent to null, reset any merge-tracking fields (e.g.,
_mergeState, _mergeCandidates or similar flags used by the merge logic) and
clear any pending notification collections (e.g., _pendingNotifications or
pending operation queues) so all merge/notification state is fully reset after
Clear().
- Around line 75-147: The bug is that _lastRecordedEvent and _lastRecordedTime
are set before the merge logic, causing ShouldMergeWithLast to compare the
incoming event to itself; update Record so that assignment to _lastRecordedEvent
and _lastRecordedTime happens inside the _queryLock after you either call
MergeWithLastEventLocked or add evtWithSequence to _events (i.e., move the two
assignments to immediately after the merge/add block and before releasing the
lock), ensuring ShouldMergeWithLast and MergeWithLastEventLocked see the true
previous event.
In `@MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs`:
- Around line 171-182: GetPreviousValue currently returns the
PropertyModification wrapper because it checks "previousValue" before
"previousValue.value"; change the method so it returns the actual previous
value: first try GetNestedValue(undoMod, "previousValue.value"), then try
GetNestedValue(undoMod, "previousValue"), and if the latter returns a
PropertyModification wrapper (inspect its type/name), extract its inner value
(e.g., via GetNestedValue on the returned object for "value" or use reflection
to read the "value" field) so callers receive the raw previous value; update
GetPreviousValue and use GetNestedValue/PropertyModification handling
consistently for UndoPropertyModification results.
In `@MCPForUnity/Editor/ActionTrace/Query/TransactionAggregator.cs`:
- Around line 100-139: ShouldSplit currently only splits when current has tool
metadata and first lacks it, making boundaries asymmetric; update ShouldSplit
(referencing GetToolCallId and GetTriggeredByTool and the local variables
firstToolCallId, currentToolCallId, firstTool, currentTool) to treat any
mismatch (non-empty vs empty) as a split in both priority checks: if
firstToolCallId is non-empty and currentToolCallId is empty, return true (same
for the reverse which already exists), and likewise if firstTool is non-empty
and currentTool is empty, return true; keep the existing time-window fallback
(transactionWindowMs) unchanged.
In `@MCPForUnity/Editor/ActionTrace/Semantics/DefaultIntentInferrer.cs`:
- Around line 16-63: Infer currently calls IsReparenting(surrounding) and
IsBatchOperation(surrounding) but doesn't guard for surrounding being null
(ActionTraceQuery passes surrounding: null), causing a NullReferenceException;
normalize surrounding to an empty IReadOnlyList<EditorEvent> at the start of the
Infer method (before the switch) so downstream helpers IsReparenting and
IsBatchOperation always receive a non-null list, or alternatively add null
handling inside those helper methods, ensuring neither dereferences a null
reference.
In `@MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs`:
- Around line 54-205: RefreshContext/QueryGitStatus currently invoke three
synchronous git calls on the Editor update loop via RunGitCommand, which can
block the editor; move Git polling off the main thread by executing
QueryGitStatus (or the git invocations inside RunGitCommand) on a background
Task/Thread and marshal the resulting VcsContext back to the main thread (e.g.,
via EditorApplication.delayCall or scheduling _currentContext update in
OnUpdate) so the Editor is never blocked. Also harden RunGitCommand to use a
Process wait-with-timeout (WaitForExit(timeoutMs)), kill the process on timeout,
and return null/error instead of blocking. Finally, change
VcsContext.ToDictionary so it does not return a cached mutable Dictionary
instance—return a new Dictionary copy or an
IReadOnlyDictionary/ReadOnlyDictionary wrapper each call to avoid shared-mutable
state.
In `@MCPForUnity/Editor/Helpers/AssetPathUtility.cs`:
- Line 164: Remove the debug logging that exposes potentially sensitive data by
deleting the McpLog.Info call that logs sourceOverride in AssetPathUtility (the
line containing McpLog.Info($"[DEBUG] Using override: {sourceOverride}"));
ensure no other statements reference or re-log sourceOverride in the same method
so the override value is not written to logs.
- Line 161: Remove the unconditional debug log that prints sensitive data:
delete the McpLog.Info call that logs EditorPrefKeys.GitUrlOverride and the
sourceOverride value in AssetPathUtility.cs (the line referencing
McpLog.Info($"[DEBUG] GitUrlOverride key: '{EditorPrefKeys.GitUrlOverride}',
value: '{sourceOverride}'")). Ensure no other code in the same method/class logs
the raw GitUrlOverride value; if you need retain diagnostics, replace with a
non-sensitive message or guard it behind a secure debug flag that never runs in
production.
In `@MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs`:
- Around line 165-252: QueryWithContext currently ignores taskId and
conversationId when includeSemantics (and context-only) is true; modify
QueryWithContext to filter eventsWithContext by the provided taskId and/or
conversationId before calling ActionTraceQuery.ProjectWithContext or before
applying the DefaultEventScorer. Specifically, apply the task/conversation
predicates to the collection returned by EventStore.QueryWithContext(limit,
sinceSequence) (use the local variable eventsWithContext) so both the projected
variable (used by ProjectWithContext) and the filtered variable in the else
branch only contain events matching taskId/conversationId; keep the subsequent
semantic projection (ActionTraceQuery.ProjectWithContext) and scoring
(DefaultEventScorer.Score) logic unchanged.
- Around line 366-405: Apply a null-check for EditorEvent.Payload inside
ApplyTaskFilters to avoid NullReferenceException for dehydrated AINote events:
when e.Type == "AINote" and either taskId or conversationId is specified, first
verify e.Payload != null and treat a null payload as a non-match (i.e., return
false) before calling Payload.TryGetValue for "task_id" or "conversation_id";
ensure the existing logic for comparing eventTaskId and eventConvId remains the
same but only runs after the payload null guard.
- Around line 90-118: In QueryBasic replace the call to
EventSummarizer.Summarize(e) with the event's own retrieval method
(e.GetSummary()) so dehydrated events return their PrecomputedSummary; update
the projection in QueryBasic (the anonymous object construction) to use
e.GetSummary() for the summary field and apply the same change to any other
query paths that currently call EventSummarizer.Summarize to ensure precomputed
summaries are preserved.
In `@MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs`:
- Around line 193-207: OnBeforeAssemblyReload currently calls
WriteHeartbeat(true, "domain_reload") then Stop(), but Stop() unconditionally
deletes the heartbeat/status file; fix by setting an explicit "reloading" flag
(e.g. a new static bool or a static property like s_isReloading) before calling
Stop() in OnBeforeAssemblyReload, update the ResumeStdioAfterReload EditorPref
deletion logic to check both EditorApplication.isCompiling and this new
s_isReloading flag, and guard the status-file deletion in Stop() (or the helper
that removes the file) to skip deletion when s_isReloading is true so the
heartbeat remains across the domain reload detection window; ensure symbol names
referenced: OnBeforeAssemblyReload, WriteHeartbeat, Stop,
ResumeStdioAfterReload, and EditorApplication.isCompiling.
- Around line 318-395: The CleanupZombieListeners method currently treats read
timeouts as fatal because stream.Read can throw IOException on ReceiveTimeout;
modify CleanupZombieListeners to wrap the stream.Read(...) call in a
try-catch(IOException) and treat that path the same as “no handshake” (log as
zombie and continue cleanup), and ensure you check bytesRead > 0 before decoding
the buffer; also avoid blocking the editor by reducing/blocking waits (replace
connectTask.Wait(500) and subsequent Thread.Sleep loops with short non-blocking
attempts or shorter timeouts when calling ConnectAsync on testClient/forceClient
and bail earlier) — update references in the method around
testClient.ConnectAsync, testClient.ReceiveTimeout, stream.Read, and the
forceClient ConnectAsync loop.
In `@MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs`:
- Around line 29-30: The McpForUnityTool attribute on the AddActionTraceNoteTool
class lacks a Description parameter required for MCP clients; update the
attribute declaration on the AddActionTraceNoteTool class to include a
descriptive Description="..." argument (e.g., a short summary of the tool's
purpose) so MCP can parse the tool reliably, ensuring the Description is concise
and accurately reflects the tool's behavior.
In `@MCPForUnity/Editor/Tools/GetActionTraceTool.cs`:
- Around line 14-15: The McpForUnityTool attribute on the GetActionTraceTool
class is missing a Description property needed for MCP client parsing; update
the attribute usage on the GetActionTraceTool class (the
[McpForUnityTool("get_action_trace")] declaration) to include a Description =
"Get action trace for a specific entity/action" (or similar brief description)
so the attribute becomes [McpForUnityTool("get_action_trace", Description = "Get
action trace for a specific entity/action")].
♻️ Duplicate comments (3)
MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs (3)
126-159: Same summary handling applies here.
Switch top.Event.GetSummary()to preserve dehydrated summaries.
411-445: Same null‑payload risk applies here.
Guardp.Event.Payloadbefore accessing task/conversation IDs.
450-484: Same null‑payload risk applies here.
Guardx.Event.Payloadbefore accessing task/conversation IDs.
🟡 Minor comments (25)
Server/src/services/tools/add_action_trace_note.py-113-122 (1)
113-122: Persist auto-generated task/conversation IDs to context.Right now, if callers omit IDs, each call gets a new
task_id/conversation_id, which defeats implicit grouping across a session. If the intended behavior is “generate once, then reuse,” set the ContextVars when you auto-generate.🔧 Suggested fix
if not effective_task_id: effective_task_id = f"task-{uuid.uuid4().hex[:8]}" + _current_task_id.set(effective_task_id) if not effective_conv_id: effective_conv_id = f"conv-{uuid.uuid4().hex[:8]}" + _current_conversation_id.set(effective_conv_id)Server/src/services/tools/add_action_trace_note.py-151-151 (1)
151-151: Preserve MCPResponse error details instead of stringifying.
async_send_command_with_retrycan return anMCPResponsewhich haserror,hint, anddatafields. The current code converts it to a string, losing these structured fields. Usemodel_dump()to convert to dict instead.🔧 Suggested fix
- return response if isinstance(response, dict) else {"success": False, "message": str(response)} + if isinstance(response, dict): + return response + if hasattr(response, "model_dump"): + return response.model_dump() + return {"success": False, "message": str(response)}Server/src/services/tools/undo_to_sequence.py-91-99 (1)
91-99: Preserve structured error details from non-dict responses.When Unity returns an
MCPResponse, collapsing it to{"message": str(response)}loses structured fields likeerror/hint. Consider normalizing it to a dict to keep a consistent schema.🩹 Suggested normalization
-from transport.unity_transport import send_with_unity_instance +from transport.unity_transport import send_with_unity_instance, normalize_unity_response ... - return response if isinstance(response, dict) else {"success": False, "message": str(response)} + return response if isinstance(response, dict) else normalize_unity_response(response)Server/src/services/tools/undo_to_sequence.py-58-61 (1)
58-61: Update the docstring example to use the correct tool name.Line 60 references
get_actionTrace, but the actual tool name isget_action_trace(snake_case). Update the example to:result = await get_action_trace(ctx, limit=1)Server/src/services/tools/undo_to_sequence.py-72-78 (1)
72-78: Add validation to guard against negative sequence_id values.
coerce_intaccepts negative values, but ActionTrace sequences are monotonically increasing and therefore must be non-negative. The C# handler's EventStore query may behave unexpectedly or cause issues with negativesinceSequencevalues. Add a validation check before sending to the C# handler.🩹 Suggested validation
- if coerced_sequence is None: + if coerced_sequence is None or coerced_sequence < 0: return { "success": False, - "message": "sequence_id parameter is required and must be a number." + "message": "sequence_id parameter is required and must be a non-negative number." }MCPForUnity/Editor/ActionTrace/Integration/ManageAssetBridge.cs-37-74 (1)
37-74: Include full exception details in warnings.Logging only
ex.Messagedrops stack traces and inner exception details, which makes ActionTrace failures harder to diagnose.🛠️ Proposed fix
- McpLog.Warn($"[ManageAssetBridge] Failed to record asset modification: {ex.Message}"); + McpLog.Warn($"[ManageAssetBridge] Failed to record asset modification: {ex}"); ... - McpLog.Warn($"[ManageAssetBridge] Failed to record asset creation: {ex.Message}"); + McpLog.Warn($"[ManageAssetBridge] Failed to record asset creation: {ex}"); ... - McpLog.Warn($"[ManageAssetBridge] Failed to record asset deletion: {ex.Message}"); + McpLog.Warn($"[ManageAssetBridge] Failed to record asset deletion: {ex}");MCPForUnity/Editor/ActionTrace/Descriptors/IEventDescriptor.cs-67-82 (1)
67-82: MakeGetTargetNamenull-safe for payload values.If a payload key exists but the value is null,
ToString()will throw. Use null-conditional conversion to avoid a hard crash.🔧 Proposed fix
- if (evt.Payload.TryGetValue("name", out var name)) - return name.ToString(); + if (evt.Payload.TryGetValue("name", out var name)) + return name?.ToString() ?? string.Empty; - if (evt.Payload.TryGetValue("game_object", out var goName)) - return goName.ToString(); + if (evt.Payload.TryGetValue("game_object", out var goName)) + return goName?.ToString() ?? string.Empty; - if (evt.Payload.TryGetValue("scene_name", out var sceneName)) - return sceneName.ToString(); + if (evt.Payload.TryGetValue("scene_name", out var sceneName)) + return sceneName?.ToString() ?? string.Empty; - if (evt.Payload.TryGetValue("component_type", out var componentType)) - return componentType.ToString(); + if (evt.Payload.TryGetValue("component_type", out var componentType)) + return componentType?.ToString() ?? string.Empty; - if (evt.Payload.TryGetValue("path", out var path)) - return path.ToString(); + if (evt.Payload.TryGetValue("path", out var path)) + return path?.ToString() ?? string.Empty;MCPForUnity/Editor/ActionTrace/Semantics/IIntentInferrer.cs-11-20 (1)
11-20: Fix return type to match documented nullable contract.The method documentation states it may return null, but the signature returns non-nullable
string. The implementation (DefaultIntentInferrer) explicitly returnsnullwhen unable to infer. Update the signature tostring?to align with the documented contract and actual behavior.🔧 Proposed fix
- string Infer(EditorEvent evt, IReadOnlyList<EditorEvent> surrounding); + string? Infer(EditorEvent evt, IReadOnlyList<EditorEvent> surrounding);MCPForUnity/Editor/ActionTrace/Core/GlobalIdHelper.cs-94-100 (1)
94-100: Guard against empty scene paths for unsaved scenes.
Line 95–99: For unsaved scenes,go.scene.pathis empty, producing IDs likeScene:@Foo``.ParseFallbackIdthen calls `GetSceneByPath("")`, which will fail, so the ID can’t resolve even within the same session. Consider falling back to Asset/Instance IDs when the scene path is empty.🔧 Suggested fix
- if (obj is GameObject go && go.scene.IsValid()) + if (obj is GameObject go && go.scene.IsValid() && !string.IsNullOrEmpty(go.scene.path)) { // Reuse GameObjectLookup.GetGameObjectPath() string hierarchyPath = GameObjectLookup.GetGameObjectPath(go); return $"{ScenePrefix}{go.scene.path}{PathSeparator}{hierarchyPath}"; }MCPForUnity/Editor/ActionTrace/Helpers/ActionTraceHelper.cs-28-39 (1)
28-39: Fix FormatToolName output to match the documented examples.
Line 33–38: The current regex removes underscores without inserting spaces and doesn’t title-case the first segment, so"manage_gameobject"becomes"manageGameobject"instead of"Manage GameObject"(per docs). Consider splitting and joining with spaces.🔧 Suggested fix
- return System.Text.RegularExpressions.Regex.Replace( - toolName, - "_([a-z])", - match => match.Groups[1].Value.ToUpper() - ); + var parts = toolName.Split('_', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + if (part.Length == 0) continue; + parts[i] = char.ToUpperInvariant(part[0]) + part.Substring(1); + } + return string.Join(" ", parts);MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs-64-67 (1)
64-67: BuildCompleted score conflicts with its comment.The comment says success is less important than failure, but
BuildCompletedis scored1.0f(critical). Consider lowering the score or updating the comment.💡 Possible fix (align to comment)
- EventTypes.BuildCompleted => 1.0f, + EventTypes.BuildCompleted => 0.9f,MCPForUnity/Editor/Tools/ManageAsset.cs-31-38 (1)
31-38: Documented change map doesn’t match emitted payload.
The XML doc says the changes dictionary contains{old,new}, butModifyAssetforwards only the incoming properties (see Line 499-503). Either compute diffs or update the contract to avoid misleading consumers.✏️ Minimal doc fix (if raw properties are intended)
- /// - changesDictionary: property path -> {old, new} values + /// - changesDictionary: property path -> new values (or update implementation to capture old/new)MCPForUnity/Editor/Tools/ManageAsset.cs-291-294 (1)
291-294: Folder creations don’t trigger OnAssetCreated.
CreateAssetreturnsCreateFolderearly andcreate_folderalso callsCreateFolder, so the callback never fires for folders. Consider emitting the event on successful folder creation.🔧 Suggested hook in CreateFolder (success path)
// AssetDatabase.Refresh(); // CreateFolder usually handles refresh + OnAssetCreated?.Invoke(fullPath, "Folder"); return new SuccessResponse(MCPForUnity/Editor/Tools/GetActionTraceTool.cs-34-44 (1)
34-44: Remove unused EventTypes and IncludePayload parameters or implement their functionality.
These parameters are defined inGetActionTraceTool.Parameters(lines 34-44) but are never parsed or used inActionTraceViewResource.HandleCommand. The resource documentation does not list them as supported parameters. Either implement filtering by event types and payload inclusion logic, or remove these parameters to avoid misleading clients.MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs-156-173 (1)
156-173: Missing lock inSaveToStoragecould cause concurrent modification issues.
SaveToStoragereads from_eventsand_contextMappingswithout holding_queryLock, while other methods likeTrimToMaxEventsLimitandQueryuse the lock. If a save occurs concurrently with event recording or trimming,.ToList()could throwInvalidOperationException.Suggested fix
private static void SaveToStorage() { try { + EventStoreState state; + lock (_queryLock) + { - var state = new EventStoreState + state = new EventStoreState - { + { - SchemaVersion = CurrentSchemaVersion, + SchemaVersion = CurrentSchemaVersion, - SequenceCounter = _sequenceCounter, + SequenceCounter = _sequenceCounter, - Events = _events.ToList(), + Events = _events.ToList(), - ContextMappings = _contextMappings.ToList() + ContextMappings = _contextMappings.ToList() - }; + }; + } McpJobStateStore.SaveState(StateKey, state); }MCPForUnity/Editor/Tools/UndoToSequenceTool.cs-133-142 (1)
133-142: Undo operations may silently fail without feedback.
Undo.PerformUndo()doesn't return a success indicator. If the undo history was cleared (e.g., by domain reload), the loop runs but accomplishes nothing. The user receives a "Successfully reverted X steps" message that may be misleading.Additionally,
MarkSceneDirtyis called unconditionally, which will mark the scene dirty even if no actual changes were reverted.Suggested improvement
Consider tracking the undo group before and after to detect if operations actually occurred:
+ int groupBefore = Undo.GetCurrentGroup(); for (int i = 0; i < stepsToUndo; i++) { Undo.PerformUndo(); } + int groupAfter = Undo.GetCurrentGroup(); + int actualSteps = groupBefore - groupAfter; // Force GUI refresh to update the scene - EditorApplication.delayCall += () => + if (actualSteps > 0) { - UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene()); - }; + EditorApplication.delayCall += () => + { + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty( + UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene()); + }; + }MCPForUnity/Editor/ActionTrace/Capture/UndoGroupManager.cs-50-69 (1)
50-69: Missing re-entrancy guard could cause lost undo group context.If
BeginToolCallis called twice without an interveningEndToolCall, the first call's_currentUndoGroupStartis silently overwritten. This would prevent proper undo collapsing for the first tool call.Suggested fix
public static void BeginToolCall(string toolName, string toolCallId) { + if (_isInToolCall) + { + UnityEngine.Debug.LogWarning( + $"[UndoGroupManager] BeginToolCall({toolName}) called while already in tool call ({_currentToolName}). " + + "Ending previous tool call first."); + EndToolCall(); + } + if (string.IsNullOrEmpty(toolName)) {MCPForUnity/Editor/ActionTrace/Capture/EventFilter.cs-48-75 (1)
48-75: Non-extension entries inJunkExtensionswill not filter correctly.Several entries in
JunkExtensionsare not file extensions:
"~$"(Line 61) is a filename prefix pattern for Office temp files, not a suffix.".DS_Store","Thumbs.db","desktop.ini"(Lines 64-66) are complete filenames.The
EndsWithcheck on Line 127 won't match paths likefolder/~$document.docxorfolder/.DS_Storecorrectly—only paths literally ending with these strings (e.g.,something.DS_Store) would match.Suggested fix
Move these to separate collections with appropriate matching logic:
+ /// <summary> + /// Filename patterns to filter (matched at end of path after last separator). + /// </summary> + private static readonly HashSet<string> JunkFilenames = new(StringComparer.OrdinalIgnoreCase) + { + ".DS_Store", + "Thumbs.db", + "desktop.ini" + }; + + /// <summary> + /// Filename prefix patterns (e.g., Office temp files starting with ~$). + /// </summary> + private static readonly HashSet<string> JunkFilenamePrefixes = new(StringComparer.OrdinalIgnoreCase) + { + "~$" + };Then add matching logic in
IsJunkPath:// Extract filename and check against junk filenames/prefixes string filename = Path.GetFileName(path); if (JunkFilenames.Contains(filename)) return true; foreach (var prefix in JunkFilenamePrefixes) { if (filename.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true; }MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs-183-198 (1)
183-198: "All Events (Debug)" and "Low+" presets are functionally identical.Both buttons call
SetImportance(0f), making "Low+" redundant. Either differentiate the values or remove the duplicate.🐛 Proposed fix - differentiate or remove
if (GUILayout.Button("All Events (Debug)")) { SetImportance(0f); } - if (GUILayout.Button("Low+")) - { - SetImportance(0f); - } + if (GUILayout.Button("Low+")) + { + SetImportance(0.2f); // Or remove this button entirely + }MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs-92-97 (1)
92-97: Unsafe cast ofmerge_countvalue could throw InvalidCastException.If
existingCountis stored as along(e.g., from JSON deserialization), the direct cast(int)existingCountwill fail. Use safe conversion instead.🐛 Proposed fix using Convert.ToInt32
- if (_lastRecordedEvent.Payload.TryGetValue("merge_count", out var existingCount)) - { - mergeCount = (int)existingCount + 1; - } + if (_lastRecordedEvent.Payload.TryGetValue("merge_count", out var existingCount) + && existingCount != null) + { + mergeCount = Convert.ToInt32(existingCount) + 1; + }MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs-27-40 (1)
27-40: Optimization comment claims mappings are ordered, but order isn't enforced.Line 37 states "mappings are ordered by EventSequence" as an optimization, but
AddContextMappingsimply appends to the list without maintaining sort order. If events are recorded out of order (e.g., from async sources), this early-exit optimization could skip newer duplicates.🐛 Option 1: Remove the optimization and always scan fully
for (int i = _contextMappings.Count - 1; i >= 0; i--) { var existing = _contextMappings[i]; if (existing.EventSequence == mapping.EventSequence && existing.ContextId == mapping.ContextId) { isDuplicate = true; break; } - // Optimization: mappings are ordered by EventSequence - if (existing.EventSequence < mapping.EventSequence) - break; }MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs-144-147 (1)
144-147: QueryWithContext loses multiple contexts per event.The
GroupBy(...).ToDictionary(..., g.FirstOrDefault())pattern keeps only the first context mapping per event sequence. Per the docstring at line 18-19, "Multiple mappings allowed for same eventSequence (different contexts)". Consider returning all mappings or documenting that only one is returned.🐛 Option: Return ILookup to preserve all mappings
- var mappingBySequence = mappingsSnapshot - .GroupBy(m => m.EventSequence) - .ToDictionary(g => g.Key, g => g.FirstOrDefault()); + var mappingsBySequence = mappingsSnapshot.ToLookup(m => m.EventSequence);Then update the select to return all contexts:
.Select(e => (Event: e, Contexts: mappingsBySequence[e.Sequence].ToList()))This would require changing the return type signature.
MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs-14-14 (1)
14-14: Move settings file to a dedicated subfolder for better organization.The settings file is created at
Assets/ActionTraceSettings.asset, which places it at the root of the Assets folder. Consider moving it to a dedicated subfolder such asAssets/Editor/ActionTraceSettings.assetorAssets/Resources/ActionTraceSettings.asset(if runtime-accessible) to keep editor configuration organized and separate from user-created assets.MCPForUnity/Editor/ActionTrace/Descriptors/ComponentEventDescriptor.cs-46-53 (1)
46-53: Timestamp generated at extraction time may be inconsistent.
ExtractPayloadgenerates a new timestamp on every invocation rather than extracting it from the raw payload or the event's actual timestamp. If this method is called multiple times or at a later point, the timestamp won't reflect when the hierarchy actually changed.Consider extracting from
rawPayloadif available, or relying on the event'sTimestampUnixMs:Suggested fix
public override Dictionary<string, object> ExtractPayload(Dictionary<string, object> rawPayload) { return new Dictionary<string, object> { - ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ["timestamp"] = rawPayload?.GetValueOrDefault("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; }MCPForUnity/Editor/ActionTrace/Core/EditorEvent.cs-24-24 (1)
24-24: Potential thread-safety issue with lazy PrecomputedSummary mutation.The class is documented as immutable, but
GetSummary()mutatesPrecomputedSummaryvia the private setter. If multiple threads callGetSummary()concurrently on the same instance, there's a data race. While Unity editor code is typically single-threaded, this could cause issues if events are accessed from background threads (e.g., during persistence or MCP server calls).Consider using
Interlocked.CompareExchangeor accepting the benign race (since string assignment is atomic and all threads would compute the same value).🔒 Thread-safe lazy initialization option
public string GetSummary() { - if (PrecomputedSummary != null) - return PrecomputedSummary; - - // Compute and cache (this mutates the object, but it's just a string field) - PrecomputedSummary = ComputeSummary(); - return PrecomputedSummary; + var summary = PrecomputedSummary; + if (summary != null) + return summary; + + // Compute and cache - benign race if multiple threads compute simultaneously + summary = ComputeSummary(); + PrecomputedSummary = summary; + return summary; }Also applies to: 76-76, 162-170
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs
Outdated
Show resolved
Hide resolved
…uery improvements
This commit introduces significant improvements to the ActionTrace system:
**Layered Settings Architecture:**
- Refactor ActionTraceSettings into layered structure (Filtering, Merging, Storage, Sampling)
- Add custom Editor with foldout sections for better UX
- Move GlobalIdHelper from Core/ to Helpers/ for better organization
**Preset System:**
- Add ActionTracePreset with Standard/Verbose/Minimal/Silent presets
- Enable quick configuration switching via ApplyPreset()
- Include preset descriptions and memory estimates
**Configurable Filtering:**
- Transform EventFilter from static blacklist to rule-based system
- Support Prefix, Extension, Regex, and GameObject rule types
- Add EventFilterSettings for persistence and customization
**Stable Cross-Session IDs:**
- Use GlobalIdHelper for all GameObject/Component event TargetIds
- Use Asset:{path} format for asset events
- Ensure TargetIds remain stable across domain reloads
**Query & Analysis Enhancements:**
- Add EventQueryBuilder for fluent query API
- Add ContextCompressor for event data optimization
- Add EventStatistics for comprehensive analytics
- Enhance EventSummarizer with better grouping
**Capture Layer Improvements:**
- Add AssetChangePostprocessor for asset change tracking
- Add SamplingMiddleware for high-frequency event throttling
- Add ToolCallScope for MCP tool call tracking
- Enhance UnityEventHooks with comprehensive event coverage
**UI/UX Improvements:**
- Add sort modes (ByTimeDesc, AIFiltered) to ActionTraceEditorWindow
- Improve filter menu with "All" option
- Add tool descriptions for better AI discoverability
**Helper Classes:**
- Add GameObjectTrackingHelper for GameObject lifecycle tracking
- Add UndoReflectionHelper for Undo system introspection
- Add BuildTargetUtility for build target detection
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…al loops and enhancing GameObject tracking
…capacity - Add Range attributes to settings fields with Inspector sliders: - MinImportanceForRecording: [Range(0f, 1f)] - MergeWindowMs: [Range(0, 5000)] - TransactionWindowMs: [Range(100, 10000)] - HotEventCount: [Range(10, 1000)] - MaxEvents: [Range(100, 5000)] (already existed) - Change ContextMappings from fixed 2000 to dynamic MaxEvents × 2 - Remove redundant validation code (now enforced by Range attributes) - Fix UndoReflectionHelper.GetPreviousValue to try nested value first - Clean up StdioBridgeHost: remove ineffective CleanupZombieListeners - Add task/conversation filtering to ActionTraceViewResource - Preserve modified-object identity in SelectionPropertyTracker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…on in ActionTrace
…leaks Replace infinite recursive delayCall patterns with EditorApplication.update in PropertyChangeTracker and SamplingMiddleware. This ensures proper cleanup on domain reload and prevents memory leaks. Changes: - PropertyChangeTracker: Replace delayCall with update + FlushCheck() - SamplingMiddleware: Replace delayCall with update directly - Remove redundant helper methods (use UndoReflectionHelper/PropertyFormatter) - Clean up unused using statements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry @whatevertogo, your pull request is larger than the review limit of 300000 diff characters
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 10
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs`:
- Around line 103-128: The code emits duplicate events for the same imported
assets because importedAssets are processed earlier to emit AssetImported (via
RecordEvent) and this later loop emits AssetModified for the same entries;
either remove this AssetModified loop or make it mutually exclusive by using
IsNewlyCreatedAsset correctly so an asset only triggers
AssetImported/AssetCreated OR AssetModified; update the logic around
importedAssets, IsNewlyCreatedAsset, ShouldTrackModification and RecordEvent (or
adjust ShouldTrackModification) to ensure only one event type is recorded per
asset.
In `@MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs`:
- Around line 127-133: The pending-change cap check in PropertyChangeTracker is
insufficient because _pendingChanges.Count >= MaxPendingEntries only calls
FlushPendingChanges(), which currently only removes expired entries; update the
logic to enforce the cap by performing a real eviction or immediate flush when
the limit is reached: inside the block where MaxPendingEntries is checked
(referencing _pendingChanges, MaxPendingEntries and FlushPendingChanges()), call
a new or existing force flush method (e.g., ForceFlushPendingChanges()) that
writes out or clears all pending entries, or implement explicit eviction (remove
oldest entries from _pendingChanges until Count < MaxPendingEntries) before
adding the new entry so the cache cannot grow beyond the cap.
In `@MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs`:
- Around line 533-536: There is a stray preprocessor directive "#endregion"
placed after the closing brace of the UnityEventHooks class; remove that
trailing "#endregion" (or move it so it matches the corresponding "#region"
inside the class) to ensure the "#endregion" is inside the class scope and
eliminate the syntax error in UnityEventHooks.cs.
In `@MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs`:
- Around line 471-494: ExecuteAsync<T> creates a ToolCallScope but never
disposes it (scope leaks on both asynchronous and synchronous-failure paths);
fix by ensuring scope.Dispose() is always called: wrap the func() invocation so
synchronous exceptions are caught (try/catch around func()), and change the
continuation flow to guarantee disposal (either by converting ExecuteAsync<T> to
an async method that awaits func() and uses try { var result = await func();
scope.Complete(...); return result; } catch (Exception ex) { scope.Fail(...);
throw; } finally { scope.Dispose(); } or, if keeping ContinueWith, invoke
scope.Dispose() after calling scope.Complete/Fail inside every continuation
branch and also catch synchronous exceptions from func() to call scope.Fail(...)
and scope.Dispose() before rethrowing). Ensure you reference ExecuteAsync<T>,
ToolCallScope, scope, func(), ContinueWith when applying the fix.
In `@MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs`:
- Around line 155-158: The code uses mappingsSnapshot.GroupBy(m =>
m.EventSequence).ToDictionary(g => g.Key, g => g.FirstOrDefault()), which drops
additional mappings for the same EventSequence; update the logic to preserve all
mappings by changing the dictionary value to a collection (e.g., use g =>
g.ToList()) and update any dependent return types/usages of mappingBySequence to
accept IEnumerable/List of mappings, or if you intentionally want only one
mapping, explicitly document that behavior and keep FirstOrDefault; reference
mappingBySequence, mappingsSnapshot, EventSequence, and FirstOrDefault when
making the change.
In `@MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs`:
- Around line 70-113: DetectChanges is currently calling
FindObjectsOfType<GameObject>(true) every editor frame (via the
EditorApplication.update hook), which is expensive; update DetectChanges (and
the registration in EditorApplication.update) to short-circuit and avoid calling
FindObjectsOfType each frame by adding simple throttling or event-driven checks:
add a static/frame counter or lastRunTime check and return cached results until
N frames or T milliseconds have passed, or only run the full traversal when
specific editor events occur, then keep the existing logic that fills results
and updates _previousInstanceIds when the check actually runs; ensure the method
still asserts main thread via AssertMainThread and preserves behavior of marking
isNew by comparing against _previousInstanceIds.
In `@MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs`:
- Around line 94-323: WithAllSearchTerms currently adds terms into the same
_searchTerms set (making it behave like OR) and accepts null/empty entries;
change it to record required-all semantics and ignore null/empty terms.
Specifically, introduce or use a distinct collection/name (e.g., _allSearchTerms
or a boolean flag paired with _allSearchTerms) in the EventQueryBuilder and have
WithAllSearchTerms(params string[] terms) skip null/empty strings
(String.IsNullOrWhiteSpace) when adding; update the query evaluation logic to
check _allSearchTerms for an AND (all terms must match) requirement instead of
treating them as OR. Ensure WithSearchTerm retains case-insensitive HashSet
behavior and does not accept null/empty either.
In `@MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs`:
- Around line 414-447: FindTopTargets currently uses targetEventsList[^1] which
assumes the per-target events are chronological; instead sort each targetEvents
list by TimestampUnixMs (ascending) before calling CalculateRecencyFactor and
before reading the last event for LastActivityMs and DisplayName. Update
FindTopTargets to replace/derive targetEventsList with a timestamp-ordered
sequence (or in-place sort) and then compute recencyFactor =
CalculateRecencyFactor(orderedList, nowMs), ActivityScore, EventCount,
EventTypes, LastActivityMs and DisplayName from the orderedList.Last() (or
orderedList[^1]) so all derived values use the most recent event. Use the same
ordered list when building TargetStats to ensure consistency.
In `@MCPForUnity/Editor/ActionTrace/Query/EventSummarizer.cs`:
- Around line 102-112: The while-loop that replaces placeholders in sb can
infinite-loop if a formatted value contains the same placeholder; replace that
manual index loop with StringBuilder.Replace to perform a safe global
replacement: for each kvp in evt.Payload (or empty dict) compute placeholder =
"{" + kvp.Key + "}" and value = FormatValue(kvp.Value) and call
sb.Replace(placeholder, value). Ensure you keep the surrounding foreach over
evt.Payload (and the null-coalescing) and remove the manual index-based
removal/Insert logic to avoid the self-referential replacement bug.
In `@MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs`:
- Around line 119-121: The window currently forces
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true in
ActionTraceEditorWindow (causing a global side-effect); change this so the
previous value is preserved and restored: in ActionTraceEditorWindow add a
private bool (e.g. _previousBypassValue), capture
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter in OnEnable before
setting it to true, and restore that saved value in OnDisable (also ensure
EditorApplication.update handlers are balanced in OnEnable/OnDisable);
alternatively implement a query-level bypass inside the window’s data-fetching
path instead of touching
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter.
♻️ Duplicate comments (1)
MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs (1)
29-30: LGTM!The tool now includes a
Descriptionattribute for MCP compatibility, addressing the previous review feedback.
🟡 Minor comments (21)
MCPForUnity/Editor/Helpers/GlobalIdHelper.cs-152-161 (1)
152-161: Instance ID resolution may fail for non-GameObject objects.
GameObjectLookup.FindById(line 159) returns onlyGameObject, butGetFallbackIdcan store Instance IDs for anyUnityEngine.Objectincluding Components. Consider usingEditorUtility.InstanceIDToObjectdirectly for broader compatibility:🐛 Suggested fix
if (idStr.StartsWith(InstancePrefix)) { string instanceStr = idStr.Substring(InstancePrefix.Length); if (int.TryParse(instanceStr, out int instanceId)) { - return GameObjectLookup.FindById(instanceId); + return EditorUtility.InstanceIDToObject(instanceId); } }MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs-264-269 (1)
264-269: Missing timeout in PATH verification could block editor.The
git --versioncheck when verifying PATH availability has no timeout. If Git exists but hangs (e.g., waiting for credentials), this will block indefinitely.🐛 Suggested fix: Add timeout to PATH check
using (var process = Process.Start(startInfo)) { - process.WaitForExit(); - if (process.ExitCode == 0) - return "git"; // Found in PATH + if (process.WaitForExit(2000) && process.ExitCode == 0) + return "git"; // Found in PATH + try { process.Kill(); } catch { } }MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs-291-297 (1)
291-297: UnityEngine.Random is not thread-safe.
UnityEngine.Randomshould only be called from Unity's main thread. SinceToolCallScopeusesThreadLocalstorage (implying multi-thread usage), this could cause issues when scopes are created on background threads.🔧 Proposed fix using thread-safe random
+private static readonly System.Random _random = new(); +private static readonly object _randomLock = new(); private string GenerateCallId() { // Compact ID: tool name + timestamp + random suffix long timestamp = _startTimestampMs % 1000000; // Last 6 digits of timestamp - int random = UnityEngine.Random.Range(1000, 9999); + int random; + lock (_randomLock) + { + random = _random.Next(1000, 9999); + } return $"{_toolId}_{timestamp}_{random}"; }MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs-249-255 (1)
249-255: Menu separator syntax may not work correctly.
AppendAction("/", ...)likely won't render as a separator in Unity'sToolbarMenu. UseAppendSeparator()instead.🔧 Suggested fix
_filterMenu?.menu.AppendAction("All", a => SetImportance(0f)); - _filterMenu?.menu.AppendAction("/", a => { }); + _filterMenu?.menu.AppendSeparator(); _filterMenu?.menu.AppendAction("AI Can See", a => SetImportanceFromSettings());MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs-28-29 (1)
28-29:MainThreadIdcaptured at static initialization may be incorrect.If this class is first accessed from a background thread (e.g., during async asset import),
MainThreadIdwould capture the wrong thread ID. Consider lazy initialization on first use from a known main-thread context.🔧 Suggested fix
- // Cache for the main thread ID to validate thread safety - private static readonly int MainThreadId = System.Threading.Thread.CurrentThread.ManagedThreadId; + // Cache for the main thread ID to validate thread safety + private static int _mainThreadId = -1; + + private static int MainThreadId + { + get + { + if (_mainThreadId < 0) + { + // Initialize on first access - caller must be on main thread + _mainThreadId = System.Threading.Thread.CurrentThread.ManagedThreadId; + } + return _mainThreadId; + } + }Alternatively, initialize it in
InitializeTracking()which is documented to be called from the main thread.MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs-64-68 (1)
64-68: Potential data race:_isDirtymodified outside lock.
_isDirtyis written outside thelock (_queryLock)block. If another thread reads or writes_isDirtyconcurrently, this could lead to a data race. While typically benign on x86/x64, it's inconsistent with the thread-safety guarantees stated in the doc.🔒 Suggested fix
lock (_queryLock) { // ... existing code ... + + // Mark dirty inside lock for thread safety + _isDirty = true; } - // Mark dirty and schedule deferred save - _isDirty = true; ScheduleSave();MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs-78-87 (1)
78-87: Inconsistent locking for_saveScheduled.
ClearPendingOperationssets_saveScheduled = false(line 85) outside any lock, butScheduleSaveaccesses_saveScheduledunder_queryLock. This inconsistency could lead to race conditions.🐛 Suggested fix
public static void ClearPendingOperations() { lock (_pendingNotifications) { _pendingNotifications.Clear(); _notifyScheduled = false; } - _saveScheduled = false; + lock (_queryLock) + { + _saveScheduled = false; + } _lastDehydratedCount = -1; // Reset dehydration optimization marker }MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs-100-105 (1)
100-105: PotentialInvalidCastExceptionwhen castingmerge_count.When
merge_countis deserialized from JSON (e.g., after domain reload), Newtonsoft.Json may deserialize it aslongrather thanint. The direct cast(int)existingCountwill throw anInvalidCastExceptionin that case.🐛 Suggested fix
int mergeCount = 1; if (_lastRecordedEvent.Payload.TryGetValue("merge_count", out var existingCount)) { - mergeCount = (int)existingCount + 1; + mergeCount = Convert.ToInt32(existingCount) + 1; }MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs-135-148 (1)
135-148:IsNewlyCreatedAssetimplementation is non-functional.The method attempts to load
.metafiles viaAssetDatabase.LoadMainAssetAtPath, but.metafiles are not Unity assets and cannot be loaded this way—this will always returnnull. Additionally, even if it worked, the result isn't used before returningfalse.The current implementation always returns
false, which may be acceptable as a placeholder but should be documented more clearly or implemented properly.🔧 Suggested approach if implementation is needed
To detect newly created assets, consider using file creation timestamps:
private static bool IsNewlyCreatedAsset(string assetPath) { try { - string metaPath = assetPath + ".meta"; - var meta = AssetDatabase.LoadMainAssetAtPath(metaPath); - // This is a simplified check - in production you'd check file creation time - return false; // Default to treating as imported for now + string fullPath = System.IO.Path.GetFullPath(assetPath); + if (!System.IO.File.Exists(fullPath)) + return false; + + var creationTime = System.IO.File.GetCreationTimeUtc(fullPath); + var timeSinceCreation = DateTime.UtcNow - creationTime; + + // Consider "newly created" if created within the last 2 seconds + return timeSinceCreation.TotalSeconds < 2; } catch { return false; } }MCPForUnity/Editor/ActionTrace/Core/EventStore.Persistence.cs-186-203 (1)
186-203: Missing lock when reading shared state inSaveToStorage.
SaveToStoragereads_sequenceCounter,_events, and_contextMappingswithout holding_queryLock. While theToList()calls create copies, there's a race window where another thread could modify these collections during iteration.The comment in
ScheduleSave(line 66) says "Perform save outside lock to avoid holding lock during I/O", but the state snapshot should be taken inside the lock.🐛 Suggested fix
private static void SaveToStorage() { try { - var state = new EventStoreState + EventStoreState state; + lock (_queryLock) { - SchemaVersion = CurrentSchemaVersion, - SequenceCounter = _sequenceCounter, - Events = _events.ToList(), - ContextMappings = _contextMappings.ToList() - }; + state = new EventStoreState + { + SchemaVersion = CurrentSchemaVersion, + SequenceCounter = _sequenceCounter, + Events = _events.ToList(), + ContextMappings = _contextMappings.ToList() + }; + } + // I/O happens outside the lock McpJobStateStore.SaveState(StateKey, state); } catch (Exception ex) { McpLog.Error($"[EventStore] Failed to save to storage: {ex.Message}\n{ex.StackTrace}"); } }MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs-146-153 (1)
146-153: AddAINoteconstant toEventTypesclass for consistency with other event types.All event types in
Core/EventTypes.csare defined aspublic const stringconstants (e.g.,ComponentAdded,PropertyModified,GameObjectCreated), butAINoteis missing from this pattern and only appears in the metadata dictionary. Add the constantpublic const string AINote = "AINote";toEventTypesinMCPForUnity/Editor/ActionTrace/Core/EventTypes.cs, then useEventTypes.AINoteinstead of the hardcoded string here.MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs-393-417 (1)
393-417: Consider adding debug logging to troubleshoot missing compilation error type.
GetCompilationErrorCountrelies on reflection to access the internalUnityEditor.Scripting.ScriptCompilationErrorCounttype, which may not exist in all Unity versions. The fallback of returning 0 is safe, but adding debug logging when the type is not found would help diagnose issues in environments where the reflection fails. For example, log once whentype == nullusing the existingMcpLoginfrastructure.MCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs-55-61 (1)
55-61: Guard against null@paramsto preserve default behavior.If
HandleCommandis called withnull, the helpers throw and return an error instead of applying defaults.🐛 Proposed fix
public static object HandleCommand(JObject `@params`) { try { + `@params` ??= new JObject(); int limit = GetLimit(`@params`); long? sinceSequence = GetSinceSequence(`@params`);MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs-118-149 (1)
118-149: Preset name won’t survive domain reloads.
_currentPresetNameis[NonSerialized], soCurrentPresetNameresets to "Standard" even if values match another preset. Consider serializing it or recomputing on load.MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs-488-494 (1)
488-494: Build error details are dropped.Emitters use
error_message, butFindErrorsonly checkserror, so the detailed message is lost.♻️ Proposed fix
if (evt.Type == EventTypes.BuildFailed) { - if (evt.Payload != null && evt.Payload.TryGetValue("error", out var error)) - errors.Add($"Build: {error}"); - else - errors.Add("Build failed"); + if (evt.Payload != null && + (evt.Payload.TryGetValue("error", out var error) || + evt.Payload.TryGetValue("error_message", out error))) + errors.Add($"Build: {error}"); + else + errors.Add("Build failed"); }MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs-266-272 (1)
266-272: Clamp negative cold-event counts.If
HotEventCountexceedsMaxEvents(e.g., via manual asset edits),coldEventsbecomes negative and the estimate underflows.♻️ Proposed fix
int hotEvents = Storage.HotEventCount; int coldEvents = Storage.MaxEvents - Storage.HotEventCount; + if (coldEvents < 0) coldEvents = 0; return (long)(hotEvents * 300 + coldEvents * 100);MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs-258-276 (1)
258-276:GetSummarycan null‑ref on empty stats.
TopTargets/ErrorMessagesare null whenAnalyzereturns early; guard before.Count.♻️ Proposed fix
- if (stats.TopTargets.Count > 0) + if (stats.TopTargets != null && stats.TopTargets.Count > 0) { summary.AppendLine("Top Targets:"); foreach (var target in stats.TopTargets.Take(5)) { summary.AppendLine($" - {target.DisplayName}: {target.EventCount} events"); } summary.AppendLine(); } - if (stats.ErrorCount > 0) + if (stats.ErrorMessages != null && stats.ErrorCount > 0) { summary.AppendLine($"Errors: {stats.ErrorCount}"); foreach (var error in stats.ErrorMessages.Take(3)) { summary.AppendLine($" - {error}"); } summary.AppendLine(); }MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs-445-449 (1)
445-449: Null event types can throw under category filtering.
EventTypes.Metadata.Get(evt.Type)will throw ifevt.Typeis null; normalize before lookup.♻️ Proposed fix
- var meta = EventTypes.Metadata.Get(evt.Type); + var meta = EventTypes.Metadata.Get(evt.Type ?? string.Empty);MCPForUnity/Editor/ActionTrace/Query/ContextCompressor.cs-419-435 (1)
419-435: Guard againstwindowMs <= 0to avoid divide‑by‑zero.
evt.TimestampUnixMs / windowMswill throw for zero or negative values.♻️ Proposed fix
public static List<EditorEvent> Deduplicate(this IReadOnlyList<EditorEvent> events, int windowMs = 100) { if (events == null || events.Count == 0) return new List<EditorEvent>(); + if (windowMs <= 0) + windowMs = 1; + var seen = new HashSet<string>(); var result = new List<EditorEvent>();MCPForUnity/Editor/ActionTrace/Capture/ActionTraceEventEmitter.cs-509-515 (1)
509-515: UseToLowerInvariant()for file extension normalization.
ToLower()is culture-sensitive; file extensions should use invariant casing to avoid locale-specific bugs. This is the preferred pattern throughout the codebase.♻️ Proposed fix
- var extension = System.IO.Path.GetExtension(assetPath).ToLower(); + var extension = System.IO.Path.GetExtension(assetPath).ToLowerInvariant();MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.cs-206-214 (1)
206-214: Reorder asset creation before preset application.
CreateSettings()callsApplyPreset()which invokesSave()beforeAssetDatabase.CreateAsset()is executed. SinceSave()callsEditorUtility.SetDirty()andAssetDatabase.SaveAssets()on an asset not yet registered in the AssetDatabase, the preset values may not persist properly on first creation. Create the asset first.♻️ Proposed fix
private static ActionTraceSettings CreateSettings() { var settings = CreateInstance<ActionTraceSettings>(); + AssetDatabase.CreateAsset(settings, SettingsPath); // Apply Standard preset by default settings.ApplyPreset(ActionTracePreset.Standard); - AssetDatabase.CreateAsset(settings, SettingsPath); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); McpLog.Info($"[ActionTraceSettings] Created new settings at {SettingsPath}"); return settings; }
🧹 Nitpick comments (19)
MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs (2)
172-192: Registration failure is silently swallowed.If the reflection-based event registration fails (e.g., API change in a future Unity version), the zombie listener prevention won't work and there's no indication of the failure. Consider adding a debug-level warning in the catch block to aid troubleshooting.
💡 Suggested enhancement
} - catch { } + catch (Exception ex) + { + if (IsDebugEnabled()) + McpLog.Warn($"[StdioBridgeHost] Failed to register beforeAssemblyReload handler: {ex.Message}"); + }
521-544: LGTM - Status file preserved during domain reload.This correctly addresses the past review concern about the status file being deleted during reload. The guard ensures the Python server can detect the reloading state.
Minor note: The status file path computation logic (lines 528-533) duplicates code in
WriteHeartbeat. Consider extracting to a helper method for DRY, though this is optional.MCPForUnity/Editor/ActionTrace/VCS/VcsContextProvider.cs (1)
68-76: Thread-safety claim may be overstated but is practically safe.The comment claims thread-safety, but
GetCurrentContexthas a check-then-act pattern (lines 70-73) that isn't atomic. In practice this is benign—worst case is a redundantGetInitialContext()call. Consider usingInterlocked.CompareExchangeor??=pattern if strict thread-safety is required, or soften the comment.MCPForUnity/Editor/Helpers/GlobalIdHelper.cs (1)
224-230: Method nameIsValidIdmay be misleading.
IsValidIdonly checks for non-empty strings, not actual format validity. Consider renaming toIsNonEmptyor enhancing to verify the format matches one of the known patterns (GlobalObjectId, Scene:, Asset:, Instance:).MCPForUnity/Editor/ActionTrace/Capture/UndoGroupManager.cs (1)
51-70: NestedBeginToolCalloverwrites previous state without warning.If
BeginToolCallis called while already in a tool call, the previous state is silently overwritten. This could lead to orphaned Undo operations from the first call not being collapsed.♻️ Suggested fix: Warn or auto-end previous tool call
public static void BeginToolCall(string toolName, string toolCallId) { + if (_isInToolCall) + { + McpLog.Warn($"[UndoGroupManager] BeginToolCall called while already in tool call '{_currentToolName}'. Ending previous call."); + EndToolCall(); + } + if (string.IsNullOrEmpty(toolName)) {MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs (1)
272-289: Consider logging when Dispose finds scope not at stack top.The
Disposemethod silently skips popping ifthisisn't at the top of the stack (line 283). While properusing()patterns should prevent this, silent failure could hide bugs. Consider adding a warning log similar toContextStack.Pop's mismatch handling.♻️ Suggested diagnostic logging
// Pop from stack if (_scopeStack.Value.Count > 0 && _scopeStack.Value.Peek() == this) { _scopeStack.Value.Pop(); } +else if (_scopeStack.Value.Count > 0) +{ + UnityEngine.Debug.LogWarning( + $"[ToolCallScope] Dispose called but scope {CallId} is not at top of stack. " + + $"Top is {_scopeStack.Value.Peek().CallId}. Possible out-of-order disposal."); +}MCPForUnity/Editor/ActionTrace/Semantics/DefaultEventScorer.cs (1)
79-105: Consider case-insensitive extension matching.The extension checks (e.g.,
ext.ToString() == ".cs") are case-sensitive. While Unity typically uses lowercase extensions, imported assets from external sources might have uppercase extensions (e.g.,.CS,.Unity).♻️ Optional: Case-insensitive comparison
private static bool IsScript(EditorEvent e) { if (e.Payload.TryGetValue("extension", out var ext)) - return ext.ToString() == ".cs"; + return string.Equals(ext.ToString(), ".cs", StringComparison.OrdinalIgnoreCase); if (e.Payload.TryGetValue("asset_type", out var type)) return type.ToString()?.Contains("Script") == true || type.ToString()?.Contains("MonoScript") == true; return false; }Apply similar changes to
IsSceneandIsPrefab.MCPForUnity/Editor/ActionTrace/Capture/SelectionPropertyTracker.cs (1)
32-44: Event subscriptions are never unregistered.The static constructor subscribes to
Selection.selectionChangedandUndo.postprocessModificationsbut never unsubscribes. While Unity typically handles this during domain reloads, explicit cleanup in a domain-reload handler (AssemblyReloadEvents) would be more robust and prevent potential duplicate subscriptions.♻️ Suggested cleanup handling
static SelectionPropertyTracker() { // Initialize with current selection UpdateSelectionState(); // Monitor selection changes Selection.selectionChanged += OnSelectionChanged; // Monitor property modifications Undo.postprocessModifications += OnPropertyModified; + // Cleanup on domain reload + AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; + McpLog.Debug("[SelectionPropertyTracker] Initialized"); } +private static void OnBeforeAssemblyReload() +{ + Selection.selectionChanged -= OnSelectionChanged; + Undo.postprocessModifications -= OnPropertyModified; + AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; +}MCPForUnity/Editor/ActionTrace/Core/ActionTraceSettings.Editor.cs (1)
74-78: Redundant save when applying presets.
ApplyPresetalready callsMarkDirty()andSave()internally (seeActionTraceSettings.cslines 159-175). SettingGUI.changed = truewill causeApplyModifiedProperties()to return true, triggering anotherMarkDirty()+Save()cycle at lines 58-59.Consider removing the redundant call:
♻️ Suggested fix
if (GUILayout.Button(preset.Name)) { settings.ApplyPreset(preset); - GUI.changed = true; }MCPForUnity/Editor/ActionTrace/Core/Presets/ActionTracePreset.cs (1)
95-98: Consider makingAllPresetsimmutable to prevent accidental modification.
AllPresetsis a mutableList<ActionTracePreset>that external code could accidentally or maliciously modify (e.g.,AllPresets.Clear()). Since this is a public API, consider using an immutable collection.♻️ Suggested fix
- public static readonly List<ActionTracePreset> AllPresets = new() + public static IReadOnlyList<ActionTracePreset> AllPresets { get; } = new List<ActionTracePreset> { DebugAll, Standard, Lean, AIFocused, Realtime, Performance - }; + }.AsReadOnly();Note: This also requires updating
ActionTraceSettings.FindPresetto use LINQ'sFirstOrDefaultinstead ofList.Find:return ActionTracePreset.AllPresets.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs (1)
100-105: Misleading comment: "swap hash sets" doesn't match implementation.The comment says "swap hash sets to avoid allocation" but the code clears
_previousInstanceIdsand re-adds items fromcurrentIds. This is not a swap operation and does allocate (the foreach iteration). Consider updating the comment or implementing an actual swap.♻️ Suggested fix - update comment
- // Update tracking: swap hash sets to avoid allocation + // Update tracking: replace previous IDs with current _previousInstanceIds.Clear(); foreach (int id in currentIds) { _previousInstanceIds.Add(id); }MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs (1)
16-23: Consider using English for code comments.The enum and its members have Chinese comments. For consistency and broader team accessibility, consider using English.
♻️ Suggested change
/// <summary> - /// 排序模式:控制事件列表的排序方式 + /// Sort mode: controls event list ordering /// </summary> public enum SortMode { - /// <summary>纯时间排序(最新优先)- 给用户查看记录</summary> + /// <summary>Sort by time (newest first) - for user viewing</summary> ByTimeDesc, - /// <summary>AI 视角排序 - 先按时间再按重要性分组</summary> + /// <summary>AI perspective sorting - by time then importance grouping</summary> AIFiltered }MCPForUnity/Editor/ActionTrace/Core/EventStore.Merging.cs (1)
69-71: Unused parameterevtWithSequence.The parameter
evtWithSequenceis declared but never used in the method body. Either remove it or use it for its intended purpose (the doc comment mentions "for updating _lastRecordedEvent").♻️ Suggested fix
If the parameter is not needed:
- private static void MergeWithLastEventLocked(EditorEvent evt, EditorEvent evtWithSequence) + private static void MergeWithLastEventLocked(EditorEvent evt)Update callers accordingly.
MCPForUnity/Editor/Tools/AddTimelineNoteTool.cs (1)
35-78: Parameters class is defined but not used for deserialization.The
Parametersclass withToolParameterattributes is defined butHandleCommandmanually parses theJObjectinstead of deserializing into aParametersinstance. This creates duplication and potential drift between the two.Consider either:
- Using the
Parametersclass for deserialization- Removing the
Parametersclass if it's only for documentation/schema generationAlso applies to: 80-144
MCPForUnity/Editor/ActionTrace/Capture/SamplingMiddleware.cs (1)
360-401: Thread safety concern:Strategiesdictionary is not thread-safe.
SamplingConfig.Strategiesis a regularDictionary<string, SamplingStrategy>that's publicly accessible and mutated viaSetStrategy/RemoveStrategy. However, it's read fromShouldRecordandFlushExpiredDebounceSamples, which run onEditorApplication.update.While Unity Editor is mostly single-threaded, the use of
ConcurrentDictionaryfor_pendingSamplessuggests thread safety is a design concern. Consider usingConcurrentDictionaryforStrategiesas well, or documenting that modifications must only occur from the main thread.♻️ Suggested fix
- public static readonly Dictionary<string, SamplingStrategy> Strategies = new() + public static readonly ConcurrentDictionary<string, SamplingStrategy> Strategies = new( + new Dictionary<string, SamplingStrategy> { // Hierarchy changes: Throttle to 1 event per second { EventTypes.HierarchyChanged, new SamplingStrategy(SamplingMode.Throttle, 1000) }, - }; + });And update methods:
public static void SetStrategy(string eventType, SamplingMode mode, long windowMs = 1000) { Strategies[eventType] = new SamplingStrategy(mode, windowMs); } public static void RemoveStrategy(string eventType) { - Strategies.Remove(eventType); + Strategies.TryRemove(eventType, out _); }MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs (1)
198-212: UseDateTime.UtcNowinstead ofDateTime.Nowfor debouncing.Line 200 uses
DateTime.Nowwhile other timestamp operations in the codebase useDateTime.UtcNow(e.g., lines 343, 351, 428, 444). Using local time can cause issues during DST transitions.♻️ Suggested fix
private static void OnHierarchyChanged() { - var now = DateTime.Now; + var now = DateTime.UtcNow; lock (_lock) {MCPForUnity/Editor/ActionTrace/Capture/EventFilter.cs (1)
249-327: Consider caching active rules to avoid per-call allocations.
GetActiveRules()builds and sorts a list on every filter check. If these paths are hot, cache the computed list and invalidate it when settings or rules change.MCPForUnity/Editor/ActionTrace/Query/ContextCompressor.cs (1)
48-53: Unused config knob.
TargetCompressionRatioisn’t referenced; consider implementing or removing to avoid a misleading config surface.MCPForUnity/Editor/ActionTrace/Core/EventTypes.cs (1)
98-103: Avoid string literal duplication for AINote.
"AINote"is duplicated here and inMCPForUnity/Editor/Resources/ActionTrace/ActionTraceViewResource.cs. Add anEventTypes.AINoteconstant and use it here (and update the resource class) to prevent drift.♻️ Proposed fix (this file)
// Build events public const string BuildStarted = "BuildStarted"; public const string BuildCompleted = "BuildCompleted"; public const string BuildFailed = "BuildFailed"; + // AI/system events + public const string AINote = "AINote"; @@ - ["AINote"] = new EventMetadata + [AINote] = new EventMetadata { Category = EventCategory.System, DefaultImportance = 1.0f, SummaryTemplate = "AI Note{if:agent_id, ({agent_id})}: {note}", },
| // ========== Modified Assets ========== | ||
| // Track asset modifications separately (e.g., texture imports, prefab changes) | ||
| foreach (var assetPath in importedAssets) | ||
| { | ||
| if (string.IsNullOrEmpty(assetPath)) continue; | ||
|
|
||
| // Only track modifications for existing assets | ||
| if (!IsNewlyCreatedAsset(assetPath) && EventFilter.ShouldTrackAsset(assetPath)) | ||
| { | ||
| string targetId = $"Asset:{assetPath}"; | ||
| string assetType = GetAssetType(assetPath); | ||
|
|
||
| // Only record modifications for certain asset types | ||
| if (ShouldTrackModification(assetPath)) | ||
| { | ||
| var payload = new Dictionary<string, object> | ||
| { | ||
| ["path"] = assetPath, | ||
| ["extension"] = System.IO.Path.GetExtension(assetPath), | ||
| ["asset_type"] = assetType | ||
| }; | ||
|
|
||
| RecordEvent(EventTypes.AssetModified, targetId, payload); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate event emission for imported assets.
This section iterates importedAssets again and emits AssetModified events for non-newly-created assets. However, lines 56-58 already emit AssetImported for these same assets, resulting in duplicate events for the same import operation.
Since IsNewlyCreatedAsset always returns false, every imported asset that passes ShouldTrackModification will generate both an AssetImported event (line 58) and an AssetModified event (line 125).
Consider removing this section or making it mutually exclusive with the imported assets handling above.
🐛 Suggested fix
- // ========== Modified Assets ==========
- // Track asset modifications separately (e.g., texture imports, prefab changes)
- foreach (var assetPath in importedAssets)
- {
- if (string.IsNullOrEmpty(assetPath)) continue;
-
- // Only track modifications for existing assets
- if (!IsNewlyCreatedAsset(assetPath) && EventFilter.ShouldTrackAsset(assetPath))
- {
- string targetId = $"Asset:{assetPath}";
- string assetType = GetAssetType(assetPath);
-
- // Only record modifications for certain asset types
- if (ShouldTrackModification(assetPath))
- {
- var payload = new Dictionary<string, object>
- {
- ["path"] = assetPath,
- ["extension"] = System.IO.Path.GetExtension(assetPath),
- ["asset_type"] = assetType
- };
-
- RecordEvent(EventTypes.AssetModified, targetId, payload);
- }
- }
- }If you need to distinguish modifications from imports, implement IsNewlyCreatedAsset properly first, then use it to emit either AssetCreated/AssetImported OR AssetModified, but not both.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // ========== Modified Assets ========== | |
| // Track asset modifications separately (e.g., texture imports, prefab changes) | |
| foreach (var assetPath in importedAssets) | |
| { | |
| if (string.IsNullOrEmpty(assetPath)) continue; | |
| // Only track modifications for existing assets | |
| if (!IsNewlyCreatedAsset(assetPath) && EventFilter.ShouldTrackAsset(assetPath)) | |
| { | |
| string targetId = $"Asset:{assetPath}"; | |
| string assetType = GetAssetType(assetPath); | |
| // Only record modifications for certain asset types | |
| if (ShouldTrackModification(assetPath)) | |
| { | |
| var payload = new Dictionary<string, object> | |
| { | |
| ["path"] = assetPath, | |
| ["extension"] = System.IO.Path.GetExtension(assetPath), | |
| ["asset_type"] = assetType | |
| }; | |
| RecordEvent(EventTypes.AssetModified, targetId, payload); | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Capture/AssetChangePostprocessor.cs` around
lines 103 - 128, The code emits duplicate events for the same imported assets
because importedAssets are processed earlier to emit AssetImported (via
RecordEvent) and this later loop emits AssetModified for the same entries;
either remove this AssetModified loop or make it mutually exclusive by using
IsNewlyCreatedAsset correctly so an asset only triggers
AssetImported/AssetCreated OR AssetModified; update the logic around
importedAssets, IsNewlyCreatedAsset, ShouldTrackModification and RecordEvent (or
adjust ShouldTrackModification) to ensure only one event type is recorded per
asset.
| // Enforce cache limit to prevent unbounded growth | ||
| if (_pendingChanges.Count >= MaxPendingEntries) | ||
| { | ||
| // Force flush before adding new entry | ||
| FlushPendingChanges(); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce MaxPendingEntries even when debounce hasn’t expired.
When the cache hits the limit, FlushPendingChanges() only removes expired entries, so under continuous edits the cache can still grow past the cap. Consider a force-flush or explicit eviction path when the limit is reached.
🐛 Proposed fix
- if (_pendingChanges.Count >= MaxPendingEntries)
- {
- // Force flush before adding new entry
- FlushPendingChanges();
- }
+ if (_pendingChanges.Count >= MaxPendingEntries)
+ {
+ // Force flush before adding new entry
+ FlushPendingChanges(force: true);
+ }- private static void FlushPendingChanges()
+ private static void FlushPendingChanges(bool force = false)
{
if (_pendingChanges.Count == 0)
return;
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var kvp in _pendingChanges)
{
// Check if entry has expired (no updates for DebounceWindowMs)
- if (nowMs - kvp.Value.LastUpdateMs >= DebounceWindowMs)
+ if (force || nowMs - kvp.Value.LastUpdateMs >= DebounceWindowMs)
{
// Record the PropertyModified event
RecordPropertyModifiedEvent(kvp.Value);Also applies to: 159-170
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Capture/PropertyChangeTracker.cs` around lines
127 - 133, The pending-change cap check in PropertyChangeTracker is insufficient
because _pendingChanges.Count >= MaxPendingEntries only calls
FlushPendingChanges(), which currently only removes expired entries; update the
logic to enforce the cap by performing a real eviction or immediate flush when
the limit is reached: inside the block where MaxPendingEntries is checked
(referencing _pendingChanges, MaxPendingEntries and FlushPendingChanges()), call
a new or existing force flush method (e.g., ForceFlushPendingChanges()) that
writes out or clears all pending entries, or implement explicit eviction (remove
oldest entries from _pendingChanges until Count < MaxPendingEntries) before
adding the new entry so the cache cannot grow beyond the cap.
| } | ||
|
|
||
| #endregion | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Syntax error: stray #endregion outside class.
Line 535 has an #endregion directive after the class closing brace, which will cause a compilation error.
🐛 Suggested fix
}
- }
`#endregion`
+ }
}Or remove the stray #endregion if it's not needed:
}
}
-
- `#endregion`
}🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs` around lines 533 -
536, There is a stray preprocessor directive "#endregion" placed after the
closing brace of the UnityEventHooks class; remove that trailing "#endregion"
(or move it so it matches the corresponding "#region" inside the class) to
ensure the "#endregion" is inside the class scope and eliminate the syntax error
in UnityEventHooks.cs.
| public static System.Threading.Tasks.Task<T> ExecuteAsync<T>( | ||
| string toolName, | ||
| Func<System.Threading.Tasks.Task<T>> func, | ||
| string toolId = null, | ||
| Dictionary<string, object> parameters = null) | ||
| { | ||
| var scope = new ToolCallScope(toolName, toolId, parameters); | ||
|
|
||
| var task = func(); | ||
|
|
||
| return task.ContinueWith(t => | ||
| { | ||
| if (t.IsFaulted) | ||
| { | ||
| scope.Fail(t.Exception?.Message ?? "Async faulted"); | ||
| throw t.Exception ?? new Exception("Async task faulted"); | ||
| } | ||
| else | ||
| { | ||
| scope.Complete(t.Result?.ToString() ?? ""); | ||
| return t.Result; | ||
| } | ||
| }, System.Threading.Tasks.TaskScheduler.Default); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ExecuteAsync leaks the scope on all paths.
The ToolCallScope is created but never disposed. The ContinueWith callback completes or fails the scope but doesn't call Dispose(), leaving it on the stack. Additionally, if func() throws synchronously before returning a Task, the scope leaks entirely.
🐛 Proposed fix for async scope management
public static System.Threading.Tasks.Task<T> ExecuteAsync<T>(
string toolName,
Func<System.Threading.Tasks.Task<T>> func,
string toolId = null,
Dictionary<string, object> parameters = null)
{
var scope = new ToolCallScope(toolName, toolId, parameters);
- var task = func();
-
- return task.ContinueWith(t =>
+ System.Threading.Tasks.Task<T> task;
+ try
+ {
+ task = func();
+ }
+ catch (Exception ex)
{
- if (t.IsFaulted)
- {
- scope.Fail(t.Exception?.Message ?? "Async faulted");
- throw t.Exception ?? new Exception("Async task faulted");
- }
- else
+ scope.Fail(ex.Message);
+ scope.Dispose();
+ throw;
+ }
+
+ return task.ContinueWith(t =>
+ {
+ try
{
- scope.Complete(t.Result?.ToString() ?? "");
- return t.Result;
+ if (t.IsFaulted)
+ {
+ scope.Fail(t.Exception?.InnerException?.Message ?? "Async faulted");
+ throw t.Exception?.InnerException ?? new Exception("Async task faulted");
+ }
+ else
+ {
+ scope.Complete(t.Result?.ToString() ?? "");
+ return t.Result;
+ }
+ }
+ finally
+ {
+ scope.Dispose();
}
}, System.Threading.Tasks.TaskScheduler.Default);
}🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Context/ToolCallScope.cs` around lines 471 -
494, ExecuteAsync<T> creates a ToolCallScope but never disposes it (scope leaks
on both asynchronous and synchronous-failure paths); fix by ensuring
scope.Dispose() is always called: wrap the func() invocation so synchronous
exceptions are caught (try/catch around func()), and change the continuation
flow to guarantee disposal (either by converting ExecuteAsync<T> to an async
method that awaits func() and uses try { var result = await func();
scope.Complete(...); return result; } catch (Exception ex) { scope.Fail(...);
throw; } finally { scope.Dispose(); } or, if keeping ContinueWith, invoke
scope.Dispose() after calling scope.Complete/Fail inside every continuation
branch and also catch synchronous exceptions from func() to call scope.Fail(...)
and scope.Dispose() before rethrowing). Ensure you reference ExecuteAsync<T>,
ToolCallScope, scope, func(), ContinueWith when applying the fix.
| // Build lookup dictionary outside lock | ||
| var mappingBySequence = mappingsSnapshot | ||
| .GroupBy(m => m.EventSequence) | ||
| .ToDictionary(g => g.Key, g => g.FirstOrDefault()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FirstOrDefault discards multiple context mappings per event.
The class doc at line 28-29 states "Multiple mappings allowed for same eventSequence (different contexts)", but FirstOrDefault() only returns one mapping per event sequence. This loses context information when an event is associated with multiple contexts.
🐛 Suggested fix: Return all mappings or change the return type
Option 1 - Keep first mapping (document behavior):
- // Build lookup dictionary outside lock
- var mappingBySequence = mappingsSnapshot
- .GroupBy(m => m.EventSequence)
- .ToDictionary(g => g.Key, g => g.FirstOrDefault());
+ // Build lookup dictionary outside lock (returns first mapping per event)
+ var mappingBySequence = mappingsSnapshot
+ .GroupBy(m => m.EventSequence)
+ .ToDictionary(g => g.Key, g => g.First());Option 2 - Return all mappings (requires signature change):
// Change return type to include all mappings
var mappingBySequence = mappingsSnapshot
.GroupBy(m => m.EventSequence)
.ToDictionary(g => g.Key, g => g.ToList());🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Core/EventStore.Context.cs` around lines 155 -
158, The code uses mappingsSnapshot.GroupBy(m => m.EventSequence).ToDictionary(g
=> g.Key, g => g.FirstOrDefault()), which drops additional mappings for the same
EventSequence; update the logic to preserve all mappings by changing the
dictionary value to a collection (e.g., use g => g.ToList()) and update any
dependent return types/usages of mappingBySequence to accept IEnumerable/List of
mappings, or if you intentionally want only one mapping, explicitly document
that behavior and keep FirstOrDefault; reference mappingBySequence,
mappingsSnapshot, EventSequence, and FirstOrDefault when making the change.
| public static List<(GameObject obj, bool isNew)> DetectChanges() | ||
| { | ||
| AssertMainThread(); | ||
|
|
||
| if (!_hasInitialized) | ||
| { | ||
| InitializeTracking(); | ||
| return new List<(GameObject, bool)>(0); | ||
| } | ||
|
|
||
| var results = new List<(GameObject, bool)>(64); | ||
| var currentIds = new HashSet<int>(256); | ||
|
|
||
| try | ||
| { | ||
| GameObject[] currentObjects = GameObject.FindObjectsOfType<GameObject>(true); | ||
|
|
||
| // First pass: collect current IDs and detect new objects | ||
| foreach (var go in currentObjects) | ||
| { | ||
| if (go == null) continue; | ||
|
|
||
| int id = go.GetInstanceID(); | ||
| currentIds.Add(id); | ||
|
|
||
| // HashSet.Contains() is O(1) vs List.Contains() O(n) | ||
| bool isNew = !_previousInstanceIds.Contains(id); | ||
| results.Add((go, isNew)); | ||
| } | ||
|
|
||
| // Update tracking: swap hash sets to avoid allocation | ||
| _previousInstanceIds.Clear(); | ||
| foreach (int id in currentIds) | ||
| { | ||
| _previousInstanceIds.Add(id); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Debug.LogError($"[ActionTrace] Failed to detect GameObject changes: {ex.Message}"); | ||
| } | ||
|
|
||
| return results; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how DetectChanges is called to understand frequency
rg -n "DetectChanges\(\)" --type=cs -C3Repository: CoplayDev/unity-mcp
Length of output: 1458
🏁 Script executed:
#!/bin/bash
# Check how TrackGameObjectChanges is called and what invokes it
rg -n "TrackGameObjectChanges" --type=cs -B5 -A5Repository: CoplayDev/unity-mcp
Length of output: 1607
🏁 Script executed:
#!/bin/bash
# Read UnityEventHooks.cs to understand the context and frequency of calls
rg -n "class UnityEventHooks|void.*\(\)|private static void\|public static void" MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs -B2 -A10 | head -100Repository: CoplayDev/unity-mcp
Length of output: 3211
🏁 Script executed:
#!/bin/bash
# Search for registration of OnUpdate event
rg -n "OnUpdate|EditorApplication.update" MCPForUnity/Editor/ActionTrace/Capture/UnityEventHooks.cs -B2 -A2Repository: CoplayDev/unity-mcp
Length of output: 507
FindObjectsOfType is called every editor frame and will impact performance.
FindObjectsOfType<GameObject>(true) is registered to EditorApplication.update and runs unconditionally every frame (see line 70 in UnityEventHooks.cs). This method traverses the entire scene hierarchy and allocates an array on each invocation. In large scenes with many GameObjects, this will noticeably impact editor responsiveness. Consider adding throttling (e.g., checking changes only every N frames or on time-based intervals) or only running detection when specific events occur rather than every frame.
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Helpers/GameObjectTrackingHelper.cs` around
lines 70 - 113, DetectChanges is currently calling
FindObjectsOfType<GameObject>(true) every editor frame (via the
EditorApplication.update hook), which is expensive; update DetectChanges (and
the registration in EditorApplication.update) to short-circuit and avoid calling
FindObjectsOfType each frame by adding simple throttling or event-driven checks:
add a static/frame counter or lastRunTime check and return cached results until
N frames or T milliseconds have passed, or only run the full traversal when
specific editor events occur, then keep the existing logic that fills results
and updates _previousInstanceIds when the check actually runs; ensure the method
still asserts main thread via AssertMainThread and preserves behavior of marking
isNew by comparing against _previousInstanceIds.
| private HashSet<string> _searchTerms; | ||
| private float? _minImportance; | ||
| private float? _maxImportance; | ||
| private QueryTimeRange? _timeRange; | ||
| private QuerySortOrder _sortOrder = QuerySortOrder.NewestFirst; | ||
| private int? _limit; | ||
| private int? _offset; | ||
|
|
||
| public EventQueryBuilder(IEventScorer scorer = null, IEventCategorizer categorizer = null) | ||
| { | ||
| _scorer = scorer ?? new DefaultEventScorer(); | ||
| _categorizer = categorizer ?? new DefaultCategorizer(); | ||
| } | ||
|
|
||
| // ========== Type Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events of the specified type. | ||
| /// </summary> | ||
| public EventQueryBuilder OfType(string eventType) | ||
| { | ||
| _includedTypes ??= new HashSet<string>(); | ||
| _includedTypes.Add(eventType); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events of any of the specified types. | ||
| /// </summary> | ||
| public EventQueryBuilder OfTypes(params string[] eventTypes) | ||
| { | ||
| _includedTypes ??= new HashSet<string>(); | ||
| foreach (string type in eventTypes) | ||
| _includedTypes.Add(type); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Exclude events of the specified type. | ||
| /// </summary> | ||
| public EventQueryBuilder NotOfType(string eventType) | ||
| { | ||
| _excludedTypes ??= new HashSet<string>(); | ||
| _excludedTypes.Add(eventType); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Exclude events of any of the specified types. | ||
| /// </summary> | ||
| public EventQueryBuilder NotOfTypes(params string[] eventTypes) | ||
| { | ||
| _excludedTypes ??= new HashSet<string>(); | ||
| foreach (string type in eventTypes) | ||
| _excludedTypes.Add(type); | ||
| return this; | ||
| } | ||
|
|
||
| // ========== Category Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events in the specified category. | ||
| /// </summary> | ||
| public EventQueryBuilder InCategory(EventCategory category) | ||
| { | ||
| _includedCategories ??= new HashSet<EventCategory>(); | ||
| _includedCategories.Add(category); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events in any of the specified categories. | ||
| /// </summary> | ||
| public EventQueryBuilder InCategories(params EventCategory[] categories) | ||
| { | ||
| _includedCategories ??= new HashSet<EventCategory>(); | ||
| foreach (var cat in categories) | ||
| _includedCategories.Add(cat); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Exclude events in the specified category. | ||
| /// </summary> | ||
| public EventQueryBuilder NotInCategory(EventCategory category) | ||
| { | ||
| _excludedCategories ??= new HashSet<EventCategory>(); | ||
| _excludedCategories.Add(category); | ||
| return this; | ||
| } | ||
|
|
||
| // ========== Target Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events for the specified target ID. | ||
| /// </summary> | ||
| public EventQueryBuilder ForTarget(string targetId) | ||
| { | ||
| _includedTargets ??= new HashSet<string>(); | ||
| _includedTargets.Add(targetId); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events for any of the specified targets. | ||
| /// </summary> | ||
| public EventQueryBuilder ForTargets(params string[] targetIds) | ||
| { | ||
| _includedTargets ??= new HashSet<string>(); | ||
| foreach (string id in targetIds) | ||
| _includedTargets.Add(id); | ||
| return this; | ||
| } | ||
|
|
||
| // ========== Importance Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events with minimum importance score. | ||
| /// </summary> | ||
| public EventQueryBuilder WithMinImportance(float minScore) | ||
| { | ||
| _minImportance = minScore; | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events with maximum importance score. | ||
| /// </summary> | ||
| public EventQueryBuilder WithMaxImportance(float maxScore) | ||
| { | ||
| _maxImportance = maxScore; | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events within an importance range. | ||
| /// </summary> | ||
| public EventQueryBuilder WithImportanceBetween(float minScore, float maxScore) | ||
| { | ||
| _minImportance = minScore; | ||
| _maxImportance = maxScore; | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to critical events only. | ||
| /// </summary> | ||
| public EventQueryBuilder CriticalOnly() | ||
| { | ||
| return WithMinImportance(0.9f); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to important events (high and critical). | ||
| /// </summary> | ||
| public EventQueryBuilder ImportantOnly() | ||
| { | ||
| return WithMinImportance(0.7f); | ||
| } | ||
|
|
||
| // ========== Time Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events within the specified time range. | ||
| /// </summary> | ||
| public EventQueryBuilder InTimeRange(QueryTimeRange range) | ||
| { | ||
| _timeRange = range; | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events in the last N minutes. | ||
| /// </summary> | ||
| public EventQueryBuilder InLastMinutes(int minutes) | ||
| { | ||
| _timeRange = QueryTimeRange.LastMinutes(minutes); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events in the last N hours. | ||
| /// </summary> | ||
| public EventQueryBuilder InLastHours(int hours) | ||
| { | ||
| _timeRange = QueryTimeRange.LastHours(hours); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events since a specific timestamp. | ||
| /// </summary> | ||
| public EventQueryBuilder Since(long timestampMs) | ||
| { | ||
| _timeRange = QueryTimeRange.Since(timestampMs); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events between two timestamps. | ||
| /// </summary> | ||
| public EventQueryBuilder Between(long startMs, long endMs) | ||
| { | ||
| _timeRange = QueryTimeRange.Between(startMs, endMs); | ||
| return this; | ||
| } | ||
|
|
||
| // ========== Search Filters ========== | ||
|
|
||
| /// <summary> | ||
| /// Filter to events containing any of the search terms (case-insensitive). | ||
| /// Searches in summary text and target ID. | ||
| /// </summary> | ||
| public EventQueryBuilder WithSearchTerm(string term) | ||
| { | ||
| _searchTerms ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| _searchTerms.Add(term); | ||
| return this; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Filter to events containing all of the search terms. | ||
| /// </summary> | ||
| public EventQueryBuilder WithAllSearchTerms(params string[] terms) | ||
| { | ||
| _searchTerms ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| foreach (string term in terms) | ||
| _searchTerms.Add(term); | ||
| return this; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WithAllSearchTerms behaves like OR and accepts null terms.
This contradicts the API contract and can throw on null entries; track “all terms” semantics and ignore empty terms.
🐛 Proposed fix
private HashSet<string> _includedTargets;
private HashSet<string> _searchTerms;
+ private bool _requireAllSearchTerms;
private float? _minImportance;
private float? _maxImportance;
@@
public EventQueryBuilder WithSearchTerm(string term)
{
- _searchTerms ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- _searchTerms.Add(term);
+ if (string.IsNullOrWhiteSpace(term)) return this;
+ _searchTerms ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+ _requireAllSearchTerms = false;
+ _searchTerms.Add(term);
return this;
}
@@
public EventQueryBuilder WithAllSearchTerms(params string[] terms)
{
_searchTerms ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- foreach (string term in terms)
- _searchTerms.Add(term);
+ _requireAllSearchTerms = true;
+ foreach (string term in terms)
+ if (!string.IsNullOrWhiteSpace(term))
+ _searchTerms.Add(term);
return this;
}
@@
if (_searchTerms != null && _searchTerms.Count > 0)
{
string summary = (evt.GetSummary() ?? "").ToLowerInvariant();
string target = (evt.TargetId ?? "").ToLowerInvariant();
- bool matchesAny = false;
+ bool matchesAny = false;
+ bool matchesAll = true;
foreach (string term in _searchTerms)
{
string lowerTerm = term.ToLowerInvariant();
- if (summary.Contains(lowerTerm) || target.Contains(lowerTerm))
- {
- matchesAny = true;
- break;
- }
+ bool matched = summary.Contains(lowerTerm) || target.Contains(lowerTerm);
+ matchesAny |= matched;
+ matchesAll &= matched;
+ if (!_requireAllSearchTerms && matchesAny) break;
+ if (_requireAllSearchTerms && !matchesAll) break;
}
- if (!matchesAny)
+ if (_requireAllSearchTerms ? !matchesAll : !matchesAny)
return false;
}Also applies to: 478-495
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Query/EventQueryBuilder.cs` around lines 94 -
323, WithAllSearchTerms currently adds terms into the same _searchTerms set
(making it behave like OR) and accepts null/empty entries; change it to record
required-all semantics and ignore null/empty terms. Specifically, introduce or
use a distinct collection/name (e.g., _allSearchTerms or a boolean flag paired
with _allSearchTerms) in the EventQueryBuilder and have
WithAllSearchTerms(params string[] terms) skip null/empty strings
(String.IsNullOrWhiteSpace) when adding; update the query evaluation logic to
check _allSearchTerms for an AND (all terms must match) requirement instead of
treating them as OR. Ensure WithSearchTerm retains case-insensitive HashSet
behavior and does not accept null/empty either.
| private List<TargetStats> FindTopTargets(IReadOnlyList<EditorEvent> events) | ||
| { | ||
| var targetEvents = new Dictionary<string, List<EditorEvent>>(); | ||
|
|
||
| foreach (var evt in events) | ||
| { | ||
| if (string.IsNullOrEmpty(evt.TargetId)) continue; | ||
|
|
||
| if (!targetEvents.TryGetValue(evt.TargetId, out var list)) | ||
| { | ||
| list = new List<EditorEvent>(); | ||
| targetEvents[evt.TargetId] = list; | ||
| } | ||
| list.Add(evt); | ||
| } | ||
|
|
||
| var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); | ||
| var stats = new List<TargetStats>(); | ||
|
|
||
| foreach (var kvp in targetEvents) | ||
| { | ||
| var targetEventsList = kvp.Value; | ||
| double recencyFactor = CalculateRecencyFactor(targetEventsList, nowMs); | ||
| double activityScore = targetEventsList.Count * recencyFactor; | ||
|
|
||
| stats.Add(new TargetStats | ||
| { | ||
| TargetId = kvp.Key, | ||
| DisplayName = GetDisplayName(targetEventsList[^1]), | ||
| EventCount = targetEventsList.Count, | ||
| EventTypes = targetEventsList.Select(e => e.Type).Distinct().ToList(), | ||
| LastActivityMs = targetEventsList[^1].TimestampUnixMs, | ||
| ActivityScore = activityScore | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Top‑target recency uses list order, not timestamp order.
targetEventsList[^1] depends on input order, so LastActivityMs, DisplayName, and ActivityScore can be wrong if the input isn’t chronological. Sort per‑target lists by timestamp before using the last item.
🐛 Proposed fix
foreach (var kvp in targetEvents)
{
var targetEventsList = kvp.Value;
- double recencyFactor = CalculateRecencyFactor(targetEventsList, nowMs);
+ var orderedTargetEvents = targetEventsList.OrderBy(e => e.TimestampUnixMs).ToList();
+ double recencyFactor = CalculateRecencyFactor(orderedTargetEvents, nowMs);
double activityScore = targetEventsList.Count * recencyFactor;
stats.Add(new TargetStats
{
TargetId = kvp.Key,
- DisplayName = GetDisplayName(targetEventsList[^1]),
+ DisplayName = GetDisplayName(orderedTargetEvents[^1]),
EventCount = targetEventsList.Count,
- EventTypes = targetEventsList.Select(e => e.Type).Distinct().ToList(),
- LastActivityMs = targetEventsList[^1].TimestampUnixMs,
+ EventTypes = orderedTargetEvents.Select(e => e.Type).Distinct().ToList(),
+ LastActivityMs = orderedTargetEvents[^1].TimestampUnixMs,
ActivityScore = activityScore
});
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private List<TargetStats> FindTopTargets(IReadOnlyList<EditorEvent> events) | |
| { | |
| var targetEvents = new Dictionary<string, List<EditorEvent>>(); | |
| foreach (var evt in events) | |
| { | |
| if (string.IsNullOrEmpty(evt.TargetId)) continue; | |
| if (!targetEvents.TryGetValue(evt.TargetId, out var list)) | |
| { | |
| list = new List<EditorEvent>(); | |
| targetEvents[evt.TargetId] = list; | |
| } | |
| list.Add(evt); | |
| } | |
| var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); | |
| var stats = new List<TargetStats>(); | |
| foreach (var kvp in targetEvents) | |
| { | |
| var targetEventsList = kvp.Value; | |
| double recencyFactor = CalculateRecencyFactor(targetEventsList, nowMs); | |
| double activityScore = targetEventsList.Count * recencyFactor; | |
| stats.Add(new TargetStats | |
| { | |
| TargetId = kvp.Key, | |
| DisplayName = GetDisplayName(targetEventsList[^1]), | |
| EventCount = targetEventsList.Count, | |
| EventTypes = targetEventsList.Select(e => e.Type).Distinct().ToList(), | |
| LastActivityMs = targetEventsList[^1].TimestampUnixMs, | |
| ActivityScore = activityScore | |
| }); | |
| private List<TargetStats> FindTopTargets(IReadOnlyList<EditorEvent> events) | |
| { | |
| var targetEvents = new Dictionary<string, List<EditorEvent>>(); | |
| foreach (var evt in events) | |
| { | |
| if (string.IsNullOrEmpty(evt.TargetId)) continue; | |
| if (!targetEvents.TryGetValue(evt.TargetId, out var list)) | |
| { | |
| list = new List<EditorEvent>(); | |
| targetEvents[evt.TargetId] = list; | |
| } | |
| list.Add(evt); | |
| } | |
| var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); | |
| var stats = new List<TargetStats>(); | |
| foreach (var kvp in targetEvents) | |
| { | |
| var targetEventsList = kvp.Value; | |
| var orderedTargetEvents = targetEventsList.OrderBy(e => e.TimestampUnixMs).ToList(); | |
| double recencyFactor = CalculateRecencyFactor(orderedTargetEvents, nowMs); | |
| double activityScore = targetEventsList.Count * recencyFactor; | |
| stats.Add(new TargetStats | |
| { | |
| TargetId = kvp.Key, | |
| DisplayName = GetDisplayName(orderedTargetEvents[^1]), | |
| EventCount = targetEventsList.Count, | |
| EventTypes = orderedTargetEvents.Select(e => e.Type).Distinct().ToList(), | |
| LastActivityMs = orderedTargetEvents[^1].TimestampUnixMs, | |
| ActivityScore = activityScore | |
| }); |
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Query/EventStatistics.cs` around lines 414 -
447, FindTopTargets currently uses targetEventsList[^1] which assumes the
per-target events are chronological; instead sort each targetEvents list by
TimestampUnixMs (ascending) before calling CalculateRecencyFactor and before
reading the last event for LastActivityMs and DisplayName. Update FindTopTargets
to replace/derive targetEventsList with a timestamp-ordered sequence (or
in-place sort) and then compute recencyFactor =
CalculateRecencyFactor(orderedList, nowMs), ActivityScore, EventCount,
EventTypes, LastActivityMs and DisplayName from the orderedList.Last() (or
orderedList[^1]) so all derived values use the most recent event. Use the same
ordered list when building TargetStats to ensure consistency.
| // Handle regular placeholders | ||
| foreach (var kvp in evt.Payload ?? new Dictionary<string, object>()) | ||
| { | ||
| string placeholder = "{" + kvp.Key + "}"; | ||
| int index = 0; | ||
| while ((index = sb.ToString().IndexOf(placeholder, index, StringComparison.Ordinal)) >= 0) | ||
| { | ||
| string value = FormatValue(kvp.Value); | ||
| sb.Remove(index, placeholder.Length); | ||
| sb.Insert(index, value); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid potential infinite loop when placeholder value contains itself.
The current replacement loop can spin indefinitely if a payload value contains its own placeholder (e.g., value = "{foo}"). Using StringBuilder.Replace avoids this hazard and is simpler.
🐛 Proposed fix
- foreach (var kvp in evt.Payload ?? new Dictionary<string, object>())
- {
- string placeholder = "{" + kvp.Key + "}";
- int index = 0;
- while ((index = sb.ToString().IndexOf(placeholder, index, StringComparison.Ordinal)) >= 0)
- {
- string value = FormatValue(kvp.Value);
- sb.Remove(index, placeholder.Length);
- sb.Insert(index, value);
- }
- }
+ foreach (var kvp in evt.Payload ?? new Dictionary<string, object>())
+ {
+ string placeholder = "{" + kvp.Key + "}";
+ string value = FormatValue(kvp.Value);
+ sb.Replace(placeholder, value);
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Handle regular placeholders | |
| foreach (var kvp in evt.Payload ?? new Dictionary<string, object>()) | |
| { | |
| string placeholder = "{" + kvp.Key + "}"; | |
| int index = 0; | |
| while ((index = sb.ToString().IndexOf(placeholder, index, StringComparison.Ordinal)) >= 0) | |
| { | |
| string value = FormatValue(kvp.Value); | |
| sb.Remove(index, placeholder.Length); | |
| sb.Insert(index, value); | |
| } | |
| // Handle regular placeholders | |
| foreach (var kvp in evt.Payload ?? new Dictionary<string, object>()) | |
| { | |
| string placeholder = "{" + kvp.Key + "}"; | |
| string value = FormatValue(kvp.Value); | |
| sb.Replace(placeholder, value); | |
| } |
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/ActionTrace/Query/EventSummarizer.cs` around lines 102 -
112, The while-loop that replaces placeholders in sb can infinite-loop if a
formatted value contains the same placeholder; replace that manual index loop
with StringBuilder.Replace to perform a safe global replacement: for each kvp in
evt.Payload (or empty dict) compute placeholder = "{" + kvp.Key + "}" and value
= FormatValue(kvp.Value) and call sb.Replace(placeholder, value). Ensure you
keep the surrounding foreach over evt.Payload (and the null-coalescing) and
remove the manual index-based removal/Insert logic to avoid the self-referential
replacement bug.
| // Always record all events, filter at query time based on mode | ||
| if (ActionTraceSettings.Instance != null) | ||
| ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Side effect: Window modifies global BypassImportanceFilter setting.
Opening this window permanently changes ActionTraceSettings.Instance.Filtering.BypassImportanceFilter to true, affecting all consumers of ActionTraceSettings even after the window is closed. This is unexpected behavior for a viewer window.
🐛 Suggested approaches
Option 1 - Restore on disable:
private bool _previousBypassValue;
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
if (ActionTraceSettings.Instance != null)
{
_previousBypassValue = ActionTraceSettings.Instance.Filtering.BypassImportanceFilter;
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true;
}
}
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
if (ActionTraceSettings.Instance != null)
{
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = _previousBypassValue;
}
}Option 2 - Query all events without modifying settings:
Use a separate query path that bypasses filtering at the query level rather than modifying global settings.
🤖 Prompt for AI Agents
In `@MCPForUnity/Editor/Windows/ActionTraceEditorWindow.cs` around lines 119 -
121, The window currently forces
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter = true in
ActionTraceEditorWindow (causing a global side-effect); change this so the
previous value is preserved and restored: in ActionTraceEditorWindow add a
private bool (e.g. _previousBypassValue), capture
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter in OnEnable before
setting it to true, and restore that saved value in OnDisable (also ensure
EditorApplication.update handlers are balanced in OnEnable/OnDisable);
alternatively implement a query-level bypass inside the window’s data-fetching
path instead of touching
ActionTraceSettings.Instance.Filtering.BypassImportanceFilter.
OverviewActionTrace is an event tracing and recording system for the Unity Editor, designed to capture, store, and query editor operation events. This system is built for AI-assisted development scenarios, providing traceability of operations and semantic understanding capabilities. Design Principles
Module Architecturetext ActionTrace/ ├── Core/ # Core data models and storage ├── Capture/ # Unity event capture and filtering ├── Context/ # Operation context management ├── Semantics/ # Semantic analysis (scoring, categorization, intent inference) ├── Query/ # Query and projection engine ├── VCS/ # Version control integration ├── Helpers/ # Utility classes └── Descriptors/ # Event descriptors File Inventory (40 CS Files)Core Module (10 files)
File | Description
-- | --
EventTypes.cs | Event type constant definitions
EventMetadata.cs | Event metadata (labels, templates)
EditorEvent.cs | Immutable event class supporting dehydration
EventStore.cs | Core API for event storage
EventStore.Merging.cs | Event merging/deduplication logic
EventStore.Persistence.cs | Persistence and domain reload survival
EventStore.Context.cs | Context mapping management
EventStore.Diagnostics.cs | Memory diagnostics and dehydration
ActionTraceSettings.cs | Persistent settings configuration (layered properties)
SamplingMode.cs | Sampling mode enumeration
Performance Characteristics
Key Design Decisions
|
#553
ActionTrace System - Feature Overview
Overview
ActionTrace is a comprehensive Unity Editor event tracking and replay system. It captures, stores, and queries editor operations for debugging, analysis, and undo/replay capabilities.
Core Functionality
1. Event Capture
2. Event Storage
3. Query & Analysis
4. Semantic Analysis
Component | Purpose -- | -- Categorizer | Classifies events (scene edit, asset import, etc.) Scorer | Assigns importance (critical, high, medium, low) Intent Inferrer | Derives user intent from action patternsDesign Principles
Use Cases
Here is the English translation of your architecture overview, maintaining the original formatting and structure:
Data Storage Location
Storage Architecture
┌─────────────────────────────────────────────────────────┐
│ In-Memory Storage │
│ ┌─────────────────────────────────────────────────┐ │
│ │ List<EditorEvent> _events │ │
│ │ - Latest 100: Full payload (Hot Data) │ │
│ │ - Older events: Dehydrated payload=null (Cold) │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ List<ContextMapping> _contextMappings │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ Delayed Save (delayCall)
▼
┌─────────────────────────────────────────────────────────┐
│ Library/McpState_timeline_events.json │
│ (JSON Serialization, Persistent across Domain Reload)│
└─────────────────────────────────────────────────────────┘
Specific Locations
Type | Path | Description -- | -- | -- Runtime Memory | List | Thread-safe collection Persistence | Library/McpState_timeline_events.json | JSON format, auto-savedSummary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.