nandi/oripublic Fork 0
35061753be581c0ba47a3189520d64e7788c183d
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

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

forked from bots-garden/ori

mockagent.go · 258 lines · 8.6 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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
// Package mockagent implements a small, deterministic ACP agent used to
// exercise and demo ori without any real AI agent behind it: it streams a
// thought, a plan, tool calls (including a file diff), asks one permission,
// then answers. The ori-mock-agent command exposes it on stdio.
package mockagent

import (
	"context"
	"fmt"
	"io"
	"strconv"
	"sync"
	"time"

	acp "github.com/coder/acp-go-sdk"
)

// Agent is the mock ACP agent. The embedded acp.Agent interface covers the
// protocol methods ori's client never calls (session/load, logout, ...);
// invoking one of them would panic, which is acceptable for a test double.
type Agent struct {
	acp.Agent

	// Delay spaces the streamed updates out so a human watching the UI sees
	// the streaming happen. Tests keep it at zero.
	Delay time.Duration

	conn     *acp.AgentSideConnection
	mu       sync.Mutex
	sessions int
}

// New returns a mock agent; wire it with SetAgentConnection before use.
func New() *Agent { return &Agent{} }

// SetAgentConnection injects the connection used to push updates and ask
// permissions. The SDK does not do this automatically.
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn }

// Run serves the mock agent over the given transport (stdio in the command)
// and blocks until the peer disconnects.
//
// Example:
//
//	agent := mockagent.New()
//	if err := agent.Run(os.Stdin, os.Stdout); err != nil {
//		log.Fatal(err)
//	}
func (a *Agent) Run(in io.Reader, out io.Writer) error {
	conn := acp.NewAgentSideConnection(a, out, in)
	a.SetAgentConnection(conn)
	<-conn.Done()
	return nil
}

func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
	return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil
}

func (a *Agent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
	return acp.AuthenticateResponse{}, nil
}

// NewSession opens a session and, like the Claude Code adapter, announces
// the slash commands the agent understands before answering, so the panel's
// "/" selector can be exercised without a real agent.
func (a *Agent) NewSession(ctx context.Context, _ acp.NewSessionRequest) (acp.NewSessionResponse, error) {
	a.mu.Lock()
	a.sessions++
	sessionId := acp.SessionId("mock-" + strconv.Itoa(a.sessions))
	a.mu.Unlock()

	if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{SessionId: sessionId, Update: availableCommands()}); err != nil {
		return acp.NewSessionResponse{}, fmt.Errorf("announce commands: %w", err)
	}
	return acp.NewSessionResponse{SessionId: sessionId}, nil
}

// availableCommands is the mock's available_commands_update payload.
func availableCommands() acp.SessionUpdate {
	return acp.SessionUpdate{AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{
		SessionUpdate: "available_commands_update",
		AvailableCommands: []acp.AvailableCommand{
			{Name: "review", Description: "Review the pending changes", Input: &acp.AvailableCommandInput{Unstructured: &acp.UnstructuredCommandInput{Hint: "optional focus"}}},
			{Name: "compact", Description: "Summarise the conversation so far"},
		},
	}}
}

// Cancel is a no-op: the SDK already cancels the context of the running
// Prompt when session/cancel arrives, which the scenario checks at each step.
func (a *Agent) Cancel(context.Context, acp.CancelNotification) error { return nil }

// Prompt plays the demo scenario and streams it to the client.
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
	scenario := &scenario{agent: a, ctx: ctx, sessionId: params.SessionId, prompt: promptText(params)}
	stop, err := scenario.play()
	if err != nil {
		if ctx.Err() != nil {
			return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil
		}
		return acp.PromptResponse{}, err
	}
	return acp.PromptResponse{StopReason: stop}, nil
}

// promptText joins the text blocks of the user's prompt and names the
// attached resource links, so a demo shows the @-mentions reached the agent.
func promptText(params acp.PromptRequest) string {
	text := ""
	for _, block := range params.Prompt {
		switch {
		case block.Text != nil:
			text += block.Text.Text
		case block.ResourceLink != nil:
			text += " [attached: " + block.ResourceLink.Name + "]"
		}
	}
	return text
}

// scenario streams one full demo turn step by step.
type scenario struct {
	agent     *Agent
	ctx       context.Context
	sessionId acp.SessionId
	prompt    string
}

func (s *scenario) play() (acp.StopReason, error) {
	if err := s.runSteps(s.sayThought, s.sendPlan, s.sendReadToolCall, s.sayIntro); err != nil {
		return "", err
	}

	allowed, err := s.askPermission()
	if err != nil {
		return "", err
	}
	if !allowed {
		return acp.StopReasonRefusal, s.runSteps(s.sayRefused)
	}

	return acp.StopReasonEndTurn, s.runSteps(s.sendEditToolCall, s.saySummary, s.completePlan)
}

func (s *scenario) sayThought() error {
	return s.update(acp.UpdateAgentThoughtText("The user wants a demo. Let me show every panel feature."))
}

func (s *scenario) sayIntro() error {
	return s.update(acp.UpdateAgentMessageText("I looked at the project. "))
}

func (s *scenario) sayRefused() error {
	return s.update(acp.UpdateAgentMessageText("Understood, I will not write the file."))
}

func (s *scenario) saySummary() error {
	return s.update(acp.UpdateAgentMessageText(fmt.Sprintf("Done! You said: **%s**\n\n- streamed thought ✅\n- plan ✅\n- tool calls ✅\n- permission ✅", s.prompt)))
}

// runSteps executes scenario actions in order, stopping at the first error.
func (s *scenario) runSteps(steps ...func() error) error {
	for _, step := range steps {
		if err := s.step(step); err != nil {
			return err
		}
	}
	return nil
}

// step runs one scenario action, respecting cancellation and pacing.
func (s *scenario) step(action func() error) error {
	if err := s.ctx.Err(); err != nil {
		return err
	}
	if s.agent.Delay > 0 {
		select {
		case <-time.After(s.agent.Delay):
		case <-s.ctx.Done():
			return s.ctx.Err()
		}
	}
	return action()
}

func (s *scenario) update(u acp.SessionUpdate) error {
	return s.agent.conn.SessionUpdate(s.ctx, acp.SessionNotification{SessionId: s.sessionId, Update: u})
}

func (s *scenario) sendPlan() error {
	return s.update(acp.UpdatePlan(
		acp.PlanEntry{Content: "Inspect the project", Priority: acp.PlanEntryPriorityHigh, Status: acp.PlanEntryStatusInProgress},
		acp.PlanEntry{Content: "Write hello.txt", Priority: acp.PlanEntryPriorityMedium, Status: acp.PlanEntryStatusPending},
	))
}

func (s *scenario) completePlan() error {
	return s.update(acp.UpdatePlan(
		acp.PlanEntry{Content: "Inspect the project", Priority: acp.PlanEntryPriorityHigh, Status: acp.PlanEntryStatusCompleted},
		acp.PlanEntry{Content: "Write hello.txt", Priority: acp.PlanEntryPriorityMedium, Status: acp.PlanEntryStatusCompleted},
	))
}

func (s *scenario) sendReadToolCall() error {
	if err := s.update(acp.StartToolCall(
		"read-1", "Reading README.md",
		acp.WithStartKind(acp.ToolKindRead),
		acp.WithStartStatus(acp.ToolCallStatusInProgress),
		acp.WithStartLocations([]acp.ToolCallLocation{{Path: "README.md"}}),
	)); err != nil {
		return err
	}
	return s.step(func() error {
		return s.update(acp.UpdateToolCall(
			"read-1",
			acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
			acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("# Ori\n> demo project"))}),
		))
	})
}

func (s *scenario) sendEditToolCall() error {
	if err := s.update(acp.StartToolCall(
		"edit-1", "Writing hello.txt",
		acp.WithStartKind(acp.ToolKindEdit),
		acp.WithStartStatus(acp.ToolCallStatusInProgress),
		acp.WithStartLocations([]acp.ToolCallLocation{{Path: "hello.txt"}}),
	)); err != nil {
		return err
	}
	return s.step(func() error {
		return s.update(acp.UpdateToolCall(
			"edit-1",
			acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
			acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolDiffContent("hello.txt", "hello from the ori mock agent\n")}),
		))
	})
}

// askPermission asks the client whether the mock may "write" a file and
// interprets the outcome.
func (s *scenario) askPermission() (bool, error) {
	resp, err := s.agent.conn.RequestPermission(s.ctx, acp.RequestPermissionRequest{
		SessionId: s.sessionId,
		ToolCall:  acp.ToolCallUpdate{ToolCallId: "edit-1", Title: strPtr("Write hello.txt")},
		Options: []acp.PermissionOption{
			{OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce},
			{OptionId: "reject-once", Name: "Reject", Kind: acp.PermissionOptionKindRejectOnce},
		},
	})
	if err != nil {
		return false, err
	}
	return resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow-once", nil
}

func strPtr(s string) *string { return &s }