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