| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/evidence" |
| 11 | "reasonix/internal/i18n" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | var pastedLabel = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\]$`) |
| 16 | |
| 17 | // CollapseExpandedPaste keeps the display card while SubmitText retains the |
| 18 | // original expanded payload used to reconstruct an edited prompt. |
| 19 | func CollapseExpandedPaste(content string) string { |
| 20 | const prefix = "--- Begin " |
| 21 | for scan := 0; scan < len(content); { |
| 22 | begin := strings.Index(content[scan:], prefix) |
| 23 | if begin < 0 { |
| 24 | break |
| 25 | } |
| 26 | begin += scan |
| 27 | start := begin + len(prefix) |
| 28 | end := strings.Index(content[start:], " ---") |
| 29 | if end < 0 { |
| 30 | break |
| 31 | } |
| 32 | end += start |
| 33 | label := content[start:end] |
| 34 | body := end + len(" ---") |
| 35 | scan = body |
| 36 | if !pastedLabel.MatchString(label) { |
| 37 | continue |
| 38 | } |
| 39 | marker := "--- End " + label + " ---" |
| 40 | last := strings.Index(content[body:], marker) |
| 41 | if last < 0 { |
| 42 | continue |
| 43 | } |
| 44 | copy := strings.LastIndex(content[:begin], label) |
| 45 | if copy < 0 || strings.TrimSpace(content[copy+len(label):begin]) != "" { |
| 46 | continue |
| 47 | } |
| 48 | content = content[:copy+len(label)] + content[body+last+len(marker):] |
| 49 | scan = copy + len(label) |
| 50 | } |
| 51 | return strings.TrimSpace(content) |
| 52 | } |
| 53 | |
| 54 | func interruptedNotice(recovery *provider.InterruptedTurnRecovery) Message { |
| 55 | if recovery != nil && recovery.TerminalStatus == "failed" { |
| 56 | diagnostic := recovery.FailureDiagnostic |
| 57 | text := "The provider request failed. Check the connection settings and try again." |
| 58 | if diagnostic != nil { |
| 59 | if status := i18n.M.ProviderStatusMessage(diagnostic.Status); status != "" { |
| 60 | text = status |
| 61 | } else if diagnostic.Status > 0 { |
| 62 | text = fmt.Sprintf("Provider request failed (HTTP %d).", diagnostic.Status) |
| 63 | } |
| 64 | if label := provider.ProviderDisplayLabel(diagnostic.ProviderID, diagnostic.ProviderDisplayName, diagnostic.Protocol); label != "" { |
| 65 | text = label + ": " + text |
| 66 | } |
| 67 | } |
| 68 | return Message{Role: "notice", Level: "warn", Code: event.NoticeCodeProviderRequestFailed, Content: text, Detail: provider.FailureDiagnosticDetail(diagnostic), Diagnostic: diagnostic} |
| 69 | } |
| 70 | return Message{Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn, |
| 71 | Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes."} |
| 72 | } |
| 73 | |
| 74 | func toolFailed(content string) bool { |
| 75 | content = strings.TrimSpace(content) |
| 76 | return strings.HasPrefix(content, "error:") || strings.HasPrefix(content, "blocked:") || strings.HasPrefix(content, "Error:") || strings.HasPrefix(content, "[error") |
| 77 | } |
| 78 | |
| 79 | func completedTodoArguments(messages []provider.Message) map[string]string { |
| 80 | successful := make(map[string]bool) |
| 81 | outputs := make(map[string]string) |
| 82 | for _, message := range messages { |
| 83 | if message.Role == provider.RoleTool && message.ToolCallID != "" && !toolFailed(message.Content) { |
| 84 | successful[message.ToolCallID] = true |
| 85 | outputs[message.ToolCallID] = message.Content |
| 86 | } |
| 87 | } |
| 88 | out := make(map[string]string) |
| 89 | var todos []evidence.TodoItem |
| 90 | var latest string |
| 91 | for _, message := range messages { |
| 92 | for _, call := range message.ToolCalls { |
| 93 | if call.ID == "" || !successful[call.ID] { |
| 94 | continue |
| 95 | } |
| 96 | switch call.Name { |
| 97 | case "todo_write": |
| 98 | receipt := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), true, true) |
| 99 | if len(receipt.Todos) == 0 { |
| 100 | continue |
| 101 | } |
| 102 | todos, latest = evidence.ReplayTodoList(receipt.Todos, outputs[call.ID]), call.ID |
| 103 | case "complete_step": |
| 104 | if latest == "" || len(todos) == 0 { |
| 105 | continue |
| 106 | } |
| 107 | receipt := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), true, true) |
| 108 | match, ok := evidence.MatchStep(receipt.Step, todos) |
| 109 | if !ok || !evidence.ReplayTodoCompletion(todos, match.Index-1, outputs[call.ID]) { |
| 110 | continue |
| 111 | } |
| 112 | default: |
| 113 | continue |
| 114 | } |
| 115 | if encoded, err := json.Marshal(map[string]any{"todos": todos}); err == nil { |
| 116 | out[latest] = string(encoded) |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | return out |
| 121 | } |
| 122 |