// 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 } func (a *Agent) NewSession(context.Context, acp.NewSessionRequest) (acp.NewSessionResponse, error) { a.mu.Lock() defer a.mu.Unlock() a.sessions++ return acp.NewSessionResponse{SessionId: acp.SessionId("mock-" + strconv.Itoa(a.sessions))}, nil } // 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. func promptText(params acp.PromptRequest) string { text := "" for _, block := range params.Prompt { if block.Text != nil { text += block.Text.Text } } 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 }