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 "" }