diff --git a/docs/GATEWAY_FIXTURE_TESTING.md b/docs/GATEWAY_FIXTURE_TESTING.md index cf9cd620e9..795b34f655 100644 --- a/docs/GATEWAY_FIXTURE_TESTING.md +++ b/docs/GATEWAY_FIXTURE_TESTING.md @@ -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 diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 1a1e3ca271..0e47817ddd 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -203,6 +203,9 @@ Task CreateSessionAsync(SessionCreateRequest request, int t /// List only the archived sessions (sessions.list with archived:true). Rows are detached snapshots. Task ListArchivedSessionsAsync(int timeoutMs = 15000) => Task.FromResult(new SessionListResult { IsSupported = false }); + /// Search active transcripts across the Gateway's visible session roster. + Task SearchSessionsAsync(SessionSearchRequest request, int timeoutMs = 15000) + => Task.FromResult(new SessionSearchResult([], IsSupported: false)); } public sealed record CronRunRequestResult( diff --git a/src/OpenClaw.Shared/Sessions/SessionSearch.cs b/src/OpenClaw.Shared/Sessions/SessionSearch.cs new file mode 100644 index 0000000000..03c7e387f4 --- /dev/null +++ b/src/OpenClaw.Shared/Sessions/SessionSearch.cs @@ -0,0 +1,127 @@ +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// Searches active conversation transcripts, not just the locally loaded titles. +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 + { + ["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 Results, + bool Indexing = false, + bool Truncated = false, + int ArchivedTranscriptsExcluded = 0, + bool IsSupported = true); + +public partial class OpenClawGatewayClient +{ + public async Task 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(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()!; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index c32267f1a9..bbf673eef4 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -63,6 +63,38 @@ + + + + + + +