diff --git a/.github/proof/pr-1130/history-collision-68351789.png b/.github/proof/pr-1130/history-collision-68351789.png new file mode 100644 index 000000000..53db6da1b Binary files /dev/null and b/.github/proof/pr-1130/history-collision-68351789.png differ diff --git a/.github/proof/pr-1130/history-collision-68351789.txt b/.github/proof/pr-1130/history-collision-68351789.txt new file mode 100644 index 000000000..3cf9cb90e --- /dev/null +++ b/.github/proof/pr-1130/history-collision-68351789.txt @@ -0,0 +1,13 @@ +head=683517894bd13dfeeb9996d3934bdac5b4b4863b +dirty=sha256:F10E5599298252AA5BA0941ABB4B159A6B9545C3BF0D546D05409914429A1BEC; files=4; base=4712e227d110746400e93107c60616544d68f7b4 +product-version=0.6.13-bkudiess-finalize-history-cache-correlation.1+159.Branch.bkudiess-finalize-history-cache-correlation.Sha.683517894bd13dfeeb9996d3934bdac5b4b4863b.683517894bd13dfeeb9996d3934bdac5b4b4863b +proof-scope=production reducer, activity projection, and Reactor renderer; allocator=focused provider regression +visual=two production tool cards plus the synthetic output text, composed without coordinate cropping +UIA expanded="Activity: Ran 2 commands. 2 tools. Collapsed." +UIA expanded="Tool call Exec. Interrupted." +UIA expanded="Tool call Bash. Done." +UIA tool-row-count=2 +UIA structured="automationId=ChatToolCall_e3; state=Interrupted; output=absent" +UIA synthetic="automationId=ChatToolCall_e4; state=Done; output=flattened output owned by history-tool-1" +screenshot=history-collision.png bytes=43204 +result=pass diff --git a/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs new file mode 100644 index 000000000..a21b7d9de --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/AccessibilityHistoryCollisionFixture.cs @@ -0,0 +1,261 @@ +using OpenClaw.Chat; +using OpenClaw.Shared; +using OpenClawTray.Services; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OpenClawTray.Chat; + +internal static class AccessibilityHistoryCollisionFixture +{ + internal const string FixtureName = "history-collision"; + internal const string ThreadId = "accessibility-main"; + + internal static OpenClawChatDataProvider Create( + string isolatedDataDirectory, + Action? post = null) + { + ValidateIsolationGate(isolatedDataDirectory); + return CreateCore(isolatedDataDirectory, post).Provider; + } + + internal static (OpenClawChatDataProvider Provider, Bridge GatewayBridge) CreateWithBridge( + string isolatedDataDirectory, + Action? post = null) + { + ValidateIsolationGate(isolatedDataDirectory); + return CreateCore(isolatedDataDirectory, post); + } + + internal static OpenClawChatDataProvider CreateForTesting( + string isolatedDataDirectory, + Func environmentLookup) + { + ValidateIsolationGate(isolatedDataDirectory, environmentLookup); + return CreateCore(isolatedDataDirectory, post: null).Provider; + } + + internal static (OpenClawChatDataProvider Provider, Bridge GatewayBridge) CreateWithBridgeForTesting( + string isolatedDataDirectory, + Func environmentLookup) + { + ValidateIsolationGate(isolatedDataDirectory, environmentLookup); + return CreateCore(isolatedDataDirectory, post: null); + } + + private static (OpenClawChatDataProvider Provider, Bridge GatewayBridge) CreateCore( + string isolatedDataDirectory, + Action? post) + { + Directory.CreateDirectory(isolatedDataDirectory); + + var bridge = new Bridge(); + var provider = new OpenClawChatDataProvider( + bridge, + post, + toolMetaCacheFilePath: Path.Combine( + isolatedDataDirectory, + "accessibility-history-collision-tool-metadata.json"), + attachmentMetaCacheFilePath: Path.Combine( + isolatedDataDirectory, + "accessibility-history-collision-attachment-metadata.json"), + lastChatStateFilePath: Path.Combine( + isolatedDataDirectory, + "accessibility-history-collision-last-chat-state.json")); + + provider.CacheToolMeta( + ThreadId, + tsMs: 100, + toolName: "Exec", + label: "Verified structured history call", + toolCallId: "history-tool-0", + toolArgs: new JsonObject + { + ["command"] = "verified structured id: history-tool-0", + }, + identityStrength: ChatToolIdentityStrength.Specific); + provider.CacheToolMeta( + ThreadId, + tsMs: 200, + toolName: "Bash", + label: "Flattened history output", + toolCallId: "unverified-cached-flat-id", + toolArgs: new JsonObject + { + ["command"] = "synthetic flattened id: history-tool-1", + }, + identityStrength: ChatToolIdentityStrength.Specific, + runId: "unverified-cached-run"); + + return (provider, bridge); + } + + private static void ValidateIsolationGate(string isolatedDataDirectory) + => ValidateIsolationGate( + isolatedDataDirectory, + Environment.GetEnvironmentVariable); + + private static void ValidateIsolationGate( + string isolatedDataDirectory, + Func environmentLookup) + { + var configuredDataDirectory = + environmentLookup("OPENCLAW_TRAY_DATA_DIR"); + if (!string.Equals( + environmentLookup("OPENCLAW_ACCESSIBILITY_TEST_CHAT"), + "1", + StringComparison.Ordinal) + || !string.Equals( + environmentLookup("OPENCLAW_ACCESSIBILITY_TEST_CHAT_FIXTURE"), + FixtureName, + StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(configuredDataDirectory) + || !string.Equals( + Path.GetFullPath(isolatedDataDirectory), + Path.GetFullPath(configuredDataDirectory), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The history collision fixture requires the isolated accessibility test gate."); + } + } + + internal sealed class Bridge : IChatGatewayBridge + { + private static readonly SessionInfo[] Sessions = + [ + new() + { + Key = ThreadId, + IsMain = true, + DisplayName = "Accessibility session", + Status = "active", + Model = "test-model", + }, + ]; + + public bool IsConnected => true; + public ConnectionStatus CurrentStatus => ConnectionStatus.Connected; + public string MainSessionKey => ThreadId; + public bool HasHandshakeSnapshot => true; + public int HistoryRequestCount { get; private set; } + public List RequestedHistoryKeys { get; } = []; + + public SessionInfo[] GetSessionList() => Sessions; + public ModelsListInfo? GetCurrentModelsList() => null; + public void StartProactiveBootstrap() { } + + public Task RequestChatHistoryAsync(string? sessionKey) + { + HistoryRequestCount++; + RequestedHistoryKeys.Add(sessionKey); + return Task.FromResult(new ChatHistoryInfo + { + SessionId = "accessibility-history-collision-session", + SessionKey = ThreadId, + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "history-tool-0", + ToolName = "Exec", + Args = ParseArgs( + """{"command":"verified structured id: history-tool-0"}"""), + }, + ], + }, + new ChatMessageInfo + { + Role = "toolresult", + Text = "flattened output owned by history-tool-1", + Ts = 200, + }, + ], + }); + } + + public Task SendChatMessageAsync( + string message, + string? sessionKey, + string? sessionId, + IReadOnlyList? attachments = null) + => Task.CompletedTask; + + public Task SendChatMessageForRunAsync( + string message, + string? sessionKey, + string? sessionId, + IReadOnlyList? attachments = null, + string? idempotencyKey = null) + => Task.FromResult(new ChatSendResult()); + + public Task ListCommandsAsync(CommandCatalogQuery? query = null) + => Task.FromResult(new CommandCatalog { IsSupported = false }); + + public Task PatchSessionModelAsync(string sessionKey, string model) => + Task.CompletedTask; + + public Task ClearSessionModelAsync(string sessionKey) => + Task.CompletedTask; + + public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => + Task.CompletedTask; + + public Task SendChatAbortAsync(string runId, string? sessionKey = null) => + Task.CompletedTask; + + public Task ResolveExecApprovalAsync(string approvalId, string decision) => + Task.CompletedTask; + + public void Dispose() { } + + public event EventHandler? StatusChanged + { + add { } + remove { } + } + + public event EventHandler? SessionsUpdated + { + add { } + remove { } + } + + public event EventHandler? SessionCommandCompleted + { + add { } + remove { } + } + + public event EventHandler? ChatMessageReceived + { + add { } + remove { } + } + + public event EventHandler? AgentEventReceived + { + add { } + remove { } + } + + public event EventHandler? ModelsListUpdated + { + add { } + remove { } + } + + private static JsonElement ParseArgs(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index d45094474..75910fd01 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -1168,10 +1168,26 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr bool nextAssistantIsAborted = false; var attachmentMatcher = CreateAttachmentMetaMatcher(history.SessionId, threadId); var pendingUnkeyedToolCalls = new Queue(); + var replayParts = ChatHistoryReplayProjection.Project(ordered).ToList(); + var reservedToolCallIds = replayParts + .SelectMany(static part => part.ToolContent) + .Select(static tool => tool.CallId) + .Where(static callId => !string.IsNullOrWhiteSpace(callId)) + .ToHashSet(StringComparer.Ordinal); var syntheticToolCallSequence = 0; ChatMessageInfo? suppressedAbortedAssistant = null; - foreach (var replayPart in ChatHistoryReplayProjection.Project(ordered)) + string AllocateSyntheticToolCallId() + { + while (true) + { + var candidate = $"history-tool-{syntheticToolCallSequence++}"; + if (reservedToolCallIds.Add(candidate)) + return candidate; + } + } + + foreach (var replayPart in replayParts) { var msg = replayPart.Message; if (suppressedAbortedAssistant is not null) @@ -1317,6 +1333,7 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr var cached = TryMatchCachedTool(cachedTools, msg.Ts); var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text); var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text); + var historyToolCallId = AllocateSyntheticToolCallId(); Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}' cached={cached is not null}"); rebuilt = ApplyAndCaptureMeta( rebuilt, @@ -1324,16 +1341,14 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr label, kind, ToolArgs: cached?.ToolArgs, - ToolCallId: cached?.ToolCallId, - IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind), - RunId: cached?.RunId), + ToolCallId: historyToolCallId, + IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind)), msgMeta); rebuilt = ApplyAndCaptureMeta( rebuilt, new ChatToolOutputEvent( text, - ToolCallId: cached?.ToolCallId, - RunId: cached?.RunId), + ToolCallId: historyToolCallId), msgMeta); break; } @@ -1376,6 +1391,7 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr var cached = TryMatchCachedTool(cachedTools, msg.Ts); var kind = cached?.ToolName ?? NativeToolProjector.ClassifyFlattenedToolOutput(text); var label = cached?.Label ?? NativeToolProjector.ExtractFlattenedToolSummary(text); + var historyToolCallId = AllocateSyntheticToolCallId(); Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}' cached={cached is not null})"); rebuilt = ApplyAndCaptureMeta( rebuilt, @@ -1383,16 +1399,14 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr label, kind, ToolArgs: cached?.ToolArgs, - ToolCallId: cached?.ToolCallId, - IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind), - RunId: cached?.RunId), + ToolCallId: historyToolCallId, + IdentityStrength: cached?.IdentityStrength ?? NativeToolProjector.ClassifyHistoryIdentityStrength(kind)), msgMeta); rebuilt = ApplyAndCaptureMeta( rebuilt, new ChatToolOutputEvent( text, - ToolCallId: cached?.ToolCallId, - RunId: cached?.RunId), + ToolCallId: historyToolCallId), msgMeta); } break; @@ -1424,14 +1438,18 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr { if (toolBlock.Kind == ChatToolContentKind.Call) { - _ = TryMatchCachedTool(cachedTools, msg.Ts); var args = ConvertToolArgs(toolBlock.Args); var callId = toolBlock.CallId; if (string.IsNullOrWhiteSpace(callId)) { - callId = $"history-tool-{syntheticToolCallSequence++}"; + _ = TryMatchCachedTool(cachedTools, msg.Ts); + callId = AllocateSyntheticToolCallId(); pendingUnkeyedToolCalls.Enqueue(callId); } + else + { + _ = TryMatchCachedToolByCallId(cachedTools, callId); + } rebuilt = ApplyAndCaptureMeta( rebuilt, new ChatToolStartEvent( @@ -1444,27 +1462,31 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr else { var callId = toolBlock.CallId; - if (string.IsNullOrWhiteSpace(callId)) + var hasVerifiedCallId = !string.IsNullOrWhiteSpace(callId); + if (!hasVerifiedCallId) { callId = pendingUnkeyedToolCalls.Count > 0 ? pendingUnkeyedToolCalls.Dequeue() - : $"history-tool-{syntheticToolCallSequence++}"; + : AllocateSyntheticToolCallId(); } + var resolvedCallId = callId!; var correlationKey = new ChatToolCorrelationKey( RunId: null, LegacyTurn: rebuilt.ToolLegacyTurn, - ToolCallId: callId); + ToolCallId: resolvedCallId); if (!rebuilt.ActiveToolCalls.ContainsKey(correlationKey)) { - var cached = TryMatchCachedTool(cachedTools, msg.Ts); + var cached = hasVerifiedCallId + ? TryMatchCachedToolByCallId(cachedTools, resolvedCallId) + : TryMatchCachedTool(cachedTools, msg.Ts); var toolName = cached?.ToolName ?? toolBlock.ToolName; rebuilt = ApplyAndCaptureMeta( rebuilt, new ChatToolStartEvent( cached?.Label ?? toolName, toolName, - ToolCallId: callId), + ToolCallId: resolvedCallId), msgMeta); } @@ -1472,8 +1494,8 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr rebuilt = ApplyAndCaptureMeta( rebuilt, toolBlock.IsError - ? new ChatToolErrorEvent(output, callId) - : new ChatToolOutputEvent(output, callId), + ? new ChatToolErrorEvent(output, resolvedCallId) + : new ChatToolOutputEvent(output, resolvedCallId), msgMeta); } } @@ -6687,6 +6709,11 @@ internal sealed class CachedToolMeta public ChatToolIdentityStrength IdentityStrength { get; set; } = ChatToolIdentityStrength.Heuristic; } + private readonly record struct CachedToolCorrelationIdentity( + string? RunId, + string ToolCallId, + long LegacyTurn); + /// Attachment display metadata persisted without attachment bytes. internal sealed class CachedAttachmentMeta { @@ -7193,24 +7220,105 @@ internal void CacheToolMeta( if (string.IsNullOrEmpty(sessionId) && string.IsNullOrEmpty(threadId)) return null; lock (_gate) { - var entries = new List(); + IReadOnlyList? sessionEntries = null; + IReadOnlyList? threadEntries = null; if (!string.IsNullOrEmpty(sessionId) && - _toolMetaCache.TryGetValue(sessionId!, out var sessionEntries)) + _toolMetaCache.TryGetValue(sessionId!, out var cachedSessionEntries)) { - entries.AddRange(sessionEntries); + sessionEntries = cachedSessionEntries; } if (!string.IsNullOrEmpty(threadId) && (string.IsNullOrEmpty(sessionId) || !string.Equals(sessionId, threadId, StringComparison.Ordinal)) && - _toolMetaCache.TryGetValue(threadId, out var threadEntries)) + _toolMetaCache.TryGetValue(threadId, out var cachedThreadEntries)) { - entries.AddRange(threadEntries); + threadEntries = cachedThreadEntries; } - if (entries.Count > 0) - return new Queue(entries.OrderBy(e => e.Ts)); + return BuildCachedToolQueue(sessionEntries, threadEntries); } - return null; + } + + internal static Queue? BuildCachedToolQueue( + IReadOnlyList? sessionEntries, + IReadOnlyList? threadEntries) + { + var ordered = (sessionEntries ?? Array.Empty()) + .Concat(threadEntries ?? Array.Empty()) + .OrderBy(entry => entry.Ts); + var merged = new List(); + var stableIdentities = new Dictionary(); + + foreach (var source in ordered) + { + var entry = CloneCachedToolMeta(source); + if (!TryGetCachedToolCorrelationIdentity(entry, out var identity) || + !stableIdentities.TryGetValue(identity, out var existingIndex)) + { + if (TryGetCachedToolCorrelationIdentity(entry, out identity)) + stableIdentities[identity] = merged.Count; + merged.Add(entry); + continue; + } + + MergeCachedToolMeta(merged[existingIndex], entry); + } + + return merged.Count == 0 ? null : new Queue(merged); + } + + private static bool TryGetCachedToolCorrelationIdentity( + CachedToolMeta entry, + out CachedToolCorrelationIdentity identity) + { + if (string.IsNullOrWhiteSpace(entry.ToolCallId)) + { + identity = default; + return false; + } + + var runId = string.IsNullOrWhiteSpace(entry.RunId) ? null : entry.RunId; + identity = new CachedToolCorrelationIdentity( + runId, + entry.ToolCallId, + runId is null ? entry.LegacyTurn : 0); + return true; + } + + private static CachedToolMeta CloneCachedToolMeta(CachedToolMeta source) + { + var identity = NativeToolProjector.CanonicalizeToolIdentity( + NormalizeCachedDisplayText(source.ToolName), + source.IdentityStrength); + return new CachedToolMeta + { + Ts = source.Ts, + ToolName = identity.Name, + Label = NativeToolProjector.SanitizeToolDisplayValue( + NormalizeCachedDisplayText(source.Label)), + ToolCallId = source.ToolCallId, + RunId = string.IsNullOrWhiteSpace(source.RunId) ? null : source.RunId, + LegacyTurn = string.IsNullOrWhiteSpace(source.RunId) ? source.LegacyTurn : 0, + ToolArgs = NormalizeCachedToolArgs(source.ToolArgs), + IdentityStrength = identity.Strength + }; + } + + private static void MergeCachedToolMeta(CachedToolMeta existing, CachedToolMeta incoming) + { + if (incoming.IdentityStrength > existing.IdentityStrength) + { + existing.ToolName = incoming.ToolName; + existing.IdentityStrength = incoming.IdentityStrength; + } + else if (string.IsNullOrWhiteSpace(existing.ToolName)) + { + existing.ToolName = incoming.ToolName; + } + + if (!string.IsNullOrWhiteSpace(incoming.Label)) + existing.Label = incoming.Label; + existing.ToolArgs = MergeCachedToolArgs(existing.ToolArgs, incoming.ToolArgs); } /// @@ -7232,11 +7340,37 @@ internal void CacheToolMeta( if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000) return null; // cached entry is >5 min after this history entry — not a match - var match = cache.Dequeue(); - match.ToolName = NormalizeCachedDisplayText(match.ToolName); - match.Label = NormalizeCachedDisplayText(match.Label); - match.ToolArgs = NormalizeCachedToolArgs(match.ToolArgs); - return match; + return CloneCachedToolMeta(cache.Dequeue()); + } + + /// + /// Consume the earliest cached entry with the exact structured call ID while + /// preserving the relative order of every unmatched entry. + /// + internal static CachedToolMeta? TryMatchCachedToolByCallId( + Queue? cache, + string? toolCallId) + { + if (cache is null || cache.Count == 0 || string.IsNullOrWhiteSpace(toolCallId)) + return null; + + CachedToolMeta? match = null; + var entryCount = cache.Count; + for (var index = 0; index < entryCount; index++) + { + var candidate = cache.Dequeue(); + if (match is null + && !string.IsNullOrWhiteSpace(candidate.ToolCallId) + && string.Equals(candidate.ToolCallId, toolCallId, StringComparison.Ordinal)) + { + match = candidate; + continue; + } + + cache.Enqueue(candidate); + } + + return match is null ? null : CloneCachedToolMeta(match); } private static JsonObject? NormalizeCachedToolArgs(JsonObject? args) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs b/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs index fe7722dcc..f034bd743 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs @@ -1,5 +1,8 @@ using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; using OpenClawTray.Services; @@ -50,21 +53,38 @@ private static async Task CaptureWhenSignaledAsync( if (!File.Exists(signalPath)) continue; - await CaptureAsync(root, surfaceName); + var rootDir = GetVisualTestDirectory(); + if (rootDir is null) + return; + await CaptureToDirectoryAsync( + root, + Path.Combine(rootDir, SanitizePathSegment(surfaceName)), + Environment.GetEnvironmentVariable( + "OPENCLAW_VISUAL_TEST_ELEMENT_AUTOMATION_ID_PREFIX"), + Environment.GetEnvironmentVariable( + "OPENCLAW_VISUAL_TEST_TEXT")); return; } Logger.Warn($"[VisualTest] Timed out waiting for capture signal for {surfaceName}."); } - private static async Task CaptureToDirectoryAsync(FrameworkElement root, string surfaceDir) + private static async Task CaptureToDirectoryAsync( + FrameworkElement root, + string surfaceDir, + string? automationIdPrefix = null, + string? exactText = null) { try { Directory.CreateDirectory(surfaceDir); if (root.DispatcherQueue.HasThreadAccess) { - await CaptureOnUiThreadAsync(root, surfaceDir); + foreach (var captureRoot in ResolveCaptureRoots( + root, + automationIdPrefix, + exactText)) + await CaptureOnUiThreadAsync(captureRoot, surfaceDir); return; } @@ -73,7 +93,11 @@ private static async Task CaptureToDirectoryAsync(FrameworkElement root, string { try { - await CaptureOnUiThreadAsync(root, surfaceDir); + foreach (var captureRoot in ResolveCaptureRoots( + root, + automationIdPrefix, + exactText)) + await CaptureOnUiThreadAsync(captureRoot, surfaceDir); tcs.SetResult(); } catch (Exception ex) @@ -93,6 +117,96 @@ private static async Task CaptureToDirectoryAsync(FrameworkElement root, string } } + private static IReadOnlyList ResolveCaptureRoots( + FrameworkElement root, + string? automationIdPrefix, + string? exactText) + { + if (string.IsNullOrWhiteSpace(automationIdPrefix) + && string.IsNullOrWhiteSpace(exactText)) + return [root]; + + var matches = new List(); + FindDescendants(root, automationIdPrefix, exactText, matches); + if (matches.Count == 0) + { + throw new InvalidOperationException( + "Could not find the requested visual elements for capture."); + } + + return matches; + } + + private static void FindDescendants( + FrameworkElement root, + string? automationIdPrefix, + string? exactText, + ICollection matches) + { + if (!string.IsNullOrWhiteSpace(automationIdPrefix) + && AutomationProperties.GetAutomationId(root).StartsWith( + automationIdPrefix, + StringComparison.Ordinal)) + { + matches.Add(root); + } + if (!string.IsNullOrWhiteSpace(exactText) + && MatchesText(root, exactText)) + { + matches.Add(FindScrollViewer(root) ?? root); + } + + var childCount = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < childCount; index++) + { + if (VisualTreeHelper.GetChild(root, index) is FrameworkElement child) + FindDescendants(child, automationIdPrefix, exactText, matches); + } + } + + private static FrameworkElement? FindScrollViewer(FrameworkElement element) + { + var current = VisualTreeHelper.GetParent(element); + for (var depth = 0; current is not null && depth < 16; depth++) + { + if (current is ScrollViewer scrollViewer) + return scrollViewer; + current = VisualTreeHelper.GetParent(current); + } + + return null; + } + + private static bool MatchesText(FrameworkElement element, string exactText) + { + var text = ReadText(element)?.TrimEnd('\r', '\n'); + if (string.Equals(text, exactText, StringComparison.Ordinal)) + return true; + + var peer = FrameworkElementAutomationPeer.FromElement(element) + ?? FrameworkElementAutomationPeer.CreatePeerForElement(element); + return string.Equals(peer?.GetName(), exactText, StringComparison.Ordinal); + } + + private static string? ReadText(FrameworkElement element) => element switch + { + TextBlock textBlock => textBlock.Text, + RichTextBlock richTextBlock => string.Concat( + richTextBlock.Blocks + .OfType() + .SelectMany(paragraph => paragraph.Inlines) + .Select(ReadInline)), + _ => null, + }; + + private static string ReadInline(Inline inline) => inline switch + { + Run run => run.Text, + Span span => string.Concat(span.Inlines.Select(ReadInline)), + LineBreak => Environment.NewLine, + _ => string.Empty, + }; + private static async Task CaptureOnUiThreadAsync(FrameworkElement root, string surfaceDir) { Action restoreBackground = () => { }; diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index a47756423..0766e77e7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -338,8 +338,19 @@ private void OpenSessionCheckpoints(string sessionKey) => // test flag so Axe scans the real Reactor timeline and composer, // not merely the disconnected page shell. if (Environment.GetEnvironmentVariable("OPENCLAW_ACCESSIBILITY_TEST_CHAT") == "1" - && Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 }) + && Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } dataDirectory) { + if (string.Equals( + Environment.GetEnvironmentVariable("OPENCLAW_ACCESSIBILITY_TEST_CHAT_FIXTURE"), + AccessibilityHistoryCollisionFixture.FixtureName, + StringComparison.Ordinal)) + { + return _accessibilityTestProvider ??= + AccessibilityHistoryCollisionFixture.Create( + dataDirectory, + action => DispatcherQueue.TryEnqueue(() => action())); + } + return _accessibilityTestProvider ??= new AccessibilityChatDataProvider(); } diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 64331f648..e24ecbf57 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -20,6 +20,7 @@ + @@ -51,6 +52,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs index b924668e3..cb0b37cc3 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs +++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs @@ -1,6 +1,7 @@ using OpenClaw.Chat; using OpenClaw.Shared; using OpenClaw.Shared.Telemetry; +using OpenClaw.TestSupport; using OpenClawTray.Chat; using System.Collections.Concurrent; using System.Diagnostics; @@ -7123,6 +7124,548 @@ public async Task LoadHistoryAsync_CachedSpecificIdentityAndArgs_MatchLiveProjec historyEntry.ToolArgs!["command"]!.GetValue()); } + [Fact] + public async Task LoadHistoryAsync_CrossKeyDuplicateCacheIdentity_DoesNotCollapseFlattenedRows() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + File.WriteAllText( + cachePath, + JsonSerializer.Serialize(new Dictionary> + { + ["session-1"] = + [ + new() + { + Ts = 100, + ToolName = "Bash", + Label = "first", + ToolCallId = "shared", + RunId = "run-1" + } + ], + ["main"] = + [ + new() + { + Ts = 110, + ToolName = "Bash", + Label = "first enriched", + ToolCallId = "shared", + RunId = "run-1", + IdentityStrength = ChatToolIdentityStrength.Specific + }, + new() + { + Ts = 200, + ToolName = "Apply Patch", + Label = "second", + ToolCallId = "shared", + RunId = "run-2", + IdentityStrength = ChatToolIdentityStrength.Specific + } + ] + })); + var (bridge, provider, snapshots, _) = CreateProvider( + [MainSession()], + toolMetaCachePath: cachePath); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1", + Messages = + [ + new ChatMessageInfo { Role = "toolresult", Text = "first output", Ts = 150 }, + new ChatMessageInfo { Role = "toolresult", Text = "second output", Ts = 250 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + first => + { + Assert.Equal("Bash", first.ToolName); + Assert.Equal("first output", first.ToolOutput); + Assert.StartsWith("history-tool-", first.ToolCallId); + Assert.Null(first.ToolRunId); + }, + second => + { + Assert.Equal("Apply Patch", second.ToolName); + Assert.Equal("second output", second.ToolOutput); + Assert.StartsWith("history-tool-", second.ToolCallId); + Assert.Null(second.ToolRunId); + Assert.NotEqual(snapshots[^1].Timelines["main"].Entries[0].ToolCallId, second.ToolCallId); + }); + } + + [Fact] + public async Task LoadHistoryAsync_MixedStructuredAndFlattenedCache_UsesOnlyVerifiedCorrelation() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + File.WriteAllText( + cachePath, + JsonSerializer.Serialize(new Dictionary> + { + ["session-1"] = + [ + new() + { + Ts = 100, + ToolName = "Bash", + ToolCallId = "call-1", + RunId = "run-1" + }, + new() + { + Ts = 200, + ToolName = "Apply Patch", + ToolCallId = "cached-flat", + RunId = "run-2", + IdentityStrength = ChatToolIdentityStrength.Specific + } + ] + })); + var (bridge, provider, snapshots, _) = CreateProvider( + [MainSession()], + toolMetaCachePath: cachePath); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "call-1", + ToolName = "exec" + } + ] + }, + new ChatMessageInfo + { + Role = "toolresult", + Ts = 150, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Result, + CallId = "call-1", + ToolName = "exec", + Text = "structured output" + } + ] + }, + new ChatMessageInfo { Role = "toolresult", Text = "flattened output", Ts = 250 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + structured => + { + Assert.Equal("call-1", structured.ToolCallId); + Assert.Equal("structured output", structured.ToolOutput); + }, + flattened => + { + Assert.Equal("Apply Patch", flattened.ToolName); + Assert.Equal("flattened output", flattened.ToolOutput); + Assert.StartsWith("history-tool-", flattened.ToolCallId); + Assert.NotEqual("cached-flat", flattened.ToolCallId); + Assert.Null(flattened.ToolRunId); + }); + } + + [Fact] + public async Task LoadHistoryAsync_FlattenedToolIdSkipsActiveStructuredCollision() + { + var (bridge, provider, snapshots, _) = CreateProvider([MainSession()]); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "history-tool-0", + ToolName = "exec" + } + ] + }, + new ChatMessageInfo + { + Role = "toolresult", + Text = "flattened output", + Ts = 200 + } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + structured => + { + Assert.Equal("history-tool-0", structured.ToolCallId); + Assert.Equal(ChatToolCallStatus.Interrupted, structured.ToolResult); + Assert.Null(structured.ToolOutput); + }, + flattened => + { + Assert.Equal("history-tool-1", flattened.ToolCallId); + Assert.Equal(ChatToolCallStatus.Success, flattened.ToolResult); + Assert.Equal("flattened output", flattened.ToolOutput); + }); + } + + [Fact] + public async Task LoadHistoryAsync_KeyedStructuredCallDoesNotConsumeFlattenedCacheEntry() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + File.WriteAllText( + cachePath, + JsonSerializer.Serialize(new Dictionary> + { + ["session-1"] = + [ + new() + { + Ts = 200, + ToolName = "Bash", + Label = "cached B", + ToolCallId = "call-b", + ToolArgs = new JsonObject { ["command"] = "echo B" }, + IdentityStrength = ChatToolIdentityStrength.Specific + } + ] + })); + var (bridge, provider, snapshots, _) = CreateProvider( + [MainSession()], + toolMetaCachePath: cachePath); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "call-a", + ToolName = "read" + } + ] + }, + new ChatMessageInfo { Role = "toolresult", Text = "output B", Ts = 200 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + structured => + { + Assert.Equal("call-a", structured.ToolCallId); + Assert.Equal("read", structured.ToolName); + Assert.Equal(ChatToolCallStatus.Interrupted, structured.ToolResult); + Assert.Null(structured.ToolOutput); + }, + flattened => + { + Assert.StartsWith("history-tool-", flattened.ToolCallId); + Assert.Equal("Bash", flattened.ToolName); + Assert.Equal("cached B", flattened.Text); + Assert.Equal("echo B", flattened.ToolArgs?["command"]?.GetValue()); + Assert.Equal("output B", flattened.ToolOutput); + }); + } + + [Fact] + public async Task LoadHistoryAsync_KeyedResultOnlyDoesNotConsumeFlattenedCacheEntry() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + File.WriteAllText( + cachePath, + JsonSerializer.Serialize(new Dictionary> + { + ["session-1"] = + [ + new() + { + Ts = 200, + ToolName = "Bash", + Label = "cached B", + ToolCallId = "call-b", + ToolArgs = new JsonObject { ["command"] = "echo B" }, + IdentityStrength = ChatToolIdentityStrength.Specific + } + ] + })); + var (bridge, provider, snapshots, _) = CreateProvider( + [MainSession()], + toolMetaCachePath: cachePath); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1", + Messages = + [ + new ChatMessageInfo + { + Role = "toolresult", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Result, + CallId = "call-a", + ToolName = "read", + Text = "output A" + } + ] + }, + new ChatMessageInfo { Role = "toolresult", Text = "output B", Ts = 200 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + structured => + { + Assert.Equal("call-a", structured.ToolCallId); + Assert.Equal("read", structured.ToolName); + Assert.Equal("output A", structured.ToolOutput); + }, + flattened => + { + Assert.StartsWith("history-tool-", flattened.ToolCallId); + Assert.Equal("Bash", flattened.ToolName); + Assert.Equal("cached B", flattened.Text); + Assert.Equal("echo B", flattened.ToolArgs?["command"]?.GetValue()); + Assert.Equal("output B", flattened.ToolOutput); + }); + } + + [Fact] + public async Task LoadHistoryAsync_ExactKeyedCacheMatchIsConsumedOnce() + { + using var tempDir = new TempDirectory(); + var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json"); + File.WriteAllText( + cachePath, + JsonSerializer.Serialize(new Dictionary> + { + ["session-1"] = + [ + new() + { + Ts = 100, + ToolName = "Apply Patch", + Label = "cached A", + ToolCallId = "call-a", + IdentityStrength = ChatToolIdentityStrength.Specific + } + ] + })); + var (bridge, provider, snapshots, _) = CreateProvider( + [MainSession()], + toolMetaCachePath: cachePath); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + SessionId = "session-1", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "call-a", + ToolName = "read" + } + ] + }, + new ChatMessageInfo { Role = "toolresult", Text = "flattened output", Ts = 200 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + Assert.Collection( + snapshots[^1].Timelines["main"].Entries, + structured => Assert.Equal("call-a", structured.ToolCallId), + flattened => + { + Assert.StartsWith("history-tool-", flattened.ToolCallId); + Assert.NotEqual("Apply Patch", flattened.ToolName); + Assert.Null(flattened.ToolArgs); + Assert.Equal("flattened output", flattened.ToolOutput); + }); + } + + [Fact] + public async Task AccessibilityHistoryCollisionFixture_LoadsThroughProviderAndCacheAllocator() + { + using var tempDir = new TempDirectory(); + var (provider, bridge) = + AccessibilityHistoryCollisionFixture.CreateWithBridgeForTesting( + tempDir.DirectoryPath, + name => name switch + { + "OPENCLAW_ACCESSIBILITY_TEST_CHAT" => "1", + "OPENCLAW_ACCESSIBILITY_TEST_CHAT_FIXTURE" => + AccessibilityHistoryCollisionFixture.FixtureName, + "OPENCLAW_TRAY_DATA_DIR" => tempDir.DirectoryPath, + _ => null + }); + await using var providerScope = provider; + + await provider.LoadAsync(); + await provider.LoadHistoryAsync(AccessibilityHistoryCollisionFixture.ThreadId); + var snapshot = await provider.LoadAsync(); + + Assert.Equal(1, bridge.HistoryRequestCount); + Assert.Equal( + [AccessibilityHistoryCollisionFixture.ThreadId], + bridge.RequestedHistoryKeys); + Assert.Collection( + snapshot.Timelines[AccessibilityHistoryCollisionFixture.ThreadId].Entries, + structured => + { + Assert.Equal("history-tool-0", structured.ToolCallId); + Assert.Equal("Exec", structured.ToolName); + Assert.Equal( + "verified structured id: history-tool-0", + structured.ToolArgs?["command"]?.GetValue()); + Assert.Equal(ChatToolCallStatus.Interrupted, structured.ToolResult); + Assert.Null(structured.ToolOutput); + }, + flattened => + { + Assert.Equal("history-tool-1", flattened.ToolCallId); + Assert.NotEqual("unverified-cached-flat-id", flattened.ToolCallId); + Assert.Null(flattened.ToolRunId); + Assert.Equal("Bash", flattened.ToolName); + Assert.Equal( + "synthetic flattened id: history-tool-1", + flattened.ToolArgs?["command"]?.GetValue()); + Assert.Equal(ChatToolCallStatus.Success, flattened.ToolResult); + Assert.Equal( + "flattened output owned by history-tool-1", + flattened.ToolOutput); + }); + } + + [Theory] + [InlineData(null, AccessibilityHistoryCollisionFixture.FixtureName, true)] + [InlineData("0", AccessibilityHistoryCollisionFixture.FixtureName, true)] + [InlineData("1", null, true)] + [InlineData("1", "different-fixture", true)] + [InlineData("1", AccessibilityHistoryCollisionFixture.FixtureName, false)] + public void AccessibilityHistoryCollisionFixture_RejectsIncompleteIsolationGate( + string? enabled, + string? fixture, + bool useConfiguredDataDirectory) + { + using var tempDir = new TempDirectory(); + var configuredDataDirectory = useConfiguredDataDirectory + ? tempDir.DirectoryPath + : Path.Combine(tempDir.DirectoryPath, "different"); + + Assert.Throws( + () => AccessibilityHistoryCollisionFixture.CreateForTesting( + tempDir.DirectoryPath, + name => name switch + { + "OPENCLAW_ACCESSIBILITY_TEST_CHAT" => enabled, + "OPENCLAW_ACCESSIBILITY_TEST_CHAT_FIXTURE" => fixture, + "OPENCLAW_TRAY_DATA_DIR" => configuredDataDirectory, + _ => null + })); + } + + [Fact] + public async Task LoadHistoryAsync_SyntheticToolIdsSkipMultipleReservedStructuredIdsInOrder() + { + var (bridge, provider, snapshots, _) = CreateProvider([MainSession()]); + bridge.HistoryBehavior = _ => Task.FromResult(new ChatHistoryInfo + { + SessionKey = "main", + Messages = + [ + new ChatMessageInfo + { + Role = "assistant", + Ts = 100, + ToolContent = + [ + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "history-tool-0", + ToolName = "exec" + }, + new ChatToolContentInfo + { + Kind = ChatToolContentKind.Call, + CallId = "history-tool-2", + ToolName = "read" + } + ] + }, + new ChatMessageInfo { Role = "toolresult", Text = "first flattened", Ts = 200 }, + new ChatMessageInfo { Role = "toolresult", Text = "second flattened", Ts = 300 } + ] + }); + + await provider.LoadHistoryAsync("main"); + + var flattened = snapshots[^1].Timelines["main"].Entries + .Where(entry => entry.ToolOutput is not null) + .ToArray(); + Assert.Equal(["history-tool-1", "history-tool-3"], flattened.Select(entry => entry.ToolCallId)); + Assert.Equal(["first flattened", "second flattened"], flattened.Select(entry => entry.ToolOutput)); + } + [Fact] public async Task AgentEvent_ToolResultIsError_ExtractsCoreErrorDetails() { diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs index 989dfc205..196379994 100644 --- a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs +++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs @@ -39,7 +39,7 @@ public void TryMatch_SingleEntry_DequeuesAndReturns() var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200); Assert.NotNull(result); - Assert.Equal("bash", result!.ToolName); + Assert.Equal("Bash", result!.ToolName); Assert.Equal("ls -la", result.Label); Assert.Empty(cache); // consumed } @@ -57,7 +57,7 @@ public void TryMatch_SequentialOrder_MatchesByPosition() var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600); var r3 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 700); - Assert.Equal("bash", r1!.ToolName); + Assert.Equal("Bash", r1!.ToolName); Assert.Equal("grep", r2!.ToolName); Assert.Equal("view", r3!.ToolName); Assert.Empty(cache); @@ -100,7 +100,7 @@ public void TryMatch_CachedEntrySlightlyAfterHistory_StillMatches() var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000); Assert.NotNull(result); - Assert.Equal("bash", result!.ToolName); + Assert.Equal("Bash", result!.ToolName); } [Fact] @@ -131,6 +131,81 @@ public void TryMatch_RepeatedToolNames_PreservesOrder() Assert.Equal("second bash", r2!.Label); } + [Fact] + public void TryMatchByCallId_MiddleMatchPreservesUnmatchedOrder() + { + var cache = new Queue( + [ + new() { Ts = 100, ToolName = "read", Label = "first", ToolCallId = "call-a" }, + new() { Ts = 200, ToolName = "bash", Label = "middle", ToolCallId = "call-b" }, + new() { Ts = 300, ToolName = "write", Label = "last", ToolCallId = "call-c" } + ]); + + var match = OpenClawChatDataProvider.TryMatchCachedToolByCallId(cache, "call-b"); + + Assert.Equal("Bash", match!.ToolName); + Assert.Equal("middle", match.Label); + Assert.Equal(["call-a", "call-c"], cache.Select(entry => entry.ToolCallId)); + } + + [Fact] + public void TryMatchByCallId_AbsentMatchPreservesEntireQueueOrder() + { + var cache = new Queue( + [ + new() { Ts = 100, ToolCallId = "call-a" }, + new() { Ts = 200, ToolCallId = "call-b" }, + new() { Ts = 300, ToolCallId = "call-c" } + ]); + + var match = OpenClawChatDataProvider.TryMatchCachedToolByCallId(cache, "missing"); + + Assert.Null(match); + Assert.Equal(["call-a", "call-b", "call-c"], cache.Select(entry => entry.ToolCallId)); + } + + [Fact] + public void TryMatchByCallId_ReusedIdConsumesEarliestOccurrence() + { + var cache = new Queue( + [ + new() { Ts = 100, ToolName = "read", Label = "first match", ToolCallId = "shared" }, + new() { Ts = 200, ToolName = "bash", Label = "unmatched", ToolCallId = "other" }, + new() { Ts = 300, ToolName = "write", Label = "second match", ToolCallId = "shared" } + ]); + + var match = OpenClawChatDataProvider.TryMatchCachedToolByCallId(cache, "shared"); + + Assert.Equal("first match", match!.Label); + Assert.Equal(["other", "shared"], cache.Select(entry => entry.ToolCallId)); + Assert.Equal(["unmatched", "second match"], cache.Select(entry => entry.Label)); + } + + [Fact] + public void TryMatchByCallId_NormalizesReturnedCloneWithoutMutatingSource() + { + var rawName = "bash\u202Ehidden"; + var rawLabel = "line1\r\nline2\u202E"; + var source = new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 100, + ToolName = rawName, + Label = rawLabel, + ToolCallId = "call-a" + }; + var cache = new Queue([source]); + + var match = OpenClawChatDataProvider.TryMatchCachedToolByCallId(cache, "call-a"); + + Assert.NotSame(source, match); + Assert.Equal(rawName, source.ToolName); + Assert.Equal(rawLabel, source.Label); + Assert.Equal("Tool", match!.ToolName); + Assert.DoesNotContain('\u202E', match.Label); + Assert.DoesNotContain('\r', match.Label); + Assert.DoesNotContain('\n', match.Label); + } + // ── Constants ── [Fact] @@ -371,10 +446,123 @@ public void TryMatch_NormalizesLegacyCachedNewlines() var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200); - Assert.Equal("bash name", result!.ToolName); + Assert.Equal("Bash", result!.ToolName); Assert.Equal("line1 \"line2\"", result.Label); } + [Fact] + public void BuildCachedToolQueue_CrossKeyDuplicateUsesEarliestPositionAndRichestMetadata() + { + var sessionEntries = new[] + { + new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 100, + ToolName = "Tool", + Label = "starting", + ToolCallId = "tool-1", + RunId = "run-1", + IdentityStrength = ChatToolIdentityStrength.Fallback, + ToolArgs = new System.Text.Json.Nodes.JsonObject { ["command"] = "Get-Date" } + } + }; + var threadEntries = new[] + { + new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 110, + ToolName = "Bash", + Label = "finished", + ToolCallId = "tool-1", + RunId = "run-1", + IdentityStrength = ChatToolIdentityStrength.Specific, + ToolArgs = new System.Text.Json.Nodes.JsonObject { ["path"] = "src" } + } + }; + + var queue = OpenClawChatDataProvider.BuildCachedToolQueue(sessionEntries, threadEntries); + + var merged = Assert.Single(queue!); + Assert.Equal(100, merged.Ts); + Assert.Equal("Bash", merged.ToolName); + Assert.Equal("finished", merged.Label); + Assert.Equal("Get-Date", merged.ToolArgs!["command"]!.GetValue()); + Assert.Equal("src", merged.ToolArgs["path"]!.GetValue()); + } + + [Fact] + public void BuildCachedToolQueue_ReusedIdsAcrossRunsAndLegacyTurnsRemainDistinct() + { + var entries = new[] + { + new OpenClawChatDataProvider.CachedToolMeta { Ts = 1, ToolCallId = "same", RunId = "run-1" }, + new OpenClawChatDataProvider.CachedToolMeta { Ts = 2, ToolCallId = "same", RunId = "run-2" }, + new OpenClawChatDataProvider.CachedToolMeta { Ts = 3, ToolCallId = "same", LegacyTurn = 1 }, + new OpenClawChatDataProvider.CachedToolMeta { Ts = 4, ToolCallId = "same", LegacyTurn = 2 } + }; + + var queue = OpenClawChatDataProvider.BuildCachedToolQueue(entries, null); + + Assert.Equal(4, queue!.Count); + Assert.Equal([1L, 2L, 3L, 4L], queue.Select(entry => entry.Ts).ToArray()); + } + + [Fact] + public void BuildCachedToolQueue_ReadSanitizationDoesNotMutateSource() + { + var rawName = "bash\u202Eevil"; + var rawLabel = new string('x', NativeToolProjector.MaxDisplayValueChars + 20) + "\u202E"; + var source = new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 100, + ToolName = rawName, + Label = rawLabel, + ToolCallId = "tool-1" + }; + + var queue = OpenClawChatDataProvider.BuildCachedToolQueue([source], null); + var result = OpenClawChatDataProvider.TryMatchCachedTool(queue, 200); + + Assert.Equal(rawName, source.ToolName); + Assert.Equal(rawLabel, source.Label); + Assert.Equal("Tool", result!.ToolName); + Assert.DoesNotContain('\u202E', result.Label); + Assert.True(result.Label.Length <= NativeToolProjector.MaxDisplayValueChars); + } + + [Fact] + public void BuildCachedToolQueue_RevalidatesIdentityStrengthBeforeMerging() + { + var entries = new[] + { + new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 100, + ToolName = "invalid\u202E", + ToolCallId = "tool-1", + RunId = "run-1", + IdentityStrength = ChatToolIdentityStrength.Explicit + }, + new OpenClawChatDataProvider.CachedToolMeta + { + Ts = 110, + ToolName = "bash", + ToolCallId = "tool-1", + RunId = "run-1", + IdentityStrength = ChatToolIdentityStrength.Heuristic + } + }; + + var queue = OpenClawChatDataProvider.BuildCachedToolQueue(entries, null); + + var merged = Assert.Single(queue!); + Assert.Equal(100, merged.Ts); + Assert.Equal("Bash", merged.ToolName); + Assert.Equal(ChatToolIdentityStrength.Specific, merged.IdentityStrength); + Assert.Equal("invalid\u202E", entries[0].ToolName); + Assert.Equal(ChatToolIdentityStrength.Explicit, entries[0].IdentityStrength); + } + [Fact] public async Task CacheToolMeta_SameToolCallId_UpgradesSpecificIdentityWithoutDuplicate() { diff --git a/tests/OpenClaw.Tray.UITests/AccessibilityAppFixture.cs b/tests/OpenClaw.Tray.UITests/AccessibilityAppFixture.cs index 50c2ead4f..cd838b167 100644 --- a/tests/OpenClaw.Tray.UITests/AccessibilityAppFixture.cs +++ b/tests/OpenClaw.Tray.UITests/AccessibilityAppFixture.cs @@ -4,6 +4,7 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -29,19 +30,38 @@ public sealed class AccessibilityAppFixture : IDisposable private readonly string _dataDirectory; private readonly string _executablePath; + private readonly string? _chatFixture; + private readonly string? _nativeChatProofElementPrefix; + private readonly string? _nativeChatProofText; + private readonly int _nativeChatProofCaptureCount; private readonly string? _nativeChatProofSignalPath; + private readonly string _nativeChatProofSurface; private readonly string? _nativeChatProofVisualDirectory; private readonly Process _process; public IntPtr HubWindowHandle { get; } + public string ProductionProductVersion => + FileVersionInfo.GetVersionInfo(_executablePath).ProductVersion ?? string.Empty; + public AccessibilityAppFixture() : this(initializeAxe: true) { } - internal AccessibilityAppFixture(bool initializeAxe) + internal AccessibilityAppFixture( + bool initializeAxe, + string? chatFixture = null, + string nativeChatProofSurface = "NativeToolIdentity", + string? nativeChatProofElementPrefix = null, + string? nativeChatProofText = null, + int nativeChatProofCaptureCount = 1) { + _chatFixture = chatFixture; + _nativeChatProofElementPrefix = nativeChatProofElementPrefix; + _nativeChatProofText = nativeChatProofText; + _nativeChatProofCaptureCount = nativeChatProofCaptureCount; + _nativeChatProofSurface = nativeChatProofSurface; _executablePath = Path.Combine(AppContext.BaseDirectory, "OpenClaw.Tray.WinUI.exe"); if (!File.Exists(_executablePath)) { @@ -186,29 +206,42 @@ public async Task NavigateAsync(string pageTag, string pageMarkerAutomationId) } EnsureTargetIsAlive(); - var capturedPath = Path.Combine( - _nativeChatProofVisualDirectory, - "NativeToolIdentity", - "capture-00.png"); + var capturedPaths = Enumerable.Range(0, _nativeChatProofCaptureCount) + .Select(index => Path.Combine( + _nativeChatProofVisualDirectory, + _nativeChatProofSurface, + $"capture-{index:D2}.png")) + .ToArray(); File.WriteAllText(_nativeChatProofSignalPath, "capture"); + var previousLengths = new long[capturedPaths.Length]; var stopwatch = Stopwatch.StartNew(); while (stopwatch.Elapsed < TimeSpan.FromSeconds(30)) { EnsureTargetIsAlive(); - if (File.Exists(capturedPath) && new FileInfo(capturedPath).Length > 0) + if (CapturedVisualsAreStableAndDecodable(capturedPaths, previousLengths)) break; Thread.Sleep(100); } - if (!File.Exists(capturedPath) || new FileInfo(capturedPath).Length == 0) + if (!CapturedVisualsAreStableAndDecodable(capturedPaths, previousLengths)) { + var produced = Directory.Exists(Path.GetDirectoryName(capturedPaths[0])) + ? string.Join( + ", ", + Directory.GetFiles(Path.GetDirectoryName(capturedPaths[0])!, "capture-*.png") + .Select(Path.GetFileName) + .Order(StringComparer.Ordinal)) + : "none"; throw new TimeoutException( - "The isolated app did not produce the native chat visual proof."); + $"The isolated app did not produce all native chat visuals. Produced: {produced}."); } var path = Path.GetFullPath(configuredPath, Environment.CurrentDirectory); Directory.CreateDirectory(Path.GetDirectoryName(path)!); - File.Copy(capturedPath, path, overwrite: true); + if (capturedPaths.Length == 1) + File.Copy(capturedPaths[0], path, overwrite: true); + else + CombineCapturedVisuals(capturedPaths, path); using (var bitmap = new Bitmap(path)) { @@ -237,6 +270,74 @@ public async Task NavigateAsync(string pageTag, string pageMarkerAutomationId) return path; } + private static bool CapturedVisualsAreStableAndDecodable( + IReadOnlyList capturedPaths, + long[] previousLengths) + { + var stable = true; + for (var index = 0; index < capturedPaths.Count; index++) + { + var path = capturedPaths[index]; + var length = File.Exists(path) ? new FileInfo(path).Length : 0; + stable &= length > 0 && length == previousLengths[index]; + previousLengths[index] = length; + } + if (!stable) + return false; + + try + { + foreach (var path in capturedPaths) + { + using var bitmap = new Bitmap(path); + if (bitmap.Width <= 0 || bitmap.Height <= 0) + return false; + } + return true; + } + catch (ArgumentException) + { + return false; + } + catch (IOException) + { + return false; + } + } + + private static void CombineCapturedVisuals( + IReadOnlyList capturedPaths, + string outputPath) + { + var captures = capturedPaths.Select(path => new Bitmap(path)).ToArray(); + try + { + const int gap = 12; + var width = captures.Max(capture => capture.Width); + var height = captures.Sum(capture => capture.Height) + + (captures.Length + 1) * gap; + using var combined = new Bitmap(width, height, PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(combined)) + { + graphics.Clear(Color.White); + var top = gap; + foreach (var capture in captures) + { + graphics.DrawImageUnscaled(capture, 0, top); + top += capture.Height + gap; + } + } + if (File.Exists(outputPath)) + File.Delete(outputPath); + combined.Save(outputPath, ImageFormat.Png); + } + finally + { + foreach (var capture in captures) + capture.Dispose(); + } + } + private async Task WaitForPageMarkerAsync(string pageTag, string automationId) { var stopwatch = Stopwatch.StartNew(); @@ -273,6 +374,8 @@ private Process StartProcess(string deepLink) startInfo.Environment["OPENCLAW_LANGUAGE"] = "en-US"; startInfo.Environment["OPENCLAW_ACCESSIBILITY_TEST_CHAT"] = "1"; startInfo.Environment["OPENCLAW_ACCESSIBILITY_TEST_SESSIONS"] = "1"; + if (!string.IsNullOrWhiteSpace(_chatFixture)) + startInfo.Environment["OPENCLAW_ACCESSIBILITY_TEST_CHAT_FIXTURE"] = _chatFixture; if (_nativeChatProofSignalPath is not null && _nativeChatProofVisualDirectory is not null) { @@ -282,7 +385,18 @@ private Process StartProcess(string deepLink) startInfo.Environment["OPENCLAW_VISUAL_TEST_DIR"] = _nativeChatProofVisualDirectory; startInfo.Environment["OPENCLAW_VISUAL_TEST_SURFACE"] = - "NativeToolIdentity"; + _nativeChatProofSurface; + if (!string.IsNullOrWhiteSpace(_nativeChatProofElementPrefix)) + { + startInfo.Environment[ + "OPENCLAW_VISUAL_TEST_ELEMENT_AUTOMATION_ID_PREFIX"] = + _nativeChatProofElementPrefix; + } + if (!string.IsNullOrWhiteSpace(_nativeChatProofText)) + { + startInfo.Environment["OPENCLAW_VISUAL_TEST_TEXT"] = + _nativeChatProofText; + } } return Process.Start(startInfo) diff --git a/tests/OpenClaw.Tray.UITests/NativeToolIdentityScreenshotProofTests.cs b/tests/OpenClaw.Tray.UITests/NativeToolIdentityScreenshotProofTests.cs index e22c01fce..b6f0e8120 100644 --- a/tests/OpenClaw.Tray.UITests/NativeToolIdentityScreenshotProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/NativeToolIdentityScreenshotProofTests.cs @@ -32,6 +32,36 @@ public Task NavigateAsync(string pageTag, string pageMarkerAutomationId) => public void Dispose() => _app.Dispose(); } +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class HistoryCollisionScreenshotCollection : + ICollectionFixture +{ + public const string Name = "History collision screenshot"; +} + +public sealed class HistoryCollisionScreenshotFixture : IDisposable +{ + private readonly AccessibilityAppFixture _app = new( + initializeAxe: false, + chatFixture: "history-collision", + nativeChatProofSurface: "HistoryCollision", + nativeChatProofElementPrefix: "ChatToolCall_", + nativeChatProofText: "flattened output owned by history-tool-1", + nativeChatProofCaptureCount: 3); + + public IntPtr HubWindowHandle => _app.HubWindowHandle; + + public string ProductionProductVersion => _app.ProductionProductVersion; + + public Task NavigateAsync(string pageTag, string pageMarkerAutomationId) => + _app.NavigateAsync(pageTag, pageMarkerAutomationId); + + public string? CaptureCollisionVisualIfRequested() => + _app.CaptureNativeChatVisualIfRequested(); + + public void Dispose() => _app.Dispose(); +} + [Collection(NativeToolIdentityScreenshotCollection.Name)] public sealed class NativeToolIdentityScreenshotProofTests { @@ -57,6 +87,7 @@ public async Task SyntheticNativeRows_RenderTrustedIdentitySafeInputAndTruthfulF var proof = new List { $"head={Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_HEAD") ?? "local"}", + $"dirty={Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_DIRTY") ?? "unknown"}", }; ExpandToolActivity(proof); @@ -165,6 +196,7 @@ private static IEnumerable ReadTextCandidates(AutomationElement element) yield return text; } } + } private AutomationElement WaitForElement(Condition condition) @@ -225,3 +257,279 @@ private static void WriteProofArtifactIfRequested(IEnumerable proof) File.WriteAllLines(path, proof); } } + +[Collection(HistoryCollisionScreenshotCollection.Name)] +public sealed class HistoryCollisionScreenshotProofTests +{ + private static readonly TimeSpan UiTimeout = TimeSpan.FromSeconds(15); + + private readonly HistoryCollisionScreenshotFixture _app; + private readonly ITestOutputHelper _output; + + public HistoryCollisionScreenshotProofTests( + HistoryCollisionScreenshotFixture app, + ITestOutputHelper output) + { + _app = app; + _output = output; + } + + [Fact] + [Trait("Category", "Accessibility")] + public async Task StructuredAndFlattenedHistoryCollision_RendersTwoDistinctRows() + { + await _app.NavigateAsync("chat", "ChatComposerInput"); + + var head = Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_HEAD"); + var dirty = Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_DIRTY"); + var proofArtifactPath = Environment.GetEnvironmentVariable( + "OPENCLAW_UI_PROOF_ARTIFACT_PATH"); + var configuredScreenshotPath = Environment.GetEnvironmentVariable( + "OPENCLAW_UI_SCREENSHOT_PATH"); + if (!string.IsNullOrWhiteSpace(proofArtifactPath) + || !string.IsNullOrWhiteSpace(configuredScreenshotPath)) + { + Assert.Matches("^[0-9a-f]{40}$", head ?? string.Empty); + Assert.Matches( + "^(?:sha256:[0-9A-Fa-f]{64}; files=[1-9][0-9]*; base=[0-9a-f]{40}|clean:tree=[0-9a-f]{40})$", + dirty ?? string.Empty); + Assert.Contains( + $".Sha.{head}.{head}", + _app.ProductionProductVersion, + StringComparison.Ordinal); + } + + var proof = new List + { + $"head={head ?? "local"}", + $"dirty={dirty ?? "unknown"}", + $"product-version={_app.ProductionProductVersion}", + "proof-scope=provider LoadHistoryAsync, metadata cache, allocator, production reducer, activity projection, and Reactor renderer", + "visual=two production tool cards plus the synthetic output text, composed without coordinate cropping", + }; + + var activity = ExpandToolActivity(proof); + ExpandTool("Tool call Exec. Interrupted.", proof); + ExpandTool("Tool call Bash. Done.", proof); + + var structuredText = WaitForSubtreeText( + "Tool call Exec. Interrupted.", + text => text.Contains( + "command: verified structured id: history-tool-0"), + "structured history row details to render"); + var syntheticText = WaitForSubtreeText( + "Tool call Bash. Done.", + text => text.Contains( + "command: synthetic flattened id: history-tool-1") + && text.Contains("flattened output owned by history-tool-1"), + "synthetic history row details to render"); + Assert.Contains("command: verified structured id: history-tool-0", structuredText); + Assert.DoesNotContain("Tool output", structuredText); + Assert.DoesNotContain("Tool error", structuredText); + Assert.DoesNotContain("flattened output owned by history-tool-1", structuredText); + Assert.Contains("command: synthetic flattened id: history-tool-1", syntheticText); + Assert.Contains("Tool output", syntheticText); + Assert.DoesNotContain("Tool error", syntheticText); + Assert.Contains("flattened output owned by history-tool-1", syntheticText); + + var hub = AutomationElement.FromHandle(_app.HubWindowHandle); + var toolRows = hub.FindAll(TreeScope.Descendants, Condition.TrueCondition) + .Cast() + .Where(element => element.Current.AutomationId.StartsWith( + "ChatToolCall_", + StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(2, toolRows.Length); + Assert.Equal( + 2, + toolRows.Select(row => row.Current.AutomationId) + .Distinct(StringComparer.Ordinal) + .Count()); + var hubBounds = hub.Current.BoundingRectangle; + var activityBounds = activity.Current.BoundingRectangle; + Assert.True(activityBounds.Width > 0 && activityBounds.Height > 0); + Assert.False(activity.Current.IsOffscreen); + Assert.True( + activityBounds.Left >= hubBounds.Left + && activityBounds.Right <= hubBounds.Right + && activityBounds.Top >= hubBounds.Top + && activityBounds.Bottom <= hubBounds.Bottom, + "The tool activity was not fully contained by the visible Hub window."); + Assert.All(toolRows, row => + { + var rowBounds = row.Current.BoundingRectangle; + Assert.True(rowBounds.Width > 0 && rowBounds.Height > 0); + Assert.False(row.Current.IsOffscreen); + Assert.True( + rowBounds.Left >= activityBounds.Left + && rowBounds.Right <= activityBounds.Right + && rowBounds.Top >= activityBounds.Top + && rowBounds.Bottom <= activityBounds.Bottom, + $"{row.Current.AutomationId} was not fully contained by the visible activity bounds."); + }); + + var structuredAutomationId = toolRows.Single( + row => row.Current.Name == "Tool call Exec. Interrupted.") + .Current.AutomationId; + var syntheticAutomationId = toolRows.Single( + row => row.Current.Name == "Tool call Bash. Done.") + .Current.AutomationId; + proof.Add("UIA tool-row-count=2"); + proof.Add( + $"UIA structured=\"automationId={structuredAutomationId}; state=Interrupted; output=absent\""); + proof.Add( + $"UIA synthetic=\"automationId={syntheticAutomationId}; state=Done; output=flattened output owned by history-tool-1\""); + + if (_app.CaptureCollisionVisualIfRequested() is { } screenshotPath) + { + proof.Add( + $"screenshot={Path.GetFileName(screenshotPath)} " + + $"bytes={new FileInfo(screenshotPath).Length}"); + } + + proof.Add("result=pass"); + foreach (var line in proof) + _output.WriteLine(line); + WriteProofArtifactIfRequested(proof); + } + + private AutomationElement ExpandToolActivity(ICollection proof) + { + var activity = WaitForElement( + element => element.Current.AutomationId.StartsWith( + "ChatToolActivity_", + StringComparison.Ordinal), + "history collision activity to appear"); + Expand(activity, activity.Current.Name, proof); + return activity; + } + + private void ExpandTool( + string automationName, + ICollection proof) + { + var element = WaitForElement(new PropertyCondition( + AutomationElement.NameProperty, + automationName)); + Expand(element, automationName, proof); + } + + private static void Expand( + AutomationElement element, + string automationName, + ICollection proof) + { + Assert.True( + element.TryGetCurrentPattern(ExpandCollapsePattern.Pattern, out var rawPattern), + $"{automationName} did not expose ExpandCollapsePattern."); + var pattern = Assert.IsType(rawPattern); + if (pattern.Current.ExpandCollapseState == ExpandCollapseState.Collapsed) + pattern.Expand(); + proof.Add($"UIA expanded=\"{automationName}\""); + } + + private static HashSet ReadSubtreeText(AutomationElement root) => + root.FindAll(TreeScope.Subtree, Condition.TrueCondition) + .Cast() + .SelectMany(ReadTextCandidates) + .ToHashSet(StringComparer.Ordinal); + + private HashSet WaitForSubtreeText( + string automationName, + Func, bool> predicate, + string description) + { + HashSet? text = null; + WaitUntil(() => + { + var hub = AutomationElement.FromHandle(_app.HubWindowHandle); + var element = hub.FindFirst( + TreeScope.Descendants, + new PropertyCondition( + AutomationElement.NameProperty, + automationName)); + if (element is null) + return false; + + text = ReadSubtreeText(element); + return predicate(text); + }, description); + return text!; + } + + private static IEnumerable ReadTextCandidates(AutomationElement element) + { + var name = element.Current.Name; + if (!string.IsNullOrWhiteSpace(name)) + yield return name; + + if (element.TryGetCurrentPattern(TextPattern.Pattern, out var rawPattern) + && rawPattern is TextPattern textPattern) + { + var text = textPattern.DocumentRange.GetText(-1).TrimEnd('\r', '\n'); + if (!string.IsNullOrWhiteSpace(text) + && !string.Equals(text, name, StringComparison.Ordinal)) + { + yield return text; + } + } + } + + private AutomationElement WaitForElement(Condition condition) + { + AutomationElement? element = null; + WaitUntil(() => + { + var hub = AutomationElement.FromHandle(_app.HubWindowHandle); + element = hub.FindFirst(TreeScope.Descendants, condition); + return element is not null; + }, "history collision tool row to appear"); + return element!; + } + + private AutomationElement WaitForElement( + Func predicate, + string description) + { + AutomationElement? element = null; + WaitUntil(() => + { + var hub = AutomationElement.FromHandle(_app.HubWindowHandle); + element = hub.FindAll(TreeScope.Descendants, Condition.TrueCondition) + .Cast() + .FirstOrDefault(predicate); + return element is not null; + }, description); + return element!; + } + + private static void WaitUntil(Func predicate, string description) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < UiTimeout) + { + try + { + if (predicate()) + return; + } + catch (ElementNotAvailableException) + { + // React navigation and flyouts replace their automation subtrees. + } + Thread.Sleep(100); + } + throw new TimeoutException($"Timed out waiting for {description}."); + } + + private static void WriteProofArtifactIfRequested(IEnumerable proof) + { + var path = Environment.GetEnvironmentVariable("OPENCLAW_UI_PROOF_ARTIFACT_PATH"); + if (string.IsNullOrWhiteSpace(path)) + return; + + path = Path.GetFullPath(path, Environment.CurrentDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllLines(path, proof); + } +}