| 🛟 Updated. 28d5985 k33g 15h ago | 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // EntryKind says what one entry of a conversation is. |
| 10 | type EntryKind int |
| 11 | |
| 12 | // The kinds of entry a conversation holds. |
| 13 | const ( |
| 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. |
| 35 | type 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. |
| 59 | func (e Entry) Done() bool { return e.Status == StatusCompleted || e.Status == StatusFailed } |
| 60 | |
| 61 | // Failed reports whether a tool call ended badly. |
| 62 | func (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. |
| 67 | const 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() { … } |
| 82 | type 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. |
| 96 | func (t *Transcript) SetAgentName(name string) { t.agentName = name } |
| 97 | |
| 98 | // AgentName returns the label the agent's messages carry. |
| 99 | func (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. |
| 106 | func (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. |
| 113 | func (t *Transcript) Len() int { return len(t.entries) } |
| 114 | |
| 115 | // AddUser records something you typed. |
| 116 | func (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. |
| 125 | func (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) |
| 138 | func (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. |
| 161 | func (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. |
| 179 | func (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. |
| 186 | func (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. |
| 210 | func (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. |
| 220 | func (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. |
| 241 | func (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. |
| 257 | func (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. |
| 268 | func (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. |
| 278 | func 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. |
| 298 | func 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. |
| 317 | func 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. |
| 339 | func 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. |
| 345 | func 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. |
| 358 | func 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. |
| 366 | func 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. |
| 374 | func 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. |
| 386 | func firstNonEmpty(values ...string) string { |
| 387 | for _, value := range values { |
| 388 | if value != "" { |
| 389 | return value |
| 390 | } |
| 391 | } |
| 392 | return "" |
| 393 | } |