mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix: unify claude cli imported tool messages
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user