package serve
import (
"context"
"strings"
"sync"
"time"
"codey/internal/acp"
"codey/internal/agent"
"codey/internal/approval"
)
// flushAfter and flushBytes are when buffered text goes out. One notification
// per token would be correct and unusable: T3 Code buffers inbound
// notifications in a queue 32 deep and drops the oldest when it overflows, so
// a fast model would lose the middle of its own sentence. Coalescing is what
// keeps us clear of that, and 50 ms is still below what reads as lag.
const (
flushAfter = 50 * time.Millisecond
flushBytes = 512
)
// The same idea for a tool that is still running, with a slower hand. Each
// update carries the whole output so far rather than a delta, so they cost
// more than a text chunk does and are worth less often: a progress view is
// read at a glance, not word by word.
//
// progressMaxBytes is the tail kept. The client bounds what it accepts at
// 8,000 characters and keeps the end; matching that here means the bound costs
// one truncation rather than two.
const (
progressAfter = 200 * time.Millisecond
progressBytes = 256
progressMaxBytes = 8000
)
// emitter turns one turn's [agent.Event] stream into session/update
// notifications.
//
// It is called from the agent's own goroutine, one event at a time, and holds
// a mutex anyway: the final flush comes from whichever goroutine ended the
// turn, and that is a different one.
type emitter struct {
send func(acp.Update)
now func() time.Time
afterTime time.Duration
afterSize int
mu sync.Mutex
buf strings.Builder
since time.Time
// anon names tool calls the model did not give an id to — the hosted
// tools, whose result blocks can arrive after a pause split them from
// their call. Dispatch is sequential, so the last one started is the one a
// result with no id belongs to.
anonN int
lastAnon string
// current is the call most recently announced, which is the one an
// approval prompt is about: the loop emits tool_start and then asks, one
// call at a time.
current string
// running accumulates the output of tool calls that have not finished,
// keyed by call id, with sentLen recording how much of each was last sent.
// Both are dropped when the call completes.
running map[string]*strings.Builder
sentLen map[string]int
lastProgress time.Time
}
// currentCall is the id of the tool call an approval prompt belongs to.
func (e *emitter) currentCall() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.current
}
func newEmitter(send func(acp.Update)) *emitter {
return &emitter{
send: send,
now: time.Now,
afterTime: flushAfter,
afterSize: flushBytes,
running: make(map[string]*strings.Builder),
sentLen: make(map[string]int),
}
}
// handle is the [agent.Agent] observer.
func (e *emitter) handle(ev agent.Event) {
switch ev.Kind {
case agent.EventTextDelta:
e.appendText(ev.Text)
case agent.EventText:
// The non-streaming path: the whole message at once.
e.Flush()
e.chunk(acp.UpdateAgentMessageChunk, ev.Text)
case agent.EventToolStart:
// Anything buffered is said before the tool runs. Otherwise a sentence
// written just before a thirty-second command would sit in the buffer
// until the command finished, and the user would watch a tool run for
// no stated reason.
e.Flush()
e.toolCall(ev)
case agent.EventToolDelta:
e.toolProgress(ev)
case agent.EventToolResult:
e.Flush()
e.toolResult(ev)
case agent.EventNotice, agent.EventCompact:
// The loop talking about the run rather than about the task. It goes
// in the transcript because there is nowhere else for it to go, and
// silence here is how a compaction becomes a mystery.
if ev.Text != "" {
e.Flush()
e.chunk(acp.UpdateAgentMessageChunk, ev.Text)
}
}
}
func (e *emitter) appendText(text string) {
if text == "" {
return
}
e.mu.Lock()
if e.buf.Len() == 0 {
e.since = e.now()
}
e.buf.WriteString(text)
ready := e.buf.Len() >= e.afterSize || e.now().Sub(e.since) >= e.afterTime
var out string
if ready {
out = e.takeLocked()
}
e.mu.Unlock()
if out != "" {
e.chunk(acp.UpdateAgentMessageChunk, out)
}
}
// Flush sends whatever text is buffered. It is safe to call when there is
// none, and must be called once more when the turn ends: the last delta of a
// message is usually short and recent, so nothing else would push it out.
func (e *emitter) Flush() {
e.mu.Lock()
out := e.takeLocked()
e.mu.Unlock()
if out != "" {
e.chunk(acp.UpdateAgentMessageChunk, out)
}
}
func (e *emitter) takeLocked() string {
out := e.buf.String()
e.buf.Reset()
return out
}
func (e *emitter) chunk(kind, text string) {
e.send(acp.Update{SessionUpdate: kind, Content: acp.TextContent(text)})
}
func (e *emitter) toolCall(ev agent.Event) {
desc := approval.Describe(ev.Tool, ev.Input)
id := e.callID(ev, true)
e.mu.Lock()
e.current = id
e.mu.Unlock()
e.send(acp.Update{
SessionUpdate: acp.UpdateToolCall,
ToolCallID: id,
Title: title(desc),
Kind: toolKind(ev.Tool),
Status: acp.StatusInProgress,
RawInput: ev.Input,
})
}
// toolProgress reports output a tool is still producing.
//
// The protocol has no delta for this: a tool_call_update replaces the content
// of the call it names rather than appending to it, so each one carries
// everything so far. That is the shape clients expect — and defend against, by
// keeping only a bounded tail — so the bound is applied here too rather than
// making somebody else pay for our chattiness.
//
// Throttling is ours as well. A build writing a line at a time would otherwise
// be one notification per line, each carrying the whole log.
func (e *emitter) toolProgress(ev agent.Event) {
if ev.Text == "" {
return
}
id := e.callID(ev, false)
e.mu.Lock()
if e.running == nil {
e.running = make(map[string]*strings.Builder)
}
buf, ok := e.running[id]
if !ok {
buf = &strings.Builder{}
e.running[id] = buf
}
buf.WriteString(ev.Text)
grown := buf.Len() - e.sentLen[id]
due := grown >= progressBytes || e.now().Sub(e.lastProgress) >= progressAfter
var out string
if due {
out = tailOf(buf.String(), progressMaxBytes)
e.sentLen[id] = buf.Len()
e.lastProgress = e.now()
}
e.mu.Unlock()
if out == "" {
return
}
e.send(acp.Update{
SessionUpdate: acp.UpdateToolCallUpdate,
ToolCallID: id,
Status: acp.StatusInProgress,
Content: []acp.ToolCallContent{acp.ToolCallText(out)},
})
}
func (e *emitter) toolResult(ev agent.Event) {
status := acp.StatusCompleted
if ev.IsError {
status = acp.StatusFailed
}
id := e.callID(ev, false)
e.mu.Lock()
delete(e.running, id)
delete(e.sentLen, id)
e.mu.Unlock()
// The result replaces whatever progress said, which is what we want: the
// report a tool ends with is capped head and tail, where the live view was
// a tail alone.
e.send(acp.Update{
SessionUpdate: acp.UpdateToolCallUpdate,
ToolCallID: id,
Status: status,
Content: []acp.ToolCallContent{acp.ToolCallText(ev.Result)},
})
}
// tailOf keeps the end of a live-growing output, which is the part worth
// watching, and says that it did so.
func tailOf(s string, max int) string {
if len(s) <= max {
return s
}
return "[earlier output truncated]\n\n" + s[len(s)-max:]
}
func (e *emitter) callID(ev agent.Event, start bool) string {
if ev.ID != "" {
return ev.ID
}
e.mu.Lock()
defer e.mu.Unlock()
if start {
e.anonN++
e.lastAnon = "call-" + itoa(e.anonN)
}
if e.lastAnon == "" {
e.lastAnon = "call-0"
}
return e.lastAnon
}
// title is the one line a client shows beside the tool row. The summary
// already says what will happen; the detail is the argument that makes it
// specific, and a call without one is better off saying nothing than saying
// an empty pair of quotes.
func title(desc approval.Request) string {
detail := strings.TrimSpace(desc.Detail)
if detail == "" {
return desc.Summary
}
if i := strings.IndexByte(detail, '\n'); i >= 0 {
detail = detail[:i] + " …"
}
const max = 120
if len(detail) > max {
detail = detail[:max] + " …"
}
return desc.Summary + ": " + detail
}
// toolKind maps Codey's tools onto ACP's vocabulary, which a client uses to
// choose an icon and decide what to fold away. An unmapped tool is "other",
// which is a fine thing to be.
func toolKind(tool string) string {
switch tool {
case "read_file", "skill":
return acp.KindRead
case "glob", "grep":
return acp.KindSearch
case "edit_file", "write_file":
return acp.KindEdit
case "bash":
return acp.KindExecute
case "web_search", "web_fetch":
return acp.KindFetch
default:
return acp.KindOther
}
}
// itoa avoids pulling strconv in for one call site.
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// notifier is what an emitter sends through: the connection, bound to one
// session id.
func notifier(conn *acp.Conn, sessionID string) func(acp.Update) {
return func(u acp.Update) {
// A notification that cannot be written means the connection is gone,
// and the turn will find that out on its next move. There is nowhere
// useful to report it from here.
_ = conn.Notify(context.Background(), acp.MethodSessionUpdate,
acp.SessionNotification{SessionID: sessionID, Update: u})
}
}