package agentruntime import ( "encoding/json" "strings" activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events" ) func cloneOptionMaps(in []map[string]any) []map[string]any { if len(in) == 0 { return nil } out := make([]map[string]any, 0, len(in)) for _, item := range in { out = append(out, clonePayload(item)) } return out } func (p *pendingInteractiveRequest) hasOption(optionID string) bool { optionID = strings.TrimSpace(optionID) if p == nil || optionID == "" { return false } for _, option := range p.options { if firstNonEmpty(asString(option["optionId"]), asString(option["id"])) == optionID { return true } } return false } func (p *pendingInteractiveRequest) resolvePermissionOptionID(optionID string) (string, bool) { optionID = strings.TrimSpace(optionID) if p == nil || optionID == "" { return "", false } if p.hasOption(optionID) { return optionID, true } decision := permissionOptionDecision(optionID) if decision == "" { return "", false } aliases := permissionOptionDecisionAliases(decision) for _, option := range p.options { resolvedOptionID := firstNonEmpty(asString(option["optionId"]), asString(option["id"])) if resolvedOptionID == "" { continue } for _, value := range []string{ resolvedOptionID, asString(option["kind"]), asString(option["name"]), asString(option["label"]), } { token := normalizePermissionOptionToken(value) if token == "" { continue } for _, alias := range aliases { if token == alias { return resolvedOptionID, true } } } } return "", false } func permissionOptionDecision(value string) string { switch normalizePermissionOptionToken(value) { case "approve", "approved", "allow", "allowed", "allowonce", "accept", "accepted", "acceptedits", "confirm", "confirmed", "ok", "proceed", "yes": return "approved" case "deny", "denied", "disallow", "reject", "rejected", "rejectonce", "decline", "declined", "no": return "denied" default: return "" } } func permissionOptionDecisionAliases(decision string) []string { switch decision { case "approved": return []string{"approve", "approved", "allow", "allowed", "allowonce", "accept", "accepted", "acceptedits", "confirm", "confirmed", "ok", "proceed", "yes"} case "denied": return []string{"deny", "denied", "disallow", "reject", "rejected", "rejectonce", "decline", "declined", "no"} default: return nil } } func normalizePermissionOptionToken(value string) string { var builder strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(value)) { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { builder.WriteRune(r) } } return builder.String() } func normalizedPermissionResolvedEvents(session Session, turnID string, pending *pendingInteractiveRequest, response pendingInteractiveResponse, err error) []activityshared.Event { if pending == nil { return nil } callType := firstNonEmpty(strings.TrimSpace(pending.callType), "approval") if err != nil { return []activityshared.Event{normalizedInteractionSupersededEvent(session, turnID, pending), newTurnActivityEventWithID(session, pending.eventID, EventCallFailed, turnID, messageStreamStateFailed, "", pending.name, map[string]any{ "callId": pending.callID, "callType": callType, "name": pending.name, "toolName": pending.toolName, "status": messageStreamStateFailed, "error": map[string]any{ "requestId": pending.requestID, "message": err.Error(), }, })} } return []activityshared.Event{ newTurnActivityEventWithID(session, pending.eventID, EventCallCompleted, turnID, messageStreamStateCompleted, "", pending.name, map[string]any{ "callId": pending.callID, "callType": callType, "name": pending.name, "toolName": pending.toolName, "status": messageStreamStateCompleted, "output": pending.resolvedOutput(response), }), newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWorking, "", "", map[string]any{ "phase": string(activityshared.TurnPhaseWorking), "requestId": pending.requestID, }), } } func normalizedInteractionRequestedEvent(session Session, turnID string, pending *pendingInteractiveRequest) activityshared.Event { if pending == nil { return activityshared.Event{} } ctx, ok := activityEventContext(session, "interaction:"+pending.requestID+":requested", turnID) if !ok { return activityshared.Event{} } return activityshared.NewInteractionRequested(ctx, pendingInteractionTransition(turnID, pending)) } func normalizedInteractionSupersededEvent(session Session, turnID string, pending *pendingInteractiveRequest) activityshared.Event { if pending == nil { return activityshared.Event{} } ctx, ok := activityEventContext(session, "interaction:"+pending.requestID+":superseded", turnID) if !ok { return activityshared.Event{} } return activityshared.NewInteractionSuperseded(ctx, pendingInteractionTransition(turnID, pending)) } func pendingInteractionTransition(turnID string, pending *pendingInteractiveRequest) activityshared.InteractionTransition { kind := "approval" switch strings.ToLower(strings.TrimSpace(pending.kind)) { case "ask-user", "question": kind = "question" case "exit-plan", "plan": kind = "plan" } metadata := map[string]any{ "callId": strings.TrimSpace(pending.callID), "callType": firstNonEmpty(strings.TrimSpace(pending.callType), "approval"), } if purpose := strings.TrimSpace(pending.approvalPurpose); purpose != "" { metadata["approvalPurpose"] = purpose } if pending.prompt != nil { for key, value := range pending.prompt.Metadata { metadata[key] = value } } metadata["actions"] = normalizedInteractionActions(pending.options) return activityshared.InteractionTransition{ RequestID: strings.TrimSpace(pending.requestID), TurnID: firstNonEmptyString(strings.TrimSpace(turnID), strings.TrimSpace(pending.turnID)), Kind: kind, ToolName: firstNonEmpty(strings.TrimSpace(pending.toolName), strings.TrimSpace(pending.name)), Input: persistedInteractionInput(kind, pending.input), Metadata: clonePayload(metadata), } } // Runtime adapters keep a flat approval input for provider-specific resolver // compatibility. The durable interaction has one canonical display payload at // toolCall.input; persisting the same values again at the root nearly doubles // large approval records and every Message Center projection derived from them. func persistedInteractionInput(kind string, input map[string]any) map[string]any { if kind != "approval" { return clonePayload(input) } toolCall := payloadObject(input["toolCall"]) if len(payloadObject(toolCall["input"])) == 0 { return clonePayload(input) } result := map[string]any{} for _, key := range []string{"requestId", "toolCall", "options"} { if value, exists := input[key]; exists { result[key] = clonePayloadValue(value) } } return result } func normalizedInteractionActions(options []map[string]any) []any { actions := make([]any, 0, len(options)) for _, option := range options { id := firstNonEmpty(asString(option["optionId"]), asString(option["id"])) if id == "" { continue } label := firstNonEmpty(asString(option["name"]), asString(option["label"]), id) actions = append(actions, map[string]any{ "id": id, "label": label, "semantic": interactionActionSemantic(asString(option["kind"])), }) } return actions } func interactionActionSemantic(kind string) string { switch normalizePermissionOptionToken(kind) { case "allowonce": return "approve" case "allowalways": return "approve_always" case "rejectonce": return "deny" case "rejectalways": return "deny_and_stop" default: return "" } } func interactiveApprovalOptionID(input SubmitInteractiveInput) string { if optionID := strings.TrimSpace(input.OptionID); optionID != "" { return optionID } if action := strings.TrimSpace(input.Action); action != "" { return action } if input.Payload != nil { return strings.TrimSpace(asString(input.Payload["optionId"])) } return "" } func (p *pendingInteractiveRequest) snapshotPrompt() *SessionInteractivePrompt { if p == nil { return nil } state := p.disposition() if state != pendingInteractiveRequestStatePending && state != pendingInteractiveRequestStateResolving { return nil } if p.prompt != nil { prompt := *p.prompt prompt.RequestID = firstNonEmpty(strings.TrimSpace(prompt.RequestID), p.requestID) prompt.ToolName = firstNonEmpty(strings.TrimSpace(prompt.ToolName), p.toolName, p.name) prompt.Status = firstNonEmpty(strings.TrimSpace(prompt.Status), SessionStatusWaiting) prompt.Input = clonePayload(prompt.Input) prompt.Output = clonePayload(prompt.Output) prompt.Error = clonePayload(prompt.Error) prompt.Metadata = clonePayload(prompt.Metadata) return &prompt } return &SessionInteractivePrompt{ Kind: "approval", RequestID: p.requestID, ToolName: p.name, Status: SessionStatusWaiting, Input: p.snapshotApprovalInput(), Metadata: map[string]any{ "callType": "approval", "toolName": firstNonEmpty(strings.TrimSpace(p.toolName), p.name), }, } } func (p *pendingInteractiveRequest) resolvedOutput(response pendingInteractiveResponse) map[string]any { output := map[string]any{ "requestId": p.requestID, } if p.callType == "interactive" { if response.action != "" { output["action"] = response.action } if response.optionID != "" { output["selectedId"] = strings.TrimSpace(response.optionID) } if payload := clonePayload(response.payload); payload != nil { output["payload"] = payload } return output } output["selectedId"] = strings.TrimSpace(response.optionID) return output } func (p *pendingInteractiveRequest) snapshotApprovalInput() map[string]any { input := clonePayload(p.input) if input == nil { input = map[string]any{} } if _, ok := input["requestId"]; !ok && strings.TrimSpace(p.requestID) != "" { input["requestId"] = p.requestID } if _, ok := input["callId"]; !ok && strings.TrimSpace(p.callID) != "" { input["callId"] = p.callID } if _, ok := input["options"]; !ok { input["options"] = cloneOptionMaps(p.options) } return input } func normalizedInteractivePrompt(toolCall map[string]any, options []map[string]any, requestID string) *SessionInteractivePrompt { toolName := normalizedInteractiveToolName(toolCall) switch toolName { case "AskUserQuestion": input := clonePayload(payloadObject(toolCall["input"])) if input == nil { input = map[string]any{} } if _, ok := input["questions"]; !ok { if questions := payloadArray(toolCall["questions"]); len(questions) > 0 { input["questions"] = questions } } return &SessionInteractivePrompt{ Kind: "ask-user", RequestID: requestID, ToolName: toolName, Status: "waiting_input", Input: input, Metadata: map[string]any{ "callType": "interactive", "interactiveKind": "ask-user", "toolName": toolName, "options": cloneOptionMaps(options), }, } case "ExitPlanMode": input := clonePayload(payloadObject(toolCall["input"])) if input == nil { input = map[string]any{} } return &SessionInteractivePrompt{ Kind: "exit-plan", RequestID: requestID, ToolName: toolName, Status: "waiting_input", Input: input, Metadata: map[string]any{ "callType": "interactive", "interactiveKind": "exit-plan", "toolName": toolName, "options": cloneOptionMaps(options), }, } default: return nil } } func normalizedApprovalInput(toolCall map[string]any, options []map[string]any, requestID string, knownInput map[string]any) map[string]any { displayInput := normalizedApprovalDisplayInput(toolCall, knownInput) input := map[string]any{ "requestId": requestID, "toolCall": normalizedApprovalToolCall(toolCall, displayInput), "options": cloneOptionMaps(options), } for key, value := range displayInput { if _, exists := input[key]; !exists { input[key] = clonePayloadValue(value) } } return input } func normalizedApprovalToolCall(toolCall map[string]any, displayInput map[string]any) map[string]any { normalized := map[string]any{} for _, key := range []string{ "toolCallId", "callId", "id", "title", "name", "toolName", "kind", "status", "locations", } { if value, exists := toolCall[key]; exists { normalized[key] = clonePayloadValue(value) } } if len(displayInput) > 0 { normalized["input"] = clonePayload(displayInput) } return normalized } // normalizedApprovalDisplayInput builds the preview detail (command, path, // query, URL, prompt, reason, ...) shown on an approval card. Some ACP providers (Cursor) omit // `rawInput` on the permission request's own `toolCall` and only repeat // `toolCallId`/`title`/`kind`, so `knownInput` — the input captured from an // earlier `tool_call`/`tool_call_update` for the same call id, when available // — is used as a last-resort fallback per field. func normalizedApprovalDisplayInput(toolCall map[string]any, knownInput map[string]any) map[string]any { if len(toolCall) == 0 && len(knownInput) == 0 { return nil } displayInput := clonePayload(payloadObject(toolCall["input"])) if displayInput == nil { displayInput = clonePayload(payloadObject(toolCall["rawInput"])) } if displayInput == nil { displayInput = clonePayload(payloadObject(toolCall["raw_input"])) } if displayInput == nil { displayInput = clonePayload(payloadObject(toolCall["arguments"])) } if displayInput == nil { displayInput = clonePayload(payloadObject(toolCall["args"])) } if displayInput == nil { displayInput = map[string]any{} } for _, key := range []string{ "command", "cmd", "description", "reason", "grantRoot", "file_path", "filePath", "path", "notebook_path", "query", "search_query", "searchQuery", "url", "uri", "prompt", "instruction", "pattern", "cwd", "changes", "fileChanges", } { if _, exists := displayInput[key]; exists { continue } if value, exists := toolCall[key]; exists { displayInput[key] = clonePayloadValue(value) continue } if value, exists := knownInput[key]; exists { displayInput[key] = clonePayloadValue(value) } } acpApplyDiffContent(displayInput, toolCall["content"]) if command := normalizedApprovalDisplayCommand(firstNonEmptyShellCommand(displayInput["command"], displayInput["cmd"])); command != "" { displayInput["command"] = command delete(displayInput, "cmd") } if len(displayInput) == 0 { return nil } return displayInput } func normalizedApprovalDisplayCommand(command any) string { switch typed := command.(type) { case string: return strings.TrimSpace(typed) case []any: if len(typed) >= 3 { flag := strings.TrimSpace(asString(typed[len(typed)-2])) if flag == "-c" || flag == "-lc" { return strings.TrimSpace(asString(typed[len(typed)-1])) } } parts := make([]string, 0, len(typed)) for _, part := range typed { if text := strings.TrimSpace(asString(part)); text != "" { parts = append(parts, text) } } return strings.Join(parts, " ") default: return "" } } func interactivePromptKind(prompt *SessionInteractivePrompt) string { if prompt == nil { return "" } return strings.TrimSpace(prompt.Kind) } func normalizedInteractiveToolName(toolCall map[string]any) string { name := firstNonEmpty( asString(toolCall["name"]), asString(toolCall["toolName"]), asString(toolCall["title"]), ) switch normalizeInteractiveName(name) { case "askuserquestion": return "AskUserQuestion" case "exitplanmode": return "ExitPlanMode" default: return "" } } func normalizeInteractiveName(name string) string { return strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(name))) } func payloadObject(value any) map[string]any { obj, _ := value.(map[string]any) return obj } func payloadArray(value any) []map[string]any { items, _ := value.([]map[string]any) if items != nil { return cloneOptionMaps(items) } if generic, ok := value.([]any); ok { out := make([]map[string]any, 0, len(generic)) for _, item := range generic { if obj, ok := item.(map[string]any); ok { out = append(out, clonePayload(obj)) } } return out } return nil } func asString(value any) string { switch typed := value.(type) { case string: return strings.TrimSpace(typed) case json.Number: return typed.String() default: return "" } } func newSessionActivityEvent(session Session, eventType string, status string, metadata map[string]any) activityshared.Event { ctx, ok := activityEventContext(session, newID(), "") if !ok { return activityshared.Event{} } var event activityshared.Event switch eventType { case EventSessionStarted: event = activityshared.NewSessionStarted(ctx) case EventSessionUpdated: event = activityshared.NewSessionUpdated(ctx, activityshared.SessionStatus(status)) case EventSessionCompleted: event = activityshared.NewSessionCompleted(ctx) case EventSessionFailed: event = activityshared.NewSessionFailed(ctx) case EventSessionCanceled: event = activityshared.NewSessionUpdated(ctx, activityshared.SessionStatusPaused) default: return activityshared.Event{} } event.Payload.Metadata = clonePayload(metadata) return event } func newTurnActivityEvent(session Session, eventType string, turnID string, status string, role string, content string, payload map[string]any) activityshared.Event { return newTurnActivityEventWithID(session, newID(), eventType, turnID, status, role, content, payload) } func newTurnActivityEventWithID(session Session, eventID string, eventType string, turnID string, status string, role string, content string, payload map[string]any) activityshared.Event { return newTurnActivityEventWithIDAt(session, eventID, eventType, turnID, status, role, content, payload, 0) } func newTurnActivityEventWithIDAt( session Session, eventID string, eventType string, turnID string, status string, role string, content string, payload map[string]any, occurredAtUnixMS int64, ) activityshared.Event { ctx, ok := activityEventContextAt(session, eventID, turnID, occurredAtUnixMS) if !ok { return activityshared.Event{} } switch eventType { case EventTurnStarted: event := activityshared.NewTurnStarted(ctx, turnID) event.Payload.Metadata = clonePayload(payload) return event case EventTurnUpdated: phase := activityshared.TurnPhase(firstNonEmpty(payloadString(payload, "phase"), string(activityshared.TurnPhaseWorking))) event := activityshared.NewTurnUpdated(ctx, turnID, phase) event.Payload.Metadata = clonePayload(payload) return event case EventTurnCompleted: event := activityshared.NewTurnCompleted(ctx, turnID, activityshared.TurnOutcomeCompleted) event.Payload.Metadata = clonePayload(payload) return event case EventTurnFailed: event := activityshared.NewTurnFailed(ctx, turnID) event.Payload.Metadata = clonePayload(payload) return event case EventTurnCanceled: event := activityshared.NewTurnCompleted(ctx, turnID, activityshared.TurnOutcomeInterrupted) event.Payload.Metadata = clonePayload(payload) return event case EventMessage: messageRole := activityshared.MessageRole(strings.TrimSpace(role)) if messageRole == "" { messageRole = activityshared.MessageRoleAssistant } if status == "" && messageRole == activityshared.MessageRoleUser { status = messageStreamStateCompleted } event := activityshared.NewMessageAppended( ctx, messageRole, content, messageRole == activityshared.MessageRoleAssistant && payloadString(payload, "kind") != "agent_system_notice", ) event.Payload.Metadata = clonePayload(payload) if event.Payload.Metadata == nil { event.Payload.Metadata = map[string]any{} } if strings.TrimSpace(payloadString(event.Payload.Metadata, "messageId")) == "" { event.Payload.Metadata["messageId"] = eventID } if strings.TrimSpace(payloadString(event.Payload.Metadata, "contentMode")) == "" { event.Payload.Metadata["contentMode"] = messageContentModeSnapshot } if status != "" { event.Payload.Metadata["streamState"] = status } return event case EventCallStarted: event := activityshared.NewCallStarted( ctx, payloadString(payload, "callId"), firstNonEmpty(payloadString(payload, "callType"), "tool"), payloadString(payload, "name"), payloadMap(payload, "input"), ) if status := payloadString(payload, "status"); status != "" { event.Payload.Status = status } event.Payload.Metadata = clonePayload(payload) return event case EventCallCompleted: event := activityshared.NewCallCompleted( ctx, payloadString(payload, "callId"), firstNonEmpty(payloadString(payload, "callType"), "tool"), payloadString(payload, "name"), payloadMap(payload, "output"), ) if status := payloadString(payload, "status"); status != "" { event.Payload.Status = status } event.Payload.Metadata = clonePayload(payload) return event case EventCallFailed: event := activityshared.NewCallFailed( ctx, payloadString(payload, "callId"), firstNonEmpty(payloadString(payload, "callType"), "tool"), payloadString(payload, "name"), payloadMap(payload, "error"), ) if output := payloadMap(payload, "output"); len(output) > 0 { event.Payload.Output = output } if status := payloadString(payload, "status"); status != "" { event.Payload.Status = status } event.Payload.Metadata = clonePayload(payload) return event default: return activityshared.Event{} } }