mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -442,7 +442,7 @@ export function renderMessageGroup(
|
||||
normalizedRole === "user"
|
||||
? (userLabel ?? resolvedUserName)
|
||||
: normalizedRole === "assistant"
|
||||
? assistantName
|
||||
? (userLabel ?? assistantName)
|
||||
: normalizedRole === "tool"
|
||||
? "Tool"
|
||||
: normalizedRole;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user