package agentruntime import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events" ) func (a *CodexAppServerAdapter) appServerServerRequest( ctx context.Context, client *codexAppServerClient, session Session, turnID string, message acpMessage, normalizer *acpTurnNormalizer, emit EventSink, ) ([]activityshared.Event, error) { if strings.TrimSpace(turnID) == "" || emit == nil { err := errors.New("approval request outside active prompt turn is not supported") _ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32000, Message: err.Error()}) return nil, err } params := map[string]any{} if len(message.Params) > 0 { if err := json.Unmarshal(message.Params, ¶ms); err != nil { _ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32602, Message: err.Error()}) return nil, fmt.Errorf("invalid approval request: %w", err) } } eventSession, eventTurnID, child, err := a.appServerInteractiveRequestScope(session, turnID, params) if err != nil { _ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32000, Message: err.Error()}) return nil, err } eventNormalizer := normalizer if child != nil { eventNormalizer = child.normalizer } events, pending, err := a.appServerApprovalRequested(eventSession, eventTurnID, message.ID, message.Method, params, eventNormalizer) if err != nil { _ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32602, Message: err.Error()}) return events, err } events = appServerEventsForChild(events, child) if len(events) > 0 { emit(events) } processingTurn := a.activeTurnForNormalizer(session.AgentSessionID, normalizer) go a.respondAppServerServerRequest(ctx, client, eventSession, eventTurnID, child, message, params, pending, processingTurn, emit) return nil, nil } func (a *CodexAppServerAdapter) appServerInteractiveRequestScope( root Session, rootTurnID string, params map[string]any, ) (Session, string, *codexAppServerThreadContext, error) { rootProviderThreadID := strings.TrimSpace(root.ProviderSessionID) requestThreadID := appServerMessageThreadID(params) if requestThreadID == "" || rootProviderThreadID == "" || requestThreadID == rootProviderThreadID { return root, rootTurnID, nil, nil } child, ok := a.appServerChildThread(root.AgentSessionID, requestThreadID) if !ok { return Session{}, "", nil, fmt.Errorf("interactive request references unknown child thread %q", requestThreadID) } return appServerChildSession(root, requestThreadID, child), child.turnID, child, nil } func (a *CodexAppServerAdapter) respondAppServerServerRequest( ctx context.Context, client *codexAppServerClient, session Session, turnID string, child *codexAppServerThreadContext, message acpMessage, params map[string]any, pending *pendingInteractiveRequest, processingTurn *codexAppServerActiveTurn, emit EventSink, ) { if pending == nil { return } selection, err := pending.wait(ctx) if err != nil { pending.finish(pendingInteractiveRequestStateInterrupted) resolved := normalizedPermissionResolvedEvents(session, turnID, pending, pendingInteractiveResponse{}, err) // The shared error path emits only call.failed; append the // back-to-running turn.updated so the lifecycle cannot strand in // waiting_approval when a request is rejected or canceled. resolved = append(resolved, newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWorking, "", "", map[string]any{ "phase": string(activityshared.TurnPhaseWorking), "requestId": pending.requestID, })) resolved = appServerEventsForChild(resolved, child) if emit != nil { emit(resolved) } _ = client.Respond(ctx, message.ID, nil, &acpError{Code: -32000, Message: err.Error()}) return } if selection.outOfBandResolved { resolved := appServerEventsForChild(acpPermissionOutOfBandResolvedEvents(session, turnID, pending), child) if emit != nil { emit(resolved) } return } // A provider may complete the turn immediately after accepting this // response. Serialize the acknowledgement and its resolution events with // turn finalization so call.completed cannot arrive after the terminal event // and get dropped by the turn's closed-event guard. if processingTurn != nil { processingTurn.processMu.Lock() defer processingTurn.processMu.Unlock() } result, responseErr := appServerApprovalResult(message.Method, params, selection) if err := client.Respond(ctx, message.ID, result, responseErr); err != nil { if pending.finish(pendingInteractiveRequestStateSuperseded) && emit != nil { emit(appServerEventsForChild(normalizedPermissionResolvedEvents(session, turnID, pending, selection, err), child)) } return } if pending.finish(pendingInteractiveRequestStateAnswered) { if emit != nil { emit(appServerEventsForChild(normalizedPermissionResolvedEvents(session, turnID, pending, selection, nil), child)) } a.scheduleAppServerInteractiveDenyFeedback(ctx, client, session, pending, selection) } } func (a *CodexAppServerAdapter) scheduleAppServerInteractiveDenyFeedback( ctx context.Context, client *codexAppServerClient, session Session, pending *pendingInteractiveRequest, selection pendingInteractiveResponse, ) { feedback := strings.TrimSpace(asString(selection.payload["denyMessage"])) if feedback == "" || !isDenyInteractiveSelectionValue(selection.optionID) { return } threadID := strings.TrimSpace(session.ProviderSessionID) providerTurnID := strings.TrimSpace(pending.providerTurnID) if threadID == "" || providerTurnID == "" { slog.Warn("agent interactive deny feedback cannot be steered without provider identity", "agent_session_id", session.AgentSessionID, "turn_id", pending.turnID, "request_id", pending.requestID, ) return } timeout := a.turnSteerTimeout if timeout <= 0 { timeout = defaultCodexAppServerTurnSteerTimeout } go func() { _, err := client.TurnSteerNoHandler(context.WithoutCancel(ctx), timeout, map[string]any{ "threadId": threadID, "expectedTurnId": providerTurnID, "input": appServerUserInput([]PromptContentBlock{{ Type: "text", Text: feedback, }}), }) if err != nil { slog.Warn("agent interactive deny feedback steer failed", "agent_session_id", session.AgentSessionID, "provider_thread_id", threadID, "provider_turn_id", providerTurnID, "request_id", pending.requestID, "error", err, ) } }() } func (a *CodexAppServerAdapter) appServerApprovalRequested( session Session, turnID string, rawRequestID json.RawMessage, method string, params map[string]any, normalizer *acpTurnNormalizer, ) ([]activityshared.Event, *pendingInteractiveRequest, error) { requestID := acpRequestID(rawRequestID) if requestID == "" { return nil, nil, errors.New("approval request id is required") } if method == appServerMethodRequestUserInput { return a.appServerUserInputRequested(session, turnID, requestID, params) } if method == appServerMethodMCPElicitation { return a.appServerMCPElicitationRequested(session, turnID, requestID, params) } toolCall := appServerApprovalToolCall(method, params) options := appServerApprovalOptions(method) title := firstNonEmpty(asString(toolCall["title"]), "Permission requested") callID := firstNonEmpty(asString(toolCall["toolCallId"]), newID()) status := string(activityshared.TurnPhaseWaitingApproval) approvalPurpose := normalizedApprovalPurpose(toolCall) knownInput := normalizer.KnownToolCallInput(asString(toolCall["toolCallId"])) input := normalizedApprovalInput(toolCall, options, requestID, knownInput) payload := map[string]any{ "callId": callID, "callType": "approval", "name": title, "toolName": "Approval", "status": status, "input": input, } if approvalPurpose != "" { payload["approvalPurpose"] = approvalPurpose } pending := &pendingInteractiveRequest{ agentSessionID: strings.TrimSpace(session.AgentSessionID), turnID: strings.TrimSpace(turnID), requestID: requestID, eventID: newID(), callID: callID, callType: "approval", input: input, kind: "approval", providerTurnID: strings.TrimSpace(asString(params["turnId"])), approvalPurpose: approvalPurpose, name: title, toolName: "Approval", options: options, response: make(chan pendingInteractiveResponse, 1), } a.storePendingRequest(pending) return []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), }, pending, nil } func (*CodexAppServerAdapter) ControllerSendsInteractiveDenyFollowUp() bool { return false } func appServerUnsupportedServerRequestEvents( session Session, turnID string, message acpMessage, err error, ) []activityshared.Event { if strings.TrimSpace(turnID) == "" || err == nil { return nil } requestID := acpRequestID(message.ID) callID := firstNonEmpty(requestID, newID()) return []activityshared.Event{ newTurnActivityEventWithID( session, "server-request:"+callID, EventCallFailed, turnID, messageStreamStateFailed, "", "Unsupported server request", map[string]any{ "callId": callID, "callType": "server_request", "name": "Unsupported server request", "toolName": "ServerRequest", "status": messageStreamStateFailed, "error": map[string]any{ "requestId": requestID, "method": message.Method, "message": err.Error(), }, }, ), } } func (a *CodexAppServerAdapter) appServerUserInputRequested( session Session, turnID string, requestID string, params map[string]any, ) ([]activityshared.Event, *pendingInteractiveRequest, error) { questions, _ := params["questions"].([]any) input := map[string]any{ "requestId": requestID, "questions": clonePayloadValue(questions), } prompt := &SessionInteractivePrompt{ Kind: "ask-user", RequestID: requestID, ToolName: "AskUserQuestion", Status: "waiting_input", Input: clonePayload(input), Metadata: map[string]any{ "callType": "interactive", "interactiveKind": "ask-user", "toolName": "AskUserQuestion", }, } callID := firstNonEmpty(asString(params["itemId"]), newID()) payload := map[string]any{ "callId": callID, "callType": "interactive", "name": "AskUserQuestion", "toolName": "AskUserQuestion", "status": "waiting_input", "input": clonePayload(input), "metadata": clonePayload(prompt.Metadata), } pending := &pendingInteractiveRequest{ agentSessionID: strings.TrimSpace(session.AgentSessionID), turnID: strings.TrimSpace(turnID), requestID: requestID, eventID: newID(), callID: callID, callType: "interactive", input: input, kind: "ask-user", name: "AskUserQuestion", toolName: "AskUserQuestion", prompt: prompt, response: make(chan pendingInteractiveResponse, 1), } a.storePendingRequest(pending) return []activityshared.Event{ newTurnActivityEvent(session, EventTurnUpdated, turnID, SessionStatusWaiting, "", "", map[string]any{ "phase": string(activityshared.TurnPhaseWaitingApproval), "requestId": requestID, }), newTurnActivityEventWithID( session, pending.eventID, EventCallStarted, turnID, SessionStatusWaiting, "", "AskUserQuestion", payload, ), normalizedInteractionRequestedEvent(session, turnID, pending), }, pending, nil } func appServerApprovalToolCall(method string, params map[string]any) map[string]any { switch method { case appServerMethodCommandApproval: command := asStringRaw(params["command"]) input := map[string]any{ "command": command, "cwd": asString(params["cwd"]), } if reason := asStringRaw(params["reason"]); reason != "" { input["reason"] = reason } return map[string]any{ "toolCallId": firstNonEmpty(asString(params["itemId"]), asString(params["approvalId"])), "title": firstNonEmpty(command, "Run command"), "kind": "execute", "input": input, } case appServerMethodExecApprovalV1: command := normalizedApprovalDisplayCommand(params["command"]) input := map[string]any{ "command": command, "cwd": asString(params["cwd"]), } if reason := asStringRaw(params["reason"]); reason != "" { input["reason"] = reason } return map[string]any{ "toolCallId": firstNonEmpty(asString(params["callId"]), asString(params["approvalId"])), "title": firstNonEmpty(command, "Run command"), "kind": "execute", "input": input, } case appServerMethodFileChangeApproval, appServerMethodPatchApprovalV1: input := map[string]any{} if reason := asStringRaw(params["reason"]); reason != "" { input["reason"] = reason } if grantRoot := asString(params["grantRoot"]); grantRoot != "" { input["grantRoot"] = grantRoot } if fileChanges := params["fileChanges"]; fileChanges != nil { input["fileChanges"] = clonePayloadValue(fileChanges) } return map[string]any{ "toolCallId": firstNonEmpty(asString(params["itemId"]), asString(params["callId"])), "title": "Apply file changes", "kind": "edit", "input": input, } case appServerMethodPermissionsApproval: input := map[string]any{ "permissions": clonePayloadValue(params["permissions"]), "cwd": asString(params["cwd"]), } if reason := asStringRaw(params["reason"]); reason != "" { input["reason"] = reason } return map[string]any{ "toolCallId": firstNonEmpty(asString(params["itemId"]), newID()), "title": "Grant additional permissions", "kind": "other", "input": input, } default: return map[string]any{ "toolCallId": newID(), "title": "Permission requested", } } } func appServerApprovalOptions(method string) []map[string]any { switch method { case appServerMethodPermissionsApproval: return []map[string]any{ {"optionId": "approve", "name": "Approve", "kind": "allow_once"}, {"optionId": "deny", "name": "Deny", "kind": "reject_once"}, } default: return []map[string]any{ {"optionId": "approve", "name": "Approve", "kind": "allow_once"}, {"optionId": "approve_for_session", "name": "Approve for session", "kind": "allow_always"}, {"optionId": "deny", "name": "Deny", "kind": "reject_once"}, {"optionId": "abort", "name": "Deny and stop the turn", "kind": "reject_always"}, } } } func appServerApprovalResult(method string, params map[string]any, selection pendingInteractiveResponse) (any, *acpError) { optionID := strings.TrimSpace(selection.optionID) switch method { case appServerMethodCommandApproval, appServerMethodFileChangeApproval: decision := map[string]string{ "approve": "accept", "approve_for_session": "acceptForSession", "deny": "decline", "abort": "cancel", }[optionID] if decision == "" { decision = "decline" } return map[string]any{"decision": decision}, nil case appServerMethodExecApprovalV1, appServerMethodPatchApprovalV1: decision := map[string]string{ "approve": "approved", "approve_for_session": "approved_for_session", "deny": "denied", "abort": "abort", }[optionID] if decision == "" { decision = "denied" } return map[string]any{"decision": decision}, nil case appServerMethodPermissionsApproval: if optionID == "approve" { return map[string]any{ "permissions": clonePayloadValue(params["permissions"]), "scope": "session", }, nil } return nil, &acpError{Code: -32000, Message: "user denied the permission request"} case appServerMethodRequestUserInput: return map[string]any{ "answers": appServerUserInputAnswers(params, selection), }, nil case appServerMethodMCPElicitation: return appServerMCPElicitationResult(selection), nil default: return map[string]any{}, nil } } func appServerUserInputAnswers(params map[string]any, selection pendingInteractiveResponse) map[string]any { answers := map[string]any{} // The GUI sends per-question answers keyed by question id under // answersByQuestionId (its `answers` field is a flat display list, not a // map). Accept a bare `answers` map too for callers that inline it. keyed := payloadObject(selection.payload["answersByQuestionId"]) if len(keyed) == 0 { keyed = payloadObject(selection.payload["answers"]) } if len(keyed) > 0 { for questionID, value := range keyed { answers[questionID] = map[string]any{"answers": appServerAnswerValues(value)} } return answers } questions, _ := params["questions"].([]any) answerText := firstNonEmpty( asString(selection.payload["answer"]), strings.TrimSpace(selection.optionID), ) for _, question := range questions { questionID := asString(payloadObject(question)["id"]) if questionID == "" { continue } answers[questionID] = map[string]any{"answers": appServerAnswerValues(answerText)} } return answers } func appServerAnswerValues(value any) []string { switch typed := value.(type) { case string: if trimmed := strings.TrimSpace(typed); trimmed != "" { return []string{trimmed} } return []string{} case []any: out := make([]string, 0, len(typed)) for _, entry := range typed { if text := strings.TrimSpace(asString(entry)); text != "" { out = append(out, text) } } return out default: return []string{} } } // --- request parameter builders --- // appServerThreadReasoningSummaryConfig selects the thread-level reasoning // summary mode for codex app-server. Inline /review turns interleave readable // reasoning via summaryTextDelta; the ACP transport disables summaries for // spark, but app-server review still needs them.