Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

427 lines
11 KiB
Go

package parser
import (
"fmt"
"strings"
"github.com/tidwall/gjson"
)
// ExtractTextContent extracts readable text from message content.
// content can be a string or a JSON array of blocks.
// Returns: flattened text (with inline [Thinking] markers for UI
// compatibility), concatenated thinking-block text (no markers),
// hasThinking, hasToolUse, tool calls, and tool results.
// Thinking blocks are joined with "\n\n" to give an unambiguous
// block boundary in the concatenated thinking text.
func ExtractTextContent(
content gjson.Result,
) (string, string, bool, bool, []ParsedToolCall, []ParsedToolResult) {
if content.Type == gjson.String {
return content.Str, "", false, false, nil, nil
}
if !content.IsArray() {
return "", "", false, false, nil, nil
}
var (
parts []string
thinkingParts []string
toolCalls []ParsedToolCall
toolResults []ParsedToolResult
hasThinking bool
hasToolUse bool
)
content.ForEach(func(_, block gjson.Result) bool {
switch block.Get("type").Str {
case "text":
text := block.Get("text").Str
if text != "" {
parts = append(parts, text)
}
case "thinking":
thinking := block.Get("thinking").Str
if thinking != "" {
hasThinking = true
thinkingParts = append(thinkingParts, thinking)
parts = append(parts,
"[Thinking]\n"+thinking+"\n[/Thinking]")
}
case "tool_use", "toolCall":
// "tool_use" is the Anthropic block type; "toolCall" is
// the camelCase variant emitted by OpenClaw. OpenClaw
// usually carries the call arguments under "input", but
// some tools populate only "arguments", so fall back to
// it when "input" is missing or empty.
hasToolUse = true
if tc, ok := parseToolCall(block); ok {
toolCalls = append(toolCalls, tc)
}
parts = append(parts, formatToolUse(block))
case "tool_result":
if tr, ok := parseToolResult(block); ok {
toolResults = append(toolResults, tr)
}
}
return true
})
return strings.Join(parts, "\n"),
strings.Join(thinkingParts, "\n\n"),
hasThinking, hasToolUse, toolCalls, toolResults
}
func parseToolCall(block gjson.Result) (ParsedToolCall, bool) {
name := block.Get("name").Str
if name == "" {
return ParsedToolCall{}, false
}
input := toolCallInput(block)
tc := ParsedToolCall{
ToolUseID: block.Get("id").Str,
ToolName: name,
Category: NormalizeToolCategory(name),
InputJSON: input.Raw,
}
switch name {
case "Skill":
tc.SkillName = input.Get("skill").Str
case "skill":
tc.SkillName = input.Get("skill").Str
if tc.SkillName == "" {
tc.SkillName = input.Get("name").Str
}
default:
tc.SkillName = inferToolSkillName(name, tc.InputJSON)
}
return tc, true
}
func toolCallInput(block gjson.Result) gjson.Result {
input := block.Get("input")
if input.Raw == "" || input.Raw == "{}" {
if args := block.Get("arguments"); args.Exists() {
input = args
}
}
return input
}
func parseToolResult(block gjson.Result) (ParsedToolResult, bool) {
tuid := block.Get("tool_use_id").Str
if tuid == "" {
return ParsedToolResult{}, false
}
rc := block.Get("content")
return ParsedToolResult{
ToolUseID: tuid,
ContentLength: toolResultContentLength(rc),
ContentRaw: rc.Raw,
}, true
}
func toolResultContentLength(content gjson.Result) int {
if content.Type == gjson.String {
return len(content.Str)
}
if content.IsArray() {
total := 0
content.ForEach(func(_, block gjson.Result) bool {
total += len(block.Get("text").Str)
return true
})
return total
}
// iFlow tool results use an object with nested output at
// responseParts.functionResponse.response.output.
if content.IsObject() {
return len(content.Get(
"responseParts.functionResponse.response.output",
).Str)
}
return 0
}
// DecodeContent extracts the text from a raw JSON tool result content
// value (the ContentRaw field of ParsedToolResult). It handles both
// plain string and array-of-blocks formats.
func DecodeContent(raw string) string {
return decodeContent(gjson.Parse(raw))
}
func decodeContent(content gjson.Result) string {
if content.Type == gjson.String {
return content.Str
}
if content.IsArray() {
var parts []string
content.ForEach(func(_, block gjson.Result) bool {
if t := block.Get("text").Str; t != "" {
parts = append(parts, t)
}
return true
})
return strings.Join(parts, "")
}
// iFlow tool results use an object with nested output.
if content.IsObject() {
return content.Get(
"responseParts.functionResponse.response.output",
).Str
}
return ""
}
var todoIcons = map[string]string{
"completed": "✓",
"in_progress": "→",
"pending": "○",
}
func formatToolUse(block gjson.Result) string {
name := block.Get("name").Str
input := block.Get("input")
if input.Raw == "" || input.Raw == "{}" {
// OpenClaw emits some tool calls with args only under
// "arguments" rather than "input".
if args := block.Get("arguments"); args.Exists() {
input = args
}
}
switch name {
case "AskUserQuestion":
return formatAskUserQuestion(name, input)
case "TodoWrite":
return formatTodoWrite(input)
case "EnterPlanMode":
return "[Entering Plan Mode]"
case "ExitPlanMode":
return "[Exiting Plan Mode]"
case "Read":
// Claude Code uses "file_path"; Amp uses "path"
path := input.Get("file_path").Str
if path == "" {
path = input.Get("path").Str
}
return fmt.Sprintf("[Read: %s]", path)
case "Glob":
return formatGlob(input)
case "Grep":
return fmt.Sprintf("[Grep: %s]", input.Get("pattern").Str)
case "Edit":
return fmt.Sprintf("[Edit: %s]", input.Get("file_path").Str)
case "Write":
return fmt.Sprintf("[Write: %s]", input.Get("file_path").Str)
case "Bash":
// Claude Code uses "command"; Amp uses "cmd"
if input.Get("command").Str == "" && input.Get("cmd").Str != "" {
return fmt.Sprintf("[Bash]\n$ %s", input.Get("cmd").Str)
}
return formatBash(input)
// Amp tools
case "edit_file":
return fmt.Sprintf("[Edit: %s]", input.Get("path").Str)
case "create_file":
return fmt.Sprintf("[Write: %s]", input.Get("path").Str)
case "shell_command":
return fmt.Sprintf("[Bash]\n$ %s", input.Get("command").Str)
case "glob":
return fmt.Sprintf("[Glob: %s]", input.Get("filePattern").Str)
case "look_at":
return fmt.Sprintf("[Read: %s]", input.Get("path").Str)
case "apply_patch":
return formatPatch(input)
case "ApplyPatch":
return formatPatch(input)
case "undo_edit":
return fmt.Sprintf("[Undo: %s]", input.Get("path").Str)
case "finder":
return fmt.Sprintf("[Find: %s]", input.Get("query").Str)
case "read_web_page":
return fmt.Sprintf("[Web: %s]", input.Get("url").Str)
// Pi tools (lowercase variants)
case "read":
return fmt.Sprintf("[Read: %s]", resolveFilePath(input))
case "read_file":
return fmt.Sprintf("[Read: %s]", resolveFilePath(input))
case "write":
return fmt.Sprintf("[Write: %s]", resolveFilePath(input))
case "edit":
return fmt.Sprintf("[Edit: %s]", resolveFilePath(input))
case "str_replace":
return fmt.Sprintf("[Edit: %s]", resolveFilePath(input))
case "bash":
cmd := input.Get("command").Str
if cmd == "" {
cmd = input.Get("cmd").Str
}
desc := input.Get("description").Str
if desc != "" {
return fmt.Sprintf("[Bash: %s]\n$ %s", desc, cmd)
}
return fmt.Sprintf("[Bash]\n$ %s", cmd)
case "run_command":
return fmt.Sprintf("[Bash]\n$ %s", input.Get("command").Str)
case "find":
pattern := input.Get("pattern").Str
if pattern == "" {
pattern = input.Get("query").Str
}
return fmt.Sprintf("[Find: %s]", pattern)
case "skill":
skill := input.Get("skill").Str
if skill == "" {
skill = input.Get("name").Str
}
return fmt.Sprintf("[Skill: %s]", skill)
case "Task", "Agent":
return formatTask(input)
case "Skill":
return fmt.Sprintf("[Skill: %s]", input.Get("skill").Str)
case "TaskCreate":
subject := input.Get("subject").Str
if subject != "" {
return fmt.Sprintf("[TaskCreate: %s]", subject)
}
return "[TaskCreate]"
case "TaskUpdate":
taskID := input.Get("taskId").Str
status := input.Get("status").Str
if status != "" {
return fmt.Sprintf("[TaskUpdate: #%s %s]", taskID, status)
}
return fmt.Sprintf("[TaskUpdate: #%s]", taskID)
case "TaskGet":
return fmt.Sprintf("[TaskGet: #%s]", input.Get("taskId").Str)
case "TaskList":
return "[TaskList]"
case "SendMessage":
msgType := input.Get("type").Str
recipient := input.Get("recipient").Str
if recipient != "" {
return fmt.Sprintf("[SendMessage: %s to %s]", msgType, recipient)
}
return fmt.Sprintf("[SendMessage: %s]", msgType)
default:
// MCP tools may have a server prefix (e.g.
// "Zencoder_subagent__ZencoderSubagent").
if strings.Contains(name, "subagent") {
return formatTask(input)
}
return fmt.Sprintf("[Tool: %s]", name)
}
}
func formatAskUserQuestion(
name string, input gjson.Result,
) string {
var lines []string
lines = append(lines, fmt.Sprintf("[Question: %s]", name))
input.Get("questions").ForEach(func(_, q gjson.Result) bool {
lines = append(lines, " "+q.Get("question").Str)
q.Get("options").ForEach(func(_, opt gjson.Result) bool {
lines = append(lines, fmt.Sprintf(
" - %s: %s",
opt.Get("label").Str,
opt.Get("description").Str,
))
return true
})
return true
})
return strings.Join(lines, "\n")
}
func formatTodoWrite(input gjson.Result) string {
var lines []string
lines = append(lines, "[Todo List]")
input.Get("todos").ForEach(func(_, todo gjson.Result) bool {
status := todo.Get("status").Str
icon := todoIcons[status]
if icon == "" {
icon = "○"
}
lines = append(lines, fmt.Sprintf(
" %s %s", icon, todo.Get("content").Str,
))
return true
})
return strings.Join(lines, "\n")
}
func formatGlob(input gjson.Result) string {
return fmt.Sprintf("[Glob: %s in %s]",
input.Get("pattern").Str,
orDefault(input.Get("path").Str, "."))
}
func formatBash(input gjson.Result) string {
cmd := input.Get("command").Str
desc := input.Get("description").Str
if desc != "" {
return fmt.Sprintf("[Bash: %s]\n$ %s", desc, cmd)
}
return fmt.Sprintf("[Bash]\n$ %s", cmd)
}
func formatPatch(input gjson.Result) string {
path := resolveFilePath(input)
if path == "" {
return "[Patch]"
}
return fmt.Sprintf("[Patch: %s]", path)
}
func formatTask(input gjson.Result) string {
desc := input.Get("description").Str
if desc == "" {
desc = input.Get("prompt").Str
}
agentType := input.Get("subagent_type").Str
if agentType == "" {
agentType = input.Get("agent").Str
}
if desc == "" && agentType == "" {
return "[Task]"
}
if agentType == "" {
return fmt.Sprintf("[Task: %s]", desc)
}
return fmt.Sprintf("[Task: %s (%s)]", desc, agentType)
}
// resolveFilePath extracts a file path from tool input, trying
// file_path, path, filePath, and file in order. Covers Claude Code,
// Amp, Pi, and Kiro IDE payload shapes.
func resolveFilePath(input gjson.Result) string {
if p := input.Get("file_path").Str; p != "" {
return p
}
if p := input.Get("path").Str; p != "" {
return p
}
if p := input.Get("filePath").Str; p != "" {
return p
}
return input.Get("file").Str
}
// ResolveFilePathFromJSON extracts a file path from a tool call's raw input
// JSON, checking file_path, path, filePath, then file. It returns "" when the
// input is not valid JSON (e.g. a raw diff string) or carries no path key.
func ResolveFilePathFromJSON(inputJSON string) string {
if inputJSON == "" || !gjson.Valid(inputJSON) {
return ""
}
return resolveFilePath(gjson.Parse(inputJSON))
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}