package acp import ( "context" "mm/internal/ui" sdk "github.com/coder/acp-go-sdk" ) // sink translates bob's tool events (ui.Sink) into ACP messages for one turn. // It is the ACP counterpart of what the terminal front end does with fmt: Text // is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the // client renders them, so they travel as structure, not as emojis. type sink struct { f *front ctx context.Context // the turn's context: cancelled by session/cancel sid sdk.SessionId s *session } var _ ui.Sink = (*sink)(nil) // send pushes one session/update. Errors are dropped on purpose: a // notification that cannot be written means the client is gone, and the turn's // context is about to be cancelled anyway — there is nobody left to tell. func (k *sink) send(u sdk.SessionUpdate) { _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u}) } // Text streams one chunk of the model's answer. The raw chunk, whitespace // included: the editor renders Markdown and owns the layout, so the terminal's // gap/squeeze machinery (which fights the screen, not the text) stays out. func (k *sink) Text(text string) { k.send(sdk.UpdateAgentMessageText(text)) } // WorkDir is the session's cwd — the project the editor opened. bash runs // there, and relative file paths resolve against it (ui.Resolve). func (k *sink) WorkDir() string { return k.s.cwd } // ToolStart announces the call, status pending, before anything runs — the // same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its // absence proves nothing ran. func (k *sink) ToolStart(ev ui.ToolEvent) { opts := []sdk.ToolCallStartOpt{ sdk.WithStartKind(toolKind(ev.Kind)), sdk.WithStartStatus(sdk.ToolCallStatusPending), sdk.WithStartRawInput(ev.Input), } if ev.Path != "" { // The location is what lets the editor "follow the agent" — jump to // the file the tool is touching. Absolute, per the spec. opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}})) } k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...)) } // Allow asks the user, through the editor, before the tool runs. This dialog // simply does not exist in the terminal front end — it is the main thing an // editor adds — so the policy lives here, not in the tools: "allow always" is // remembered per tool for the rest of the session. func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool { if k.s.allowAlways[ev.Tool] { return true } resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{ SessionId: k.sid, ToolCall: sdk.ToolCallUpdate{ ToolCallId: sdk.ToolCallId(ev.ID), Title: sdk.Ptr(ev.Title), Kind: sdk.Ptr(toolKind(ev.Kind)), Status: sdk.Ptr(sdk.ToolCallStatusPending), RawInput: ev.Input, }, Options: []sdk.PermissionOption{ {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce}, {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways}, {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce}, }, }) // An error or a cancelled outcome both mean "do not run": the client hung // up, or the user hit stop while the dialog was open — the spec requires // the client to answer "cancelled" in that case. if err != nil || resp.Outcome.Cancelled != nil { return false } if sel := resp.Outcome.Selected; sel != nil { switch string(sel.OptionId) { case "allow": return true case "allow_always": k.s.allowAlways[ev.Tool] = true return true } } return false } // ToolRunning flips the call to in_progress: permission granted, executing. func (k *sink) ToolRunning(id string) { k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress))) } // ToolEnd closes the call. A file change travels as ACP diff content — that is // what the editor renders as a real diff; anything else ships its output as // text content, the equivalent of the terminal's grey echo. rawOutput carries // the output either way, for clients that show the raw exchange. func (k *sink) ToolEnd(id string, res ui.ToolResult) { status := sdk.ToolCallStatusCompleted if res.Failed { status = sdk.ToolCallStatusFailed } var content []sdk.ToolCallContent switch { case res.Diff != nil && res.Diff.Created: content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText)) case res.Diff != nil: content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText)) case res.Output != "": content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output))) } k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(status), sdk.WithUpdateContent(content), sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}), )) } // toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only // drives how a client displays the call (icon, colour), so unknown values // default to execute rather than failing. func toolKind(kind string) sdk.ToolKind { switch kind { case "read": return sdk.ToolKindRead case "edit": return sdk.ToolKindEdit default: return sdk.ToolKindExecute } }