turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

transcript.go · 393 lines · 11.9 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 4h ago1package acp
2
3import (
4 "encoding/json"
5 "strconv"
6 "strings"
7)
8
9// EntryKind says what one entry of a conversation is.
10type EntryKind int
11
12// The kinds of entry a conversation holds.
13const (
14 // EntryUser is something you typed.
15 EntryUser EntryKind = iota
16 // EntryAgent is what the agent said.
17 EntryAgent
18 // EntryThought is the agent thinking aloud, which is worth showing and
19 // worth showing differently.
20 EntryThought
21 // EntryTool is a tool the agent ran, with whatever it produced.
22 EntryTool
23 // EntryPlan is a plan the agent published.
24 EntryPlan
25 // EntryNotice is the editor speaking for itself: a failure to start, a
26 // turn that was cancelled, output that had to be dropped.
27 EntryNotice
28)
29
30// Entry is one thing in the conversation.
31//
32// A run of message chunks is one entry, not one per chunk: an agent sends
33// text a token at a time — "I", " found", " agent", ".yaml" — and an entry per
34// chunk could neither be wrapped nor told apart from a fenced code block.
35type Entry struct {
36 Kind EntryKind
37 // Speaker is who said it, for the label above the text: your name for a
38 // prompt, the agent's for a reply. It is empty on an entry whose label is
39 // fixed.
40 Speaker string
41 // Text is what was said. It is Markdown when it came from an agent, so
42 // fenced code blocks in it are real.
43 Text string
44
45 // ToolCallID identifies a tool entry, so that a later update can be folded
46 // onto it rather than appended after it. It is empty on every other kind.
47 ToolCallID string
48 // Status is a tool call's status: pending, in_progress, completed, failed.
49 Status string
50 // Detail is the one-line summary of what a tool was asked to do — the
51 // command, the path — taken from the tool call's raw input.
52 Detail string
53
54 // Plan is the entries of a plan, on an EntryPlan.
55 Plan []PlanEntry
56}
57
58// Done reports whether a tool call has finished, either way.
59func (e Entry) Done() bool { return e.Status == StatusCompleted || e.Status == StatusFailed }
60
61// Failed reports whether a tool call ended badly.
62func (e Entry) Failed() bool { return e.Status == StatusFailed }
63
64// MaxEntryBytes caps how much text one entry keeps. An agent printing a
65// megabyte of build output should not be able to make the editor unusable;
66// what was dropped is said in the entry itself.
67const MaxEntryBytes = 1 << 20
68
69// Transcript is a conversation, as a value.
70//
71// It knows nothing about a terminal, a window or a colour: it turns the
72// stream of updates an agent sends into an ordered list of entries, and hands
73// that list to whoever draws. That is what lets it be tested by calling
74// functions and comparing values, and what makes the window's own tests
75// deterministic — a window drawn from a fixed transcript cannot race a live
76// agent.
77//
78// var t acp.Transcript
79// t.AddUser("You", "hello")
80// t.Apply(update)
81// for _, entry := range t.Entries() { … }
82type Transcript struct {
83 entries []Entry
84 // agentName labels what the agent says. It is learnt from the handshake,
85 // so it is the agent's own name rather than the one in the menu.
86 agentName string
87 // openKind is the kind of entry chunks are currently being appended to, or
88 // -1 when the last thing added was not a chunk. It is what coalesces a run
89 // of tokens into one entry without coalescing a reply with the thought
90 // before it.
91 openKind EntryKind
92 open bool
93}
94
95// SetAgentName says what to label the agent's own messages with.
96func (t *Transcript) SetAgentName(name string) { t.agentName = name }
97
98// AgentName returns the label the agent's messages carry.
99func (t *Transcript) AgentName() string { return t.agentName }
100
101// Entries returns a copy of the conversation, in order.
102//
103// A copy, because the list is built on the connection's reading goroutine and
104// drawn on the one that owns the screen. Handing out the slice itself would be
105// a data race that a terminal is slow enough to hide for a long time.
106func (t *Transcript) Entries() []Entry {
107 out := make([]Entry, len(t.entries))
108 copy(out, t.entries)
109 return out
110}
111
112// Len returns how many entries there are.
113func (t *Transcript) Len() int { return len(t.entries) }
114
115// AddUser records something you typed.
116func (t *Transcript) AddUser(speaker, text string) {
117 t.closeRun()
118 t.entries = append(t.entries, Entry{Kind: EntryUser, Speaker: speaker, Text: text})
119}
120
121// AddNotice records the editor speaking for itself.
122//
123// It is a separate kind rather than an agent message so that "the agent could
124// not be started" is never mistaken for something the agent said.
125func (t *Transcript) AddNotice(text string) {
126 t.closeRun()
127 t.entries = append(t.entries, Entry{Kind: EntryNotice, Text: text})
128}
129
130// Apply folds one session/update into the conversation.
131//
132// An update this package does not know is ignored and reported as such by the
133// return value. The protocol grows, and an editor that refused to carry on
134// talking to an agent because it learnt a new kind of message would be wrong
135// more often than it was right.
136//
137// known := transcript.Apply(notification.Update)
138func (t *Transcript) Apply(u Update) (known bool) {
139 switch u.SessionUpdate {
140 case UpdateAgentMessage:
141 t.appendChunk(EntryAgent, t.agentName, u.Block().Text)
142 case UpdateAgentThought:
143 t.appendChunk(EntryThought, t.agentName, u.Block().Text)
144 case UpdateUserMessage:
145 t.appendChunk(EntryUser, "", u.Block().Text)
146 case UpdateToolCall:
147 t.addToolCall(u)
148 case UpdateToolCallDone:
149 t.updateToolCall(u)
150 case UpdatePlan:
151 t.addPlan(u)
152 case UpdateCommands, UpdateUsage, UpdateCurrentMode:
153 // Kept by the session rather than drawn in the conversation.
154 default:
155 return false
156 }
157 return true
158}
159
160// appendChunk adds text to the run in progress, or starts a new entry.
161func (t *Transcript) appendChunk(kind EntryKind, speaker, text string) {
162 if text == "" {
163 return
164 }
165
166 if t.open && t.openKind == kind && len(t.entries) > 0 {
167 last := &t.entries[len(t.entries)-1]
168 last.Text = capped(last.Text + text)
169 return
170 }
171
172 t.entries = append(t.entries, Entry{Kind: kind, Speaker: speaker, Text: text})
173 t.openKind = kind
174 t.open = true
175}
176
177// closeRun ends the run of chunks in progress, so that the next one starts an
178// entry of its own.
179func (t *Transcript) closeRun() { t.open = false }
180
181// addToolCall starts a tool entry.
182//
183// A tool call whose id is already here is updated rather than repeated: an
184// agent may send the tool_call *after* the permission request that named it,
185// which is exactly what docker agent does.
186func (t *Transcript) addToolCall(u Update) {
187 t.closeRun()
188
189 if at := t.indexOfTool(u.ToolCallID); at >= 0 {
190 t.fold(at, u)
191 return
192 }
193 t.entries = append(t.entries, Entry{
194 Kind: EntryTool,
195 Speaker: u.Title,
196 ToolCallID: u.ToolCallID,
197 Status: firstNonEmpty(u.Status, StatusPending),
198 })
199
200 // The rest of the update is folded on rather than read again here, so that
201 // a tool_call carrying its own output — which the protocol allows, even
202 // though docker agent sends it separately — does not lose it.
203 t.fold(len(t.entries)-1, u)
204}
205
206// updateToolCall folds a later update onto the call it belongs to.
207//
208// An update naming an id nothing here has becomes an entry of its own, rather
209// than being dropped: it is still something the agent did.
210func (t *Transcript) updateToolCall(u Update) {
211 if at := t.indexOfTool(u.ToolCallID); at >= 0 {
212 t.fold(at, u)
213 return
214 }
215 t.addToolCall(u)
216}
217
218// fold merges an update into an existing tool entry, leaving alone whatever
219// the update does not mention.
220func (t *Transcript) fold(at int, u Update) {
221 entry := &t.entries[at]
222
223 if u.Status != "" {
224 entry.Status = u.Status
225 }
226 if u.Title != "" {
227 entry.Speaker = u.Title
228 }
229 if detail := summarise(u.RawInput); detail != "" {
230 entry.Detail = detail
231 }
232 if output := toolOutput(u); output != "" {
233 entry.Text = capped(joinNonEmpty(entry.Text, output))
234 }
235}
236
237// indexOfTool returns where a tool call already sits, or -1.
238//
239// An empty id never matches: two calls that both failed to name themselves are
240// two different calls, and folding them together would lose one.
241func (t *Transcript) indexOfTool(id string) int {
242 if id == "" {
243 return -1
244 }
245 for i, entry := range t.entries {
246 if entry.Kind == EntryTool && entry.ToolCallID == id {
247 return i
248 }
249 }
250 return -1
251}
252
253// addPlan records a plan, replacing the one before it.
254//
255// An agent republishes the whole plan every time a step changes, so appending
256// would leave five copies of a four-line plan in the window.
257func (t *Transcript) addPlan(u Update) {
258 t.closeRun()
259
260 if at := t.lastPlan(); at >= 0 {
261 t.entries[at].Plan = u.Entries
262 return
263 }
264 t.entries = append(t.entries, Entry{Kind: EntryPlan, Plan: u.Entries})
265}
266
267// lastPlan returns where the plan sits, or -1.
268func (t *Transcript) lastPlan() int {
269 for i := len(t.entries) - 1; i >= 0; i-- {
270 if t.entries[i].Kind == EntryPlan {
271 return i
272 }
273 }
274 return -1
275}
276
277// toolOutput returns the text a tool call's content carries.
278func toolOutput(u Update) string {
279 var parts []string
280 for _, block := range u.Blocks() {
281 switch {
282 case block.Content.Text != "":
283 parts = append(parts, block.Content.Text)
284 case block.Type == "diff" && block.Path != "":
285 parts = append(parts, "edited "+block.Path)
286 }
287 }
288 return strings.Join(parts, "\n")
289}
290
291// summarise turns a tool's raw input into the one line a reader wants: the
292// command, or the path, or nothing.
293//
294// It reads a handful of well-known keys rather than pretty-printing the JSON.
295// A reader wants to see `ls -1`, not `{"cmd":"ls -1","cwd":".","timeout":30}`,
296// and an input with none of these keys is better summarised by silence than by
297// a brace.
298func summarise(raw []byte) string {
299 if len(raw) == 0 {
300 return ""
301 }
302
303 fields := decodeObject(raw)
304 for _, key := range []string{"cmd", "command", "path", "file_path", "pattern", "query", "url"} {
305 if value, ok := fields[key]; ok && value != "" {
306 return oneLine(value)
307 }
308 }
309 return ""
310}
311
312// decodeObject reads a JSON object into the string form of each of its values.
313//
314// A tool's raw input is whatever the agent's tool schema says, so the values
315// are of every type; numbers and booleans are rendered rather than dropped so
316// that a summary key holding one still says something.
317func decodeObject(raw []byte) map[string]string {
318 var fields map[string]any
319 if err := json.Unmarshal(raw, &fields); err != nil {
320 return nil
321 }
322
323 out := make(map[string]string, len(fields))
324 for key, value := range fields {
325 switch typed := value.(type) {
326 case string:
327 out[key] = typed
328 case float64:
329 out[key] = strconv.FormatFloat(typed, 'f', -1, 64)
330 case bool:
331 out[key] = strconv.FormatBool(typed)
332 }
333 }
334 return out
335}
336
337// oneLine flattens text to a single line, so a multi-line command cannot
338// break the layout of a summary.
339func oneLine(text string) string {
340 return strings.Join(strings.Fields(strings.ReplaceAll(text, "\n", " ")), " ")
341}
342
343// splitLines splits text into lines, keeping neither the newlines nor a
344// phantom empty line after a trailing one.
345func splitLines(text string) []string {
346 if text == "" {
347 return nil
348 }
349 lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
350 if last := len(lines) - 1; lines[last] == "" {
351 lines = lines[:last]
352 }
353 return lines
354}
355
356// joinLines puts lines back together, with a trailing newline when there is
357// anything at all — which is what a text file has.
358func joinLines(lines []string) string {
359 if len(lines) == 0 {
360 return ""
361 }
362 return strings.Join(lines, "\n") + "\n"
363}
364
365// capped truncates text that has grown past the limit, saying so.
366func capped(text string) string {
367 if len(text) <= MaxEntryBytes {
368 return text
369 }
370 return text[:MaxEntryBytes] + "\n… (truncated)"
371}
372
373// joinNonEmpty joins two pieces with a newline, skipping either if it is empty.
374func joinNonEmpty(first, second string) string {
375 switch {
376 case first == "":
377 return second
378 case second == "":
379 return first
380 default:
381 return first + "\n" + second
382 }
383}
384
385// firstNonEmpty returns the first of its arguments that is not empty.
386func firstNonEmpty(values ...string) string {
387 for _, value := range values {
388 if value != "" {
389 return value
390 }
391 }
392 return ""
393}