nandi/oripublic Fork 0
76d62ac0da1600e1c208017584c3fa22e54feaa3
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
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday1// Package mockagent implements a small, deterministic ACP agent used to
2// exercise and demo ori without any real AI agent behind it: it streams a
3// thought, a plan, tool calls (including a file diff), asks one permission,
4// then answers. The ori-mock-agent command exposes it on stdio.
5package mockagent
6
7import (
8 "context"
9 "fmt"
10 "io"
11 "strconv"
12 "sync"
13 "time"
14
15 acp "github.com/coder/acp-go-sdk"
16)
17
18// Agent is the mock ACP agent. The embedded acp.Agent interface covers the
19// protocol methods ori's client never calls (session/load, logout, ...);
20// invoking one of them would panic, which is acceptable for a test double.
21type Agent struct {
22 acp.Agent
23
24 // Delay spaces the streamed updates out so a human watching the UI sees
25 // the streaming happen. Tests keep it at zero.
26 Delay time.Duration
27
28 conn *acp.AgentSideConnection
29 mu sync.Mutex
30 sessions int
31}
32
33// New returns a mock agent; wire it with SetAgentConnection before use.
34func New() *Agent { return &Agent{} }
35
36// SetAgentConnection injects the connection used to push updates and ask
37// permissions. The SDK does not do this automatically.
38func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn }
39
40// Run serves the mock agent over the given transport (stdio in the command)
41// and blocks until the peer disconnects.
42//
43// Example:
44//
45// agent := mockagent.New()
46// if err := agent.Run(os.Stdin, os.Stdout); err != nil {
47// log.Fatal(err)
48// }
49func (a *Agent) Run(in io.Reader, out io.Writer) error {
50 conn := acp.NewAgentSideConnection(a, out, in)
51 a.SetAgentConnection(conn)
52 <-conn.Done()
53 return nil
54}
55
56func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
57 return acp.InitializeResponse{ProtocolVersion: params.ProtocolVersion}, nil
58}
59
60func (a *Agent) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
61 return acp.AuthenticateResponse{}, nil
62}
63
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday64// NewSession opens a session and, like the Claude Code adapter, announces
65// the slash commands the agent understands before answering, so the panel's
66// "/" selector can be exercised without a real agent.
67func (a *Agent) NewSession(ctx context.Context, _ acp.NewSessionRequest) (acp.NewSessionResponse, error) {
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday68 a.mu.Lock()
69 a.sessions++
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday70 sessionId := acp.SessionId("mock-" + strconv.Itoa(a.sessions))
71 a.mu.Unlock()
72
73 if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{SessionId: sessionId, Update: availableCommands()}); err != nil {
74 return acp.NewSessionResponse{}, fmt.Errorf("announce commands: %w", err)
75 }
76 return acp.NewSessionResponse{SessionId: sessionId}, nil
77}
78
79// availableCommands is the mock's available_commands_update payload.
80func availableCommands() acp.SessionUpdate {
81 return acp.SessionUpdate{AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{
82 SessionUpdate: "available_commands_update",
83 AvailableCommands: []acp.AvailableCommand{
84 {Name: "review", Description: "Review the pending changes", Input: &acp.AvailableCommandInput{Unstructured: &acp.UnstructuredCommandInput{Hint: "optional focus"}}},
85 {Name: "compact", Description: "Summarise the conversation so far"},
86 },
87 }}
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday88}
89
90// Cancel is a no-op: the SDK already cancels the context of the running
91// Prompt when session/cancel arrives, which the scenario checks at each step.
92func (a *Agent) Cancel(context.Context, acp.CancelNotification) error { return nil }
93
94// Prompt plays the demo scenario and streams it to the client.
95func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
96 scenario := &scenario{agent: a, ctx: ctx, sessionId: params.SessionId, prompt: promptText(params)}
97 stop, err := scenario.play()
98 if err != nil {
99 if ctx.Err() != nil {
100 return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil
101 }
102 return acp.PromptResponse{}, err
103 }
104 return acp.PromptResponse{StopReason: stop}, nil
105}
106
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday107// promptText joins the text blocks of the user's prompt and names the
108// attached resource links, so a demo shows the @-mentions reached the agent.
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday109func promptText(params acp.PromptRequest) string {
110 text := ""
111 for _, block := range params.Prompt {
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday112 switch {
113 case block.Text != nil:
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday114 text += block.Text.Text
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday115 case block.ResourceLink != nil:
116 text += " [attached: " + block.ResourceLink.Name + "]"
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday117 }
118 }
119 return text
120}
121
122// scenario streams one full demo turn step by step.
123type scenario struct {
124 agent *Agent
125 ctx context.Context
126 sessionId acp.SessionId
127 prompt string
128}
129
130func (s *scenario) play() (acp.StopReason, error) {
131 if err := s.runSteps(s.sayThought, s.sendPlan, s.sendReadToolCall, s.sayIntro); err != nil {
132 return "", err
133 }
134
135 allowed, err := s.askPermission()
136 if err != nil {
137 return "", err
138 }
139 if !allowed {
140 return acp.StopReasonRefusal, s.runSteps(s.sayRefused)
141 }
142
143 return acp.StopReasonEndTurn, s.runSteps(s.sendEditToolCall, s.saySummary, s.completePlan)
144}
145
146func (s *scenario) sayThought() error {
147 return s.update(acp.UpdateAgentThoughtText("The user wants a demo. Let me show every panel feature."))
148}
149
150func (s *scenario) sayIntro() error {
151 return s.update(acp.UpdateAgentMessageText("I looked at the project. "))
152}
153
154func (s *scenario) sayRefused() error {
155 return s.update(acp.UpdateAgentMessageText("Understood, I will not write the file."))
156}
157
158func (s *scenario) saySummary() error {
159 return s.update(acp.UpdateAgentMessageText(fmt.Sprintf("Done! You said: **%s**\n\n- streamed thought ✅\n- plan ✅\n- tool calls ✅\n- permission ✅", s.prompt)))
160}
161
162// runSteps executes scenario actions in order, stopping at the first error.
163func (s *scenario) runSteps(steps ...func() error) error {
164 for _, step := range steps {
165 if err := s.step(step); err != nil {
166 return err
167 }
168 }
169 return nil
170}
171
172// step runs one scenario action, respecting cancellation and pacing.
173func (s *scenario) step(action func() error) error {
174 if err := s.ctx.Err(); err != nil {
175 return err
176 }
177 if s.agent.Delay > 0 {
178 select {
179 case <-time.After(s.agent.Delay):
180 case <-s.ctx.Done():
181 return s.ctx.Err()
182 }
183 }
184 return action()
185}
186
187func (s *scenario) update(u acp.SessionUpdate) error {
188 return s.agent.conn.SessionUpdate(s.ctx, acp.SessionNotification{SessionId: s.sessionId, Update: u})
189}
190
191func (s *scenario) sendPlan() error {
192 return s.update(acp.UpdatePlan(
193 acp.PlanEntry{Content: "Inspect the project", Priority: acp.PlanEntryPriorityHigh, Status: acp.PlanEntryStatusInProgress},
194 acp.PlanEntry{Content: "Write hello.txt", Priority: acp.PlanEntryPriorityMedium, Status: acp.PlanEntryStatusPending},
195 ))
196}
197
198func (s *scenario) completePlan() error {
199 return s.update(acp.UpdatePlan(
200 acp.PlanEntry{Content: "Inspect the project", Priority: acp.PlanEntryPriorityHigh, Status: acp.PlanEntryStatusCompleted},
201 acp.PlanEntry{Content: "Write hello.txt", Priority: acp.PlanEntryPriorityMedium, Status: acp.PlanEntryStatusCompleted},
202 ))
203}
204
205func (s *scenario) sendReadToolCall() error {
206 if err := s.update(acp.StartToolCall(
207 "read-1", "Reading README.md",
208 acp.WithStartKind(acp.ToolKindRead),
209 acp.WithStartStatus(acp.ToolCallStatusInProgress),
210 acp.WithStartLocations([]acp.ToolCallLocation{{Path: "README.md"}}),
211 )); err != nil {
212 return err
213 }
214 return s.step(func() error {
215 return s.update(acp.UpdateToolCall(
216 "read-1",
217 acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
218 acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("# Ori\n> demo project"))}),
219 ))
220 })
221}
222
223func (s *scenario) sendEditToolCall() error {
224 if err := s.update(acp.StartToolCall(
225 "edit-1", "Writing hello.txt",
226 acp.WithStartKind(acp.ToolKindEdit),
227 acp.WithStartStatus(acp.ToolCallStatusInProgress),
228 acp.WithStartLocations([]acp.ToolCallLocation{{Path: "hello.txt"}}),
229 )); err != nil {
230 return err
231 }
232 return s.step(func() error {
233 return s.update(acp.UpdateToolCall(
234 "edit-1",
235 acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
236 acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolDiffContent("hello.txt", "hello from the ori mock agent\n")}),
237 ))
238 })
239}
240
241// askPermission asks the client whether the mock may "write" a file and
242// interprets the outcome.
243func (s *scenario) askPermission() (bool, error) {
244 resp, err := s.agent.conn.RequestPermission(s.ctx, acp.RequestPermissionRequest{
245 SessionId: s.sessionId,
246 ToolCall: acp.ToolCallUpdate{ToolCallId: "edit-1", Title: strPtr("Write hello.txt")},
247 Options: []acp.PermissionOption{
248 {OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce},
249 {OptionId: "reject-once", Name: "Reject", Kind: acp.PermissionOptionKindRejectOnce},
250 },
251 })
252 if err != nil {
253 return false, err
254 }
255 return resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow-once", nil
256}
257
258func strPtr(s string) *string { return &s }