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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions MCPForUnity/Editor/Tools/ReadConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ public static class ReadConsole
private static FieldInfo _messageField;
private static FieldInfo _fileField;
private static FieldInfo _lineField;

// Optional reflection members: used to neutralize the Console window's own filters
// while reading. Absent members degrade to the previous (filter-inheriting) behavior.
private static PropertyInfo _consoleFlagsProperty;
private static MethodInfo _setFilteringTextMethod;
private static MethodInfo _getFilteringTextMethod;

// Severity bits from the internal UnityEditor.ConsoleWindow.ConsoleFlags enum.
private const int ConsoleFlagLogLevelLog = 1 << 7;
private const int ConsoleFlagLogLevelWarning = 1 << 8;
private const int ConsoleFlagLogLevelError = 1 << 9;
private const int ConsoleFlagLogLevelMask =
ConsoleFlagLogLevelLog | ConsoleFlagLogLevelWarning | ConsoleFlagLogLevelError;

// Static constructor for reflection setup
static ReadConsole()
Expand Down Expand Up @@ -99,6 +112,13 @@ static ReadConsole()
if (_lineField == null)
throw new Exception("Failed to reflect LogEntry.line");

// Console window UI state. Present on every Unity version this package
// supports, but reflected optionally so a future rename degrades to the old
// behavior rather than disabling console reads outright.
_consoleFlagsProperty = logEntriesType.GetProperty("consoleFlags", staticFlags);
_setFilteringTextMethod = logEntriesType.GetMethod("SetFilteringText", staticFlags);
_getFilteringTextMethod = logEntriesType.GetMethod("GetFilteringText", staticFlags);

// (Calibration removed)

}
Expand All @@ -115,6 +135,8 @@ static ReadConsole()
_getEntryMethod =
null;
_modeField = _messageField = _fileField = _lineField = null;
_consoleFlagsProperty = null;
_setFilteringTextMethod = _getFilteringTextMethod = null;
}
}

Expand Down Expand Up @@ -202,6 +224,99 @@ public static object HandleCommand(JObject @params)

// --- Action Implementations ---

/// <summary>
/// Forces the Console window's Log/Warning/Error severity bits on so that
/// StartGettingEntries reports every entry regardless of the toolbar toggles.
/// </summary>
/// <param name="savedConsoleFlags">The flags as they were, for restoration.</param>
/// <returns>True when the flags were changed and must be restored.</returns>
private static bool TryForceLogLevelFlags(out int savedConsoleFlags)
{
savedConsoleFlags = 0;
if (_consoleFlagsProperty == null)
{
return false;
}

try
{
savedConsoleFlags = (int)_consoleFlagsProperty.GetValue(null);
int forcedFlags = savedConsoleFlags | ConsoleFlagLogLevelMask;
if (forcedFlags == savedConsoleFlags)
{
return false; // Nothing is hidden; leave the property untouched.
}

_consoleFlagsProperty.SetValue(null, forcedFlags);
return true;
}
catch (Exception e)
{
McpLog.Warn(
$"[ReadConsole] Could not override console severity flags; entries hidden by the Console window may be missing: {e.Message}"
);
return false;
}
}

private static void RestoreConsoleFlags(int savedConsoleFlags)
{
try
{
_consoleFlagsProperty.SetValue(null, savedConsoleFlags);
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Failed to restore console severity flags: {e}");
}
}

/// <summary>
/// Clears the Console window's search query for the duration of a read. Only clears it
/// when it can also be read back, so a user's search is never silently discarded.
/// </summary>
/// <param name="savedFilteringText">The query as it was, for restoration.</param>
/// <returns>True when the query was cleared and must be restored.</returns>
private static bool TryClearFilteringText(out string savedFilteringText)
{
savedFilteringText = null;
if (_setFilteringTextMethod == null || _getFilteringTextMethod == null)
{
return false;
}

try
{
savedFilteringText = _getFilteringTextMethod.Invoke(null, null) as string;
if (string.IsNullOrEmpty(savedFilteringText))
{
return false; // No search query active; nothing to neutralize.
}

_setFilteringTextMethod.Invoke(null, new object[] { string.Empty });
return true;
}
catch (Exception e)
{
McpLog.Warn(
$"[ReadConsole] Could not clear the console search filter; entries hidden by it may be missing: {e.Message}"
);
return false;
}
}

private static void RestoreFilteringText(string savedFilteringText)
{
try
{
_setFilteringTextMethod.Invoke(null, new object[] { savedFilteringText });
}
catch (Exception e)
{
McpLog.Error($"[ReadConsole] Failed to restore the console search filter: {e}");
}
}

private static object ClearConsole()
{
try
Expand Down Expand Up @@ -246,8 +361,20 @@ bool includeStacktrace
int resolvedCursor = Mathf.Max(0, cursor ?? 0);
int pageEndExclusive = resolvedCursor + resolvedPageSize;

// LogEntries filtering state is global and shared with the Console window, so a
// severity toggle switched off or a leftover search query in the toolbar silently
// starves this tool of entries. Neutralize both for the duration of the read; the
// tool applies its own 'types' and 'filterText' arguments instead.
int savedConsoleFlags = 0;
bool consoleFlagsOverridden = false;
string savedFilteringText = null;
bool filteringTextOverridden = false;

try
{
consoleFlagsOverridden = TryForceLogLevelFlags(out savedConsoleFlags);
filteringTextOverridden = TryClearFilteringText(out savedFilteringText);

// LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal.
// StartGettingEntries() returns the entry count — use it instead of GetCount()
// which may return stale values within an active iteration session.
Expand Down Expand Up @@ -392,6 +519,18 @@ bool includeStacktrace
McpLog.Error($"[ReadConsole] Failed to call EndGettingEntries: {e}");
// Don't return error here as we might have valid data, but log it.
}

// Restore the Console window's filters once the iteration session is closed,
// so the user's view is left exactly as they had it.
if (filteringTextOverridden)
{
RestoreFilteringText(savedFilteringText);
}

if (consoleFlagsOverridden)
{
RestoreConsoleFlags(savedConsoleFlags);
}
}

if (usePaging)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Reflection;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Tools;
using static MCPForUnityTests.Editor.TestUtilities;
Expand Down Expand Up @@ -152,5 +154,114 @@ public void GetLogTypeFromMode_UnknownBits_FallBackToLog()
Assert.AreEqual(LogType.Log, ReadConsole.GetLogTypeFromMode(1 << 14));
}

// The Console window's severity toggles and search box live on the shared internal
// UnityEditor.LogEntries state, so they leak into every StartGettingEntries caller.
// read_console must neutralize them for the duration of a read and restore them after.

private const int ConsoleFlagLogLevelLog = 1 << 7;
private const int ConsoleFlagLogLevelWarning = 1 << 8;

private static Type LogEntriesType =>
typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries");

private static PropertyInfo ConsoleFlagsProperty => LogEntriesType.GetProperty(
"consoleFlags", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);

private static MethodInfo SetFilteringTextMethod => LogEntriesType.GetMethod(
"SetFilteringText", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);

private static MethodInfo GetFilteringTextMethod => LogEntriesType.GetMethod(
"GetFilteringText", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);

private static int ConsoleFlags
{
get => (int)ConsoleFlagsProperty.GetValue(null);
set => ConsoleFlagsProperty.SetValue(null, value);
}

private static string FilteringText
{
get => (string)GetFilteringTextMethod.Invoke(null, null);
set => SetFilteringTextMethod.Invoke(null, new object[] { value });
}

private static JArray GetAllEntries()
{
var result = ToJObject(ReadConsole.HandleCommand(new JObject
{
["action"] = "get",
["types"] = new JArray { "error", "warning", "log" },
["format"] = "detailed",
["count"] = 1000
}));
Assert.IsTrue(result.Value<bool>("success"), result.ToString());
return result["data"] as JArray;
}

private static bool ContainsMessage(JArray entries, string needle)
{
if (entries == null) return false;
foreach (var entry in entries)
{
if (entry["message"]?.ToString().Contains(needle) == true) return true;
}
return false;
}

[Test]
public void HandleCommand_Get_IgnoresConsoleSearchFilter()
{
string uniqueMessage = $"Search filter probe {Guid.NewGuid()}";
string unrelatedQuery = $"no-entry-matches-{Guid.NewGuid()}";
string originalFilter = FilteringText;

try
{
Debug.Log(uniqueMessage);
FilteringText = unrelatedQuery;

var entries = GetAllEntries();

Assert.IsTrue(
ContainsMessage(entries, uniqueMessage),
"read_console must return entries hidden by the Console window's search query.");
Assert.AreEqual(
unrelatedQuery,
FilteringText,
"read_console must leave the user's console search query untouched.");
}
finally
{
FilteringText = originalFilter ?? string.Empty;
}
}

[Test]
public void HandleCommand_Get_IgnoresConsoleSeverityToggles()
{
string uniqueMessage = $"Severity toggle probe {Guid.NewGuid()}";
int originalFlags = ConsoleFlags;
int hiddenFlags = originalFlags & ~(ConsoleFlagLogLevelLog | ConsoleFlagLogLevelWarning);

try
{
Debug.Log(uniqueMessage);
ConsoleFlags = hiddenFlags;

var entries = GetAllEntries();

Assert.IsTrue(
ContainsMessage(entries, uniqueMessage),
"read_console must return entries hidden by the Console window's severity toggles.");
Assert.AreEqual(
hiddenFlags,
ConsoleFlags,
"read_console must restore the Console window's severity toggles.");
}
finally
{
ConsoleFlags = originalFlags;
}
}
}
}
Loading