diff --git a/CHANGELOG.md b/CHANGELOG.md index e094f9184..8c8da5374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Repo: remove the Peekaboo git submodule now that the SPM release is used. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. +- UI: separate tool output from assistant reasoning and add a thinking toggle. (#1292) — thanks @bradleypriest. ### Fixes - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e84d2b7f1..3b0f75c16 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -3,7 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { toSanitizedMarkdownHtml } from "../markdown"; import type { MessageGroup } from "../types/chat-types"; -import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer"; +import { classifyMessage } from "./message-classifier"; import { extractText, extractThinking, @@ -62,19 +62,25 @@ export function renderMessageGroup( group: MessageGroup, opts: { onOpenSidebar?: (content: string) => void; showReasoning: boolean }, ) { - const normalizedRole = normalizeRoleForGrouping(group.role); + const roleKind = group.role; const who = - normalizedRole === "user" + roleKind === "user" ? "You" - : normalizedRole === "assistant" + : roleKind === "assistant" ? "Assistant" - : normalizedRole; + : roleKind === "tool" + ? "Tool" + : roleKind === "system" + ? "System" + : roleKind; const roleClass = - normalizedRole === "user" + roleKind === "user" ? "user" - : normalizedRole === "assistant" + : roleKind === "assistant" ? "assistant" - : "other"; + : roleKind === "tool" + ? "tool" + : "other"; const timestamp = new Date(group.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", @@ -82,7 +88,7 @@ export function renderMessageGroup( return html`
- ${renderAvatar(group.role)} + ${renderAvatar(roleKind)}
${group.messages.map((item, index) => renderGroupedMessage( @@ -105,21 +111,14 @@ export function renderMessageGroup( } function renderAvatar(role: string) { - const normalized = normalizeRoleForGrouping(role); const initial = - normalized === "user" - ? "U" - : normalized === "assistant" - ? "A" - : normalized === "tool" - ? "⚙" - : "?"; + role === "user" ? "U" : role === "assistant" ? "A" : role === "tool" ? "⚙" : "?"; const className = - normalized === "user" + role === "user" ? "user" - : normalized === "assistant" + : role === "assistant" ? "assistant" - : normalized === "tool" + : role === "tool" ? "tool" : "other"; return html`
${initial}
`; @@ -130,21 +129,22 @@ function renderGroupedMessage( opts: { isStreaming: boolean; showReasoning: boolean }, onOpenSidebar?: (content: string) => void, ) { - const m = message as Record; - const role = typeof m.role === "string" ? m.role : "unknown"; + const classification = classifyMessage(message); + const roleLower = classification.roleRaw.toLowerCase(); const isToolResult = - isToolResultMessage(message) || - role.toLowerCase() === "toolresult" || - role.toLowerCase() === "tool_result" || - typeof m.toolCallId === "string" || - typeof m.tool_call_id === "string"; + classification.hasToolResults || + roleLower === "toolresult" || + roleLower === "tool_result" || + (classification.isToolLike && !classification.hasText); const toolCards = extractToolCards(message); const hasToolCards = toolCards.length > 0; const extractedText = extractText(message); const extractedThinking = - opts.showReasoning && role === "assistant" ? extractThinking(message) : null; + opts.showReasoning && classification.roleKind === "assistant" + ? extractThinking(message) + : null; const markdownBase = extractedText?.trim() ? extractedText : null; const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) @@ -181,4 +181,3 @@ function renderGroupedMessage(
`; } - diff --git a/ui/src/ui/chat/legacy-render.ts b/ui/src/ui/chat/legacy-render.ts index 44ef25cc5..69b4cf2ca 100644 --- a/ui/src/ui/chat/legacy-render.ts +++ b/ui/src/ui/chat/legacy-render.ts @@ -2,10 +2,7 @@ import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { toSanitizedMarkdownHtml } from "../markdown"; -import { - isToolResultMessage, - normalizeRoleForGrouping, -} from "./message-normalizer"; +import { classifyMessage } from "./message-classifier"; import { extractText, extractThinking, @@ -38,16 +35,19 @@ export function renderMessage( opts?: { streaming?: boolean; showReasoning?: boolean }, ) { const m = message as Record; - const role = typeof m.role === "string" ? m.role : "unknown"; + const classification = classifyMessage(message); const toolCards = extractToolCards(message); const hasToolCards = toolCards.length > 0; const isToolResult = - isToolResultMessage(message) || - typeof m.toolCallId === "string" || - typeof m.tool_call_id === "string"; + classification.hasToolResults || + classification.roleRaw.toLowerCase() === "toolresult" || + classification.roleRaw.toLowerCase() === "tool_result" || + (classification.isToolLike && !classification.hasText); const extractedText = extractText(message); const extractedThinking = - opts?.showReasoning && role === "assistant" ? extractThinking(message) : null; + opts?.showReasoning && classification.roleKind === "assistant" + ? extractThinking(message) + : null; const contentText = typeof m.content === "string" ? m.content : null; const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); @@ -72,23 +72,25 @@ export function renderMessage( const timestamp = typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; - const normalizedRole = normalizeRoleForGrouping(role); + const roleKind = classification.roleKind; const klass = - normalizedRole === "assistant" + roleKind === "assistant" ? "assistant" - : normalizedRole === "user" + : roleKind === "user" ? "user" - : normalizedRole === "tool" + : roleKind === "tool" ? "tool" : "other"; const who = - normalizedRole === "assistant" + roleKind === "assistant" ? "Assistant" - : normalizedRole === "user" + : roleKind === "user" ? "You" - : normalizedRole === "tool" - ? "Working" - : normalizedRole; + : roleKind === "tool" + ? "Tool" + : roleKind === "system" + ? "System" + : classification.roleRaw; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; const toolCardBase = @@ -126,4 +128,3 @@ export function renderMessage(
`; } - diff --git a/ui/src/ui/chat/message-classifier.ts b/ui/src/ui/chat/message-classifier.ts new file mode 100644 index 000000000..945f06a57 --- /dev/null +++ b/ui/src/ui/chat/message-classifier.ts @@ -0,0 +1,101 @@ +import { extractRawText, extractThinking } from "./message-extract"; + +export type RoleKind = "assistant" | "user" | "tool" | "system" | "other"; + +export type MessageClassification = { + roleRaw: string; + roleKind: RoleKind; + hasText: boolean; + hasThinking: boolean; + hasToolCalls: boolean; + hasToolResults: boolean; + isToolLike: boolean; + displayLabel: string; +}; + +const TOOL_CALL_TYPES = new Set([ + "toolcall", + "tool_call", + "tooluse", + "tool_use", + "toolcall", + "tooluse", + "functioncall", +]); + +const TOOL_RESULT_TYPES = new Set(["toolresult", "tool_result"]); + +export function classifyMessage(message: unknown): MessageClassification { + const m = message as Record; + const roleRaw = typeof m.role === "string" ? m.role : "unknown"; + const roleLower = roleRaw.toLowerCase(); + + const content = Array.isArray(m.content) + ? (m.content as Array>) + : []; + + const hasText = Boolean(extractRawText(message)?.trim()); + const hasThinking = Boolean(extractThinking(message)?.trim()); + const hasToolCalls = content.some((item) => { + const kind = String(item.type ?? "").toLowerCase(); + if (TOOL_CALL_TYPES.has(kind)) return true; + return typeof item.name === "string" && (item.arguments ?? item.args ?? item.input) != null; + }); + const hasToolResults = content.some((item) => + TOOL_RESULT_TYPES.has(String(item.type ?? "").toLowerCase()), + ); + + const hasToolId = + typeof m.toolCallId === "string" || + typeof m.tool_call_id === "string" || + typeof m.toolUseId === "string" || + typeof m.tool_use_id === "string"; + const hasToolName = + typeof m.toolName === "string" || typeof m.tool_name === "string"; + + const isRoleTool = + roleLower === "tool" || + roleLower === "toolresult" || + roleLower === "tool_result" || + roleLower === "function"; + + const isToolLike = + isRoleTool || hasToolId || hasToolName || hasToolCalls || hasToolResults; + + let roleKind: RoleKind; + if (roleLower === "user") { + roleKind = "user"; + } else if (roleLower === "assistant") { + roleKind = isToolLike && !hasText ? "tool" : "assistant"; + } else if (roleLower === "system") { + roleKind = "system"; + } else if (isRoleTool) { + roleKind = "tool"; + } else if (isToolLike && !hasText) { + roleKind = "tool"; + } else { + roleKind = "other"; + } + + const displayLabel = + roleKind === "assistant" + ? "Assistant" + : roleKind === "user" + ? "You" + : roleKind === "tool" + ? "Tool" + : roleKind === "system" + ? "System" + : roleRaw; + + return { + roleRaw, + roleKind, + hasText, + hasThinking, + hasToolCalls, + hasToolResults, + isToolLike, + displayLabel, + }; +} diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index 132a4be17..f409c2511 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - normalizeMessage, - normalizeRoleForGrouping, - isToolResultMessage, -} from "./message-normalizer"; +import { normalizeMessage } from "./message-normalizer"; +import { classifyMessage } from "./message-classifier"; describe("message-normalizer", () => { describe("normalizeMessage", () => { @@ -57,24 +54,24 @@ describe("message-normalizer", () => { expect(result.content).toEqual([{ type: "text", text: "Alternative format" }]); }); - it("detects tool result by toolCallId", () => { + it("preserves role when toolCallId is present", () => { const result = normalizeMessage({ role: "assistant", toolCallId: "call-123", content: "Tool output", }); - expect(result.role).toBe("toolResult"); + expect(result.role).toBe("assistant"); }); - it("detects tool result by tool_call_id (snake_case)", () => { + it("preserves role when tool_call_id is present", () => { const result = normalizeMessage({ role: "assistant", tool_call_id: "call-456", content: "Tool output", }); - expect(result.role).toBe("toolResult"); + expect(result.role).toBe("assistant"); }); it("handles missing role", () => { @@ -102,68 +99,54 @@ describe("message-normalizer", () => { }); }); - describe("normalizeRoleForGrouping", () => { - it("returns tool for toolresult", () => { - expect(normalizeRoleForGrouping("toolresult")).toBe("tool"); - expect(normalizeRoleForGrouping("toolResult")).toBe("tool"); - expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool"); + describe("classifyMessage", () => { + it("keeps assistant role when text + tool blocks are mixed", () => { + const result = classifyMessage({ + role: "assistant", + content: [ + { type: "text", text: "before" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "after" }, + { type: "text", text: "after text" }, + ], + }); + + expect(result.roleKind).toBe("assistant"); + expect(result.hasText).toBe(true); + expect(result.hasToolCalls).toBe(true); + expect(result.hasThinking).toBe(true); }); - it("returns tool for tool_result", () => { - expect(normalizeRoleForGrouping("tool_result")).toBe("tool"); - expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool"); + it("classifies tool-only assistant messages as tool", () => { + const result = classifyMessage({ + role: "assistant", + toolCallId: "call-1", + content: [{ type: "toolCall", id: "call-1", name: "read" }], + }); + + expect(result.roleKind).toBe("tool"); + expect(result.hasText).toBe(false); + expect(result.isToolLike).toBe(true); }); - it("returns tool for tool", () => { - expect(normalizeRoleForGrouping("tool")).toBe("tool"); - expect(normalizeRoleForGrouping("Tool")).toBe("tool"); + it("classifies tool role messages as tool", () => { + const result = classifyMessage({ + role: "tool", + content: "Sunny, 70F.", + }); + + expect(result.roleKind).toBe("tool"); + expect(result.hasText).toBe(true); }); - it("returns tool for function", () => { - expect(normalizeRoleForGrouping("function")).toBe("tool"); - expect(normalizeRoleForGrouping("Function")).toBe("tool"); - }); + it("classifies toolResult role messages as tool", () => { + const result = classifyMessage({ + role: "toolResult", + content: [{ type: "text", text: "ok" }], + }); - it("preserves user role", () => { - expect(normalizeRoleForGrouping("user")).toBe("user"); - expect(normalizeRoleForGrouping("User")).toBe("User"); - }); - - it("preserves assistant role", () => { - expect(normalizeRoleForGrouping("assistant")).toBe("assistant"); - }); - - it("preserves system role", () => { - expect(normalizeRoleForGrouping("system")).toBe("system"); - }); - }); - - describe("isToolResultMessage", () => { - it("returns true for toolresult role", () => { - expect(isToolResultMessage({ role: "toolresult" })).toBe(true); - expect(isToolResultMessage({ role: "toolResult" })).toBe(true); - expect(isToolResultMessage({ role: "TOOLRESULT" })).toBe(true); - }); - - it("returns true for tool_result role", () => { - expect(isToolResultMessage({ role: "tool_result" })).toBe(true); - expect(isToolResultMessage({ role: "TOOL_RESULT" })).toBe(true); - }); - - it("returns false for other roles", () => { - expect(isToolResultMessage({ role: "user" })).toBe(false); - expect(isToolResultMessage({ role: "assistant" })).toBe(false); - expect(isToolResultMessage({ role: "tool" })).toBe(false); - }); - - it("returns false for missing role", () => { - expect(isToolResultMessage({})).toBe(false); - expect(isToolResultMessage({ content: "test" })).toBe(false); - }); - - it("returns false for non-string role", () => { - expect(isToolResultMessage({ role: 123 })).toBe(false); - expect(isToolResultMessage({ role: null })).toBe(false); + expect(result.roleKind).toBe("tool"); + expect(result.isToolLike).toBe(true); }); }); }); diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index aa7b39d5c..43df2e864 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -12,40 +12,7 @@ import type { */ export function normalizeMessage(message: unknown): NormalizedMessage { const m = message as Record; - let role = typeof m.role === "string" ? m.role : "unknown"; - - // Detect tool messages by common gateway shapes. - // Some tool events come through as assistant role with tool_* items in the content array. - const hasToolId = - typeof m.toolCallId === "string" || typeof m.tool_call_id === "string"; - - const contentRaw = m.content; - const contentItems = Array.isArray(contentRaw) ? contentRaw : null; - const hasToolContent = - Array.isArray(contentItems) && - contentItems.some((item) => { - const x = item as Record; - const t = String(x.type ?? "").toLowerCase(); - return ( - t === "toolcall" || - t === "tool_call" || - t === "tooluse" || - t === "tool_use" || - t === "toolresult" || - t === "tool_result" || - t === "tool_call" || - t === "tool_result" || - (typeof x.name === "string" && x.arguments != null) - ); - }); - - const hasToolName = - typeof (m as Record).toolName === "string" || - typeof (m as Record).tool_name === "string"; - - if (hasToolId || hasToolContent || hasToolName) { - role = "toolResult"; - } + const role = typeof m.role === "string" ? m.role : "unknown"; // Extract content let content: MessageContentItem[] = []; @@ -68,33 +35,3 @@ export function normalizeMessage(message: unknown): NormalizedMessage { return { role, content, timestamp, id }; } - -/** - * Normalize role for grouping purposes. - */ -export function normalizeRoleForGrouping(role: string): string { - const lower = role.toLowerCase(); - // Keep tool-related roles distinct so the UI can style/toggle them. - if ( - lower === "toolresult" || - lower === "tool_result" || - lower === "tool" || - lower === "function" || - lower === "toolresult" - ) { - return "tool"; - } - if (lower === "assistant") return "assistant"; - if (lower === "user") return "user"; - if (lower === "system") return "system"; - return role; -} - -/** - * Check if a message is a tool result message based on its role. - */ -export function isToolResultMessage(message: unknown): boolean { - const m = message as Record; - const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; - return role === "toolresult" || role === "tool_result"; -} diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index f725363df..4bc25f54d 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -9,7 +9,7 @@ import { formatToolOutputForSidebar, getTruncatedPreview, } from "./tool-helpers"; -import { isToolResultMessage } from "./message-normalizer"; +import { classifyMessage } from "./message-classifier"; import { extractText } from "./message-extract"; export function extractToolCards(message: unknown): ToolCard[] { @@ -39,10 +39,13 @@ export function extractToolCards(message: unknown): ToolCard[] { cards.push({ kind: "result", name, text }); } - if ( - isToolResultMessage(message) && - !cards.some((card) => card.kind === "result") - ) { + const classification = classifyMessage(message); + const isToolResultMessage = + classification.hasToolResults || + classification.roleRaw.toLowerCase() === "toolresult" || + classification.roleRaw.toLowerCase() === "tool_result"; + + if (isToolResultMessage && !cards.some((card) => card.kind === "result")) { const name = (typeof m.toolName === "string" && m.toolName) || (typeof m.tool_name === "string" && m.tool_name) || @@ -197,4 +200,3 @@ function extractToolText(item: Record): string | undefined { if (typeof item.content === "string") return item.content; return undefined; } - diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f25221db5..d10f57815 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -3,10 +3,8 @@ import { repeat } from "lit/directives/repeat.js"; import type { SessionsListResult } from "../types"; import type { ChatQueueItem } from "../ui-types"; import type { ChatItem, MessageGroup } from "../types/chat-types"; -import { - normalizeMessage, - normalizeRoleForGrouping, -} from "../chat/message-normalizer"; +import { normalizeMessage } from "../chat/message-normalizer"; +import { classifyMessage } from "../chat/message-classifier"; import { extractText } from "../chat/message-extract"; import { renderMessage, renderReadingIndicator } from "../chat/legacy-render"; import { @@ -272,7 +270,8 @@ function groupMessages(items: ChatItem[]): Array { } const normalized = normalizeMessage(item.message); - const role = normalizeRoleForGrouping(normalized.role); + const classification = classifyMessage(item.message); + const role = classification.roleKind; const timestamp = normalized.timestamp || Date.now(); if (!currentGroup || currentGroup.role !== role) { @@ -312,26 +311,18 @@ function buildChatItems(props: ChatProps): Array { } for (let i = historyStart; i < history.length; i++) { const msg = history[i]; - const normalized = normalizeMessage(msg); - - if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") { - continue; - } - items.push({ kind: "message", key: messageKey(msg, i), message: msg, }); } - if (props.showThinking) { - for (let i = 0; i < tools.length; i++) { - items.push({ - kind: "message", - key: messageKey(tools[i], i + history.length), - message: tools[i], - }); - } + for (let i = 0; i < tools.length; i++) { + items.push({ + kind: "message", + key: messageKey(tools[i], i + history.length), + message: tools[i], + }); } if (props.stream !== null) {