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
618 lines
17 KiB
Go
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
|
|
}
|