Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

618 lines
17 KiB
Go

package agentruntime
import (
"regexp"
"strconv"
"strings"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
var unifiedDiffHunkPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$`)
// fileChangesFromActivityEvent projects provider tool payloads into the one
// canonical shape persisted on WorkspaceAgentTurn. Provider-specific metadata
// is intentionally consumed here and must not leak into AgentGUI inference.
func fileChangesFromActivityEvent(event activityshared.Event) map[string]any {
return canonicalFileChangesFromToolPayload(event.Payload.Metadata)
}
func canonicalFileChangesFromToolPayload(payload map[string]any) map[string]any {
if len(payload) == 0 {
return nil
}
hint := toolFileChangeKind(payload)
if fileChanges := payloadMap(payload, "fileChanges"); len(fileChanges) > 0 {
if canonical := canonicalizeFileChanges(fileChanges, hint); canonical != nil {
return canonical
}
}
input := payloadMap(payload, "input")
output := payloadMap(payload, "output")
for _, candidate := range []map[string]any{
fileChangesFromChangeEntries(output["changes"], hint),
fileChangesFromChangeEntries(input["changes"], hint),
fileChangesFromChangeEntries(payload["changes"], hint),
fileChangesFromMetadataFiles(output, input, payload),
fileChangesFromContentDiff(output["content"], hint),
fileChangesFromContentDiff(input["content"], hint),
fileChangesFromContentDiff(payload["content"], hint),
fileChangesFromPatchText(input, output, payload),
fileChangesFromDirectToolPayload(payload, input, output, hint),
} {
if candidate != nil {
return candidate
}
}
return nil
}
func canonicalizeFileChanges(value map[string]any, hint string) map[string]any {
entries := fileChangeEntryMaps(value["files"])
if len(entries) == 0 {
return nil
}
files := make([]any, 0, len(entries))
for _, entry := range entries {
if file := canonicalToolFileChange(entry, hint); file != nil {
files = append(files, file)
}
}
canonical := canonicalFileChanges(files)
if canonical == nil {
return nil
}
if coverage := strings.TrimSpace(asString(value["coverage"])); coverage != "" {
canonical["coverage"] = coverage
}
return canonical
}
func fileChangesFromChangeEntries(value any, hint string) map[string]any {
entries := fileChangeEntryMaps(value)
if len(entries) == 0 {
return nil
}
files := make([]any, 0, len(entries))
for _, entry := range entries {
if file := canonicalToolFileChange(entry, hint); file != nil {
files = append(files, file)
}
}
return canonicalFileChanges(files)
}
func fileChangeEntryMaps(value any) []map[string]any {
if entries := payloadArray(value); len(entries) > 0 {
return entries
}
byPath := payloadObject(value)
if len(byPath) == 0 {
return nil
}
entries := make([]map[string]any, 0, len(byPath))
for path, raw := range byPath {
entry := clonePayload(payloadObject(raw))
if entry == nil {
continue
}
if strings.TrimSpace(asString(entry["path"])) == "" {
entry["path"] = path
}
entries = append(entries, entry)
}
return entries
}
func fileChangesFromMetadataFiles(maps ...map[string]any) map[string]any {
for _, body := range maps {
metadata := payloadMap(body, "metadata")
files := payloadArray(metadata["files"])
if len(files) == 0 {
files = payloadArray(body["files"])
}
if len(files) == 0 {
continue
}
changes := make([]any, 0, len(files))
for _, item := range files {
if file := canonicalToolFileChange(item, ""); file != nil {
changes = append(changes, file)
}
}
if fileChanges := canonicalFileChanges(changes); fileChanges != nil {
return fileChanges
}
}
return nil
}
func fileChangesFromContentDiff(value any, hint string) map[string]any {
items := payloadArray(value)
if len(items) == 0 {
return nil
}
files := make([]any, 0, len(items))
for _, item := range items {
itemType := strings.ToLower(strings.TrimSpace(asString(item["type"])))
if itemType != "" && itemType != "diff" && itemType != "file_change" {
continue
}
if file := canonicalToolFileChange(item, hint); file != nil {
files = append(files, file)
}
}
return canonicalFileChanges(files)
}
func canonicalToolFileChange(value map[string]any, hint string) map[string]any {
path := strings.TrimSpace(firstNonEmpty(
asString(value["path"]),
asString(value["filePath"]),
asString(value["file_path"]),
asString(value["relativePath"]),
))
if path == "" {
return nil
}
validDiff, hasValidDiff, rawDiff, hasRawDiff := selectToolFileDiff(
value["diff"],
value["patch"],
value["unifiedDiff"],
value["unified_diff"],
)
diff := validDiff
hasDiff := hasValidDiff
oldString, hasOld := firstPresentToolFileString(
value["oldString"], value["old_string"], value["oldText"],
)
newString, hasNew := firstPresentToolFileString(
value["newString"], value["new_string"], value["newText"],
)
content, hasContent := firstPresentToolFileString(value["content"])
change := firstNonEmpty(
normalizeToolFileChangeKind(value["change"]),
normalizeToolFileChangeKind(value["status"]),
normalizeToolFileChangeKind(value["kind"]),
normalizeToolFileChangeKind(value["type"]),
hint,
)
if change == "" && hasContent {
change = "added"
}
// Prefer newString over content for non-deleted bodies so obsolete cassette
// shapes match live projection and the shared store-sqlite/canonical contract.
if change != "deleted" && !hasNew && hasContent {
newString, hasNew = content, true
hasContent = false
}
if !hasValidDiff && hasRawDiff {
switch {
case change == "added" && !hasNew:
newString, hasNew = rawDiff, true
case change == "deleted" && !hasOld:
oldString, hasOld = rawDiff, true
case !hasOld && !hasNew && !hasContent:
newString, hasNew = rawDiff, true
}
}
if change == "" {
change = inferToolFileChangeKind(hasOld, oldString, hasNew, newString, diff)
if change == "" && hasContent {
change = "modified"
}
}
if change == "" {
return nil
}
file := map[string]any{"path": path, "change": change}
if hasDiff {
file["diff"] = diff
file["unifiedDiff"] = diff
}
if hasOld {
file["oldString"] = oldString
}
if hasNew {
file["newString"] = newString
}
if hasContent {
file["content"] = content
}
return file
}
func fileChangesFromPatchText(maps ...map[string]any) map[string]any {
for _, body := range maps {
patchText := strings.TrimSpace(firstNonEmpty(
asStringRaw(body["patchText"]),
asStringRaw(body["patch_text"]),
asStringRaw(body["patch"]),
))
if patchText == "" {
continue
}
if fileChanges := fileChangesFromCodexPatchText(patchText); fileChanges != nil {
return fileChanges
}
}
return nil
}
func fileChangesFromDirectToolPayload(payload map[string]any, input map[string]any, output map[string]any, hint string) map[string]any {
path := strings.TrimSpace(firstNonEmpty(
asString(output["filePath"]),
asString(output["file_path"]),
asString(input["filePath"]),
asString(input["file_path"]),
asString(input["path"]),
asString(output["path"]),
acpFirstLocationPath(acpLocationList(payload["locations"])),
))
if path == "" {
return nil
}
file := map[string]any{"path": path}
if oldString, ok := firstPresentToolFileString(output["oldString"], output["old_string"], input["oldString"], input["old_string"]); ok {
file["oldString"] = oldString
}
if newString, ok := firstPresentToolFileString(output["newString"], output["new_string"], input["newString"], input["new_string"], input["content"]); ok {
file["newString"] = newString
}
return fileChangesFromChangeEntries([]any{file}, hint)
}
func fileChangesFromCodexPatchText(patchText string) map[string]any {
lines := strings.Split(strings.ReplaceAll(patchText, "\r\n", "\n"), "\n")
files := make([]any, 0)
for index := 0; index < len(lines); {
line := strings.TrimSpace(lines[index])
switch {
case strings.HasPrefix(line, "*** Add File: "):
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Add File: "))
index++
contentLines := make([]string, 0)
for index < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[index]), "*** ") {
if strings.HasPrefix(lines[index], "+") {
contentLines = append(contentLines, strings.TrimPrefix(lines[index], "+"))
}
index++
}
if path != "" {
files = append(files, map[string]any{"path": path, "change": "added", "oldString": "", "newString": strings.Join(contentLines, "\n")})
}
case strings.HasPrefix(line, "*** Delete File: "):
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Delete File: "))
if path != "" {
files = append(files, map[string]any{"path": path, "change": "deleted", "oldString": "", "newString": ""})
}
index++
case strings.HasPrefix(line, "*** Update File: "):
path := strings.TrimSpace(strings.TrimPrefix(line, "*** Update File: "))
index++
hunkLines := make([]string, 0)
oldLines := make([]string, 0)
newLines := make([]string, 0)
for index < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[index]), "*** ") {
patchLine := lines[index]
hunkLines = append(hunkLines, patchLine)
switch {
case strings.HasPrefix(patchLine, "-"):
oldLines = append(oldLines, strings.TrimPrefix(patchLine, "-"))
case strings.HasPrefix(patchLine, "+"):
newLines = append(newLines, strings.TrimPrefix(patchLine, "+"))
case strings.HasPrefix(patchLine, " "):
text := strings.TrimPrefix(patchLine, " ")
oldLines = append(oldLines, text)
newLines = append(newLines, text)
}
index++
}
if path != "" {
diff := strings.Join(hunkLines, "\n")
files = append(files, map[string]any{"path": path, "change": "modified", "diff": diff, "unifiedDiff": diff, "oldString": strings.Join(oldLines, "\n"), "newString": strings.Join(newLines, "\n")})
}
default:
index++
}
}
return canonicalFileChanges(files)
}
func canonicalFileChanges(files []any) map[string]any {
if len(files) == 0 {
return nil
}
byPath := make(map[string]map[string]any, len(files))
order := make([]string, 0, len(files))
for _, raw := range files {
file := payloadObject(raw)
path := strings.TrimSpace(asString(file["path"]))
if path == "" {
continue
}
if current, exists := byPath[path]; exists {
byPath[path] = mergeCanonicalFileChange(current, file)
continue
}
order = append(order, path)
byPath[path] = clonePayload(file)
}
result := make([]any, 0, len(order))
for _, path := range order {
if file := byPath[path]; file != nil {
result = append(result, file)
}
}
if len(result) == 0 {
return nil
}
return map[string]any{"files": result}
}
func firstPresentToolFileString(values ...any) (string, bool) {
for _, value := range values {
if text, ok := value.(string); ok {
return text, true
}
}
return "", false
}
func selectToolFileDiff(values ...any) (valid string, hasValid bool, raw string, hasRaw bool) {
for _, value := range values {
text, ok := value.(string)
if !ok {
continue
}
if !hasRaw {
raw, hasRaw = text, true
}
if !hasValid && looksLikeUnifiedDiff(text) {
valid, hasValid = text, true
}
}
return
}
func toolFileChangeKind(payload map[string]any) string {
input := payloadMap(payload, "input")
for _, value := range []any{
payload["fileChangeKind"],
payload["activityKind"],
payload["kind"],
payloadMap(payload, "acp")["kind"],
input["fileChangeKind"],
input["kind"],
} {
if kind := normalizeToolFileChangeKind(value); kind != "" {
return kind
}
}
return ""
}
func normalizeToolFileChangeKind(value any) string {
if nested := payloadObject(value); len(nested) > 0 {
value = nested["type"]
}
switch strings.ToLower(strings.TrimSpace(asString(value))) {
case "add", "added", "create", "created", "new", "write_file":
return "added"
case "delete", "deleted", "remove", "removed", "delete_file":
return "deleted"
case "modify", "modified", "update", "updated", "edit", "edited", "change", "changed", "edit_file":
return "modified"
default:
return ""
}
}
func inferToolFileChangeKind(hasOld bool, oldString string, hasNew bool, newString string, diff string) string {
if hasOld && !hasNew {
return "deleted"
}
if hasNew && !hasOld || oldString == "" && newString != "" {
return "added"
}
if hasOld || hasNew || strings.TrimSpace(diff) != "" {
return "modified"
}
return ""
}
func looksLikeUnifiedDiff(value string) bool {
normalized := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n"))
if normalized == "" {
return false
}
lines := strings.Split(normalized, "\n")
isApplyPatch := strings.Contains(normalized, "*** Begin Patch") &&
(strings.Contains(normalized, "*** Add File:") || strings.Contains(normalized, "*** Delete File:") || strings.Contains(normalized, "*** Update File:"))
hasHunk := false
hunkHasChange := false
oldCount := 0
newCount := 0
expectedOld := 0
expectedNew := 0
hunkActive := false
hunkHasCounts := false
finishHunk := func() bool {
if !hunkActive {
return true
}
if !hunkHasCounts {
return isApplyPatch && hunkHasChange
}
return oldCount == expectedOld && newCount == expectedNew && hunkHasChange
}
for _, line := range lines {
if match := unifiedDiffHunkPattern.FindStringSubmatch(line); match != nil {
if !finishHunk() {
return false
}
hasHunk = true
hunkActive = true
hunkHasCounts = true
oldCount = 0
newCount = 0
hunkHasChange = false
expectedOld = unifiedDiffHunkCount(match[2])
expectedNew = unifiedDiffHunkCount(match[4])
continue
}
if line == "@@" && isApplyPatch {
if !finishHunk() {
return false
}
hasHunk = true
hunkActive = true
hunkHasCounts = false
hunkHasChange = false
continue
}
if hunkActive && strings.HasPrefix(line, "diff --git ") {
if !finishHunk() {
return false
}
hunkActive = false
continue
}
if !hunkActive && (strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") ||
strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") ||
strings.HasPrefix(line, "new file mode ") || strings.HasPrefix(line, "deleted file mode ") ||
strings.HasPrefix(line, "old mode ") || strings.HasPrefix(line, "new mode ") ||
strings.HasPrefix(line, "similarity index ") || strings.HasPrefix(line, "rename from ") ||
strings.HasPrefix(line, "rename to ") || strings.HasPrefix(line, "*** ")) {
continue
}
if hunkActive && isApplyPatch && strings.HasPrefix(line, "*** ") {
continue
}
if line == "\\ No newline at end of file" {
continue
}
if !hasHunk {
continue
}
if !hunkHasCounts && isApplyPatch {
switch {
case strings.HasPrefix(line, "+"):
hunkHasChange = true
case strings.HasPrefix(line, "-"):
hunkHasChange = true
case strings.HasPrefix(line, " "):
default:
return false
}
continue
}
switch {
case strings.HasPrefix(line, "+"):
newCount++
hunkHasChange = true
case strings.HasPrefix(line, "-"):
oldCount++
hunkHasChange = true
case strings.HasPrefix(line, " "):
oldCount++
newCount++
default:
return false
}
}
return hasHunk && finishHunk()
}
func unifiedDiffHunkCount(value string) int {
if value == "" {
return 1
}
count, err := strconv.Atoi(value)
if err != nil {
return -1
}
return count
}
func mergeCanonicalFileChanges(current map[string]any, incoming map[string]any) map[string]any {
files := payloadArray(current["files"])
order := make([]string, 0, len(files))
byPath := make(map[string]map[string]any, len(files))
seenPaths := make(map[string]struct{}, len(files))
appendPath := func(path string) {
if _, seen := seenPaths[path]; seen {
return
}
seenPaths[path] = struct{}{}
order = append(order, path)
}
for _, file := range files {
path := strings.TrimSpace(asString(file["path"]))
if path == "" {
continue
}
appendPath(path)
if existing := byPath[path]; existing != nil {
byPath[path] = mergeCanonicalFileChange(existing, file)
continue
}
byPath[path] = clonePayload(file)
}
for _, next := range payloadArray(incoming["files"]) {
path := strings.TrimSpace(asString(next["path"]))
if path == "" {
continue
}
existing := byPath[path]
if existing == nil {
appendPath(path)
byPath[path] = clonePayload(next)
continue
}
byPath[path] = mergeCanonicalFileChange(existing, next)
}
merged := make([]any, 0, len(order))
for _, path := range order {
if file := byPath[path]; file != nil {
merged = append(merged, file)
}
}
return map[string]any{"files": merged}
}
func mergeCanonicalFileChange(existing map[string]any, next map[string]any) map[string]any {
if existing == nil {
return clonePayload(next)
}
if next == nil {
return clonePayload(existing)
}
merged := clonePayload(existing)
for key, value := range next {
if key != "path" && key != "change" && key != "oldString" {
merged[key] = clonePayloadValue(value)
}
}
if _, ok := merged["oldString"]; !ok {
if value, present := next["oldString"]; present {
merged["oldString"] = value
}
}
previousKind := normalizeToolFileChangeKind(existing["change"])
nextKind := normalizeToolFileChangeKind(next["change"])
switch {
case previousKind == "added" && nextKind == "deleted":
return nil
case previousKind == "added" && nextKind == "modified":
merged["change"] = "added"
case previousKind == "deleted" && nextKind == "added":
merged["change"] = "modified"
case previousKind == "modified" && nextKind == "deleted":
merged["change"] = "deleted"
case nextKind != "":
merged["change"] = nextKind
}
return merged
}