返回 DeepSeek-Reasonix
history.go
根目录 / internal / transcript / history.go
1 package transcript
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/agent"
8 "reasonix/internal/event"
9 "reasonix/internal/provider"
10 )
11
12 // HistoryOptions supplies surface-specific presentation without allowing a
13 // frontend to invent record identity or event coverage.
14 type HistoryOptions struct {
15 UserContent func(provider.Message) string
16 SubmitContent func(provider.Message) string
17 ToolCall func(provider.ToolCall) ToolCall
18 LegacyTurns []LegacyDisplayTurn
19 CheckpointTurns map[int]int
20 }
21
22 // History converts the persisted message identities into display records.
23 // Derived notices are keyed by their owning message, never by their body.
24 func History(messages []provider.Message, opts HistoryOptions) []Message {
25 out := make([]Message, 0, len(messages))
26 byUser := legacyTurnsByUser(opts.LegacyTurns)
27 canonicalMessages := canonicalMessageIDs(messages)
28 suppressCanonical := false
29 todoArgs := completedTodoArguments(messages)
30 // Generated identities must stay unique across the whole history, the same
31 // way repairCheckpointRecordIdentities keeps its generated values apart.
32 used := make(map[string]bool, len(messages))
33 for messageIndex, m := range messages {
34 if suppressCanonical && m.DecisionReceipt == nil && !independentLocalMessage(m) {
35 if !agent.IsUserAuthoredTurnMessage(m) {
36 continue
37 }
38 suppressCanonical = false
39 }
40 rows := historyRows(m, messageIndex, opts, todoArgs)
41 stampHistoryRows(rows, m, used)
42 out = append(out, rows...)
43 if m.Role == provider.RoleUser && !m.LocalOnly && agent.IsUserAuthoredTurnMessage(m) {
44 if appendLegacyTurnRows(&out, byUser, canonicalMessages, m) {
45 suppressCanonical = true
46 }
47 }
48 }
49 return out
50 }
51
52 func canonicalMessageIDs(messages []provider.Message) map[string]bool {
53 canonicalMessages := make(map[string]bool)
54 for _, message := range messages {
55 if message.ID != "" {
56 canonicalMessages[message.ID] = true
57 }
58 }
59 return canonicalMessages
60 }
61
62 func legacyTurnsByUser(turns []LegacyDisplayTurn) map[string][]LegacyDisplayTurn {
63 byUser := make(map[string][]LegacyDisplayTurn)
64 for _, turn := range turns {
65 key := turn.UserMessageID
66 if key == "" {
67 key = "legacy:" + turn.UserHash
68 }
69 byUser[key] = append(byUser[key], turn)
70 }
71 return byUser
72 }
73
74 func independentLocalMessage(m provider.Message) bool {
75 _, steer := agent.ReplaySteerText(m.Content)
76 return m.LocalOnly && (m.ReadPause != nil || m.ReadCompletion != nil || len(m.ProtocolRecovery) > 0 || (m.FinalReadinessRecovery != nil && m.FinalReadinessRecovery.Pending) || steer)
77 }
78
79 func historyRows(m provider.Message, messageIndex int, opts HistoryOptions, todoArgs map[string]string) []Message {
80 switch {
81 case m.Role == provider.RoleSystem:
82 return nil
83 case m.DecisionReceipt != nil:
84 return []Message{{Role: "notice", Code: event.NoticeCodeDecisionReceipt, Level: "info", DecisionReceipt: m.DecisionReceipt}}
85 case m.LocalOnly && m.ReadPause != nil:
86 return []Message{{Role: "notice", Code: event.TurnOutcomeIncompleteRead, Level: "info", ReadPause: m.ReadPause}}
87 case m.LocalOnly && m.ReadCompletion != nil:
88 return []Message{readCompletionMessage(m.ReadCompletion)}
89 case m.LocalOnly && len(m.ProtocolRecovery) > 0:
90 if recovery, ok := provider.DecodeProtocolRecovery(m.ProtocolRecovery); ok && recovery.State == "pending" {
91 return []Message{{Role: "notice", Code: "protocol_recovery", Level: "info", Pending: true, ProtocolRecovery: &provider.ProtocolRecoveryAction{ID: recovery.ID}}}
92 }
93 return nil
94 case m.LocalOnly && m.FinalReadinessRecovery != nil && m.FinalReadinessRecovery.Pending:
95 return []Message{{Role: "notice", Code: agent.HistoricalChecksNoticeCode, Level: "info",
96 Content: agent.HistoricalChecksNoticeText,
97 Readiness: agent.HistoricalChecks(m.FinalReadinessRecovery)}}
98 default:
99 return defaultHistoryRows(m, messageIndex, opts, todoArgs)
100 }
101 }
102
103 func defaultHistoryRows(m provider.Message, messageIndex int, opts HistoryOptions, todoArgs map[string]string) []Message {
104 if text, handled := agent.ReplaySteerText(m.Content); handled {
105 if text == "" {
106 return nil
107 }
108 row := Message{Role: "notice", Content: "↪ " + text}
109 if m.LocalOnly {
110 row.Content, row.Code, row.Level, row.MessageID = agent.UnappliedSteerNotice(text), event.NoticeCodeUnappliedSteer, "warn", m.ID
111 } else {
112 // The live steer receipt and canonical history refer to this same
113 // message; retain its identity when presenting it as a notice.
114 row.MessageID = m.ID
115 }
116 return []Message{row}
117 }
118 if m.Role == provider.RoleUser && agent.IsHostGeneratedUserMessage(m) {
119 return nil
120 }
121 row := Message{MessageID: m.ID, Role: string(m.Role), Content: m.Content, CreatedAt: m.CreatedAt,
122 WorkDurationMs: m.WorkDurationMs, MemoryCitations: m.MemoryCitations, Execution: m.ToolExecution,
123 PresentedFiles: provider.PresentedFileList(m.PresentedFiles)}
124 if m.LocalOnly {
125 row.Role = "assistant"
126 }
127 if m.Role == provider.RoleUser {
128 applyUserHistoryContent(&row, m, messageIndex, opts)
129 }
130 if row.Role == "assistant" {
131 applyAssistantHistoryFields(&row, m, opts, todoArgs)
132 }
133 if m.Role == provider.RoleTool && !m.LocalOnly {
134 row.ToolCallID, row.ToolName = m.ToolCallID, m.Name
135 if toolFailed(row.Content) {
136 row.ToolResultError = row.Content
137 }
138 }
139 var rows []Message
140 if !m.LocalOnly || strings.TrimSpace(row.Content+row.Reasoning) != "" || len(row.ToolCalls) > 0 {
141 rows = append(rows, row)
142 }
143 for _, receipt := range m.DecisionReceipts {
144 if receipt != nil {
145 rows = append(rows, Message{Role: "notice", Code: event.NoticeCodeDecisionReceipt, Level: "info", DecisionReceipt: receipt})
146 }
147 }
148 if m.LocalOnly && m.InterruptedTurn != nil {
149 rows = append(rows, interruptedNotice(m.InterruptedTurn))
150 }
151 return rows
152 }
153
154 func applyUserHistoryContent(row *Message, m provider.Message, messageIndex int, opts HistoryOptions) {
155 if turn, ok := opts.CheckpointTurns[messageIndex]; ok {
156 row.CheckpointTurn = &turn
157 }
158 row.Content = agent.UserMessageText(m)
159 if opts.UserContent != nil {
160 row.Content = opts.UserContent(m)
161 }
162 row.Content = CollapseExpandedPaste(row.Content)
163 if opts.SubmitContent != nil && row.Content != m.Content {
164 replay := opts.SubmitContent(m)
165 if replay != row.Content && (!agent.ContainsMemoryCompilerExecution(m.Content) || strings.HasPrefix(strings.TrimSpace(replay), "/")) {
166 row.SubmitText = replay
167 }
168 }
169 }
170
171 func applyAssistantHistoryFields(row *Message, m provider.Message, opts HistoryOptions, todoArgs map[string]string) {
172 row.Reasoning = m.ReasoningContent
173 for _, call := range m.ToolCalls {
174 converted := ToolCall{ID: call.ID, Name: call.Name, Arguments: call.Arguments, ResolvedName: call.ResolvedName,
175 CapabilityID: call.CapabilityID, ResolvedReadOnly: call.ResolvedReadOnly, Diff: call.Diff, Added: call.Added, Removed: call.Removed}
176 if opts.ToolCall != nil {
177 converted = opts.ToolCall(call)
178 }
179 if args, ok := todoArgs[call.ID]; ok && call.Name == "todo_write" {
180 converted.Arguments = args
181 }
182 row.ToolCalls = append(row.ToolCalls, converted)
183 }
184 for _, search := range m.ServerSearch {
185 search.Raw = nil
186 row.ServerSearch = append(row.ServerSearch, search)
187 }
188 }
189
190 // used carries the identities already stamped in this history. Canonical
191 // addresses are left exactly as they are, so a transcript that really does
192 // hold two records with one identity is still rejected by NewProjection.
193 // Only the derived last-resort address is disambiguated: it is keyed by the
194 // owning message id, which interrupted-attempt records are persisted without,
195 // so two of them would otherwise claim the same row and fail the whole
196 // projection.
197 func stampHistoryRows(rows []Message, m provider.Message, used map[string]bool) {
198 for i := range rows {
199 row := &rows[i]
200 switch {
201 case row.Role == "tool" && row.ToolCallID != "":
202 row.RecordID = "tool:" + row.ToolCallID
203 case row.Code == event.NoticeCodeUnappliedSteer:
204 // Keep the established record address while exposing its message
205 // identity to live notice events and history readers.
206 row.RecordID = unappliedSteerRecordID(m.ID)
207 case row.MessageID != "":
208 row.RecordID = "m:" + row.MessageID
209 default:
210 base := fmt.Sprintf("m:%s:notice:%d", m.ID, i)
211 row.RecordID = base
212 for suffix := 1; used[row.RecordID]; suffix++ {
213 row.RecordID = fmt.Sprintf("%s:%d", base, suffix)
214 }
215 }
216 used[row.RecordID] = true
217 if row.CreatedAt == 0 {
218 row.CreatedAt = m.CreatedAt
219 }
220 }
221 }
222
223 func unappliedSteerRecordID(messageID string) string {
224 return "m:" + messageID + ":notice:0"
225 }
226
227 // appendLegacyTurnRows reports whether the replayed turn carried a
228 // cancelled-turn notice, which suppresses its canonical successor messages.
229 func appendLegacyTurnRows(out *[]Message, byUser map[string][]LegacyDisplayTurn, canonicalMessages map[string]bool, m provider.Message) bool {
230 key := m.ID
231 if len(byUser[key]) == 0 {
232 key = "legacy:" + LegacyDisplayKey(agent.UserMessageText(m))
233 }
234 turns := byUser[key]
235 if len(turns) == 0 {
236 return false
237 }
238 turn := turns[0]
239 byUser[key] = turns[1:]
240 cancelled := false
241 for _, legacy := range turn.Messages {
242 if legacy.Role == "notice" && legacy.Code == event.NoticeCodeCancelledTurn {
243 cancelled = true
244 }
245 }
246 sawCancelled := false
247 for i, legacy := range turn.Messages {
248 if !cancelled && legacy.MessageID != "" && canonicalMessages[legacy.MessageID] {
249 continue
250 }
251 if legacy.RecordID == "" {
252 if legacy.MessageID != "" {
253 legacy.RecordID = "m:" + legacy.MessageID
254 } else {
255 legacy.RecordID = fmt.Sprintf("m:%s:legacy:%s:%d", m.ID, turn.TurnID, i)
256 }
257 }
258 if legacy.Role == "notice" && legacy.Code == event.NoticeCodeCancelledTurn {
259 sawCancelled = true
260 }
261 *out = append(*out, legacy)
262 }
263 return sawCancelled
264 }
265
265 lines GO