Skip to content
18 changes: 17 additions & 1 deletion src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ public record ChatTimelineState(
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
}

public enum ChatQueuedMessageSendState
{
Queued,
Sending,
Failed
}

public record ChatQueuedMessage(
string Id,
string Text,
DateTimeOffset CreatedAt,
string LocalNonce,
ChatQueuedMessageSendState SendState = ChatQueuedMessageSendState.Queued,
string? ErrorText = null);

public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore);

public abstract record ChatEvent;
Expand Down Expand Up @@ -195,7 +210,8 @@ public record ChatDataSnapshot(
IReadOnlyList<ChatModelChoice>? ModelChoices = null,
IReadOnlyList<OpenClaw.Shared.GatewayCommand>? AvailableCommands = null,
bool CommandsSupported = true,
IReadOnlyDictionary<string, long>? TimelineGenerations = null);
IReadOnlyDictionary<string, long>? TimelineGenerations = null,
IReadOnlyDictionary<string, IReadOnlyList<ChatQueuedMessage>>? QueuedMessagesByThread = null);

/// <summary>
/// Describes where the UI may send the next chat message. Distinct from
Expand Down
6 changes: 6 additions & 0 deletions src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public static ChatTimelineState AddLocalUser(ChatTimelineState state, string tex
};
}

public static ChatTimelineState BeginLocalUserTurn(ChatTimelineState state)
{
state = ClearStreamingAtTurnBoundary(state);
return state with { TurnActive = true };
}

// Hard turn boundary cleanup shared by both user-message entry points
// (AddLocalUser for typed input, ApplyUserMessage for gateway-injected
// events). Clears ActiveAssistantId/ActiveReasoningId and demotes any
Expand Down
19 changes: 19 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1998,7 +1998,26 @@ public sealed class ChatSendResult
{
public string? RunId { get; init; }
public string? SessionKey { get; init; }
public string? Status { get; init; }
public string? Error { get; init; }
public bool Cached { get; init; }

public bool IsTerminalFailure => IsFailureStatus(Status) || !string.IsNullOrWhiteSpace(Error);

public static bool IsFailureStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status))
return false;

return status.Equals("failed", StringComparison.OrdinalIgnoreCase)
|| status.Equals("failure", StringComparison.OrdinalIgnoreCase)
|| status.Equals("error", StringComparison.OrdinalIgnoreCase)
|| status.Equals("rejected", StringComparison.OrdinalIgnoreCase)
|| status.Equals("denied", StringComparison.OrdinalIgnoreCase)
|| status.Equals("aborted", StringComparison.OrdinalIgnoreCase)
|| status.Equals("cancelled", StringComparison.OrdinalIgnoreCase)
|| status.Equals("canceled", StringComparison.OrdinalIgnoreCase);
}
}

// ── Node/Device Pairing ──
Expand Down
102 changes: 87 additions & 15 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -527,16 +527,7 @@ private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sess
else if (m.TryGetProperty("ts", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.Number)
ts = tsProp2.GetInt64();

// __openclaw metadata: unique message ID + sequence number
string? openClawId = null;
int? openClawSeq = null;
if (m.TryGetProperty("__openclaw", out var oc) && oc.ValueKind == JsonValueKind.Object)
{
if (oc.TryGetProperty("id", out var ocId))
openClawId = ocId.GetString();
if (oc.TryGetProperty("seq", out var ocSeq) && ocSeq.ValueKind == JsonValueKind.Number)
openClawSeq = ocSeq.GetInt32();
}
var (openClawId, openClawSeq) = ExtractOpenClawMetadata(m);

// stopReason on assistant messages (e.g. "stop", "toolUse", possibly "abort")
string? stopReason = null;
Expand Down Expand Up @@ -1980,6 +1971,8 @@ private static ChatSendResult ParseChatSendResult(JsonElement root)
{
string? runId = null;
string? sessionKey = null;
string? status = null;
string? error = null;
var cached = false;

if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object)
Expand All @@ -1988,8 +1981,19 @@ private static ChatSendResult ParseChatSendResult(JsonElement root)
runId = runIdProp.GetString();
if (payload.TryGetProperty("sessionKey", out var sessionKeyProp))
sessionKey = sessionKeyProp.GetString();
if (payload.TryGetProperty("status", out var statusProp))
status = statusProp.GetString();
if (payload.TryGetProperty("error", out var payloadErrorProp))
error = TryReadJsonValueAsString(payloadErrorProp);
else if (payload.TryGetProperty("message", out var payloadMessageProp) && ChatSendResult.IsFailureStatus(status))
error = TryReadJsonValueAsString(payloadMessageProp);
}

if (root.TryGetProperty("status", out var rootStatusProp))
status ??= rootStatusProp.GetString();
if (root.TryGetProperty("error", out var rootErrorProp))
error ??= TryReadJsonValueAsString(rootErrorProp);

if (root.TryGetProperty("meta", out var meta) &&
meta.ValueKind == JsonValueKind.Object &&
meta.TryGetProperty("cached", out var cachedProp) &&
Expand All @@ -2002,10 +2006,21 @@ private static ChatSendResult ParseChatSendResult(JsonElement root)
{
RunId = runId,
SessionKey = sessionKey,
Status = status,
Error = error,
Cached = cached
};
}

private static string? TryReadJsonValueAsString(JsonElement value) =>
value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => value.ToString(),
JsonValueKind.Object when value.TryGetProperty("message", out var message) => TryReadJsonValueAsString(message),
_ => null
};

private void HandleRequestError(string? method, JsonElement root)
{
var message = TryGetErrorMessage(root) ?? "request failed";
Expand Down Expand Up @@ -3071,7 +3086,20 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
if (string.IsNullOrEmpty(text)) return;
if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return;

EmitChatMessageReceived(sessionKey, role, text, state, tsMs, inTok, outTok, respTok, ctxPct);
var (messageOpenClawId, messageOpenClawSeq) = ExtractOpenClawMetadata(message);
var (payloadOpenClawId, payloadOpenClawSeq) = ExtractOpenClawMetadata(payload);
EmitChatMessageReceived(
sessionKey,
role,
text,
state,
tsMs,
inTok,
outTok,
respTok,
ctxPct,
messageOpenClawId ?? payloadOpenClawId,
messageOpenClawSeq ?? payloadOpenClawSeq);

if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -3093,7 +3121,19 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
{
if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return;

EmitChatMessageReceived(sessionKey, role, text, state, tsMs, inTok, outTok, respTok, ctxPct);
var (openClawId, openClawSeq) = ExtractOpenClawMetadata(payload);
EmitChatMessageReceived(
sessionKey,
role,
text,
state,
tsMs,
inTok,
outTok,
respTok,
ctxPct,
openClawId,
openClawSeq);

if (role == "assistant")
{
Expand Down Expand Up @@ -3155,8 +3195,18 @@ JsonValueKind.Number when v.TryGetInt32(out var i) => i,
return (input, output, response, ctx);
}

private void EmitChatMessageReceived(string sessionKey, string role, string text, string? state, long tsMs,
int? inputTokens = null, int? outputTokens = null, int? responseTokens = null, int? contextPct = null)
private void EmitChatMessageReceived(
string sessionKey,
string role,
string text,
string? state,
long tsMs,
int? inputTokens = null,
int? outputTokens = null,
int? responseTokens = null,
int? contextPct = null,
string? openClawId = null,
int? openClawSeq = null)
{
if (ChatMessageInfo.IsSilentAssistantDirective(role, text))
return;
Expand All @@ -3173,7 +3223,9 @@ private void EmitChatMessageReceived(string sessionKey, string role, string text
InputTokens = inputTokens,
OutputTokens = outputTokens,
ResponseTokens = responseTokens,
ContextPercent = contextPct
ContextPercent = contextPct,
OpenClawId = openClawId,
OpenClawSeq = openClawSeq
});
}
catch (Exception ex)
Expand All @@ -3182,6 +3234,26 @@ private void EmitChatMessageReceived(string sessionKey, string role, string text
}
}

private static (string? Id, int? Seq) ExtractOpenClawMetadata(JsonElement node)
{
if (node.ValueKind != JsonValueKind.Object ||
!node.TryGetProperty("__openclaw", out var openClaw) ||
openClaw.ValueKind != JsonValueKind.Object)
{
return (null, null);
}

var id = openClaw.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
? idProp.GetString()
: null;
var seq = openClaw.TryGetProperty("seq", out var seqProp) &&
seqProp.ValueKind == JsonValueKind.Number &&
seqProp.TryGetInt32(out var parsedSeq)
? parsedSeq
: (int?)null;
return (id, seq);
}

private static long ExtractChatTimestampMs(JsonElement node)
{
if (node.ValueKind != JsonValueKind.Object)
Expand Down
14 changes: 8 additions & 6 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4394,6 +4394,14 @@ private async Task ExitApplicationAsync()
_globalHotkey = null;
});

// Stop chat first so provider event handlers cannot drain client-only
// queued prompts while the gateway connection is shutting down.
SafeShutdownStep("chat coordinator", () =>
{
_chatCoordinator?.Dispose();
_chatCoordinator = null;
});

// Dispose runtime services
var connectionManager = _connectionManager;
if (connectionManager != null)
Expand All @@ -4405,12 +4413,6 @@ await SafeShutdownStepAsync("gateway client", async () =>
_connectionManager = null;
}

SafeShutdownStep("chat coordinator", () =>
{
_chatCoordinator?.Dispose();
_chatCoordinator = null;
});

var nodeService = _nodeService;
if (nodeService != null)
{
Expand Down
22 changes: 21 additions & 1 deletion src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ namespace OpenClawTray.Chat;
/// Raw token contribution reported for this assistant response before it was
/// converted into the displayed cumulative session snapshot.
/// </param>
/// <param name="GatewayMessageId">
/// Gateway-assigned stable message id from <c>__openclaw.id</c>, when known.
/// Used to reconcile live entries with later <c>chat.history</c> rows.
/// </param>
/// <param name="OpenClawSeq">
/// Monotonic per-session sequence from <c>__openclaw.seq</c>, when known.
/// Prefer this over timestamps for transcript ordering and dedupe.
/// </param>
/// <param name="IsLocalQueuedSend">
/// True for a locally queued user prompt promoted into the transcript before
/// gateway history has provided its stable id/sequence.
/// </param>
/// <param name="LocalQueuedMessageId">
/// Stable client-side id for a local send. Used to attach a later gateway
/// identity to the exact optimistic transcript row without text matching.
/// </param>
public sealed record ChatEntryMetadata(
DateTimeOffset? Timestamp,
string? Model,
Expand All @@ -52,4 +68,8 @@ public sealed record ChatEntryMetadata(
int? ResponseTokens = null,
int? ContextPercent = null,
long? ContextTokens = null,
int? UsageContributionTokens = null);
int? UsageContributionTokens = null,
string? GatewayMessageId = null,
int? OpenClawSeq = null,
bool IsLocalQueuedSend = false,
string? LocalQueuedMessageId = null);
Loading