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
13 changes: 8 additions & 5 deletions src/OpenClaw.Chat/ChatModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public enum ChatToolCallStatus
{
InProgress,
Success,
Error
Error,
Interrupted
}

public enum ChatTone
Expand Down Expand Up @@ -87,13 +88,15 @@ public record ChatTimelineState(
string? ActiveToolCallId,
string? CurrentIntent,
System.Collections.Immutable.ImmutableHashSet<string> LocalNonces,
System.Collections.Immutable.ImmutableDictionary<string, string> ActiveToolCalls,
bool HistoryLoaded = false,
ChatPermissionRequest? PendingPermission = null)
{
public static ChatTimelineState Initial() => new(
System.Collections.Immutable.ImmutableList<ChatTimelineItem>.Empty,
false, 1, null, null, null, null,
System.Collections.Immutable.ImmutableHashSet<string>.Empty);
System.Collections.Immutable.ImmutableHashSet<string>.Empty,
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty);
}

public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore);
Expand All @@ -107,9 +110,9 @@ public record ChatMessageEvent(string Text, string? ReasoningText = null, bool R
public record ChatMessageDeltaEvent(string Text) : ChatEvent;
public record ChatTurnEndEvent() : ChatEvent;
public record ChatIntentEvent(string Intent) : ChatEvent;
public record ChatToolStartEvent(string Text, string ToolName, JsonObject? ToolArgs = null) : ChatEvent;
public record ChatToolOutputEvent(string Text) : ChatEvent;
public record ChatToolErrorEvent(string Text) : ChatEvent;
public record ChatToolStartEvent(string Text, string ToolName, JsonObject? ToolArgs = null, string? ToolCallId = null) : ChatEvent;
public record ChatToolOutputEvent(string Text, string? ToolCallId = null) : ChatEvent;
public record ChatToolErrorEvent(string Text, string? ToolCallId = null) : ChatEvent;
public record ChatContextChangedEvent(string? Cwd, string? GitBranch) : ChatEvent;
public record ChatStatusEvent(string Text, ChatTone Tone) : ChatEvent;
public record ChatErrorEvent(string Text) : ChatEvent;
Expand Down
105 changes: 88 additions & 17 deletions src/OpenClaw.Chat/ChatTimelineReducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ public static ChatTimelineState Apply(ChatTimelineState state, ChatEvent evt)
ChatReasoningDeltaEvent e => UpsertReasoning(BeginTurn(state), e.Text, replace: false),
ChatMessageDeltaEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: false, streaming: true),
ChatMessageEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: true, streaming: false, e.ReconcilePrevious),
ChatTurnEndEvent => state with { TurnActive = false, ActiveAssistantId = null, ActiveReasoningId = null, PendingPermission = null },
ChatTurnEndEvent => ApplyTurnEnd(state),
ChatIntentEvent e => state with { CurrentIntent = e.Intent },
ChatToolStartEvent e => ApplyToolStart(state, e),
ChatToolOutputEvent e => ApplyToolOutput(state, e),
ChatToolErrorEvent e => ApplyToolError(state, e),
ChatErrorEvent e => PushEntry(EndTurn(state), ChatTimelineItemKind.Status, e.Text, ChatTone.Error),
ChatErrorEvent e => PushEntry(ApplyTurnEnd(state), ChatTimelineItemKind.Status, e.Text, ChatTone.Error),
ChatStatusEvent e => PushEntry(state, ChatTimelineItemKind.Status, e.Text, e.Tone),
ChatRestoredEvent e => PushEntry(state, ChatTimelineItemKind.Status, e.Text, ChatTone.Info),
ChatContextChangedEvent => state,
Expand Down Expand Up @@ -80,39 +80,57 @@ static ChatTimelineState ApplyUserMessage(ChatTimelineState state, ChatUserMessa
static ChatTimelineState ApplyToolStart(ChatTimelineState state, ChatToolStartEvent e)
{
var id = $"e{state.NextId}";
var activeToolCalls = state.ActiveToolCalls;

// Register by ToolCallId if available (parallel-safe correlation).
if (e.ToolCallId is { } tcId)
activeToolCalls = activeToolCalls.SetItem(tcId, id);

return state with
{
Entries = state.Entries.Add(new(id, ChatTimelineItemKind.ToolCall, e.Text,
ToolName: e.ToolName, ToolResult: ChatToolCallStatus.InProgress,
IntentSummary: e.Text, ToolArgs: e.ToolArgs)),
NextId = state.NextId + 1,
ActiveToolCallId = id,
// Only update legacy positional slot for events without a correlation ID.
ActiveToolCallId = e.ToolCallId is null ? id : state.ActiveToolCallId,
ActiveToolCalls = activeToolCalls,
TurnActive = true
};
}

static ChatTimelineState ApplyToolOutput(ChatTimelineState state, ChatToolOutputEvent e)
{
var entries = state.Entries;
if (state.ActiveToolCallId is { } tid)
var (entries, entryId) = ResolveToolEntry(state, e.ToolCallId);
if (entryId is { } tid)
{
var idx = entries.FindIndex(en => en.Id == tid);
if (idx >= 0)
{
var existingOutput = entries[idx].ToolOutput;
entries = entries.SetItem(idx, entries[idx] with
{
ToolResult = ChatToolCallStatus.Success,
ToolOutput = e.Text
ToolOutput = string.IsNullOrEmpty(e.Text) && existingOutput is not null
? existingOutput
: e.Text
});
}
}
return state with { Entries = entries, ActiveToolCallId = null, PendingPermission = null };
return state with
{
Entries = entries,
// Don't remove from ActiveToolCalls here: multiple output events can arrive
// for the same tool (command_output + item end). Mapping is cleared at turn end.
ActiveToolCallId = (entryId == state.ActiveToolCallId) ? null : state.ActiveToolCallId,
PendingPermission = null
};
}

static ChatTimelineState ApplyToolError(ChatTimelineState state, ChatToolErrorEvent e)
{
var entries = state.Entries;
if (state.ActiveToolCallId is { } tid)
var (entries, entryId) = ResolveToolEntry(state, e.ToolCallId);
if (entryId is { } tid)
{
var idx = entries.FindIndex(en => en.Id == tid);
if (idx >= 0)
Expand All @@ -124,7 +142,32 @@ static ChatTimelineState ApplyToolError(ChatTimelineState state, ChatToolErrorEv
});
}
}
return state with { Entries = entries, ActiveToolCallId = null, PendingPermission = null };
return state with
{
Entries = entries,
ActiveToolCallId = (entryId == state.ActiveToolCallId) ? null : state.ActiveToolCallId,
ActiveToolCalls = e.ToolCallId is { } k ? state.ActiveToolCalls.Remove(k) : state.ActiveToolCalls,
PendingPermission = null
};
}

/// <summary>
/// Resolve which timeline entry a tool output/error belongs to.
/// Prefers ID-based lookup (parallel-safe); falls back to ActiveToolCallId only for legacy events (no ID).
/// </summary>
static (System.Collections.Immutable.ImmutableList<ChatTimelineItem> Entries, string? EntryId) ResolveToolEntry(
ChatTimelineState state, string? toolCallId)
{
if (toolCallId is { } tcId)
{
// ID provided: strict lookup only. If mapping already consumed, no-op (don't misroute).
return state.ActiveToolCalls.TryGetValue(tcId, out var entryId)
? (state.Entries, entryId)
: (state.Entries, null);
}

// No ID: legacy positional fallback.
return (state.Entries, state.ActiveToolCallId);
}

static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, bool replace, bool streaming, bool reconcilePrevious = false)
Expand Down Expand Up @@ -206,14 +249,42 @@ static ChatTimelineState PushEntry(ChatTimelineState state, ChatTimelineItemKind
};
}

static ChatTimelineState EndTurn(ChatTimelineState state) => state with
static ChatTimelineState ApplyTurnEnd(ChatTimelineState state)
{
TurnActive = false,
ActiveAssistantId = null,
ActiveReasoningId = null,
ActiveToolCallId = null,
PendingPermission = null
};
var entries = state.Entries;

// Collect all entry IDs that are still tracked as active.
var activeEntryIds = new System.Collections.Generic.HashSet<string>();
if (state.ActiveToolCallId is { } fallbackId)
activeEntryIds.Add(fallbackId);
foreach (var kvp in state.ActiveToolCalls)
activeEntryIds.Add(kvp.Value);

// Mark all remaining in-progress tools as Interrupted (reality: they never completed).
foreach (var entryId in activeEntryIds)
{
var idx = entries.FindIndex(en => en.Id == entryId);
if (idx >= 0 && entries[idx].ToolResult == ChatToolCallStatus.InProgress)
{
entries = entries.SetItem(idx, entries[idx] with
{
ToolResult = ChatToolCallStatus.Interrupted
});
}
}

return state with
{
Entries = entries,
TurnActive = false,
ActiveAssistantId = null,
ActiveReasoningId = null,
ActiveToolCallId = null,
ActiveToolCalls = System.Collections.Immutable.ImmutableDictionary<string, string>.Empty,
PendingPermission = null
};
}


static ChatTimelineState BeginTurn(ChatTimelineState state) =>
state.TurnActive ? state : state with { TurnActive = true };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public FakeChatDataProvider()
ActiveToolCallId: null,
CurrentIntent: null,
LocalNonces: ImmutableHashSet<string>.Empty,
ActiveToolCalls: ImmutableDictionary<string, string>.Empty,
HistoryLoaded: true,
PendingPermission: null);
}
Expand Down
11 changes: 6 additions & 5 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1219,15 +1219,15 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId)
var phase = evt.Data.TryGetProperty("phase", out var phaseProp) ? phaseProp.GetString() ?? "" : "";
var title = evt.Data.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : "";
var toolName = ExtractToolKindFromTitle(title);
var itemId = evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() : null;

return phase.ToLowerInvariant() switch
{
"start" => new ChatToolStartEvent(title, toolName),
"start" => new ChatToolStartEvent(title, toolName, ToolCallId: itemId),
// ``end`` flips the active tool's status to Success even when no
// command_output arrived (e.g. ``read``, ``glob`` — non-shell).
// Use the title as a no-op output so the reducer marks Success.
"end" => new ChatToolOutputEvent(string.Empty),
"error" => new ChatToolErrorEvent(title),
"end" => new ChatToolOutputEvent(string.Empty, ToolCallId: itemId),
"error" => new ChatToolErrorEvent(title, ToolCallId: itemId),
_ => null
};
}
Expand All @@ -1252,7 +1252,8 @@ private void UpdateActiveRunId(AgentEventInfo evt, string threadId)
if (string.IsNullOrEmpty(output))
return null;

return new ChatToolOutputEvent(output);
var itemId = evt.Data.TryGetProperty("itemId", out var idProp) ? idProp.GetString() : null;
return new ChatToolOutputEvent(output, ToolCallId: itemId);
}

/// <summary>
Expand Down
17 changes: 14 additions & 3 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -936,7 +936,7 @@ Element RenderToolBurst(System.Collections.Generic.IReadOnlyList<ChatTimelineIte
{
// Same palette as the previous chip — keeps continuity with
// Kenny's Cat04 tool cards. Running orange / Done green /
// Error critical.
// Error critical / Interrupted grey.
switch (entry.ToolResult)
{
case ChatToolCallStatus.Success:
Expand All @@ -945,6 +945,9 @@ Element RenderToolBurst(System.Collections.Generic.IReadOnlyList<ChatTimelineIte
case ChatToolCallStatus.Error:
var err = themeBrush("SystemFillColorCriticalBrush");
return (LocalizationHelper.GetString("Chat_Status_Error"), err, err);
case ChatToolCallStatus.Interrupted:
var grey = themeBrush("TextFillColorTertiaryBrush");
return (LocalizationHelper.GetString("Chat_Status_Interrupted"), grey, grey);
default:
var run = new SolidColorBrush(Color.FromArgb(0xFF, 0xDC, 0x78, 0x1E));
return (LocalizationHelper.GetString("Chat_Status_Running"), run, themeBrush("TextFillColorTertiaryBrush"));
Expand Down Expand Up @@ -1148,12 +1151,15 @@ Element BuildSection(string sectionLabel, string contentText)
var stepCount = entries.Count;

// Aggregate burst status: Error if any errored, Running if any
// not-yet-finished, otherwise Done. Drives the task header pill.
// not-yet-finished, Interrupted if any interrupted (but not running),
// otherwise Done. Drives the task header pill.
ChatToolCallStatus aggregateStatus = ChatToolCallStatus.Success;
foreach (var e in entries)
{
if (e.ToolResult == ChatToolCallStatus.Error) { aggregateStatus = ChatToolCallStatus.Error; break; }
if (e.ToolResult != ChatToolCallStatus.Success) aggregateStatus = ChatToolCallStatus.InProgress;
if (e.ToolResult == ChatToolCallStatus.InProgress) { aggregateStatus = ChatToolCallStatus.InProgress; }
else if (e.ToolResult == ChatToolCallStatus.Interrupted && aggregateStatus == ChatToolCallStatus.Success)
aggregateStatus = ChatToolCallStatus.Interrupted;
}
var (taskStatusText, taskStatusBg, _) = ResolveStatus(new ChatTimelineItem(
Id: "agg", Kind: ChatTimelineItemKind.ToolCall, Text: null,
Expand Down Expand Up @@ -1315,6 +1321,11 @@ Element StatusGlyph(ChatToolCallStatus status, double size = 14)
.Foreground(themeBrush("SystemFillColorCriticalBrush"))
.Set(t => { t.FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"); t.FontSize = size; })
.HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center);
case ChatToolCallStatus.Interrupted:
return Caption("\uE738")
.Foreground(themeBrush("TextFillColorTertiaryBrush"))
.Set(t => { t.FontFamily = new FontFamily("Segoe Fluent Icons, Segoe MDL2 Assets"); t.FontSize = size; })
.HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center);
default:
return ProgressRing()
.Width(size).Height(size)
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2826,6 +2826,9 @@ On your gateway host (Mac/Linux), run:
<data name="Chat_Status_Error" xml:space="preserve">
<value>Error</value>
</data>
<data name="Chat_Status_Interrupted" xml:space="preserve">
<value>Interrupted</value>
</data>
<data name="Chat_Timeline_LoadEarlier" xml:space="preserve">
<value>Load earlier messages</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2777,6 +2777,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
<data name="Chat_Status_Error" xml:space="preserve">
<value>Erreur</value>
</data>
<data name="Chat_Status_Interrupted" xml:space="preserve">
<value>Interrompu</value>
</data>
<data name="Chat_Timeline_LoadEarlier" xml:space="preserve">
<value>Charger les messages précédents</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2778,6 +2778,9 @@ Voer op uw gateway-host (Mac/Linux) uit:
<data name="Chat_Status_Error" xml:space="preserve">
<value>Fout</value>
</data>
<data name="Chat_Status_Interrupted" xml:space="preserve">
<value>Onderbroken</value>
</data>
<data name="Chat_Timeline_LoadEarlier" xml:space="preserve">
<value>Eerdere berichten laden</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2777,6 +2777,9 @@
<data name="Chat_Status_Error" xml:space="preserve">
<value>错误</value>
</data>
<data name="Chat_Status_Interrupted" xml:space="preserve">
<value>已中断</value>
</data>
<data name="Chat_Timeline_LoadEarlier" xml:space="preserve">
<value>加载更早的消息</value>
</data>
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -2777,6 +2777,9 @@
<data name="Chat_Status_Error" xml:space="preserve">
<value>錯誤</value>
</data>
<data name="Chat_Status_Interrupted" xml:space="preserve">
<value>已中斷</value>
</data>
<data name="Chat_Timeline_LoadEarlier" xml:space="preserve">
<value>載入較早的訊息</value>
</data>
Expand Down
Loading
Loading