Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
379 lines
14 KiB
Go
379 lines
14 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
// Cursor exposes these as blocking JSON-RPC requests rather than ACP
|
|
// session/request_permission calls. Keep them on the same pending interaction
|
|
// state machine so the GUI, cancellation, and terminal-disposition paths do
|
|
// not diverge by provider.
|
|
type cursorAskQuestionRequest struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
Title string `json:"title"`
|
|
Questions []cursorAskQuestion `json:"questions"`
|
|
}
|
|
|
|
type cursorAskQuestion struct {
|
|
ID string `json:"id"`
|
|
Prompt string `json:"prompt"`
|
|
Question string `json:"question"`
|
|
Options []cursorQuestionOption `json:"options"`
|
|
AllowMultiple bool `json:"allowMultiple"`
|
|
}
|
|
|
|
type cursorQuestionOption struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type cursorCreatePlanRequest struct {
|
|
ToolCallID string `json:"toolCallId"`
|
|
Name string `json:"name"`
|
|
Overview string `json:"overview"`
|
|
Plan string `json:"plan"`
|
|
}
|
|
|
|
func (a *standardACPAdapter) handleCursorInteractiveMessage(
|
|
ctx context.Context,
|
|
client *acpClient,
|
|
session Session,
|
|
turnID string,
|
|
message acpMessage,
|
|
normalizer *acpTurnNormalizer,
|
|
emit EventSink,
|
|
) ([]activityshared.Event, error) {
|
|
if client == nil {
|
|
return nil, errors.New("cursor interactive request has no ACP client")
|
|
}
|
|
if normalizer == nil || strings.TrimSpace(turnID) == "" {
|
|
err := errors.New("cursor interactive request arrived outside an active prompt turn")
|
|
_ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32000, Message: err.Error()})
|
|
return nil, err
|
|
}
|
|
|
|
requestID := acpRequestID(message.ID)
|
|
if requestID == "" {
|
|
err := errors.New("cursor interactive request id is required")
|
|
_ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32602, Message: err.Error()})
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
kind string
|
|
toolName string
|
|
callID string
|
|
title string
|
|
input map[string]any
|
|
options []map[string]any
|
|
response string
|
|
)
|
|
switch message.Method {
|
|
case cursorACPMethodAskQuestion:
|
|
parsed, normalized, err := parseCursorAskQuestionRequest(message.Params)
|
|
if err != nil {
|
|
_ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32602, Message: err.Error()})
|
|
return nil, err
|
|
}
|
|
kind = "ask-user"
|
|
// Keep the provider-independent UI identity used by the existing
|
|
// AskUserQuestion surface; providerMethod below retains the native
|
|
// Cursor response contract.
|
|
toolName = "AskUserQuestion"
|
|
callID = parsed.ToolCallID
|
|
title = firstNonEmpty(parsed.Title, toolName)
|
|
input = normalized
|
|
response = cursorACPMethodAskQuestion
|
|
case cursorACPMethodCreatePlan:
|
|
parsed, normalized, planOptions, err := parseCursorCreatePlanRequest(message.Params)
|
|
if err != nil {
|
|
_ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32602, Message: err.Error()})
|
|
return nil, err
|
|
}
|
|
kind = "exit-plan"
|
|
toolName = "CreatePlan"
|
|
callID = parsed.ToolCallID
|
|
title = firstNonEmpty(parsed.Name, toolName)
|
|
input = normalized
|
|
options = planOptions
|
|
response = cursorACPMethodCreatePlan
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Cursor interactive method %q", message.Method)
|
|
}
|
|
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
input["requestId"] = requestID
|
|
if len(options) > 0 {
|
|
input["options"] = cloneOptionMaps(options)
|
|
}
|
|
prompt := &SessionInteractivePrompt{
|
|
Kind: kind,
|
|
RequestID: requestID,
|
|
ToolName: toolName,
|
|
Status: "waiting_input",
|
|
Input: clonePayload(input),
|
|
Metadata: map[string]any{
|
|
"callType": "interactive",
|
|
"interactiveKind": kind,
|
|
"toolName": toolName,
|
|
"providerMethod": response,
|
|
},
|
|
}
|
|
pending := &pendingACPApproval{
|
|
agentSessionID: strings.TrimSpace(session.AgentSessionID),
|
|
requestID: requestID,
|
|
eventID: newID(),
|
|
callID: callID,
|
|
callType: "interactive",
|
|
turnID: strings.TrimSpace(turnID),
|
|
input: clonePayload(input),
|
|
kind: kind,
|
|
providerMethod: response,
|
|
name: title,
|
|
toolName: toolName,
|
|
prompt: prompt,
|
|
options: cloneOptionMaps(options),
|
|
response: make(chan pendingInteractiveResponse, 1),
|
|
interactionRequested: true,
|
|
}
|
|
a.storePendingApproval(pending)
|
|
|
|
payload := map[string]any{
|
|
"callId": callID,
|
|
"callType": "interactive",
|
|
"name": title,
|
|
"toolName": toolName,
|
|
"status": "waiting_input",
|
|
"input": clonePayload(input),
|
|
}
|
|
events := []activityshared.Event{
|
|
newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWaiting, "", "", map[string]any{
|
|
"phase": string(activityshared.TurnPhaseWaitingApproval),
|
|
"requestId": requestID,
|
|
}),
|
|
newTurnActivityEventWithID(session, pending.eventID, EventCallStarted, turnID, SessionStatusWaiting, "", title, payload),
|
|
normalizedInteractionRequestedEvent(session, turnID, pending),
|
|
}
|
|
if emit != nil {
|
|
emit(events)
|
|
}
|
|
go a.respondACPPermissionRequest(ctx, client, session, turnID, message.ID, pending, emit)
|
|
return nil, nil
|
|
}
|
|
|
|
func parseCursorAskQuestionRequest(raw json.RawMessage) (cursorAskQuestionRequest, map[string]any, error) {
|
|
var request cursorAskQuestionRequest
|
|
if err := json.Unmarshal(raw, &request); err != nil {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("invalid cursor ask_question request: %w", err)
|
|
}
|
|
request.ToolCallID = strings.TrimSpace(request.ToolCallID)
|
|
if request.ToolCallID == "" {
|
|
return cursorAskQuestionRequest{}, nil, errors.New("cursor ask_question toolCallId is required")
|
|
}
|
|
if len(request.Questions) == 0 {
|
|
return cursorAskQuestionRequest{}, nil, errors.New("cursor ask_question requires at least one question")
|
|
}
|
|
|
|
questions := make([]map[string]any, 0, len(request.Questions))
|
|
for index, question := range request.Questions {
|
|
question.ID = strings.TrimSpace(question.ID)
|
|
question.Prompt = strings.TrimSpace(firstNonEmpty(question.Prompt, question.Question))
|
|
if question.ID == "" {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %d id is required", index+1)
|
|
}
|
|
if question.Prompt == "" {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %q prompt is required", question.ID)
|
|
}
|
|
if question.AllowMultiple {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %q uses multi-select, which is not supported yet", question.ID)
|
|
}
|
|
if len(question.Options) == 0 {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %q requires options", question.ID)
|
|
}
|
|
seenOptions := make(map[string]struct{}, len(question.Options))
|
|
optionMaps := make([]map[string]any, 0, len(question.Options))
|
|
for optionIndex, option := range question.Options {
|
|
option.ID = strings.TrimSpace(option.ID)
|
|
option.Label = strings.TrimSpace(option.Label)
|
|
if option.ID == "" || option.Label == "" {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %q option %d requires id and label", question.ID, optionIndex+1)
|
|
}
|
|
if _, exists := seenOptions[option.ID]; exists {
|
|
return cursorAskQuestionRequest{}, nil, fmt.Errorf("cursor ask_question question %q contains duplicate option id %q", question.ID, option.ID)
|
|
}
|
|
seenOptions[option.ID] = struct{}{}
|
|
optionMaps = append(optionMaps, map[string]any{"id": option.ID, "label": option.Label})
|
|
}
|
|
questions = append(questions, map[string]any{
|
|
"id": question.ID,
|
|
"header": question.Prompt,
|
|
"question": question.Prompt,
|
|
"prompt": question.Prompt,
|
|
"options": optionMaps,
|
|
"multiSelect": false,
|
|
"allowMultiple": false,
|
|
"allowFreeText": false,
|
|
})
|
|
}
|
|
return request, map[string]any{
|
|
"toolCallId": request.ToolCallID,
|
|
"title": strings.TrimSpace(request.Title),
|
|
"questions": questions,
|
|
}, nil
|
|
}
|
|
|
|
func parseCursorCreatePlanRequest(raw json.RawMessage) (cursorCreatePlanRequest, map[string]any, []map[string]any, error) {
|
|
var request cursorCreatePlanRequest
|
|
if err := json.Unmarshal(raw, &request); err != nil {
|
|
return cursorCreatePlanRequest{}, nil, nil, fmt.Errorf("invalid cursor create_plan request: %w", err)
|
|
}
|
|
request.ToolCallID = strings.TrimSpace(request.ToolCallID)
|
|
request.Plan = strings.TrimSpace(request.Plan)
|
|
if request.ToolCallID == "" {
|
|
return cursorCreatePlanRequest{}, nil, nil, errors.New("cursor create_plan toolCallId is required")
|
|
}
|
|
if request.Plan == "" {
|
|
return cursorCreatePlanRequest{}, nil, nil, errors.New("cursor create_plan plan is required")
|
|
}
|
|
var rawInput map[string]any
|
|
if err := json.Unmarshal(raw, &rawInput); err != nil {
|
|
return cursorCreatePlanRequest{}, nil, nil, fmt.Errorf("invalid cursor create_plan payload: %w", err)
|
|
}
|
|
input := clonePayload(rawInput)
|
|
input["toolCall"] = map[string]any{
|
|
"toolCallId": request.ToolCallID,
|
|
"title": firstNonEmpty(strings.TrimSpace(request.Name), "Create plan"),
|
|
"kind": "switch_mode",
|
|
}
|
|
options := []map[string]any{
|
|
{"optionId": "accept", "id": "accept", "name": "Implement plan", "label": "Implement plan", "kind": "allow_once"},
|
|
{"optionId": "plan", "id": "plan", "name": "Keep planning", "label": "Keep planning", "kind": "reject_once"},
|
|
}
|
|
return request, input, options, nil
|
|
}
|
|
|
|
func cursorNativeInteractiveResult(pending *pendingInteractiveRequest, action string, optionID string, payload map[string]any) (map[string]any, string, error) {
|
|
if pending == nil {
|
|
return nil, "", errors.New("cursor interactive request is unavailable")
|
|
}
|
|
switch pending.providerMethod {
|
|
case cursorACPMethodAskQuestion:
|
|
return cursorAskQuestionResult(pending, action, payload)
|
|
case cursorACPMethodCreatePlan:
|
|
return cursorCreatePlanResult(action, optionID, payload)
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported Cursor interactive provider method %q", pending.providerMethod)
|
|
}
|
|
}
|
|
|
|
func cursorAskQuestionResult(pending *pendingInteractiveRequest, action string, payload map[string]any) (map[string]any, string, error) {
|
|
switch normalizePermissionOptionToken(action) {
|
|
case "cancel", "cancelled", "dismiss", "dismissed":
|
|
return map[string]any{"outcome": map[string]any{"outcome": "cancelled"}}, "", nil
|
|
case "skip", "skipped":
|
|
return map[string]any{"outcome": map[string]any{"outcome": "skipped", "reason": "user_skipped"}}, "", nil
|
|
}
|
|
answersByQuestionID := payloadObject(payload["answersByQuestionId"])
|
|
if len(answersByQuestionID) == 0 {
|
|
return nil, "", errors.New("cursor ask_question requires answersByQuestionId")
|
|
}
|
|
questions := payloadArrayOfMaps(pending.input["questions"])
|
|
if len(questions) == 0 || len(answersByQuestionID) != len(questions) {
|
|
return nil, "", errors.New("cursor ask_question requires exactly one answer for every question")
|
|
}
|
|
answers := make([]map[string]any, 0, len(questions))
|
|
for _, question := range questions {
|
|
questionID := strings.TrimSpace(asString(question["id"]))
|
|
rawAnswer, exists := answersByQuestionID[questionID]
|
|
if !exists {
|
|
return nil, "", fmt.Errorf("cursor ask_question answer for question %q is required", questionID)
|
|
}
|
|
answer, ok := cursorSingleSelectAnswer(rawAnswer)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("cursor ask_question answer for question %q must be one selected option", questionID)
|
|
}
|
|
resolvedID, ok := cursorQuestionOptionID(question, answer)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("cursor ask_question answer %q is not an available option for question %q", answer, questionID)
|
|
}
|
|
answers = append(answers, map[string]any{
|
|
"questionId": questionID,
|
|
"selectedOptionIds": []string{resolvedID},
|
|
})
|
|
}
|
|
return map[string]any{"outcome": map[string]any{"outcome": "answered", "answers": answers}}, "", nil
|
|
}
|
|
|
|
func cursorCreatePlanResult(action string, optionID string, payload map[string]any) (map[string]any, string, error) {
|
|
actionToken := normalizePermissionOptionToken(action)
|
|
optionID = strings.TrimSpace(optionID)
|
|
if actionToken == "cancel" || actionToken == "cancelled" || actionToken == "dismiss" || actionToken == "dismissed" {
|
|
return map[string]any{"outcome": map[string]any{"outcome": "cancelled"}}, optionID, nil
|
|
}
|
|
if actionToken == "deny" || actionToken == "reject" || actionToken == "rejected" || optionID == "plan" {
|
|
outcome := map[string]any{"outcome": "rejected"}
|
|
if reason := strings.TrimSpace(asString(payload["denyMessage"])); reason != "" {
|
|
outcome["reason"] = reason
|
|
}
|
|
return map[string]any{"outcome": outcome}, firstNonEmpty(optionID, "plan"), nil
|
|
}
|
|
return map[string]any{"outcome": map[string]any{"outcome": "accepted"}}, firstNonEmpty(optionID, "accept"), nil
|
|
}
|
|
|
|
func cursorSingleSelectAnswer(value any) (string, bool) {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
value := strings.TrimSpace(typed)
|
|
return value, value != ""
|
|
case []any:
|
|
if len(typed) != 1 {
|
|
return "", false
|
|
}
|
|
return cursorSingleSelectAnswer(typed[0])
|
|
case []string:
|
|
if len(typed) != 1 {
|
|
return "", false
|
|
}
|
|
value := strings.TrimSpace(typed[0])
|
|
return value, value != ""
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func cursorQuestionOptionID(question map[string]any, answer string) (string, bool) {
|
|
for _, option := range payloadArrayOfMaps(question["options"]) {
|
|
id := strings.TrimSpace(asString(option["id"]))
|
|
label := strings.TrimSpace(asString(option["label"]))
|
|
if answer == id || answer == label {
|
|
return id, id != ""
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func payloadArrayOfMaps(value any) []map[string]any {
|
|
switch typed := value.(type) {
|
|
case []map[string]any:
|
|
return typed
|
|
case []any:
|
|
result := make([]map[string]any, 0, len(typed))
|
|
for _, item := range typed {
|
|
if object, ok := item.(map[string]any); ok && object != nil {
|
|
result = append(result, object)
|
|
}
|
|
}
|
|
return result
|
|
default:
|
|
return nil
|
|
}
|
|
}
|