fix: unify claude cli imported tool messages

This commit is contained in:
Peter Steinberger
2026-03-26 22:01:27 +00:00
parent 3d0050c306
commit 672a24cbde
6 changed files with 249 additions and 11 deletions

View File

@@ -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 },

View File

@@ -33,6 +33,7 @@ type ClaudeCliMessage = NonNullable<ClaudeCliProjectEntry["message"]>;
type ClaudeCliUsage = ClaudeCliMessage["usage"];
type TranscriptLikeMessage = Record<string, unknown>;
type ToolNameRegistry = Map<string, string>;
function resolveHistoryHomeDir(homeDir?: string): string {
return homeDir?.trim() || process.env.HOME || os.homedir();
@@ -95,6 +96,137 @@ function cloneJsonValue<T>(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<Record<string, unknown>> = [];
for (const item of content) {
if (!item || typeof item !== "object") {
normalized.push(cloneJsonValue(item as Record<string, unknown>));
continue;
}
const block = cloneJsonValue(item as Record<string, unknown>);
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<Record<string, unknown>> | null {
if (!message || typeof message !== "object") {
return null;
}
const content = (message as { content?: unknown }).content;
return Array.isArray(content) ? (content as Array<Record<string, unknown>>) : null;
}
function isToolCallBlock(block: Record<string, unknown>): 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<string, unknown>): boolean {
const type = typeof block.type === "string" ? block.type.toLowerCase() : "";
return type === "toolresult" || type === "tool_result";
}
function resolveToolUseId(block: Record<string, unknown>): 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: {

View File

@@ -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",

View File

@@ -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 }];

View File

@@ -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),
});
}
}

View File

@@ -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({