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
2 changes: 1 addition & 1 deletion docs/WINDOWS_NODE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ When the node connects, it advertises these capabilities:
- `tts` - Windows speech synthesis or ElevenLabs playback, when enabled in Settings
- `stt` - Local speech-to-text via Whisper.net, when enabled in Settings

Local MCP clients also see MCP-only `app.*` commands such as `app.navigate`, `app.status`, and `app.chat.snapshot`/`app.chat.send`/`app.chat.reset`. These are local testing and automation hooks registered with the tray's MCP server and are not advertised to the gateway WebSocket.
Local MCP clients also see MCP-only `app.*` commands such as `app.navigate`, `app.status`, `app.chat.snapshot`/`app.chat.send`/`app.chat.reset`, and `app.chat.queue.list`/`app.chat.queue.cancel`. These are local testing and automation hooks registered with the tray's MCP server and are not advertised to the gateway WebSocket.

## Security Features

Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ public interface IChatDataProvider : IAsyncDisposable
Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default);
Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken, IReadOnlyList<OpenClaw.Shared.ChatAttachment>? attachments) =>
SendMessageAsync(threadId, message, cancellationToken);
Task<bool> CancelQueuedMessageAsync(string threadId, string queuedMessageId, CancellationToken cancellationToken = default) => Task.FromResult(false);
Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default);
Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default);
Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default);
Expand Down
40 changes: 36 additions & 4 deletions src/OpenClaw.Shared/Capabilities/AppCapability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public class AppCapability : NodeCapabilityBase
"app.chat.snapshot",
"app.chat.send",
"app.chat.reset",
"app.chat.queue.list",
"app.chat.queue.cancel",
};

public override IReadOnlyList<string> Commands => _commands;
Expand All @@ -48,6 +50,8 @@ public class AppCapability : NodeCapabilityBase
public Func<string?, Task<object?>>? ChatSnapshotHandler;
public Func<string?, string, Task<object?>>? ChatSendHandler;
public Func<string?, Task<object?>>? ChatResetHandler;
public Func<string?, Task<object?>>? ChatQueueListHandler;
public Func<string?, string, Task<object?>>? ChatQueueCancelHandler;

public AppCapability(IOpenClawLogger logger) : base(logger) { }

Expand All @@ -69,6 +73,8 @@ public override async Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest re
"app.chat.snapshot" => await HandleChatSnapshot(request),
"app.chat.send" => await HandleChatSend(request),
"app.chat.reset" => await HandleChatReset(request),
"app.chat.queue.list" => await HandleChatQueueList(request),
"app.chat.queue.cancel" => await HandleChatQueueCancel(request),
_ => Error($"Unknown command: {request.Command}")
};
}
Expand Down Expand Up @@ -203,7 +209,7 @@ private async Task<NodeInvokeResponse> HandleChatSnapshot(NodeInvokeRequest requ
if (ChatSnapshotHandler == null)
return Error("Chat snapshot handler not registered");

var result = await ChatSnapshotHandler(GetOptionalThreadId(request));
var result = await ChatSnapshotHandler(GetThreadIdOrSessionKeyArg(request));
return Success(result);
}

Expand All @@ -216,7 +222,7 @@ private async Task<NodeInvokeResponse> HandleChatSend(NodeInvokeRequest request)
if (string.IsNullOrWhiteSpace(message))
return Error("Missing required arg: message");

var result = await ChatSendHandler(GetOptionalThreadId(request), message);
var result = await ChatSendHandler(GetThreadIdOrSessionKeyArg(request), message);
return Success(result);
}

Expand All @@ -225,11 +231,37 @@ private async Task<NodeInvokeResponse> HandleChatReset(NodeInvokeRequest request
if (ChatResetHandler == null)
return Error("Chat reset handler not registered");

var result = await ChatResetHandler(GetOptionalThreadId(request));
var result = await ChatResetHandler(GetThreadIdOrSessionKeyArg(request));
return Success(result);
}

private string? GetOptionalThreadId(NodeInvokeRequest request) =>
private async Task<NodeInvokeResponse> HandleChatQueueList(NodeInvokeRequest request)
{
if (ChatQueueListHandler == null)
return Error("Chat queue list handler not registered");

var result = await ChatQueueListHandler(GetThreadIdOrSessionKeyArg(request));
return Success(result);
}

private async Task<NodeInvokeResponse> HandleChatQueueCancel(NodeInvokeRequest request)
{
if (ChatQueueCancelHandler == null)
return Error("Chat queue cancel handler not registered");

var queuedMessageId = GetStringArg(request.Args, "queuedMessageId");
if (string.IsNullOrWhiteSpace(queuedMessageId))
return Error("Missing required arg: queuedMessageId");

var threadId = GetThreadIdOrSessionKeyArg(request);
if (string.IsNullOrWhiteSpace(threadId))
return Error("Missing required arg: threadId");

var result = await ChatQueueCancelHandler(threadId, queuedMessageId);
return Success(result);
}

private string? GetThreadIdOrSessionKeyArg(NodeInvokeRequest request) =>
GetStringArg(request.Args, "threadId")
?? GetStringArg(request.Args, "sessionKey");
}
6 changes: 5 additions & 1 deletion src/OpenClaw.Shared/Mcp/McpToolBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,15 @@ private object HandleToolsList()
["app.dashboard.url"] =
"Build the same gateway dashboard URL the tray opens. Args: path (string, optional). Returns { url, credentialSource, usesSharedGatewayToken, hasTokenQuery }.",
["app.chat.snapshot"] =
"READ-ALL: Return the current native chat snapshot for local automation. Args: threadId/sessionKey (string, optional). Returns connection state, compose target, thread summaries, and recent timeline entries including chat text.",
"READ-ALL: Return the current native chat snapshot for local automation. Args: threadId/sessionKey (string, optional). Returns connection state, compose target, thread summaries, queued outgoing messages, and recent timeline entries including chat text.",
["app.chat.send"] =
"Send a message through the native chat provider. Args: message (string, required), threadId/sessionKey (string, optional; defaults to the current compose/default thread). Returns { sent, threadId, entryCount, turnActive, error? }.",
["app.chat.reset"] =
"Reset a chat session through the gateway sessions.reset path. Args: threadId/sessionKey (string, optional; defaults to the current compose/default thread, so no-arg reset clears the active chat). Returns { reset, threadId, error? }.",
["app.chat.queue.list"] =
"READ-ALL: List native chat outgoing queue entries. Args: threadId/sessionKey (string, optional; omit to return all queued threads). Returns { defaultThreadId, requestedThreadId, totalCount, selectedThread, threads: [{ threadId, count, messages: [{ id, text, createdAt, sendState, errorText, canCancel }] }] }.",
["app.chat.queue.cancel"] =
"Cancel/remove one native chat outgoing queue entry before it is sent. Args: queuedMessageId (string, required), threadId/sessionKey (string, required; use the threadId returned by app.chat.queue.list or app.chat.snapshot). Only Queued/Failed entries can be removed; Sending entries may already have reached the gateway. Returns { canceled, threadId, queuedMessageId, remainingCount, error? }.",

// location.*
["location.get"] =
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2015,6 +2015,7 @@ public static bool IsFailureStatus(string? status)
|| status.Equals("rejected", StringComparison.OrdinalIgnoreCase)
|| status.Equals("denied", StringComparison.OrdinalIgnoreCase)
|| status.Equals("aborted", StringComparison.OrdinalIgnoreCase)
|| status.Equals("timeout", StringComparison.OrdinalIgnoreCase)
|| status.Equals("cancelled", StringComparison.OrdinalIgnoreCase)
|| status.Equals("canceled", StringComparison.OrdinalIgnoreCase);
}
Expand Down
12 changes: 10 additions & 2 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,17 +309,25 @@ public async Task SendChatMessageAsync(string message, string? sessionKey = null
_ = await SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments).ConfigureAwait(false);
}

public async Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey = null, string? sessionId = null, IReadOnlyList<ChatAttachment>? attachments = null)
public async Task<ChatSendResult> SendChatMessageForRunAsync(
string message,
string? sessionKey = null,
string? sessionId = null,
IReadOnlyList<ChatAttachment>? attachments = null,
string? idempotencyKey = null)
{
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");

var hasAttachments = attachments is { Count: > 0 };
if (string.IsNullOrWhiteSpace(message) && !hasAttachments)
throw new ArgumentException("Message or attachment is required", nameof(message));
if (idempotencyKey is not null && string.IsNullOrWhiteSpace(idempotencyKey))
throw new ArgumentException("Idempotency key cannot be whitespace", nameof(idempotencyKey));

var effectiveSessionKey = ResolveEffectiveSessionKey(
sessionKey, Volatile.Read(ref _mainSessionKey), "chat.send");
var effectiveIdempotencyKey = idempotencyKey?.Trim() ?? Guid.NewGuid().ToString();

var requestId = Guid.NewGuid().ToString();
var completion = new TaskCompletionSource<ChatSendResult>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand All @@ -336,7 +344,7 @@ public async Task<ChatSendResult> SendChatMessageForRunAsync(string message, str
{
["sessionKey"] = effectiveSessionKey,
["message"] = message,
["idempotencyKey"] = Guid.NewGuid().ToString()
["idempotencyKey"] = effectiveIdempotencyKey
};

if (hasAttachments)
Expand Down
158 changes: 158 additions & 0 deletions src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ private void WireAppCapabilityHandlers()
app.ChatSnapshotHandler = BuildChatSnapshotForMcpAsync;
app.ChatSendHandler = SendChatMessageForMcpAsync;
app.ChatResetHandler = ResetChatSessionForMcpAsync;
app.ChatQueueListHandler = ListQueuedChatMessagesForMcpAsync;
app.ChatQueueCancelHandler = CancelQueuedChatMessageForMcpAsync;

connection.ApplySetupCodeHandler = async setupCode =>
{
Expand Down Expand Up @@ -506,6 +508,79 @@ private object BuildPendingApprovalsPayload(
};
}

private async Task<object?> ListQueuedChatMessagesForMcpAsync(string? threadId)
{
var provider = _chatCoordinator?.Provider;
if (provider == null)
return new { error = "Chat provider is not initialized" };

var snapshot = await provider.LoadAsync();
var resolvedThreadId = ResolveChatThreadId(snapshot, threadId);
return BuildChatQueuePayload(snapshot, resolvedThreadId, filterToThread: !string.IsNullOrWhiteSpace(threadId));
}

private async Task<object?> CancelQueuedChatMessageForMcpAsync(string? threadId, string queuedMessageId)
{
var provider = _chatCoordinator?.Provider;
if (provider == null)
return new { canceled = false, error = "Chat provider is not initialized" };

var snapshot = await provider.LoadAsync();
var resolvedThreadId = ResolveChatThreadId(snapshot, threadId);
if (string.IsNullOrWhiteSpace(resolvedThreadId))
return new { canceled = false, queuedMessageId, error = "Chat compose target is not ready" };

if (!TryGetQueuedMessage(snapshot, resolvedThreadId, queuedMessageId, out var queuedMessage))
{
return new
{
canceled = false,
threadId = resolvedThreadId,
queuedMessageId,
error = "Queued message was not found"
};
}

if (!CanCancelQueuedMessage(queuedMessage))
{
return new
{
canceled = false,
threadId = resolvedThreadId,
queuedMessageId,
sendState = queuedMessage.SendState.ToString(),
error = "Queued message is already sending and cannot be canceled"
};
}

bool canceled;
try
{
canceled = await provider.CancelQueuedMessageAsync(resolvedThreadId, queuedMessageId);
}
catch (Exception ex)
{
Logger.Warn($"App: Chat queue cancel for '{resolvedThreadId}' message '{queuedMessageId}' failed: {ex.Message}");
return new { canceled = false, threadId = resolvedThreadId, queuedMessageId, error = ex.Message };
}

var updated = await provider.LoadAsync();
var stillQueued = TryGetQueuedMessage(updated, resolvedThreadId, queuedMessageId, out var remaining);
var remainingCount = GetQueuedMessagesForThread(updated, resolvedThreadId).Length;
return new
{
canceled,
threadId = resolvedThreadId,
queuedMessageId,
remainingCount,
error = canceled
? null
: stillQueued
? $"Queued message is still present with state '{remaining!.SendState}'."
: "Queued message was not canceled; it may have started sending before cancellation was processed."
};
}

private static string? ResolveChatThreadId(ChatDataSnapshot snapshot, string? requestedThreadId)
{
if (!string.IsNullOrWhiteSpace(requestedThreadId))
Expand Down Expand Up @@ -548,6 +623,7 @@ private static object BuildChatSnapshotPayload(ChatDataSnapshot snapshot, string
t.TotalTokens,
t.ContextTokens
}).ToArray(),
queue = BuildChatQueuePayload(snapshot, resolvedThreadId, filterToThread: false),
selectedTimeline = selectedTimeline is null ? null : new
{
turnActive = selectedTimeline.TurnActive,
Expand Down Expand Up @@ -580,6 +656,88 @@ private static object BuildChatSnapshotPayload(ChatDataSnapshot snapshot, string
};
}

private static object BuildChatQueuePayload(
ChatDataSnapshot snapshot,
string? resolvedThreadId,
bool filterToThread)
{
var queued = snapshot.QueuedMessagesByThread ?? new Dictionary<string, IReadOnlyList<ChatQueuedMessage>>();
var threadQueues = filterToThread && !string.IsNullOrWhiteSpace(resolvedThreadId)
? new[]
{
new KeyValuePair<string, IReadOnlyList<ChatQueuedMessage>>(
resolvedThreadId,
GetQueuedMessagesForThread(snapshot, resolvedThreadId))
}
: queued
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToArray();

var selectedMessages = !string.IsNullOrWhiteSpace(resolvedThreadId)
? GetQueuedMessagesForThread(snapshot, resolvedThreadId)
: Array.Empty<ChatQueuedMessage>();

return new
{
defaultThreadId = snapshot.DefaultThreadId,
requestedThreadId = resolvedThreadId,
totalCount = threadQueues.Sum(kvp => kvp.Value.Count),
selectedThread = string.IsNullOrWhiteSpace(resolvedThreadId)
? null
: new
{
threadId = resolvedThreadId,
count = selectedMessages.Length,
messages = selectedMessages.Select(ToMcpQueuedMessage).ToArray()
},
threads = threadQueues.Select(kvp => new
{
threadId = kvp.Key,
count = kvp.Value.Count,
messages = kvp.Value.Select(ToMcpQueuedMessage).ToArray()
}).ToArray()
};
}

private static ChatQueuedMessage[] GetQueuedMessagesForThread(ChatDataSnapshot snapshot, string threadId)
{
if (snapshot.QueuedMessagesByThread?.TryGetValue(threadId, out var messages) == true)
return messages.ToArray();
return Array.Empty<ChatQueuedMessage>();
}

private static bool TryGetQueuedMessage(
ChatDataSnapshot snapshot,
string threadId,
string queuedMessageId,
out ChatQueuedMessage queuedMessage)
{
foreach (var message in GetQueuedMessagesForThread(snapshot, threadId))
{
if (string.Equals(message.Id, queuedMessageId, StringComparison.Ordinal))
{
queuedMessage = message;
return true;
}
}

queuedMessage = null!;
return false;
}

private static bool CanCancelQueuedMessage(ChatQueuedMessage message) =>
message.SendState != ChatQueuedMessageSendState.Sending;

private static object ToMcpQueuedMessage(ChatQueuedMessage message) => new
{
id = message.Id,
text = message.Text,
createdAt = message.CreatedAt,
sendState = message.SendState.ToString(),
errorText = message.ErrorText,
canCancel = CanCancelQueuedMessage(message)
};

private async Task WaitForAppStateUpdateAsync(string propertyName, Func<Task> requestAsync)
{
if (_appState == null)
Expand Down
16 changes: 13 additions & 3 deletions src/OpenClaw.Tray.WinUI/Chat/IChatGatewayBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ public interface IChatGatewayBridge : IDisposable
void StartProactiveBootstrap();

Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null);
Task<ChatSendResult> SendChatMessageForRunAsync(
string message,
string? sessionKey,
string? sessionId,
IReadOnlyList<ChatAttachment>? attachments = null,
string? idempotencyKey = null);
/// <summary>
/// Fetches the gateway command catalog (<c>commands.list</c>) via the typed
/// protocol API. Returns a <see cref="CommandCatalog"/> whose
Expand Down Expand Up @@ -170,8 +175,13 @@ public void StartProactiveBootstrap()
public Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null) =>
_client.SendChatMessageAsync(message, sessionKey, sessionId, attachments);

public Task<ChatSendResult> SendChatMessageForRunAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList<ChatAttachment>? attachments = null) =>
_client.SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments);
public Task<ChatSendResult> SendChatMessageForRunAsync(
string message,
string? sessionKey,
string? sessionId,
IReadOnlyList<ChatAttachment>? attachments = null,
string? idempotencyKey = null) =>
_client.SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments, idempotencyKey);

public Task PatchSessionModelAsync(string sessionKey, string model) =>
_client.PatchSessionAsync(sessionKey, new SessionPatch { Model = model });
Expand Down
Loading