bots-garden/mini-mepublic Fork 0
main
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.

💾 Saved. d722711 · on main · k33g · 3h ago
sink.go · 142 lines · 5.2 KBGo Blame HistoryRaw
  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
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
	}
}