From 00aedb34141503ec53fdec38d602b8245d346b08 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 22:11:14 +0000 Subject: [PATCH] refactor: split claude cli history import pipeline --- src/chat/tool-content.ts | 35 ++ src/gateway/cli-session-history.claude.ts | 334 +++++++++++++++ src/gateway/cli-session-history.merge.ts | 132 ++++++ src/gateway/cli-session-history.ts | 489 +--------------------- ui/src/ui/chat/message-normalizer.ts | 9 +- ui/src/ui/chat/tool-cards.test.ts | 34 ++ ui/src/ui/chat/tool-cards.ts | 16 +- ui/src/ui/views/chat.test.ts | 40 -- vitest.config.ts | 1 + vitest.unit-paths.mjs | 1 + 10 files changed, 563 insertions(+), 528 deletions(-) create mode 100644 src/chat/tool-content.ts create mode 100644 src/gateway/cli-session-history.claude.ts create mode 100644 src/gateway/cli-session-history.merge.ts create mode 100644 ui/src/ui/chat/tool-cards.test.ts diff --git a/src/chat/tool-content.ts b/src/chat/tool-content.ts new file mode 100644 index 00000000000..d93152e269a --- /dev/null +++ b/src/chat/tool-content.ts @@ -0,0 +1,35 @@ +export type ToolContentBlock = Record; + +export function normalizeToolContentType(value: unknown): string { + return typeof value === "string" ? value.toLowerCase() : ""; +} + +export function isToolCallContentType(value: unknown): boolean { + const type = normalizeToolContentType(value); + return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use"; +} + +export function isToolResultContentType(value: unknown): boolean { + const type = normalizeToolContentType(value); + return type === "toolresult" || type === "tool_result"; +} + +export function isToolCallBlock(block: ToolContentBlock): boolean { + return isToolCallContentType(block.type); +} + +export function isToolResultBlock(block: ToolContentBlock): boolean { + return isToolResultContentType(block.type); +} + +export function resolveToolBlockArgs(block: ToolContentBlock): unknown { + return block.args ?? block.arguments ?? block.input; +} + +export function resolveToolUseId(block: ToolContentBlock): 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; +} diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts new file mode 100644 index 00000000000..69ed564dce7 --- /dev/null +++ b/src/gateway/cli-session-history.claude.ts @@ -0,0 +1,334 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + isToolCallBlock, + isToolResultBlock, + resolveToolUseId, + type ToolContentBlock, +} from "../chat/tool-content.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js"; + +export const CLAUDE_CLI_PROVIDER = "claude-cli"; +const CLAUDE_PROJECTS_RELATIVE_DIR = path.join(".claude", "projects"); + +type ClaudeCliProjectEntry = { + type?: unknown; + timestamp?: unknown; + uuid?: unknown; + isSidechain?: unknown; + message?: { + role?: unknown; + content?: unknown; + model?: unknown; + stop_reason?: unknown; + usage?: { + input_tokens?: unknown; + output_tokens?: unknown; + cache_read_input_tokens?: unknown; + cache_creation_input_tokens?: unknown; + }; + }; +}; + +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(); +} + +function resolveClaudeProjectsDir(homeDir?: string): string { + return path.join(resolveHistoryHomeDir(homeDir), CLAUDE_PROJECTS_RELATIVE_DIR); +} + +export function resolveClaudeCliBindingSessionId( + entry: SessionEntry | undefined, +): string | undefined { + const bindingSessionId = entry?.cliSessionBindings?.[CLAUDE_CLI_PROVIDER]?.sessionId?.trim(); + if (bindingSessionId) { + return bindingSessionId; + } + const legacyMapSessionId = entry?.cliSessionIds?.[CLAUDE_CLI_PROVIDER]?.trim(); + if (legacyMapSessionId) { + return legacyMapSessionId; + } + const legacyClaudeSessionId = entry?.claudeCliSessionId?.trim(); + return legacyClaudeSessionId || undefined; +} + +function resolveFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function resolveTimestampMs(value: unknown): number | undefined { + if (typeof value !== "string") { + return undefined; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function resolveClaudeCliUsage(raw: ClaudeCliUsage) { + if (!raw || typeof raw !== "object") { + return undefined; + } + const input = resolveFiniteNumber(raw.input_tokens); + const output = resolveFiniteNumber(raw.output_tokens); + const cacheRead = resolveFiniteNumber(raw.cache_read_input_tokens); + const cacheWrite = resolveFiniteNumber(raw.cache_creation_input_tokens); + if ( + input === undefined && + output === undefined && + cacheRead === undefined && + cacheWrite === undefined + ) { + return undefined; + } + return { + ...(input !== undefined ? { input } : {}), + ...(output !== undefined ? { output } : {}), + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), + }; +} + +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: ToolContentBlock[] = []; + for (const item of content) { + if (!item || typeof item !== "object") { + normalized.push(cloneJsonValue(item as ToolContentBlock)); + continue; + } + const block = cloneJsonValue(item as ToolContentBlock); + 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 = resolveToolUseId(block); + 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): ToolContentBlock[] | null { + if (!message || typeof message !== "object") { + return null; + } + const content = (message as { content?: unknown }).content; + return Array.isArray(content) ? (content as ToolContentBlock[]) : null; +} + +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 parseClaudeCliHistoryEntry( + entry: ClaudeCliProjectEntry, + cliSessionId: string, + toolNameRegistry: ToolNameRegistry, +): TranscriptLikeMessage | null { + if (entry.isSidechain === true || !entry.message || typeof entry.message !== "object") { + return null; + } + const type = typeof entry.type === "string" ? entry.type : undefined; + const role = typeof entry.message.role === "string" ? entry.message.role : undefined; + if ((type !== "user" && type !== "assistant") || role !== type) { + return null; + } + + const timestamp = resolveTimestampMs(entry.timestamp); + const baseMeta = { + importedFrom: CLAUDE_CLI_PROVIDER, + cliSessionId, + ...(typeof entry.uuid === "string" && entry.uuid.trim() ? { externalId: entry.uuid } : {}), + }; + + const content = + typeof entry.message.content === "string" || Array.isArray(entry.message.content) + ? normalizeClaudeCliContent(entry.message.content, toolNameRegistry) + : undefined; + if (content === undefined) { + return null; + } + + if (type === "user") { + return attachOpenClawTranscriptMeta( + { + role: "user", + content, + ...(timestamp !== undefined ? { timestamp } : {}), + }, + baseMeta, + ) as TranscriptLikeMessage; + } + + return attachOpenClawTranscriptMeta( + { + role: "assistant", + content, + api: "anthropic-messages", + provider: CLAUDE_CLI_PROVIDER, + ...(typeof entry.message.model === "string" && entry.message.model.trim() + ? { model: entry.message.model } + : {}), + ...(typeof entry.message.stop_reason === "string" && entry.message.stop_reason.trim() + ? { stopReason: entry.message.stop_reason } + : {}), + ...(resolveClaudeCliUsage(entry.message.usage) + ? { usage: resolveClaudeCliUsage(entry.message.usage) } + : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + }, + baseMeta, + ) as TranscriptLikeMessage; +} + +export function resolveClaudeCliSessionFilePath(params: { + cliSessionId: string; + homeDir?: string; +}): string | undefined { + const projectsDir = resolveClaudeProjectsDir(params.homeDir); + let projectEntries: fs.Dirent[]; + try { + projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true }); + } catch { + return undefined; + } + + for (const entry of projectEntries) { + if (!entry.isDirectory()) { + continue; + } + const candidate = path.join(projectsDir, entry.name, `${params.cliSessionId}.jsonl`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +export function readClaudeCliSessionMessages(params: { + cliSessionId: string; + homeDir?: string; +}): TranscriptLikeMessage[] { + const filePath = resolveClaudeCliSessionFilePath(params); + if (!filePath) { + return []; + } + + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return []; + } + + 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, toolNameRegistry); + if (message) { + messages.push(message); + } + } catch { + // Ignore malformed external history entries. + } + } + return coalesceClaudeCliToolMessages(messages); +} diff --git a/src/gateway/cli-session-history.merge.ts b/src/gateway/cli-session-history.merge.ts new file mode 100644 index 00000000000..f4250305aa3 --- /dev/null +++ b/src/gateway/cli-session-history.merge.ts @@ -0,0 +1,132 @@ +import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; + +const DEDUPE_TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; + +function extractComparableText(message: unknown): string | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + const record = message as { role?: unknown; text?: unknown; content?: unknown }; + const role = typeof record.role === "string" ? record.role : undefined; + const parts: string[] = []; + if (typeof record.text === "string") { + parts.push(record.text); + } + if (typeof record.content === "string") { + parts.push(record.content); + } else if (Array.isArray(record.content)) { + for (const block of record.content) { + if (block && typeof block === "object" && "text" in block && typeof block.text === "string") { + parts.push(block.text); + } + } + } + if (parts.length === 0) { + return undefined; + } + const joined = parts.join("\n").trim(); + if (!joined) { + return undefined; + } + const visible = role === "user" ? stripInboundMetadata(joined) : joined; + const normalized = visible.replace(/\s+/g, " ").trim(); + return normalized || undefined; +} + +function resolveFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function resolveComparableTimestamp(message: unknown): number | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + return resolveFiniteNumber((message as { timestamp?: unknown }).timestamp); +} + +function resolveComparableRole(message: unknown): string | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + const role = (message as { role?: unknown }).role; + return typeof role === "string" ? role : undefined; +} + +function resolveImportedExternalId(message: unknown): string | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + const meta = + "__openclaw" in message && + (message as { __openclaw?: unknown }).__openclaw && + typeof (message as { __openclaw?: unknown }).__openclaw === "object" + ? ((message as { __openclaw?: Record }).__openclaw ?? {}) + : undefined; + const externalId = meta?.externalId; + return typeof externalId === "string" && externalId.trim() ? externalId : undefined; +} + +function isEquivalentImportedMessage(existing: unknown, imported: unknown): boolean { + const importedExternalId = resolveImportedExternalId(imported); + if (importedExternalId && resolveImportedExternalId(existing) === importedExternalId) { + return true; + } + + const existingRole = resolveComparableRole(existing); + const importedRole = resolveComparableRole(imported); + if (!existingRole || existingRole !== importedRole) { + return false; + } + + const existingText = extractComparableText(existing); + const importedText = extractComparableText(imported); + if (!existingText || !importedText || existingText !== importedText) { + return false; + } + + const existingTimestamp = resolveComparableTimestamp(existing); + const importedTimestamp = resolveComparableTimestamp(imported); + if (existingTimestamp === undefined || importedTimestamp === undefined) { + return true; + } + + return Math.abs(existingTimestamp - importedTimestamp) <= DEDUPE_TIMESTAMP_WINDOW_MS; +} + +function compareHistoryMessages( + a: { message: unknown; order: number }, + b: { message: unknown; order: number }, +): number { + const aTimestamp = resolveComparableTimestamp(a.message); + const bTimestamp = resolveComparableTimestamp(b.message); + if (aTimestamp !== undefined && bTimestamp !== undefined && aTimestamp !== bTimestamp) { + return aTimestamp - bTimestamp; + } + if (aTimestamp !== undefined && bTimestamp === undefined) { + return -1; + } + if (aTimestamp === undefined && bTimestamp !== undefined) { + return 1; + } + return a.order - b.order; +} + +export function mergeImportedChatHistoryMessages(params: { + localMessages: unknown[]; + importedMessages: unknown[]; +}): unknown[] { + if (params.importedMessages.length === 0) { + return params.localMessages; + } + const merged = params.localMessages.map((message, index) => ({ message, order: index })); + let nextOrder = merged.length; + for (const imported of params.importedMessages) { + if (merged.some((existing) => isEquivalentImportedMessage(existing.message, imported))) { + continue; + } + merged.push({ message: imported, order: nextOrder }); + nextOrder += 1; + } + merged.sort(compareHistoryMessages); + return merged.map((entry) => entry.message); +} diff --git a/src/gateway/cli-session-history.ts b/src/gateway/cli-session-history.ts index b815481f746..e1f669eb916 100644 --- a/src/gateway/cli-session-history.ts +++ b/src/gateway/cli-session-history.ts @@ -1,486 +1,19 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import type { SessionEntry } from "../config/sessions.js"; -import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js"; +import { + CLAUDE_CLI_PROVIDER, + readClaudeCliSessionMessages, + resolveClaudeCliBindingSessionId, + resolveClaudeCliSessionFilePath, +} from "./cli-session-history.claude.js"; +import { mergeImportedChatHistoryMessages } from "./cli-session-history.merge.js"; -const CLAUDE_CLI_PROVIDER = "claude-cli"; -const CLAUDE_PROJECTS_RELATIVE_DIR = path.join(".claude", "projects"); -const DEDUPE_TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; - -type ClaudeCliProjectEntry = { - type?: unknown; - timestamp?: unknown; - uuid?: unknown; - isSidechain?: unknown; - message?: { - role?: unknown; - content?: unknown; - model?: unknown; - stop_reason?: unknown; - usage?: { - input_tokens?: unknown; - output_tokens?: unknown; - cache_read_input_tokens?: unknown; - cache_creation_input_tokens?: unknown; - }; - }; +export { + mergeImportedChatHistoryMessages, + readClaudeCliSessionMessages, + resolveClaudeCliSessionFilePath, }; -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(); -} - -function resolveClaudeProjectsDir(homeDir?: string): string { - return path.join(resolveHistoryHomeDir(homeDir), CLAUDE_PROJECTS_RELATIVE_DIR); -} - -function resolveClaudeCliBindingSessionId(entry: SessionEntry | undefined): string | undefined { - const bindingSessionId = entry?.cliSessionBindings?.[CLAUDE_CLI_PROVIDER]?.sessionId?.trim(); - if (bindingSessionId) { - return bindingSessionId; - } - const legacyMapSessionId = entry?.cliSessionIds?.[CLAUDE_CLI_PROVIDER]?.trim(); - if (legacyMapSessionId) { - return legacyMapSessionId; - } - const legacyClaudeSessionId = entry?.claudeCliSessionId?.trim(); - return legacyClaudeSessionId || undefined; -} - -function resolveFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function resolveTimestampMs(value: unknown): number | undefined { - if (typeof value !== "string") { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function resolveClaudeCliUsage(raw: ClaudeCliUsage) { - if (!raw || typeof raw !== "object") { - return undefined; - } - const input = resolveFiniteNumber(raw.input_tokens); - const output = resolveFiniteNumber(raw.output_tokens); - const cacheRead = resolveFiniteNumber(raw.cache_read_input_tokens); - const cacheWrite = resolveFiniteNumber(raw.cache_creation_input_tokens); - if ( - input === undefined && - output === undefined && - cacheRead === undefined && - cacheWrite === undefined - ) { - return undefined; - } - return { - ...(input !== undefined ? { input } : {}), - ...(output !== undefined ? { output } : {}), - ...(cacheRead !== undefined ? { cacheRead } : {}), - ...(cacheWrite !== undefined ? { cacheWrite } : {}), - }; -} - -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; - } - const record = message as { role?: unknown; text?: unknown; content?: unknown }; - const role = typeof record.role === "string" ? record.role : undefined; - const parts: string[] = []; - if (typeof record.text === "string") { - parts.push(record.text); - } - if (typeof record.content === "string") { - parts.push(record.content); - } else if (Array.isArray(record.content)) { - for (const block of record.content) { - if (block && typeof block === "object" && "text" in block && typeof block.text === "string") { - parts.push(block.text); - } - } - } - if (parts.length === 0) { - return undefined; - } - const joined = parts.join("\n").trim(); - if (!joined) { - return undefined; - } - const visible = role === "user" ? stripInboundMetadata(joined) : joined; - const normalized = visible.replace(/\s+/g, " ").trim(); - return normalized || undefined; -} - -function resolveComparableTimestamp(message: unknown): number | undefined { - if (!message || typeof message !== "object") { - return undefined; - } - return resolveFiniteNumber((message as { timestamp?: unknown }).timestamp); -} - -function resolveComparableRole(message: unknown): string | undefined { - if (!message || typeof message !== "object") { - return undefined; - } - const role = (message as { role?: unknown }).role; - return typeof role === "string" ? role : undefined; -} - -function resolveImportedExternalId(message: unknown): string | undefined { - if (!message || typeof message !== "object") { - return undefined; - } - const meta = - "__openclaw" in message && - (message as { __openclaw?: unknown }).__openclaw && - typeof (message as { __openclaw?: unknown }).__openclaw === "object" - ? ((message as { __openclaw?: Record }).__openclaw ?? {}) - : undefined; - const externalId = meta?.externalId; - return typeof externalId === "string" && externalId.trim() ? externalId : undefined; -} - -function isEquivalentImportedMessage(existing: unknown, imported: unknown): boolean { - const importedExternalId = resolveImportedExternalId(imported); - if (importedExternalId && resolveImportedExternalId(existing) === importedExternalId) { - return true; - } - - const existingRole = resolveComparableRole(existing); - const importedRole = resolveComparableRole(imported); - if (!existingRole || existingRole !== importedRole) { - return false; - } - - const existingText = extractComparableText(existing); - const importedText = extractComparableText(imported); - if (!existingText || !importedText || existingText !== importedText) { - return false; - } - - const existingTimestamp = resolveComparableTimestamp(existing); - const importedTimestamp = resolveComparableTimestamp(imported); - if (existingTimestamp === undefined || importedTimestamp === undefined) { - return true; - } - - return Math.abs(existingTimestamp - importedTimestamp) <= DEDUPE_TIMESTAMP_WINDOW_MS; -} - -function compareHistoryMessages( - a: { message: unknown; order: number }, - b: { message: unknown; order: number }, -): number { - const aTimestamp = resolveComparableTimestamp(a.message); - const bTimestamp = resolveComparableTimestamp(b.message); - if (aTimestamp !== undefined && bTimestamp !== undefined && aTimestamp !== bTimestamp) { - return aTimestamp - bTimestamp; - } - if (aTimestamp !== undefined && bTimestamp === undefined) { - return -1; - } - if (aTimestamp === undefined && bTimestamp !== undefined) { - return 1; - } - return a.order - b.order; -} - -function parseClaudeCliHistoryEntry( - entry: ClaudeCliProjectEntry, - cliSessionId: string, - toolNameRegistry: ToolNameRegistry, -): TranscriptLikeMessage | null { - if (entry.isSidechain === true || !entry.message || typeof entry.message !== "object") { - return null; - } - const type = typeof entry.type === "string" ? entry.type : undefined; - const role = typeof entry.message.role === "string" ? entry.message.role : undefined; - if (type !== "user" && type !== "assistant") { - return null; - } - if (role !== type) { - return null; - } - - const timestamp = resolveTimestampMs(entry.timestamp); - const baseMeta = { - importedFrom: CLAUDE_CLI_PROVIDER, - cliSessionId, - ...(typeof entry.uuid === "string" && entry.uuid.trim() ? { externalId: entry.uuid } : {}), - }; - - if (type === "user") { - const content = - typeof entry.message.content === "string" || Array.isArray(entry.message.content) - ? normalizeClaudeCliContent(entry.message.content, toolNameRegistry) - : undefined; - if (content === undefined) { - return null; - } - return attachOpenClawTranscriptMeta( - { - role: "user", - content, - ...(timestamp !== undefined ? { timestamp } : {}), - }, - baseMeta, - ) as TranscriptLikeMessage; - } - - const content = - typeof entry.message.content === "string" || Array.isArray(entry.message.content) - ? normalizeClaudeCliContent(entry.message.content, toolNameRegistry) - : undefined; - if (content === undefined) { - return null; - } - return attachOpenClawTranscriptMeta( - { - role: "assistant", - content, - api: "anthropic-messages", - provider: CLAUDE_CLI_PROVIDER, - ...(typeof entry.message.model === "string" && entry.message.model.trim() - ? { model: entry.message.model } - : {}), - ...(typeof entry.message.stop_reason === "string" && entry.message.stop_reason.trim() - ? { stopReason: entry.message.stop_reason } - : {}), - ...(resolveClaudeCliUsage(entry.message.usage) - ? { usage: resolveClaudeCliUsage(entry.message.usage) } - : {}), - ...(timestamp !== undefined ? { timestamp } : {}), - }, - baseMeta, - ) as TranscriptLikeMessage; -} - -export function resolveClaudeCliSessionFilePath(params: { - cliSessionId: string; - homeDir?: string; -}): string | undefined { - const projectsDir = resolveClaudeProjectsDir(params.homeDir); - let projectEntries: fs.Dirent[]; - try { - projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true }); - } catch { - return undefined; - } - - for (const entry of projectEntries) { - if (!entry.isDirectory()) { - continue; - } - const candidate = path.join(projectsDir, entry.name, `${params.cliSessionId}.jsonl`); - if (fs.existsSync(candidate)) { - return candidate; - } - } - return undefined; -} - -export function readClaudeCliSessionMessages(params: { - cliSessionId: string; - homeDir?: string; -}): TranscriptLikeMessage[] { - const filePath = resolveClaudeCliSessionFilePath(params); - if (!filePath) { - return []; - } - - let content: string; - try { - content = fs.readFileSync(filePath, "utf-8"); - } catch { - return []; - } - - 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, toolNameRegistry); - if (message) { - messages.push(message); - } - } catch { - // Ignore malformed external history entries. - } - } - return coalesceClaudeCliToolMessages(messages); -} - -export function mergeImportedChatHistoryMessages(params: { - localMessages: unknown[]; - importedMessages: unknown[]; -}): unknown[] { - if (params.importedMessages.length === 0) { - return params.localMessages; - } - const merged = params.localMessages.map((message, index) => ({ message, order: index })); - let nextOrder = merged.length; - for (const imported of params.importedMessages) { - if (merged.some((existing) => isEquivalentImportedMessage(existing.message, imported))) { - continue; - } - merged.push({ message: imported, order: nextOrder }); - nextOrder += 1; - } - merged.sort(compareHistoryMessages); - return merged.map((entry) => entry.message); -} - export function augmentChatHistoryWithCliSessionImports(params: { entry: SessionEntry | undefined; provider?: string; diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index b2215a3f794..e54852e032f 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -3,6 +3,10 @@ */ import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; +import { + isToolResultContentType, + resolveToolBlockArgs, +} from "../../../../src/chat/tool-content.js"; import type { NormalizedMessage, MessageContentItem } from "../types/chat-types.ts"; /** @@ -22,8 +26,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { Array.isArray(contentItems) && contentItems.some((item) => { const x = item as Record; - const t = (typeof x.type === "string" ? x.type : "").toLowerCase(); - return t === "toolresult" || t === "tool_result"; + return isToolResultContentType(x.type); }); const hasToolName = typeof m.toolName === "string" || typeof m.tool_name === "string"; @@ -42,7 +45,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 || item.input, + args: resolveToolBlockArgs(item), })); } else if (typeof m.text === "string") { content = [{ type: "text", text: m.text }]; diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts new file mode 100644 index 00000000000..725f00a8169 --- /dev/null +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -0,0 +1,34 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts"; + +describe("tool cards", () => { + it("renders anthropic tool_use input details in tool cards", () => { + const cards = extractToolCards({ + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_123", + name: "Bash", + input: { command: 'time claude -p "say ok"' }, + }, + ], + }); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + kind: "call", + name: "Bash", + args: { command: 'time claude -p "say ok"' }, + }); + + const container = document.createElement("div"); + render(renderToolCardSidebar(cards[0]), container); + + expect(container.textContent).toContain('time claude -p "say ok"'); + expect(container.textContent).toContain("Bash"); + }); +}); diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index 707c8453239..32780fcc5d6 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -1,4 +1,9 @@ import { html, nothing } from "lit"; +import { + isToolCallContentType, + isToolResultContentType, + resolveToolBlockArgs, +} from "../../../../src/chat/tool-content.js"; import { icons } from "../icons.ts"; import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts"; import type { ToolCard } from "../types/chat-types.ts"; @@ -13,23 +18,20 @@ export function extractToolCards(message: unknown): ToolCard[] { const cards: ToolCard[] = []; for (const item of content) { - 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 || item.args != null || item.input != null)); + isToolCallContentType(item.type) || + (typeof item.name === "string" && resolveToolBlockArgs(item) != null); if (isToolCall) { cards.push({ kind: "call", name: (item.name as string) ?? "tool", - args: coerceArgs(item.arguments ?? item.args ?? item.input), + args: coerceArgs(resolveToolBlockArgs(item)), }); } } for (const item of content) { - const kind = (typeof item.type === "string" ? item.type : "").toLowerCase(); - if (kind !== "toolresult" && kind !== "tool_result") { + if (!isToolResultContentType(item.type)) { continue; } const text = extractToolText(item); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index a93103ae3b2..59f7760d385 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -453,46 +453,6 @@ 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({ diff --git a/vitest.config.ts b/vitest.config.ts index bb68f57d618..4ea67768b0f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -75,6 +75,7 @@ export default defineConfig({ "extensions/**/*.test.ts", "test/**/*.test.ts", "ui/src/ui/app-chat.test.ts", + "ui/src/ui/chat/**/*.test.ts", "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/channels.test.ts", "ui/src/ui/views/chat.test.ts", diff --git a/vitest.unit-paths.mjs b/vitest.unit-paths.mjs index a3a3888c935..c16aae2de9a 100644 --- a/vitest.unit-paths.mjs +++ b/vitest.unit-paths.mjs @@ -4,6 +4,7 @@ export const unitTestIncludePatterns = [ "src/**/*.test.ts", "test/**/*.test.ts", "ui/src/ui/app-chat.test.ts", + "ui/src/ui/chat/**/*.test.ts", "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/channels.test.ts", "ui/src/ui/views/chat.test.ts",