diff --git a/src/gateway/cli-session-history.test.ts b/src/gateway/cli-session-history.test.ts index 7f8c5f5d076..005499a7f99 100644 --- a/src/gateway/cli-session-history.test.ts +++ b/src/gateway/cli-session-history.test.ts @@ -46,6 +46,41 @@ function createClaudeHistoryLines(sessionId: string) { }, }, }), + JSON.stringify({ + type: "assistant", + uuid: "assistant-2", + timestamp: "2026-03-26T16:29:56.000Z", + message: { + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "Bash", + input: { + command: "pwd", + }, + }, + ], + stop_reason: "tool_use", + }, + }), + JSON.stringify({ + type: "user", + uuid: "user-2", + timestamp: "2026-03-26T16:29:56.400Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_123", + content: "/tmp/demo", + }, + ], + }, + }), JSON.stringify({ type: "last-prompt", sessionId, @@ -90,7 +125,7 @@ describe("cli session history", () => { await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => { expect(resolveClaudeCliSessionFilePath({ cliSessionId: sessionId, homeDir })).toBe(filePath); const messages = readClaudeCliSessionMessages({ cliSessionId: sessionId, homeDir }); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ role: "user", content: expect.stringContaining("[Thu 2026-03-26 16:29 GMT] hi"), @@ -116,6 +151,25 @@ describe("cli session history", () => { cliSessionId: sessionId, }, }); + expect(messages[2]).toMatchObject({ + role: "assistant", + content: [ + { + type: "toolcall", + id: "toolu_123", + name: "Bash", + arguments: { + command: "pwd", + }, + }, + { + type: "tool_result", + name: "Bash", + content: "/tmp/demo", + tool_use_id: "toolu_123", + }, + ], + }); }); }); @@ -193,7 +247,7 @@ describe("cli session history", () => { localMessages: [], homeDir, }); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ role: "user", __openclaw: { cliSessionId: sessionId }, @@ -215,7 +269,7 @@ describe("cli session history", () => { localMessages: [], homeDir, }); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[1]).toMatchObject({ role: "assistant", __openclaw: { cliSessionId: sessionId }, @@ -235,7 +289,7 @@ describe("cli session history", () => { localMessages: [], homeDir, }); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ role: "user", __openclaw: { cliSessionId: sessionId }, diff --git a/src/gateway/cli-session-history.ts b/src/gateway/cli-session-history.ts index 3a8113e3e6a..b815481f746 100644 --- a/src/gateway/cli-session-history.ts +++ b/src/gateway/cli-session-history.ts @@ -33,6 +33,7 @@ type ClaudeCliMessage = NonNullable; type ClaudeCliUsage = ClaudeCliMessage["usage"]; type TranscriptLikeMessage = Record; +type ToolNameRegistry = Map; function resolveHistoryHomeDir(homeDir?: string): string { return homeDir?.trim() || process.env.HOME || os.homedir(); @@ -95,6 +96,137 @@ function cloneJsonValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function normalizeClaudeCliContent( + content: string | unknown[], + toolNameRegistry: ToolNameRegistry, +): string | unknown[] { + if (!Array.isArray(content)) { + return cloneJsonValue(content); + } + + const normalized: Array> = []; + for (const item of content) { + if (!item || typeof item !== "object") { + normalized.push(cloneJsonValue(item as Record)); + continue; + } + const block = cloneJsonValue(item as Record); + const type = typeof block.type === "string" ? block.type : ""; + if (type === "tool_use") { + const id = typeof block.id === "string" ? block.id.trim() : ""; + const name = typeof block.name === "string" ? block.name.trim() : ""; + if (id && name) { + toolNameRegistry.set(id, name); + } + if (block.input !== undefined && block.arguments === undefined) { + block.arguments = cloneJsonValue(block.input); + } + block.type = "toolcall"; + delete block.input; + normalized.push(block); + continue; + } + if (type === "tool_result") { + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id.trim() : ""; + if (!block.name && toolUseId) { + const toolName = toolNameRegistry.get(toolUseId); + if (toolName) { + block.name = toolName; + } + } + normalized.push(block); + continue; + } + normalized.push(block); + } + return normalized; +} + +function getMessageBlocks(message: unknown): Array> | null { + if (!message || typeof message !== "object") { + return null; + } + const content = (message as { content?: unknown }).content; + return Array.isArray(content) ? (content as Array>) : null; +} + +function isToolCallBlock(block: Record): boolean { + const type = typeof block.type === "string" ? block.type.toLowerCase() : ""; + return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use"; +} + +function isToolResultBlock(block: Record): boolean { + const type = typeof block.type === "string" ? block.type.toLowerCase() : ""; + return type === "toolresult" || type === "tool_result"; +} + +function resolveToolUseId(block: Record): string | undefined { + const id = + (typeof block.id === "string" && block.id.trim()) || + (typeof block.tool_use_id === "string" && block.tool_use_id.trim()) || + (typeof block.toolUseId === "string" && block.toolUseId.trim()); + return id || undefined; +} + +function isAssistantToolCallMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const role = (message as { role?: unknown }).role; + if (role !== "assistant") { + return false; + } + const blocks = getMessageBlocks(message); + return Boolean(blocks && blocks.length > 0 && blocks.every(isToolCallBlock)); +} + +function isUserToolResultMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const role = (message as { role?: unknown }).role; + if (role !== "user") { + return false; + } + const blocks = getMessageBlocks(message); + return Boolean(blocks && blocks.length > 0 && blocks.every(isToolResultBlock)); +} + +function coalesceClaudeCliToolMessages(messages: TranscriptLikeMessage[]): TranscriptLikeMessage[] { + const coalesced: TranscriptLikeMessage[] = []; + for (let index = 0; index < messages.length; index += 1) { + const current = messages[index]; + const next = messages[index + 1]; + if (!isAssistantToolCallMessage(current) || !isUserToolResultMessage(next)) { + coalesced.push(current); + continue; + } + + const callBlocks = getMessageBlocks(current) ?? []; + const resultBlocks = getMessageBlocks(next) ?? []; + const callIds = new Set( + callBlocks.map(resolveToolUseId).filter((id): id is string => Boolean(id)), + ); + const allResultsMatch = + resultBlocks.length > 0 && + resultBlocks.every((block) => { + const toolUseId = resolveToolUseId(block); + return Boolean(toolUseId && callIds.has(toolUseId)); + }); + if (!allResultsMatch) { + coalesced.push(current); + continue; + } + + coalesced.push({ + ...current, + content: [...callBlocks.map(cloneJsonValue), ...resultBlocks.map(cloneJsonValue)], + }); + index += 1; + } + return coalesced; +} + function extractComparableText(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; @@ -203,6 +335,7 @@ function compareHistoryMessages( function parseClaudeCliHistoryEntry( entry: ClaudeCliProjectEntry, cliSessionId: string, + toolNameRegistry: ToolNameRegistry, ): TranscriptLikeMessage | null { if (entry.isSidechain === true || !entry.message || typeof entry.message !== "object") { return null; @@ -226,7 +359,7 @@ function parseClaudeCliHistoryEntry( if (type === "user") { const content = typeof entry.message.content === "string" || Array.isArray(entry.message.content) - ? cloneJsonValue(entry.message.content) + ? normalizeClaudeCliContent(entry.message.content, toolNameRegistry) : undefined; if (content === undefined) { return null; @@ -243,7 +376,7 @@ function parseClaudeCliHistoryEntry( const content = typeof entry.message.content === "string" || Array.isArray(entry.message.content) - ? cloneJsonValue(entry.message.content) + ? normalizeClaudeCliContent(entry.message.content, toolNameRegistry) : undefined; if (content === undefined) { return null; @@ -310,13 +443,14 @@ export function readClaudeCliSessionMessages(params: { } const messages: TranscriptLikeMessage[] = []; + const toolNameRegistry: ToolNameRegistry = new Map(); for (const line of content.split(/\r?\n/)) { if (!line.trim()) { continue; } try { const parsed = JSON.parse(line) as ClaudeCliProjectEntry; - const message = parseClaudeCliHistoryEntry(parsed, params.cliSessionId); + const message = parseClaudeCliHistoryEntry(parsed, params.cliSessionId, toolNameRegistry); if (message) { messages.push(message); } @@ -324,7 +458,7 @@ export function readClaudeCliSessionMessages(params: { // Ignore malformed external history entries. } } - return messages; + return coalesceClaudeCliToolMessages(messages); } export function mergeImportedChatHistoryMessages(params: { diff --git a/ui/src/ui/chat/message-normalizer.test.ts b/ui/src/ui/chat/message-normalizer.test.ts index 8b8462108d7..a8576d0aa3e 100644 --- a/ui/src/ui/chat/message-normalizer.test.ts +++ b/ui/src/ui/chat/message-normalizer.test.ts @@ -112,6 +112,15 @@ describe("message-normalizer", () => { expect(result.content[0].args).toEqual({ foo: "bar" }); }); + it("handles input field for anthropic tool_use blocks", () => { + const result = normalizeMessage({ + role: "assistant", + content: [{ type: "tool_use", name: "Bash", input: { command: "pwd" } }], + }); + + expect(result.content[0].args).toEqual({ command: "pwd" }); + }); + it("preserves top-level sender labels", () => { const result = normalizeMessage({ role: "user", diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index 0f538360c06..b2215a3f794 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -42,7 +42,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { type: (item.type as MessageContentItem["type"]) || "text", text: item.text as string | undefined, name: item.name as string | undefined, - args: item.args || item.arguments, + args: item.args || item.arguments || item.input, })); } else if (typeof m.text === "string") { content = [{ type: "text", text: m.text }]; diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index acd427b9e77..707c8453239 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -16,12 +16,13 @@ export function extractToolCards(message: unknown): ToolCard[] { const kind = (typeof item.type === "string" ? item.type : "").toLowerCase(); const isToolCall = ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || - (typeof item.name === "string" && item.arguments != null); + (typeof item.name === "string" && + (item.arguments != null || item.args != null || item.input != null)); if (isToolCall) { cards.push({ kind: "call", name: (item.name as string) ?? "tool", - args: coerceArgs(item.arguments ?? item.args), + args: coerceArgs(item.arguments ?? item.args ?? item.input), }); } } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 59f7760d385..a93103ae3b2 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -453,6 +453,46 @@ describe("chat view", () => { expect(groupedLogo?.getAttribute("src")).toBe("/openclaw/favicon.svg"); }); + it("renders anthropic tool_use input details in tool cards", () => { + const container = document.createElement("div"); + render( + renderChat( + createProps({ + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "Bash", + input: { command: 'time claude -p "say ok"' }, + }, + ], + timestamp: 1000, + }, + { + role: "user", + content: [ + { + type: "tool_result", + name: "Bash", + tool_use_id: "toolu_123", + content: "ok", + }, + ], + timestamp: 1001, + }, + ], + }), + ), + container, + ); + + expect(container.textContent).toContain('time claude -p "say ok"'); + expect(container.textContent).toContain("Bash"); + }); + it("keeps the persisted overview locale selected before i18n hydration finishes", async () => { const container = document.createElement("div"); const props = createOverviewProps({