package acp import ( "encoding/json" "strings" "unicode/utf8" "albedo/cli/internal/daemon" ) const toolDisplayLimit = 256 * 1024 const toolTruncationMarker = "[albedo: tool output truncated to fit 256 KiB]" // toolDisplay is shared by durable live events and hydrated history rows. func toolDisplay(event daemon.StreamEvent) map[string]any { title := event.ToolTitle if title == "" { title = event.ToolName } update := map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": event.ProgressCallID, "status": event.ToolStatus, "title": title, "kind": "other", "rawInput": event.ToolArgs, "rawOutput": map[string]string{"result": event.ToolResult}} content := []any{textContent(event.ToolResult)} locations := []map[string]string{} seen := map[string]bool{} addLocation := func(path string) { if path != "" && !seen[path] { locations = append(locations, map[string]string{"path": path}) seen[path] = true } } evidence := map[string]bool{} if trace := event.ToolTrace; trace != nil { for _, activity := range trace.Activities { evidence[activity.Kind] = true if activity.Kind == "read" { addLocation(activity.Target) } } for _, change := range trace.Changes { evidence["edit"] = true addLocation(change.Path) if change.Kind == "diff" && change.NewText != nil { content = append(content, map[string]any{"type": "diff", "path": change.Path, "oldText": change.OldText, "newText": *change.NewText}) } else { reason := change.Reason if reason == "" { reason = "file snapshots unavailable" } text := change.Path + ": " + reason if change.Diff != "" { text += "\nUnified diff:\n" + change.Diff } content = append(content, textContent(text)) } } } if len(evidence) == 1 { for kind := range evidence { if mapped, ok := map[string]string{"edit": "edit", "read": "read", "search": "search", "run": "execute", "web": "fetch"}[kind]; ok { update["kind"] = mapped } } } update["content"] = content update["locations"] = locations encoded, _ := json.Marshal(update) if len(encoded) <= toolDisplayLimit { return update } // Drop indivisible snapshots and oversized input before shortening result text. // The marker makes every loss visible, including JSON escaping expansion. input, _ := json.Marshal(event.ToolArgs) if len(input) > toolDisplayLimit/4 { delete(update, "rawInput") } encodedLocations, _ := json.Marshal(locations) if len(encodedLocations) > toolDisplayLimit/4 { delete(update, "locations") } update["title"] = boundedText(title, 4096) text := event.ToolResult for { update["content"] = []any{textContent(text + "\n" + toolTruncationMarker)} update["rawOutput"] = map[string]string{"result": text} encoded, _ = json.Marshal(update) if len(encoded) <= toolDisplayLimit { return update } text = boundedText(text, len(text)/2) } } func textContent(text string) map[string]any { return map[string]any{"type": "content", "content": map[string]string{"type": "text", "text": text}} } func boundedText(text string, limit int) string { if len(text) <= limit { return text } text = text[:limit] for !utf8.ValidString(text) { text = strings.TrimSuffix(text, text[len(text)-1:]) } return text }