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) {