fix(webchat): show sessions_send handoffs as forwarded

Fix WebChat display projection for sessions_send inter-session handoffs. Forwarded messages now render assistant-side with source attribution while keeping transcript user-role semantics, stripping generated inter-session envelopes from display text, and preserving heartbeat/TTS/message-tool cleanup boundaries. Fixes #89161.
This commit is contained in:
Yzx
2026-06-03 16:09:45 +08:00
committed by GitHub
parent d004b80c91
commit 932d6ea8e5
12 changed files with 663 additions and 17 deletions

View File

@@ -67,6 +67,66 @@ describe("buildChatItems", () => {
expect(groups.map((group) => group.senderLabel)).toEqual(["Iris", "Joaquin De Rojas"]);
});
it("keeps differently cased user roles in one group", () => {
const groups = messageGroups({
messages: [
{
role: "user",
content: "first",
timestamp: 1000,
},
{
role: "User",
content: "second",
timestamp: 1001,
},
],
});
expect(groups).toHaveLength(1);
expect(groups[0].role).toBe("user");
expect(groups[0].messages).toHaveLength(2);
});
it("keeps forwarded assistant display messages separate from local assistant replies", () => {
const groups = messageGroups({
messages: [
{
role: "assistant",
content: "local reply",
timestamp: 1000,
},
{
role: "assistant",
content: "forwarded report",
senderLabel: "Forwarded from main",
timestamp: 1001,
},
],
});
expect(groups).toHaveLength(2);
expect(groups.map((group) => group.senderLabel)).toEqual([null, "Forwarded from main"]);
});
it("keeps empty forwarded assistant display groups", () => {
const groups = messageGroups({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "" }],
senderLabel: "Forwarded from main",
timestamp: 1000,
},
],
});
expect(groups).toHaveLength(1);
expect(groups[0].role).toBe("assistant");
expect(groups[0].senderLabel).toBe("Forwarded from main");
expect(groups[0].messages).toHaveLength(1);
});
it("collapses consecutive duplicate text messages into one rendered item with a count", () => {
const groups = messageGroups({
messages: [

View File

@@ -191,13 +191,17 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const normalized = normalizeMessage(item.message);
const role = normalizeRoleForGrouping(normalized.role);
const senderLabel = role.toLowerCase() === "user" ? (normalized.senderLabel ?? null) : null;
const senderLabel =
role.toLowerCase() === "user" || role.toLowerCase() === "assistant"
? (normalized.senderLabel ?? null)
: null;
const timestamp = normalized.timestamp || Date.now();
const shouldSplitBySender = role.toLowerCase() === "user" || role.toLowerCase() === "assistant";
if (
!currentGroup ||
currentGroup.role !== role ||
(role.toLowerCase() === "user" && currentGroup.senderLabel !== senderLabel)
(shouldSplitBySender && currentGroup.senderLabel !== senderLabel)
) {
if (currentGroup) {
result.push(currentGroup);
@@ -253,7 +257,8 @@ function collapseDuplicateDisplaySignature(message: unknown): string | null {
if (!text) {
return null;
}
const senderLabel = role === "user" ? (normalized.senderLabel ?? "").trim() : "";
const senderLabel =
role === "user" || role === "assistant" ? (normalized.senderLabel ?? "").trim() : "";
return `${role}:${senderLabel}:${text}`;
}
@@ -285,7 +290,9 @@ function hasRenderableNormalizedMessage(message: unknown): boolean {
if (!normalized) {
return false;
}
return normalized.content.length > 0 || Boolean(normalized.replyTarget);
const role = normalizeRoleForGrouping(normalized.role);
const hasVisibleSenderLabel = role === "assistant" && Boolean(normalized.senderLabel?.trim());
return normalized.content.length > 0 || Boolean(normalized.replyTarget) || hasVisibleSenderLabel;
}
function sanitizeStreamText(text: string): string {

View File

@@ -934,6 +934,37 @@ describe("grouped chat rendering", () => {
expect(avatar?.tagName).toBe("DIV");
});
it("uses assistant senderLabel for forwarded assistant-side groups", () => {
const container = document.createElement("div");
const group: MessageGroup = {
kind: "group",
key: "forwarded-group",
role: "assistant",
senderLabel: "Forwarded from main",
messages: [
{
key: "forwarded-message",
message: { role: "assistant", content: "forwarded report", timestamp: 1000 },
},
],
timestamp: 1000,
isStreaming: false,
};
render(
renderMessageGroup(group, {
showReasoning: true,
showToolCalls: true,
assistantName: "OpenClaw",
assistantAvatar: null,
}),
container,
);
const sender = container.querySelector<HTMLElement>(".chat-group.assistant .chat-sender-name");
expect(sender?.textContent).toBe("Forwarded from main");
});
it("collapses consecutive tool results into an activity group", () => {
const container = document.createElement("div");
const group: MessageGroup = {

View File

@@ -442,7 +442,7 @@ export function renderMessageGroup(
normalizedRole === "user"
? (userLabel ?? resolvedUserName)
: normalizedRole === "assistant"
? assistantName
? (userLabel ?? assistantName)
: normalizedRole === "tool"
? "Tool"
: normalizedRole;

View File

@@ -100,6 +100,9 @@ export function isAssistantHeartbeatAckForDisplay(message: unknown): boolean {
if (role !== "assistant") {
return false;
}
if (typeof entry.senderLabel === "string" && entry.senderLabel.trim()) {
return false;
}
const content =
typeof entry.content === "string" || Array.isArray(entry.content) ? entry.content : entry.text;

View File

@@ -17,11 +17,13 @@ describe("normalizeRoleForGrouping", () => {
expect(normalizeRoleForGrouping("Function")).toBe("tool");
});
it("preserves core roles", () => {
it("normalizes core roles", () => {
expect(normalizeRoleForGrouping("user")).toBe("user");
expect(normalizeRoleForGrouping("User")).toBe("User");
expect(normalizeRoleForGrouping("User")).toBe("user");
expect(normalizeRoleForGrouping("assistant")).toBe("assistant");
expect(normalizeRoleForGrouping("Assistant")).toBe("assistant");
expect(normalizeRoleForGrouping("system")).toBe("system");
expect(normalizeRoleForGrouping("System")).toBe("system");
});
it("detects only tool result role variants", () => {

View File

@@ -3,14 +3,14 @@
*/
export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase();
// Preserve original casing when it's already a core role.
if (role === "user" || role === "User") {
return role;
// Core roles drive grouping and layout; casing variants should not split groups.
if (lower === "user") {
return "user";
}
if (role === "assistant") {
if (lower === "assistant") {
return "assistant";
}
if (role === "system") {
if (lower === "system") {
return "system";
}
// Keep tool-related roles distinct so the UI can style/toggle them.