Skip to content

Commit 4f8ab44

Browse files
committed
fix(ui): scope tool outcomes to run boundaries
1 parent 6de357a commit 4f8ab44

2 files changed

Lines changed: 67 additions & 10 deletions

File tree

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1140,13 +1140,14 @@ function createAssistantCanvasBlock(params: { suffix: string }) {
11401140
}
11411141

11421142
describe("tool turn outcome annotation (#89683)", () => {
1143-
function failedTool(timestamp: number) {
1143+
function failedTool(timestamp: number, runId?: string) {
11441144
return {
11451145
role: "toolResult",
11461146
toolName: "shell",
11471147
content: JSON.stringify({ status: "failed", exitCode: 1 }),
11481148
isError: true,
11491149
timestamp,
1150+
...(runId ? { runId } : {}),
11501151
};
11511152
}
11521153
function userMsg(text: string, timestamp: number) {
@@ -1155,6 +1156,9 @@ describe("tool turn outcome annotation (#89683)", () => {
11551156
function assistantReply(text: string, timestamp: number) {
11561157
return { role: "assistant", content: [{ type: "text", text }], timestamp };
11571158
}
1159+
function assistantRunReply(text: string, timestamp: number, runId: string) {
1160+
return { ...assistantReply(text, timestamp), runId };
1161+
}
11581162
function toolGroups(messages: unknown[]): MessageGroup[] {
11591163
return messageGroups({ messages }).filter((group) => group.role === "tool");
11601164
}
@@ -1194,4 +1198,14 @@ describe("tool turn outcome annotation (#89683)", () => {
11941198
]);
11951199
expect(tools.map((group) => group.turnSucceeded)).toEqual([true, false]);
11961200
});
1201+
1202+
it("does not carry an agent-initiated reply across run boundaries", () => {
1203+
const tools = toolGroups([
1204+
failedTool(1, "run-terminal-failure"),
1205+
failedTool(2, "run-later-success"),
1206+
assistantRunReply("Recovered on the later turn.", 3, "run-later-success"),
1207+
]);
1208+
expect(tools).toHaveLength(2);
1209+
expect(tools.map((group) => group.turnSucceeded)).toEqual([false, true]);
1210+
});
11971211
});

ui/src/ui/chat/build-chat-items.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
92120
function 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.
353388
function 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

Comments
 (0)