Skip to content
Open
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
31 changes: 31 additions & 0 deletions docs/CONNECTION_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,37 @@ The operator client is received through the `OperatorClientChanged` event. The a

Inbound chat and agent timeline events must include the gateway's canonical `sessionKey`. The tray client must not synthesize a literal `main` key for keyless inbound events, because that can merge unrelated events into the wrong timeline. When a keyless chat or agent event arrives, the tray drops it and raises a one-shot diagnostic so the protocol issue is visible without exposing the dropped message contents.

Live `chat` and `session.message` events also retain the payload's optional
`runId`, separately from message identity. `ChatConversationState` consults
`ChatLifecycleState` before admitting assistant or tool chat output, so an
aborted, completed, or mismatched run cannot replace output or end a newer
turn. Reset admission also checks the supplied run against ignored reset runs.
The most recent non-aborted lifecycle completion may still receive its final
text while idle. Suppressed terminals do not change that eligibility, and the
bounded cache evicts by arrival order rather than suppression priority. The
terminal cache also suppresses late nonterminal agent output after abort
cleanup. Late tool repair requires a retained correlation for the same run;
late approvals must match the pending approval. Known late child/parent repair
does not reactivate an idle turn. An aborted lifecycle start cannot replace
the current run. Frames without a run ID retain legacy thread-level handling;
they cannot be reliably correlated to an older run.

Upstream lifecycle errors may be followed by a same-run retry. The most recent
non-aborted failed run may restart while idle, before any newer active or
completed run or accepted assistant final. Explicit execution settlement,
exhausted fallback, timeout, cancellation and terminal liveness facts remain
closed, matching upstream
[`isDefinitiveRunLifecycle`](https://github.com/openclaw/openclaw/blob/eb82ef8b80a05058619557dff09f758a6710d4d5/packages/normalization-core/src/agent-run-terminal-outcome.ts#L145-L235).
Successful completion and abort fences remain closed to repeated starts.

Chat admission also controls notification delivery. The synchronous provider
marks rejected `ChatMessageInfo` frames with `SuppressNotification()` before
the gateway parser emits its separate notification event. That per-frame veto
is monotonic and does not suppress delivery to other chat subscribers. It
prevents rejected finals from reaching notification history, toasts or TTS
without adding another run cache or moving notification ownership into `App`.
Consumers that do not apply a veto retain the existing notification behavior.

## Startup wiring (App.xaml.cs)

```
Expand Down
22 changes: 22 additions & 0 deletions src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ public static ChatTimelineState RebuildActiveToolTracking(ChatTimelineState stat
};
}

/// <summary>
/// A completed run may repair a retained tool row or materialize a known
/// terminal child, but must not create a new tool activity from a stale frame.
/// </summary>
public static bool CanReconcileToolAfterTurnEnd(ChatTimelineState state, ChatEvent? evt)
{
var (runId, toolCallId) = evt switch
{
ChatToolStartEvent e => (e.RunId, e.ToolCallId),
ChatToolPresentationEvent e => (e.RunId, e.ParentToolCallId),
ChatToolOutputEvent e => (e.RunId, e.ToolCallId),
ChatToolErrorEvent e => (e.RunId, e.ToolCallId),
_ => ((string?)null, (string?)null),
};
if (string.IsNullOrWhiteSpace(runId) || string.IsNullOrWhiteSpace(toolCallId))
return false;

var key = CurrentCorrelationKey(state, runId, toolCallId);
return FindToolEntryIndex(state, key, allowTerminalLegacy: true) >= 0 ||
state.TerminalToolCorrelations?.ContainsKey(key) == true;
}

public static ChatTimelineState Apply(ChatTimelineState state, ChatEvent evt)
{
return evt switch
Expand Down
13 changes: 13 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1798,6 +1798,19 @@ public static bool IsSilentAssistantDirective(string? role, string? text) =>
/// <summary>Session this message belongs to (e.g. "main").</summary>
public string SessionKey { get; set; } = "";

/// <summary>Optional run identity from the live chat event payload, not the message ID.</summary>
public string? RunId { get; set; }

/// <summary>Set by synchronous chat consumers when this frame must not produce a notification.</summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool IsNotificationSuppressed { get; private set; }

/// <summary>
/// Suppresses the parser's subsequent notification without changing other
/// consumers' delivery. Suppression is monotonic for this received frame.
/// </summary>
public void SuppressNotification() => IsNotificationSuppressed = true;

/// <summary>"user", "assistant", "system", etc.</summary>
public string Role { get; set; } = "";

Expand Down
34 changes: 23 additions & 11 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3851,6 +3851,11 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
if (string.IsNullOrEmpty(sessionKey))
_logger.Warn("[GatewayClient] Chat event missing sessionKey; will be dropped downstream.");

var runId = payload.TryGetProperty("runId", out var runIdProperty) &&
runIdProperty.ValueKind == JsonValueKind.String
? runIdProperty.GetString()
: null;

// Best-effort usage extraction — gateway emits this only on terminal
// (state="final") events in practice; we still read it defensively
// from common locations so any reasonable shape lights up the chat
Expand Down Expand Up @@ -3888,7 +3893,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)

var messageOpenClawMetadata = ExtractOpenClawMetadata(message);
var payloadOpenClawMetadata = ExtractOpenClawMetadata(payload);
EmitChatMessageReceived(
var notify = EmitChatMessageReceived(
sessionKey,
role,
text,
Expand All @@ -3903,9 +3908,10 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
messageOpenClawMetadata.Kind ?? payloadOpenClawMetadata.Kind,
messageOpenClawMetadata.TokensBefore ?? payloadOpenClawMetadata.TokensBefore,
messageOpenClawMetadata.TokensAfter ?? payloadOpenClawMetadata.TokensAfter,
contentParts);
contentParts,
runId);

if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase))
if (notify && role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase))
{
// HIGH 4: log shape only — content previously
// surfaced in the operator log.
Expand All @@ -3929,7 +3935,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
if (ChatMessageInfo.IsSilentAssistantDirective(role, text)) return;

var openClawMetadata = ExtractOpenClawMetadata(payload);
EmitChatMessageReceived(
var notify = EmitChatMessageReceived(
sessionKey,
role,
text,
Expand All @@ -3944,9 +3950,10 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
openClawMetadata.Kind,
openClawMetadata.TokensBefore,
openClawMetadata.TokensAfter,
projection.ContentParts);
projection.ContentParts,
runId);

if (role == "assistant" &&
if (notify && role == "assistant" &&
(string.IsNullOrWhiteSpace(state) ||
string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)))
{
Expand Down Expand Up @@ -4009,7 +4016,7 @@ JsonValueKind.Number when v.TryGetInt32(out var i) => i,
return (input, output, response, ctx);
}

private void EmitChatMessageReceived(
private bool EmitChatMessageReceived(
string sessionKey,
string role,
string text,
Expand All @@ -4024,16 +4031,18 @@ private void EmitChatMessageReceived(
string? openClawKind = null,
long? compactionTokensBefore = null,
long? compactionTokensAfter = null,
IReadOnlyList<ChatMessageContentPartInfo>? contentParts = null)
IReadOnlyList<ChatMessageContentPartInfo>? contentParts = null,
string? runId = null)
{
if (ChatMessageInfo.IsSilentAssistantDirective(role, text))
return;
return false;

try
{
ChatMessageReceived?.Invoke(this, new ChatMessageInfo
var message = new ChatMessageInfo
{
SessionKey = sessionKey,
RunId = runId,
Role = role,
Text = text,
ContentParts = contentParts ?? Array.Empty<ChatMessageContentPartInfo>(),
Expand All @@ -4048,11 +4057,14 @@ private void EmitChatMessageReceived(
OpenClawKind = openClawKind,
CompactionTokensBefore = compactionTokensBefore,
CompactionTokensAfter = compactionTokensAfter
});
};
ChatMessageReceived?.Invoke(this, message);
return !message.IsNotificationSuppressed;
}
catch (Exception ex)
{
_logger.Warn($"ChatMessageReceived handler threw: {ex.Message}");
return false;
}
}

Expand Down
67 changes: 63 additions & 4 deletions src/OpenClaw.Tray.WinUI/Chat/ChatConversationState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ internal ChatQueuedAdmission AdmitMessage(

_lifecycle.ClearThreadSuppression(threadId);
_lifecycle.TakePendingAbortCount(threadId);
_lifecycle.ClearRetryEligibility(threadId);
var request = new ChatQueuedSendRequest(
messageId,
Guid.NewGuid().ToString(),
Expand Down Expand Up @@ -1327,6 +1328,22 @@ internal ChatIncomingMessageGate GateIncomingChatMessage(
var hasMediaEnvelope = projection?.HasMediaEnvelope ?? false;
lock (_gate)
{
if (role is "assistant" or "toolresult" or "tool_result" &&
_lifecycle.ShouldSuppressChatMessage(
threadId,
message.RunId,
message.IsFinal,
_queue.RunIdsForThread(threadId),
_timelines.TryGetValue(threadId, out var timeline) && timeline.TurnActive))
{
return new(
Drop: false,
Suppressed: true,
RequestRemoteBackfill: false,
Snapshot: null,
OpenedLifecycle: null,
CurrentRuntimeGenerationLocked(threadId));
}
_lifecycle.TryGetActiveRun(
threadId,
out var activeRunId);
Expand All @@ -1340,7 +1357,8 @@ internal ChatIncomingMessageGate GateIncomingChatMessage(
text,
attachmentCorrelationSignature,
hasMediaEnvelope),
activeRunId);
activeRunId,
message.RunId);
var openedLifecycle =
ApplyBufferedLifecycleOpenLocked(
threadId,
Expand Down Expand Up @@ -1692,11 +1710,11 @@ message.ResponseTokens is not null ||
}
}

internal string? CompleteAssistantFinal(string threadId)
internal string? CompleteAssistantFinal(string threadId, string? runId = null)
{
lock (_gate)
{
var completedRunId = _lifecycle.CompleteAssistantFinal(threadId);
var completedRunId = _lifecycle.CompleteAssistantFinal(threadId, runId);
_reset.CompleteRun(threadId, completedRunId);
if (!_queue.HasSendingMessages(threadId))
_queue.ClearLocallyInitiated(threadId);
Expand Down Expand Up @@ -1745,6 +1763,11 @@ internal ChatAgentEventTransition ProcessAgentEvent(
{
var mapping = ChatEventMapper.Map(evt);
mapped = mapping.Event;
if (mapped is ChatToolPresentationEvent presentation &&
_lifecycle.IsCompletedRun(threadId, evt.RunId))
{
mapped = presentation with { ActivatesTurn = false };
}
if (mapping.Approval is { } approval &&
!_approval.MarkSeen(approval.RequestId, approval.AlternateId))
{
Expand Down Expand Up @@ -1914,12 +1937,35 @@ private ChatAgentEventGate GateAgentEventLocked(
}
if (resetGate.Drop)
{
// Reset consumes an ignored terminal to request history reconciliation.
// Retain its identity for chat finals that can arrive after that terminal.
if (resetGate.ReloadHistory && !string.IsNullOrWhiteSpace(evt.RunId))
_lifecycle.RememberSuppressedTerminal(threadId, evt.RunId);
return new(
false,
resetGate.ReloadHistory,
null,
openedLifecycle);
}
if (ChatEventMapper.IsLifecycleStart(evt) && _lifecycle.IsRunAborted(evt.RunId))
{
Logger.Debug($"[ChatProvider] Dropping aborted-run lifecycle start for threadId='{threadId}'");
return new(false, false, null, openedLifecycle);
}
if (ChatEventMapper.IsLifecycleStart(evt) &&
(!_timelines.TryGetValue(threadId, out var currentTimeline) || !currentTimeline.TurnActive) &&
_lifecycle.TryRestartFailedRun(threadId, evt.RunId))
{
Logger.Debug($"[ChatProvider] Admitting same-run lifecycle retry for threadId='{threadId}'");
}
if (!ChatEventMapper.IsTerminalRunEvent(evt) &&
_lifecycle.IsCompletedRun(threadId, evt.RunId) &&
_lifecycle.ShouldSuppressCompletedAgentEvent(
threadId, evt, CanReconcileCompletedAgentEventLocked(threadId, evt)))
{
Logger.Debug($"[ChatProvider] Dropping completed-run agent event for threadId='{threadId}' stream='{evt.Stream}'");
return new(false, false, null, openedLifecycle);
}
if (ShouldDropTerminalAgentEventLocked(
evt,
threadId,
Expand Down Expand Up @@ -2038,6 +2084,18 @@ private ChatRunTransition UpdateRunTrackingLocked(
snapshot);
}

private bool CanReconcileCompletedAgentEventLocked(string threadId, AgentEventInfo evt)
{
if (!_timelines.TryGetValue(threadId, out var timeline))
return false;
if (ChatEventMapper.CanReconcileToolAfterRunEnd(evt, timeline))
return true;
var approval = ChatEventMapper.MapTerminalApproval(evt);
return approval is not null &&
timeline.PendingPermission is { } pending &&
_approval.Matches(pending.RequestId, approval.ApprovalSlug, approval.ApprovalId);
}

private bool TryResolveTerminalApprovalLocked(
AgentEventInfo evt,
string threadId)
Expand Down Expand Up @@ -2156,7 +2214,8 @@ private bool ShouldDropTerminalAgentEventLocked(
_queue.RunIdsForThread(threadId),
_timelines.TryGetValue(threadId, out var timeline) &&
timeline.TurnActive,
out droppedReason);
out droppedReason,
retryableError: ChatEventMapper.IsRetryableLifecycleError(evt));
}

private static bool TryGetTerminalAgentRunId(
Expand Down
48 changes: 48 additions & 0 deletions src/OpenClaw.Tray.WinUI/Chat/ChatEventMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,49 @@ internal static bool IsLifecycleStart(AgentEventInfo evt) =>
evt.Data.TryGetProperty("phase", out var phase) &&
string.Equals(phase.GetString(), "start", StringComparison.OrdinalIgnoreCase);

internal static bool IsLifecycleError(AgentEventInfo evt) =>
string.Equals(evt.Stream, "lifecycle", StringComparison.OrdinalIgnoreCase) &&
evt.Data.ValueKind == JsonValueKind.Object &&
evt.Data.TryGetProperty("phase", out var phase) &&
string.Equals(phase.GetString(), "error", StringComparison.OrdinalIgnoreCase);

internal static bool IsRetryableLifecycleError(AgentEventInfo evt)
{
if (!IsLifecycleError(evt))
return false;
var data = evt.Data;
if (IsTrue(data, "executionSettled") || IsTrue(data, "fallbackExhaustedFailure"))
return false;

// Match upstream isDefinitiveRunLifecycle's failed/non-failed projection.
// ProviderStarted only distinguishes timeout kinds, not retry eligibility.
var status = StringProperty(data, "status").ToLowerInvariant();
var stopReason = StringProperty(data, "stopReason");
if (string.IsNullOrWhiteSpace(stopReason))
stopReason = string.Empty;
var timeoutPhase = StringProperty(data, "timeoutPhase").Trim();
var timedOut = stopReason == "timeout" ||
status is "timeout" or "timed_out" ||
timeoutPhase is "queue" or "preflight" or "provider" or "post_turn" or "gateway_draining";
if (timedOut)
return false;

var aborted = IsTrue(data, "aborted") || status == "aborted";
var cancellationStatus = status is "cancelled" or "canceled" or "aborted" or "superseded";
if ((aborted || cancellationStatus) &&
stopReason is not ("aborted" or "restart" or "rpc" or "stop" or "superseded") &&
(stopReason.Length == 0 || cancellationStatus))
{
stopReason = aborted ? "aborted" : "stop";
}
var liveness = StringProperty(data, "livenessState").Trim().ToLowerInvariant();
return stopReason is not ("aborted" or "restart" or "rpc" or "stop" or "superseded") &&
liveness is not ("blocked" or "abandoned");
}

private static bool IsTrue(JsonElement data, string property) =>
data.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True;

internal static bool IsTerminalRunEvent(AgentEventInfo evt)
{
if (evt.Data.ValueKind != JsonValueKind.Object)
Expand Down Expand Up @@ -90,6 +133,11 @@ ChatStatusEvent or ChatErrorEvent or ChatReasoningEndEvent or
};
}

internal static bool CanReconcileToolAfterRunEnd(
AgentEventInfo evt,
ChatTimelineState timeline) =>
ChatTimelineReducer.CanReconcileToolAfterTurnEnd(timeline, Map(evt).Event);

internal static bool IsTerminalApprovalPhase(string phase)
{
if (string.IsNullOrEmpty(phase))
Expand Down
Loading
Loading