bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

sink.go · 142 lines · 5.2 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1package acp
2
3import (
4 "context"
5
6 "mm/internal/ui"
7
8 sdk "github.com/coder/acp-go-sdk"
9)
10
11// sink translates bob's tool events (ui.Sink) into ACP messages for one turn.
12// It is the ACP counterpart of what the terminal front end does with fmt: Text
13// is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the
14// client renders them, so they travel as structure, not as emojis.
15type sink struct {
16 f *front
17 ctx context.Context // the turn's context: cancelled by session/cancel
18 sid sdk.SessionId
19 s *session
20}
21
22var _ ui.Sink = (*sink)(nil)
23
24// send pushes one session/update. Errors are dropped on purpose: a
25// notification that cannot be written means the client is gone, and the turn's
26// context is about to be cancelled anyway — there is nobody left to tell.
27func (k *sink) send(u sdk.SessionUpdate) {
28 _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u})
29}
30
31// Text streams one chunk of the model's answer. The raw chunk, whitespace
32// included: the editor renders Markdown and owns the layout, so the terminal's
33// gap/squeeze machinery (which fights the screen, not the text) stays out.
34func (k *sink) Text(text string) {
35 k.send(sdk.UpdateAgentMessageText(text))
36}
37
38// WorkDir is the session's cwd — the project the editor opened. bash runs
39// there, and relative file paths resolve against it (ui.Resolve).
40func (k *sink) WorkDir() string { return k.s.cwd }
41
42// ToolStart announces the call, status pending, before anything runs — the
43// same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its
44// absence proves nothing ran.
45func (k *sink) ToolStart(ev ui.ToolEvent) {
46 opts := []sdk.ToolCallStartOpt{
47 sdk.WithStartKind(toolKind(ev.Kind)),
48 sdk.WithStartStatus(sdk.ToolCallStatusPending),
49 sdk.WithStartRawInput(ev.Input),
50 }
51 if ev.Path != "" {
52 // The location is what lets the editor "follow the agent" — jump to
53 // the file the tool is touching. Absolute, per the spec.
54 opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}}))
55 }
56 k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...))
57}
58
59// Allow asks the user, through the editor, before the tool runs. This dialog
60// simply does not exist in the terminal front end — it is the main thing an
61// editor adds — so the policy lives here, not in the tools: "allow always" is
62// remembered per tool for the rest of the session.
63func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool {
64 if k.s.allowAlways[ev.Tool] {
65 return true
66 }
67 resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{
68 SessionId: k.sid,
69 ToolCall: sdk.ToolCallUpdate{
70 ToolCallId: sdk.ToolCallId(ev.ID),
71 Title: sdk.Ptr(ev.Title),
72 Kind: sdk.Ptr(toolKind(ev.Kind)),
73 Status: sdk.Ptr(sdk.ToolCallStatusPending),
74 RawInput: ev.Input,
75 },
76 Options: []sdk.PermissionOption{
77 {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce},
78 {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways},
79 {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce},
80 },
81 })
82 // An error or a cancelled outcome both mean "do not run": the client hung
83 // up, or the user hit stop while the dialog was open — the spec requires
84 // the client to answer "cancelled" in that case.
85 if err != nil || resp.Outcome.Cancelled != nil {
86 return false
87 }
88 if sel := resp.Outcome.Selected; sel != nil {
89 switch string(sel.OptionId) {
90 case "allow":
91 return true
92 case "allow_always":
93 k.s.allowAlways[ev.Tool] = true
94 return true
95 }
96 }
97 return false
98}
99
100// ToolRunning flips the call to in_progress: permission granted, executing.
101func (k *sink) ToolRunning(id string) {
102 k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress)))
103}
104
105// ToolEnd closes the call. A file change travels as ACP diff content — that is
106// what the editor renders as a real diff; anything else ships its output as
107// text content, the equivalent of the terminal's grey echo. rawOutput carries
108// the output either way, for clients that show the raw exchange.
109func (k *sink) ToolEnd(id string, res ui.ToolResult) {
110 status := sdk.ToolCallStatusCompleted
111 if res.Failed {
112 status = sdk.ToolCallStatusFailed
113 }
114 var content []sdk.ToolCallContent
115 switch {
116 case res.Diff != nil && res.Diff.Created:
117 content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText))
118 case res.Diff != nil:
119 content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText))
120 case res.Output != "":
121 content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output)))
122 }
123 k.send(sdk.UpdateToolCall(sdk.ToolCallId(id),
124 sdk.WithUpdateStatus(status),
125 sdk.WithUpdateContent(content),
126 sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}),
127 ))
128}
129
130// toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only
131// drives how a client displays the call (icon, colour), so unknown values
132// default to execute rather than failing.
133func toolKind(kind string) sdk.ToolKind {
134 switch kind {
135 case "read":
136 return sdk.ToolKindRead
137 case "edit":
138 return sdk.ToolKindEdit
139 default:
140 return sdk.ToolKindExecute
141 }
142}