@@ -89,6 +89,34 @@ function safeNormalizeMessage(message: unknown): NormalizedMessage | null {
8989 }
9090}
9191
92+ function messageTurnKey ( message : unknown ) : string | null {
93+ const record = asRecord ( message ) ;
94+ if ( ! record ) {
95+ return null ;
96+ }
97+ const metadata = asRecord ( record . __openclaw ) ;
98+ const key = [
99+ record . runId ,
100+ record . agentRunId ,
101+ record . turnId ,
102+ metadata ?. runId ,
103+ metadata ?. agentRunId ,
104+ metadata ?. turnId ,
105+ ] . find ( ( value ) : value is string => typeof value === "string" && value . trim ( ) . length > 0 ) ;
106+ return key ?. trim ( ) ?? null ;
107+ }
108+
109+ function groupTurnKey ( group : MessageGroup ) : string | null {
110+ const keys = new Set < string > ( ) ;
111+ for ( const entry of group . messages ) {
112+ const key = messageTurnKey ( entry . message ) ;
113+ if ( key ) {
114+ keys . add ( key ) ;
115+ }
116+ }
117+ return keys . size === 1 ? ( keys . values ( ) . next ( ) . value ?? null ) : null ;
118+ }
119+
92120function extractChatMessagePreview ( toolMessage : unknown ) : {
93121 preview : Extract < NonNullable < ToolCard [ "preview" ] > , { kind : "canvas" } > ;
94122 text : string | null ;
@@ -193,17 +221,23 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
193221
194222 const normalized = normalizeMessage ( item . message ) ;
195223 const role = normalizeRoleForGrouping ( normalized . role ) ;
224+ const roleKey = role . toLowerCase ( ) ;
196225 const senderLabel =
197- role . toLowerCase ( ) === "user" || role . toLowerCase ( ) === "assistant"
198- ? ( normalized . senderLabel ?? null )
199- : null ;
226+ roleKey === "user" || roleKey === "assistant" ? ( normalized . senderLabel ?? null ) : null ;
227+ const itemTurnKey = messageTurnKey ( item . message ) ;
200228 const timestamp = normalized . timestamp || Date . now ( ) ;
201- const shouldSplitBySender = role . toLowerCase ( ) === "user" || role . toLowerCase ( ) === "assistant" ;
229+ const shouldSplitBySender = roleKey === "user" || roleKey === "assistant" ;
230+ const currentTurnKey = currentGroup ? groupTurnKey ( currentGroup ) : null ;
231+ const shouldSplitByTurn =
232+ ( roleKey === "assistant" || roleKey === "tool" ) &&
233+ currentGroup ?. role === role &&
234+ Boolean ( itemTurnKey && currentTurnKey && itemTurnKey !== currentTurnKey ) ;
202235
203236 if (
204237 ! currentGroup ||
205238 currentGroup . role !== role ||
206- ( shouldSplitBySender && currentGroup . senderLabel !== senderLabel )
239+ ( shouldSplitBySender && currentGroup . senderLabel !== senderLabel ) ||
240+ shouldSplitByTurn
207241 ) {
208242 if ( currentGroup ) {
209243 result . push ( currentGroup ) ;
@@ -348,12 +382,14 @@ function assistantGroupHasReplyText(group: MessageGroup): boolean {
348382// failure (e.g. a no-match search) must not render as a primary error banner
349383// once a clean reply exists. Backward pass: a user group ends the turn
350384// downstream; an assistant reply marks success for earlier tool groups in the
351- // same turn. turnSucceeded stays undefined for terminal or in-progress failures,
352- // preserving the existing error banner.
385+ // same turn. Explicit run/turn metadata scopes agent-initiated turns that have
386+ // no user boundary; legacy transcripts without that metadata still use the user
387+ // boundary fallback. Terminal or in-progress failures keep the error banner.
353388function annotateToolTurnOutcome (
354389 items : Array < ChatItem | MessageGroup > ,
355390) : Array < ChatItem | MessageGroup > {
356391 let sawAssistantReply = false ;
392+ const successfulTurnKeys = new Set < string > ( ) ;
357393 for ( let i = items . length - 1 ; i >= 0 ; i -= 1 ) {
358394 const item = items [ i ] ;
359395 if ( item . kind !== "group" ) {
@@ -362,12 +398,19 @@ function annotateToolTurnOutcome(
362398 const role = item . role . toLowerCase ( ) ;
363399 if ( role === "user" ) {
364400 sawAssistantReply = false ;
401+ successfulTurnKeys . clear ( ) ;
365402 } else if ( role === "assistant" ) {
366403 if ( assistantGroupHasReplyText ( item ) ) {
367- sawAssistantReply = true ;
404+ const turnKey = groupTurnKey ( item ) ;
405+ if ( turnKey ) {
406+ successfulTurnKeys . add ( turnKey ) ;
407+ } else {
408+ sawAssistantReply = true ;
409+ }
368410 }
369411 } else if ( role === "tool" ) {
370- item . turnSucceeded = sawAssistantReply ;
412+ const turnKey = groupTurnKey ( item ) ;
413+ item . turnSucceeded = turnKey ? successfulTurnKeys . has ( turnKey ) : sawAssistantReply ;
371414 }
372415 }
373416 return items ;
0 commit comments