Skip to content
Draft
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
18 changes: 18 additions & 0 deletions docs/GATEWAY_FIXTURE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ test container. The request-driven server and scenario live in
`tests\OpenClaw.TestSupport\Gateway`. Repeated reads and different request
orders work; this is deliberately not sequential packet playback.

In Sessions, submit **Search active conversation messages** with Enter or
**Search**. This calls `sessions.search` against the Gateway's active,
configured-agent roster rather than filtering the currently loaded titles.
Search covers all channels; **Show background** controls the Gateway's cron
and system membership filters before its 25-message limit. Select a result's
conversation title to open its chat (the key is a fallback when no title is supplied). Editing the query, changing background
scope, unloading the page, or changing connection withdraws old results.
Older Gateways, permission/connection failures, initial indexing and partial
results have distinct presentation. Search does not edit or execute snippets.

The browse fixture searches synthetic transcript text with case-insensitive
substring matching. It does not emulate production full-text ranking or prove
published Gateway compatibility. `GatewayFixtureProtocolTests.Search` separately
exercises strict parameter validation, permission denial, unsupported methods,
index readiness, membership-before-limit, archival and message provenance.
`GatewayFixtureUiTests.TranscriptSearch_ShowsVisibleMatchesAndOpensTheMatchingConversation`
checks visible matches and navigation through the real native Search button.

## Run the application smoke

```powershell
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ Task<SessionCreateResult> CreateSessionAsync(SessionCreateRequest request, int t
/// <summary>List only the archived sessions (<c>sessions.list</c> with <c>archived:true</c>). Rows are detached snapshots.</summary>
Task<SessionListResult> ListArchivedSessionsAsync(int timeoutMs = 15000)
=> Task.FromResult(new SessionListResult { IsSupported = false });
/// <summary>Search active transcripts across the Gateway's visible session roster.</summary>
Task<SessionSearchResult> SearchSessionsAsync(SessionSearchRequest request, int timeoutMs = 15000)
=> Task.FromResult(new SessionSearchResult([], IsSupported: false));
}

public sealed record CronRunRequestResult(
Expand Down
127 changes: 127 additions & 0 deletions src/OpenClaw.Shared/Sessions/SessionSearch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Text.Json;

namespace OpenClaw.Shared;

/// <summary>Searches active conversation transcripts, not just the locally loaded titles.</summary>
public sealed record SessionSearchRequest(string Query, bool IncludeBackground = false)
{
public const int ResultLimit = 25;
public const int MaximumQueryLength = 4096;

internal object ToPayload()
{
ArgumentException.ThrowIfNullOrWhiteSpace(Query);
var query = Query.Trim();
if (query.Length > MaximumQueryLength)
throw new ArgumentOutOfRangeException(nameof(Query));
var scope = new Dictionary<string, object>
{
["archived"] = false,
["includeUnknown"] = true,
["configuredAgentsOnly"] = true
};
if (!IncludeBackground)
{
scope["excludeCron"] = true;
scope["excludeSystem"] = true;
}
return new { query, limit = ResultLimit, scope };
}
}

public sealed record SessionSearchHit(
string SessionKey, string SessionId, string MessageId, string Role,
long Timestamp, string Snippet, double Score, string? Title = null)
{
public string DisplayTitle => Title ?? SessionKey;
}

public sealed record SessionSearchResult(
IReadOnlyList<SessionSearchHit> Results,
bool Indexing = false,
bool Truncated = false,
int ArchivedTranscriptsExcluded = 0,
bool IsSupported = true);

public partial class OpenClawGatewayClient
{
public async Task<SessionSearchResult> SearchSessionsAsync(SessionSearchRequest request, int timeoutMs = 15000)
{
ArgumentNullException.ThrowIfNull(request);
var parameters = request.ToPayload();
var epoch = SessionMutationConnectionEpoch
?? throw new InvalidOperationException("Gateway handshake has not completed.");
JsonElement payload;
try
{
payload = await SendResponseRequestAsync("sessions.search", parameters, timeoutMs, epoch).ConfigureAwait(false);
}
catch (InvalidOperationException ex) when (IsUnknownMethodError(ex.Message))
{
_logger.Warn("sessions.search unsupported on gateway");
return new([], IsSupported: false);
}
return ParseSessionSearch(payload);
}

internal static SessionSearchResult ParseSessionSearch(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||
!payload.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array ||
results.GetArrayLength() > SessionSearchRequest.ResultLimit)
throw new JsonException("Invalid sessions.search results.");
var titles = new Dictionary<string, string>(StringComparer.Ordinal);
if (payload.TryGetProperty("sessions", out var sessions))
{
if (sessions.ValueKind != JsonValueKind.Array || sessions.GetArrayLength() > SessionSearchRequest.ResultLimit)
throw new JsonException("Invalid sessions.search session rows.");
foreach (var session in sessions.EnumerateArray())
{
var key = Required(session, "key");
var title = OptionalTitle("label") ?? OptionalTitle("displayName") ?? key;
if (!titles.TryAdd(key, title))
throw new JsonException("Duplicate sessions.search session row.");

string? OptionalTitle(string name) => session.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString())
? value.GetString() : null;
}
}
var hits = results.EnumerateArray().Select(row =>
{
var key = Required(row, "sessionKey");
var sessionId = Required(row, "sessionId");
var messageId = Required(row, "messageId");
var role = Required(row, "role");
if (role is not ("user" or "assistant") ||
!row.TryGetProperty("timestamp", out var timestamp) || timestamp.ValueKind != JsonValueKind.Number ||
!timestamp.TryGetInt64(out var ts) || ts < 0 ||
!row.TryGetProperty("snippet", out var snippet) || snippet.ValueKind != JsonValueKind.String ||
!row.TryGetProperty("score", out var score) || score.ValueKind != JsonValueKind.Number ||
!score.TryGetDouble(out var rank) || !double.IsFinite(rank))
throw new JsonException("Invalid sessions.search match.");
return new SessionSearchHit(key, sessionId, messageId, role, ts, snippet.GetString()!, rank,
titles.GetValueOrDefault(key));
}).ToArray();
var excluded = 0;
if (payload.TryGetProperty("archivedTranscriptsExcluded", out var count) &&
(count.ValueKind != JsonValueKind.Number || !count.TryGetInt32(out excluded) || excluded < 0))
throw new JsonException("Invalid sessions.search excluded count.");
return new(hits, OptionalFlag("indexing"), OptionalFlag("truncated"), excluded);

bool OptionalFlag(string name)
{
if (!payload.TryGetProperty(name, out var flag)) return false;
if (flag.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
throw new JsonException($"Invalid sessions.search {name}.");
return flag.GetBoolean();
}
static string Required(JsonElement row, string name)
{
if (row.ValueKind != JsonValueKind.Object || !row.TryGetProperty(name, out var value) ||
value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString()))
throw new JsonException($"Invalid sessions.search {name}.");
return value.GetString()!;
}
}
}
32 changes: 32 additions & 0 deletions src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,38 @@
</Button>
</Grid>

<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<AutoSuggestBox x:Name="TranscriptQuery" x:Uid="SessionsPage_TranscriptQuery"
QuerySubmitted="OnTranscriptQuerySubmitted"
TextChanged="OnTranscriptQueryChanged"
AutomationProperties.AutomationId="SessionsTranscriptQuery"/>
<Button Grid.Column="1" x:Uid="SessionsPage_SearchButton"
Click="OnTranscriptSearchClick"
AutomationProperties.AutomationId="SessionsTranscriptSubmit"/>
</Grid>
<TextBlock x:Name="TranscriptSearchStatus" TextWrapping="Wrap"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
AutomationProperties.LiveSetting="Polite"
AutomationProperties.AutomationId="SessionsTranscriptSearchStatus"/>
<ListView x:Name="TranscriptSearchResults" SelectionMode="None"
Visibility="Collapsed"
AutomationProperties.AutomationId="SessionsTranscriptSearchResults">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="4" Padding="8">
<Button Content="{Binding DisplayTitle}" Tag="{Binding SessionKey}"
Click="OnOpenChat"/>
<TextBlock Text="{Binding Snippet}" TextWrapping="Wrap"
MaxLines="3" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>

<InfoBar x:Uid="SessionsPage_GatewayDisconnected"
x:Name="ConnectionInfoBar"
IsOpen="False" IsClosable="False" Severity="Warning"
Expand Down
41 changes: 41 additions & 0 deletions src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@ public sealed partial class SessionsPage : Page
private bool _syncingShowCompletedToggle;
private bool _showBackgroundSessions;
private readonly ArchivedSessionsSource _archived;
private readonly SessionTranscriptSearch _transcriptSearch;

public SessionsPage()
{
InitializeComponent();
_transcriptSearch = new SessionTranscriptSearch(ApplyTranscriptSearch,
ex => new AppLogger().Warn($"[Sessions] Transcript search failed ({ex.GetType().Name})."));
_archived = new ArchivedSessionsSource(ApplyArchivedSessions,
ex => new AppLogger().Warn($"[Sessions] Archived sessions unavailable ({ex.GetType().Name})."));
ArchivedSection.Header = LocalizationHelper.GetString("SessionsPage_Archived");
Loaded += (_, _) => _unloaded = false;
Unloaded += (_, _) =>
{
_unloaded = true;
_transcriptSearch.Clear();
_archived.Clear();
_refreshTimer?.Stop(); _refreshTimer = null;
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
Expand All @@ -56,6 +60,7 @@ public SessionsPage()
public void Initialize()
{
_unloaded = false;
_transcriptSearch.Clear();
// Guard against duplicate subscriptions (NavigationCacheMode reuses page)
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
_appState = CurrentApp.AppState!;
Expand Down Expand Up @@ -318,6 +323,7 @@ private void OnShowCompletedToggled(object sender, RoutedEventArgs e)
private void OnShowBackgroundToggled(object sender, RoutedEventArgs e)
{
_showBackgroundSessions = ShowBackgroundToggle.IsOn;
_transcriptSearch?.Clear();
RebuildChannelTabs();
ApplyFilter();
}
Expand All @@ -331,6 +337,7 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
_ = _archived.RefreshAsync(CurrentApp.GatewayClient);
break;
case nameof(AppState.Status):
_transcriptSearch.Clear();
if (_appState!.Status != ConnectionStatus.Connected)
_archived.Clear();
ApplyArchivedSessions();
Expand All @@ -339,6 +346,40 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
}
}

private void OnTranscriptQueryChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
=> _transcriptSearch?.Clear();

private void OnTranscriptQuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
=> RunTranscriptSearch(args.QueryText);

private void OnTranscriptSearchClick(object sender, RoutedEventArgs args)
=> RunTranscriptSearch(TranscriptQuery.Text);

private void RunTranscriptSearch(string query)
=> AsyncEventHandlerGuard.Run(() => _transcriptSearch.SearchAsync(CurrentApp.GatewayClient,
() => _unloaded ? null : CurrentApp.GatewayClient, query, _showBackgroundSessions),
new AppLogger(), nameof(RunTranscriptSearch));

private void ApplyTranscriptSearch()
{
var result = _transcriptSearch.Result;
TranscriptSearchResults.ItemsSource = result?.Results;
TranscriptSearchResults.Visibility = result?.Results.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
var key = _transcriptSearch.Status switch
{
SessionSearchStatus.Loading => "SessionsPage_SearchLoading",
SessionSearchStatus.Unsupported => "SessionsPage_SearchUnsupported",
SessionSearchStatus.Failed => "SessionsPage_SearchFailed",
SessionSearchStatus.Ready when result?.Indexing == true => "SessionsPage_SearchIndexing",
SessionSearchStatus.Ready when result?.Truncated == true || result?.ArchivedTranscriptsExcluded > 0
=> "SessionsPage_SearchPartial",
SessionSearchStatus.Ready => "SessionsPage_SearchReady",
_ => null
};
TranscriptSearchStatus.Text = key is null ? "" :
LocalizationHelper.Format(key, result?.Results.Count ?? 0);
}

private void OnArchivedExpanding(Expander sender, ExpanderExpandingEventArgs args)
{
_archived.SetExpanded(true);
Expand Down
56 changes: 56 additions & 0 deletions src/OpenClaw.Tray.WinUI/Services/SessionTranscriptSearch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.Json;
using OpenClaw.Shared;

namespace OpenClawTray.Services;

internal enum SessionSearchStatus { Idle, Loading, Ready, Unsupported, Failed }

/// <summary>UI-thread-owned search. Query, connection and view changes withdraw old results.</summary>
internal sealed class SessionTranscriptSearch(Action changed, Action<Exception> reportFailure)
{
private int _generation;
public SessionSearchStatus Status { get; private set; }
public SessionSearchResult? Result { get; private set; }

public void Clear()
{
_generation++;
Status = SessionSearchStatus.Idle;
Result = null;
changed();
}

public async Task SearchAsync(IOperatorGatewayClient? client, Func<IOperatorGatewayClient?> currentClient,
string query, bool includeBackground)
{
Clear();
if (string.IsNullOrWhiteSpace(query)) return;
if (client is not { IsConnectedToGateway: true } || client.SessionMutationConnectionEpoch is not { } epoch)
{
Status = SessionSearchStatus.Failed;
changed();
return;
}
var generation = _generation;
Status = SessionSearchStatus.Loading;
changed();
try
{
var result = await client.SearchSessionsAsync(new(query, includeBackground));
if (!IsCurrent()) return;
Result = result;
Status = result.IsSupported ? SessionSearchStatus.Ready : SessionSearchStatus.Unsupported;
}
catch (Exception ex) when (ex is InvalidOperationException or TimeoutException or
OperationCanceledException or JsonException or ArgumentException)
{
if (!IsCurrent()) return;
Status = SessionSearchStatus.Failed;
reportFailure(ex);
}
changed();

bool IsCurrent() => generation == _generation && ReferenceEquals(client, currentClient()) &&
client.IsConnectedToGateway && client.SessionMutationConnectionEpoch == epoch;
}
}
9 changes: 9 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<data name="SessionsPage_SearchButton.Content" xml:space="preserve"><value>Search</value></data>
<data name="SessionsPage_TranscriptQuery.PlaceholderText" xml:space="preserve"><value>Search active conversation messages. Press Enter.</value></data>
<data name="SessionsPage_TranscriptQuery.AutomationProperties.Name" xml:space="preserve"><value>Search active conversation messages</value></data>
<data name="SessionsPage_SearchLoading" xml:space="preserve"><value>Searching conversation messages...</value></data>
<data name="SessionsPage_SearchUnsupported" xml:space="preserve"><value>This Gateway does not support transcript search.</value></data>
<data name="SessionsPage_SearchFailed" xml:space="preserve"><value>Search failed. Check the Gateway connection and submit the search again (maximum 4096 characters).</value></data>
<data name="SessionsPage_SearchIndexing" xml:space="preserve"><value>The Gateway is still indexing messages. Submit the search again for updated results.</value></data>
<data name="SessionsPage_SearchPartial" xml:space="preserve"><value>Some matches are not shown. Narrow the query to see more results. Archived transcripts may be excluded.</value></data>
<data name="SessionsPage_SearchReady" xml:space="preserve"><value>Search complete: {0} message matches. Select a conversation to open it.</value></data>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
Expand Down
Loading
Loading