Files
tutti/packages/agent/daemon/runtime/cursor_acp_interactive.go
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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
}
}