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
757 lines
27 KiB
Go
757 lines
27 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
const claudeSDKInteractiveAckTimeout = 30 * time.Second
|
|
|
|
type claudeSDKInteractiveAck struct {
|
|
disposition InteractiveDisposition
|
|
conflict bool
|
|
err error
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) SubmitInteractive(ctx context.Context, session Session, input SubmitInteractiveInput) (SubmitInteractiveResult, error) {
|
|
turnID := strings.TrimSpace(input.TurnID)
|
|
if turnID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive turn id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
requestID := strings.TrimSpace(input.RequestID)
|
|
if requestID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive request id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
targetAgentSessionID := firstNonEmpty(strings.TrimSpace(input.AgentSessionID), strings.TrimSpace(session.AgentSessionID))
|
|
adapterSession, pending := a.getClaudeSDKPendingRequestWithSession(targetAgentSessionID, turnID, requestID)
|
|
if pending == nil {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: %q", ErrInteractiveRequestNotLive, requestID)
|
|
}
|
|
providerAgentSessionID := a.claudeSDKAdapterSessionID(adapterSession, session.AgentSessionID)
|
|
|
|
optionID := strings.TrimSpace(input.OptionID)
|
|
if optionID == "" && input.Payload != nil {
|
|
optionID = strings.TrimSpace(asString(input.Payload["optionId"]))
|
|
}
|
|
if pending.callType == "approval" {
|
|
optionID = interactiveApprovalOptionID(input)
|
|
if optionID == "" {
|
|
return SubmitInteractiveResult{}, fmt.Errorf("%w: interactive option id is required", ErrInteractiveResponseInvalid)
|
|
}
|
|
resolvedOptionID, ok := pending.resolvePermissionOptionID(optionID)
|
|
if !ok {
|
|
return SubmitInteractiveResult{}, fmt.Errorf(
|
|
"%w: permission option %q is not available for request %q",
|
|
ErrInteractiveResponseInvalid,
|
|
optionID,
|
|
requestID,
|
|
)
|
|
}
|
|
optionID = resolvedOptionID
|
|
}
|
|
response := pendingInteractiveResponse{
|
|
optionID: optionID,
|
|
action: strings.TrimSpace(input.Action),
|
|
payload: clonePayload(input.Payload),
|
|
}
|
|
if state, claimed := pending.beginResolving(); !claimed {
|
|
if state == pendingInteractiveRequestStateResolving {
|
|
ack := a.queryClaudeSDKInteractiveDisposition(ctx, session, adapterSession, pending, response)
|
|
a.applyClaudeSDKInteractiveAck(adapterSession, session, pending, response, ack)
|
|
if ack.conflict {
|
|
return claudeSDKInteractiveConflictResult(session, requestID, optionID, ack.err)
|
|
}
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, ack.err)
|
|
}
|
|
return SubmitInteractiveResult{}, interactiveDispositionError(requestID, state)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
pending.releaseResolving()
|
|
return SubmitInteractiveResult{}, err
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"agentSessionId": providerAgentSessionID,
|
|
"turnId": claudeSDKInteractiveTransportTurnID(pending),
|
|
"requestId": requestID,
|
|
"action": response.action,
|
|
"optionId": optionID,
|
|
"payload": response.payload,
|
|
}
|
|
acknowledged := make(chan claudeSDKInteractiveAck, 1)
|
|
go func() {
|
|
ackTimeout := a.interactiveAckTimeout
|
|
if ackTimeout <= 0 {
|
|
ackTimeout = claudeSDKInteractiveAckTimeout
|
|
}
|
|
ackCtx, cancel := context.WithTimeout(context.Background(), ackTimeout)
|
|
defer cancel()
|
|
event, err := a.roundTripClaudeSDKResponse(ackCtx, providerAgentSessionID, adapterSession, claudeSDKSidecarRequest{
|
|
ID: newID(),
|
|
Type: "submit_interactive",
|
|
Payload: payload,
|
|
})
|
|
ack := claudeSDKSubmitAckFromResponse(event, err)
|
|
if err != nil && (event.Type != "error" || payloadBoolValue(event.Payload, "transport")) {
|
|
queryCtx, queryCancel := context.WithTimeout(context.Background(), ackTimeout)
|
|
ack = a.queryClaudeSDKInteractiveDisposition(queryCtx, session, adapterSession, pending, response)
|
|
queryCancel()
|
|
if ack.err != nil {
|
|
ack.err = fmt.Errorf("submit interactive acknowledgment is uncertain (%v); disposition query failed: %w", err, ack.err)
|
|
}
|
|
}
|
|
a.applyClaudeSDKInteractiveAck(adapterSession, session, pending, response, ack)
|
|
acknowledged <- ack
|
|
}()
|
|
select {
|
|
case ack := <-acknowledged:
|
|
if ack.conflict {
|
|
return claudeSDKInteractiveConflictResult(session, requestID, optionID, ack.err)
|
|
}
|
|
if ack.disposition == InteractiveDispositionPending {
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, ack.err)
|
|
}
|
|
if ack.err != nil && runtimeInteractiveDisposition(pending) != InteractiveDispositionAnswered {
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, ack.err)
|
|
}
|
|
case <-ctx.Done():
|
|
return SubmitInteractiveResult{}, ctx.Err()
|
|
}
|
|
if state, err := pending.waitForDisposition(ctx); err != nil {
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, err)
|
|
} else if state != pendingInteractiveRequestStateAnswered {
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, interactiveDispositionError(requestID, state))
|
|
}
|
|
return claudeSDKInteractiveSubmitResult(session, requestID, optionID, pending, nil)
|
|
}
|
|
|
|
func claudeSDKInteractiveConflictResult(session Session, requestID string, optionID string, err error) (SubmitInteractiveResult, error) {
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
OptionID: optionID,
|
|
Disposition: InteractiveDispositionUnknown,
|
|
}, err
|
|
}
|
|
|
|
func claudeSDKInteractiveSubmitResult(session Session, requestID string, optionID string, pending *pendingInteractiveRequest, err error) (SubmitInteractiveResult, error) {
|
|
disposition := runtimeInteractiveDisposition(pending)
|
|
if err == nil && disposition != InteractiveDispositionAnswered {
|
|
err = interactiveDispositionError(requestID, pending.disposition())
|
|
}
|
|
return SubmitInteractiveResult{
|
|
AgentSessionID: session.AgentSessionID,
|
|
RequestID: requestID,
|
|
Accepted: disposition == InteractiveDispositionAnswered,
|
|
OptionID: optionID,
|
|
Disposition: disposition,
|
|
}, err
|
|
}
|
|
|
|
func claudeSDKSubmitAckFromResponse(event claudeSDKSidecarEvent, err error) claudeSDKInteractiveAck {
|
|
if event.Type == "error" {
|
|
if payloadBoolValue(event.Payload, "transport") {
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionResolving, err: err}
|
|
}
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionSuperseded, err: err}
|
|
}
|
|
return claudeSDKDispositionFromResponse(event, err)
|
|
}
|
|
|
|
func claudeSDKDispositionQueryFromResponse(event claudeSDKSidecarEvent, err error) claudeSDKInteractiveAck {
|
|
if event.Type == "error" {
|
|
if err == nil {
|
|
err = errors.New("interactive disposition query failed")
|
|
}
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionResolving, err: err}
|
|
}
|
|
return claudeSDKDispositionFromResponse(event, err)
|
|
}
|
|
|
|
func claudeSDKDispositionFromResponse(event claudeSDKSidecarEvent, err error) claudeSDKInteractiveAck {
|
|
switch payloadString(event.Payload, "disposition") {
|
|
case string(InteractiveDispositionPending):
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionPending, err: err}
|
|
case string(InteractiveDispositionAnswered):
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionAnswered, err: err}
|
|
case string(InteractiveDispositionSuperseded):
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionSuperseded, err: err}
|
|
case "conflict":
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionUnknown, conflict: true, err: errors.New("interactive response conflicts with the sidecar terminal result")}
|
|
case "unknown":
|
|
if err == nil {
|
|
err = errors.New("interactive request has unknown sidecar disposition")
|
|
}
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionResolving, err: err}
|
|
default:
|
|
return claudeSDKInteractiveAck{disposition: InteractiveDispositionUnknown, err: err}
|
|
}
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) queryClaudeSDKInteractiveDisposition(
|
|
ctx context.Context,
|
|
session Session,
|
|
adapterSession *claudeSDKAdapterSession,
|
|
pending *pendingInteractiveRequest,
|
|
response pendingInteractiveResponse,
|
|
) claudeSDKInteractiveAck {
|
|
providerAgentSessionID := a.claudeSDKAdapterSessionID(adapterSession, session.AgentSessionID)
|
|
event, err := a.roundTripClaudeSDKResponse(ctx, providerAgentSessionID, adapterSession, claudeSDKSidecarRequest{
|
|
ID: newID(),
|
|
Type: "interactive_disposition",
|
|
Payload: map[string]any{
|
|
"agentSessionId": providerAgentSessionID,
|
|
"turnId": claudeSDKInteractiveTransportTurnID(pending),
|
|
"requestId": pending.requestID,
|
|
"action": response.action,
|
|
"optionId": response.optionID,
|
|
"payload": response.payload,
|
|
},
|
|
})
|
|
ack := claudeSDKDispositionQueryFromResponse(event, err)
|
|
if ack.disposition == InteractiveDispositionUnknown {
|
|
ack.disposition = InteractiveDispositionResolving
|
|
if ack.err == nil {
|
|
ack.err = errors.New("interactive disposition query returned no disposition")
|
|
}
|
|
}
|
|
return ack
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) applyClaudeSDKInteractiveAck(
|
|
adapterSession *claudeSDKAdapterSession,
|
|
session Session,
|
|
pending *pendingInteractiveRequest,
|
|
response pendingInteractiveResponse,
|
|
ack claudeSDKInteractiveAck,
|
|
) {
|
|
if a == nil || adapterSession == nil || pending == nil {
|
|
return
|
|
}
|
|
// The sidecar response is received without this lock, but its canonical
|
|
// effects share the exact-session commit axis with reader dispatch and
|
|
// replacement. A response from E1 cannot settle or publish after E2 wins.
|
|
adapterSession.dispatchMu.Lock()
|
|
defer adapterSession.dispatchMu.Unlock()
|
|
rootAgentSessionID := firstNonEmptyString(
|
|
strings.TrimSpace(adapterSession.session.AgentSessionID),
|
|
strings.TrimSpace(session.AgentSessionID),
|
|
)
|
|
if !a.sessionMayDispatch(rootAgentSessionID, adapterSession) {
|
|
return
|
|
}
|
|
var state pendingInteractiveRequestState
|
|
var terminalErr error
|
|
switch ack.disposition {
|
|
case InteractiveDispositionAnswered:
|
|
state = pendingInteractiveRequestStateAnswered
|
|
case InteractiveDispositionSuperseded:
|
|
state = pendingInteractiveRequestStateSuperseded
|
|
terminalErr = ack.err
|
|
if terminalErr == nil {
|
|
terminalErr = errors.New("interactive request was superseded by the provider")
|
|
}
|
|
case InteractiveDispositionPending:
|
|
pending.releaseResolving()
|
|
return
|
|
default:
|
|
return
|
|
}
|
|
if !pending.finish(state) {
|
|
return
|
|
}
|
|
events := normalizedPermissionResolvedEvents(session, pending.turnID, pending, response, terminalErr)
|
|
if child, ok := adapterSession.claudeSDKChildByAgentSessionID(pending.agentSessionID); ok {
|
|
events = claudeSDKScopeInteractiveEvents(events, child, true)
|
|
}
|
|
events = a.stampTurnLifecycleSnapshots(adapterSession, events)
|
|
a.updateClaudeSDKSessionSnapshot(adapterSession, events)
|
|
if waiter := a.claudeSDKTurnWaiter(adapterSession, pending.turnID); waiter != nil {
|
|
a.completeClaudeSDKWaiterEvent(adapterSession, waiter, pending.turnID, events, false, nil)
|
|
return
|
|
}
|
|
a.emitClaudeSDKSessionEvents(a.claudeSDKAdapterSessionID(adapterSession, session.AgentSessionID), events)
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) InteractiveDisposition(session Session, turnID string, requestID string) InteractiveDisposition {
|
|
return a.InteractiveDispositionForTarget(session, session.AgentSessionID, turnID, requestID)
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) InteractiveDispositionForTarget(_ Session, agentSessionID string, turnID string, requestID string) InteractiveDisposition {
|
|
if pending := a.getClaudeSDKPendingRequest(agentSessionID, turnID, requestID); pending != nil {
|
|
return runtimeInteractiveDisposition(pending)
|
|
}
|
|
return a.terminalInteractiveDisposition(agentSessionID, turnID, requestID)
|
|
}
|
|
|
|
func (*ClaudeCodeSDKAdapter) StateAfterInteractiveSelection(
|
|
_ Session,
|
|
optionID string,
|
|
) (InteractiveSelectionState, bool) {
|
|
planMode, permissionMode, ok := claudeCodeModeFromID(optionID)
|
|
return InteractiveSelectionState{
|
|
PlanMode: planMode,
|
|
PermissionMode: permissionMode,
|
|
}, ok
|
|
}
|
|
|
|
func (*ClaudeCodeSDKAdapter) ControllerSendsInteractiveDenyFollowUp() bool {
|
|
return false
|
|
}
|
|
|
|
// claudeCodeModeFromID owns the SDK's interactive option vocabulary. The
|
|
// controller only receives the generic settings projection above.
|
|
func claudeCodeModeFromID(optionID string) (bool, string, bool) {
|
|
switch strings.TrimSpace(optionID) {
|
|
case "plan":
|
|
return true, "", true
|
|
case "default", "acceptEdits", "dontAsk", "bypassPermissions":
|
|
return false, strings.TrimSpace(optionID), true
|
|
case "auto":
|
|
// Older sidecars used "auto" for the accept-edits exit-plan choice.
|
|
return false, "acceptEdits", true
|
|
default:
|
|
return false, "", false
|
|
}
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKInteractiveRequested(
|
|
adapterSession *claudeSDKAdapterSession,
|
|
session Session,
|
|
turnID string,
|
|
providerTurnID string,
|
|
payload map[string]any,
|
|
) ([]activityshared.Event, error) {
|
|
requestID := firstNonEmpty(payloadString(payload, "requestId"), payloadString(payload, "id"), newID())
|
|
toolCall := claudeSDKInteractiveToolCall(payload, requestID)
|
|
eventSession := session
|
|
eventTurnID := strings.TrimSpace(turnID)
|
|
child, childScoped := adapterSession.claudeSDKChildForPayload(payload)
|
|
if !childScoped {
|
|
child, childScoped = adapterSession.claudeSDKChildForPayload(toolCall)
|
|
}
|
|
if childScoped {
|
|
eventSession = claudeSDKChildRuntimeSession(session, child)
|
|
eventTurnID = child.TurnID
|
|
}
|
|
transportTurnID := firstNonEmptyString(
|
|
payloadString(payload, "turnId"),
|
|
payloadString(payload, "turnID"),
|
|
)
|
|
if transportTurnID == "" {
|
|
return nil, errors.New(
|
|
"claude SDK interaction omitted transport turn identity",
|
|
)
|
|
}
|
|
options := claudeSDKInteractiveOptions(payload, toolCall)
|
|
interactivePrompt := normalizedInteractivePrompt(toolCall, options, requestID)
|
|
title := firstNonEmpty(
|
|
asString(toolCall["title"]),
|
|
asString(toolCall["name"]),
|
|
asString(toolCall["toolName"]),
|
|
asString(toolCall["toolCallId"]),
|
|
"Permission requested",
|
|
)
|
|
callID := "approval:" + requestID
|
|
callType := "approval"
|
|
status := string(activityshared.TurnPhaseWaitingApproval)
|
|
input := normalizedApprovalInput(toolCall, options, requestID, nil)
|
|
approvalPurpose := ""
|
|
if interactivePrompt == nil {
|
|
approvalPurpose = normalizedApprovalPurpose(toolCall)
|
|
}
|
|
eventPayload := map[string]any{
|
|
"callId": callID,
|
|
"callType": "approval",
|
|
"name": title,
|
|
"toolName": "Approval",
|
|
"status": status,
|
|
"input": input,
|
|
}
|
|
if approvalPurpose != "" {
|
|
eventPayload["approvalPurpose"] = approvalPurpose
|
|
}
|
|
if interactivePrompt != nil {
|
|
callType = "interactive"
|
|
callID = firstNonEmpty(asString(toolCall["toolCallId"]), asString(toolCall["id"]), requestID)
|
|
title = firstNonEmpty(interactivePrompt.ToolName, title)
|
|
status = firstNonEmpty(interactivePrompt.Status, "waiting_input")
|
|
input = clonePayload(interactivePrompt.Input)
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
input["requestId"] = requestID
|
|
if len(options) > 0 {
|
|
input["options"] = cloneOptionMaps(options)
|
|
}
|
|
eventPayload = map[string]any{
|
|
"callId": callID,
|
|
"callType": callType,
|
|
"name": title,
|
|
"toolName": interactivePrompt.ToolName,
|
|
"status": status,
|
|
"input": input,
|
|
}
|
|
if metadata := clonePayload(interactivePrompt.Metadata); metadata != nil {
|
|
eventPayload["metadata"] = metadata
|
|
}
|
|
}
|
|
pending := &pendingInteractiveRequest{
|
|
agentSessionID: strings.TrimSpace(eventSession.AgentSessionID),
|
|
requestID: requestID,
|
|
eventID: newID(),
|
|
callID: callID,
|
|
callType: callType,
|
|
turnID: eventTurnID,
|
|
providerTurnID: strings.TrimSpace(providerTurnID),
|
|
transportTurnID: transportTurnID,
|
|
input: input,
|
|
kind: firstNonEmpty(interactivePromptKind(interactivePrompt), "approval"),
|
|
approvalPurpose: approvalPurpose,
|
|
name: title,
|
|
toolName: firstNonEmpty(asString(eventPayload["toolName"]), title),
|
|
prompt: interactivePrompt,
|
|
options: options,
|
|
response: make(chan pendingInteractiveResponse, 1),
|
|
}
|
|
if !a.storeClaudeSDKPendingRequest(adapterSession, pending) {
|
|
// The SDK sidecar assigns one request ID to one interaction. A repeated
|
|
// identity is a replay, not a new approval: do not re-open the turn by
|
|
// emitting another waiting transition after the original has settled.
|
|
return nil, nil
|
|
}
|
|
events := []activityshared.Event{
|
|
newTurnActivityEvent(eventSession, EventTurnUpdated, eventTurnID, SessionStatusWaiting, "", "", map[string]any{
|
|
"phase": string(activityshared.TurnPhaseWaitingApproval),
|
|
"requestId": requestID,
|
|
}),
|
|
newTurnActivityEventWithID(
|
|
eventSession,
|
|
pending.eventID,
|
|
EventCallStarted,
|
|
eventTurnID,
|
|
SessionStatusWaiting,
|
|
"",
|
|
title,
|
|
eventPayload,
|
|
),
|
|
normalizedInteractionRequestedEvent(eventSession, eventTurnID, pending),
|
|
}
|
|
if childScoped {
|
|
for index := range events {
|
|
events[index] = claudeSDKEventForChild(events[index], child)
|
|
}
|
|
}
|
|
return events, nil
|
|
}
|
|
|
|
func claudeSDKInteractiveTransportTurnID(
|
|
pending *pendingInteractiveRequest,
|
|
) string {
|
|
if pending == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(pending.transportTurnID)
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKInteractiveResolved(
|
|
adapterSession *claudeSDKAdapterSession,
|
|
session Session,
|
|
turnID string,
|
|
payload map[string]any,
|
|
) []activityshared.Event {
|
|
requestID := payloadString(payload, "requestId")
|
|
eventTurnID := firstNonEmptyString(
|
|
strings.TrimSpace(turnID),
|
|
payloadString(payload, "turnId"),
|
|
payloadString(payload, "turnID"),
|
|
)
|
|
pending := a.getClaudeSDKPendingRequest(session.AgentSessionID, eventTurnID, requestID)
|
|
if pending == nil {
|
|
pending = a.getUniqueClaudeSDKPendingRequestByRequestID(session.AgentSessionID, requestID)
|
|
}
|
|
if pending == nil {
|
|
return nil
|
|
}
|
|
effectiveTurnID := firstNonEmptyString(
|
|
strings.TrimSpace(turnID),
|
|
payloadString(payload, "turnId"),
|
|
payloadString(payload, "turnID"),
|
|
pending.turnID,
|
|
)
|
|
eventSession := session
|
|
child, childScoped := adapterSession.claudeSDKChildByAgentSessionID(pending.agentSessionID)
|
|
if childScoped {
|
|
eventSession = claudeSDKChildRuntimeSession(session, child)
|
|
effectiveTurnID = child.TurnID
|
|
}
|
|
response := pendingInteractiveResponse{
|
|
optionID: firstNonEmpty(payloadString(payload, "optionId"), payloadString(payload, "selectedId")),
|
|
action: payloadString(payload, "action"),
|
|
payload: payloadMap(payload, "payload"),
|
|
}
|
|
if errText := payloadString(payload, "error"); errText != "" {
|
|
if !pending.finish(pendingInteractiveRequestStateSuperseded) {
|
|
return nil
|
|
}
|
|
events := normalizedPermissionResolvedEvents(eventSession, effectiveTurnID, pending, pendingInteractiveResponse{}, errors.New(errText))
|
|
return claudeSDKScopeInteractiveEvents(events, child, childScoped)
|
|
}
|
|
if !pending.finish(pendingInteractiveRequestStateAnswered) {
|
|
return nil
|
|
}
|
|
events := normalizedPermissionResolvedEvents(eventSession, effectiveTurnID, pending, response, nil)
|
|
return claudeSDKScopeInteractiveEvents(events, child, childScoped)
|
|
}
|
|
|
|
func claudeSDKScopeInteractiveEvents(events []activityshared.Event, child claudeSDKChildSession, childScoped bool) []activityshared.Event {
|
|
if !childScoped {
|
|
return events
|
|
}
|
|
for index := range events {
|
|
events[index] = claudeSDKEventForChild(events[index], child)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKPendingRequestFailureEvents(
|
|
adapterSession *claudeSDKAdapterSession,
|
|
session Session,
|
|
turnID string,
|
|
err error,
|
|
) []activityshared.Event {
|
|
if a == nil || adapterSession == nil {
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
pending := make([]*pendingInteractiveRequest, 0, len(adapterSession.pendingRequests))
|
|
for _, request := range adapterSession.pendingRequests {
|
|
pending = append(pending, request)
|
|
}
|
|
a.mu.Unlock()
|
|
events := make([]activityshared.Event, 0, len(pending))
|
|
for _, request := range pending {
|
|
if !request.finish(pendingInteractiveRequestStateSuperseded) {
|
|
continue
|
|
}
|
|
effectiveTurnID := firstNonEmptyString(strings.TrimSpace(turnID), request.turnID)
|
|
events = append(events, normalizedPermissionResolvedEvents(session, effectiveTurnID, request, pendingInteractiveResponse{}, err)...)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func claudeSDKInteractiveToolCall(payload map[string]any, requestID string) map[string]any {
|
|
toolCall := clonePayload(payloadMap(payload, "toolCall"))
|
|
if toolCall == nil {
|
|
toolCall = map[string]any{}
|
|
}
|
|
toolName := firstNonEmpty(
|
|
payloadString(payload, "toolName"),
|
|
asString(toolCall["toolName"]),
|
|
asString(toolCall["name"]),
|
|
asString(toolCall["title"]),
|
|
)
|
|
if toolName == "" {
|
|
toolName = "Approval"
|
|
}
|
|
toolCall["toolName"] = toolName
|
|
toolCall["name"] = firstNonEmpty(asString(toolCall["name"]), toolName)
|
|
toolCall["title"] = firstNonEmpty(asString(toolCall["title"]), toolName)
|
|
toolCall["toolCallId"] = firstNonEmpty(
|
|
payloadString(payload, "toolCallId"),
|
|
asString(toolCall["toolCallId"]),
|
|
asString(toolCall["id"]),
|
|
requestID,
|
|
)
|
|
if input := payloadMap(payload, "input"); len(input) > 0 {
|
|
toolCall["input"] = clonePayload(input)
|
|
}
|
|
return toolCall
|
|
}
|
|
|
|
func claudeSDKInteractiveOptions(payload map[string]any, toolCall map[string]any) []map[string]any {
|
|
if options := cloneOptionMaps(payloadArray(payload["options"])); len(options) > 0 {
|
|
return options
|
|
}
|
|
switch normalizedInteractiveToolName(toolCall) {
|
|
case "AskUserQuestion":
|
|
return nil
|
|
case "ExitPlanMode":
|
|
return []map[string]any{
|
|
{"kind": "allow_always", "name": "Yes, and auto-accept edits", "optionId": "acceptEdits"},
|
|
{"kind": "allow_once", "name": "Yes, and manually approve edits", "optionId": "default"},
|
|
{"kind": "reject_once", "name": "No, keep planning", "optionId": "plan"},
|
|
}
|
|
default:
|
|
return []map[string]any{
|
|
{"kind": "allow_always", "name": "Allow for session", "optionId": "allow_always"},
|
|
{"kind": "allow_once", "name": "Allow", "optionId": "allow"},
|
|
{"kind": "reject_once", "name": "Reject", "optionId": "reject"},
|
|
}
|
|
}
|
|
}
|
|
|
|
// storeClaudeSDKPendingRequest records one provider interaction exactly once.
|
|
// The caller must not publish its waiting events when this returns false.
|
|
func (a *ClaudeCodeSDKAdapter) storeClaudeSDKPendingRequest(adapterSession *claudeSDKAdapterSession, pending *pendingInteractiveRequest) bool {
|
|
if a == nil || adapterSession == nil || pending == nil {
|
|
return false
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if adapterSession.pendingRequests == nil {
|
|
adapterSession.pendingRequests = make(map[string]*pendingInteractiveRequest)
|
|
}
|
|
key := claudeSDKPendingRequestKey(pending.turnID, pending.requestID)
|
|
if _, exists := adapterSession.pendingRequests[key]; exists {
|
|
return false
|
|
}
|
|
terminalKey := newInteractiveRequestKey(pending.agentSessionID, pending.turnID, pending.requestID)
|
|
if a.terminalInteractions.get(terminalKey) != InteractiveDispositionUnknown {
|
|
return false
|
|
}
|
|
pending.onTerminal = a.recordTerminalInteractiveRequest
|
|
adapterSession.pendingRequests[key] = pending
|
|
return true
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) getClaudeSDKPendingRequest(agentSessionID string, turnID string, requestID string) *pendingInteractiveRequest {
|
|
_, pending := a.getClaudeSDKPendingRequestWithSession(agentSessionID, turnID, requestID)
|
|
return pending
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) getClaudeSDKPendingRequestWithSession(agentSessionID string, turnID string, requestID string) (*claudeSDKAdapterSession, *pendingInteractiveRequest) {
|
|
if a == nil {
|
|
return nil, nil
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
adapterSession := a.claudeSDKAdapterSessionForAgentSessionIDLocked(agentSessionID)
|
|
if adapterSession == nil || adapterSession.invalid {
|
|
return nil, nil
|
|
}
|
|
return adapterSession, adapterSession.pendingRequests[claudeSDKPendingRequestKey(turnID, requestID)]
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) getUniqueClaudeSDKPendingRequestByRequestID(agentSessionID string, requestID string) *pendingInteractiveRequest {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
adapterSession := a.claudeSDKAdapterSessionForAgentSessionIDLocked(agentSessionID)
|
|
if adapterSession == nil || adapterSession.invalid {
|
|
return nil
|
|
}
|
|
if adapterSession.pendingRequests == nil {
|
|
return nil
|
|
}
|
|
requestID = strings.TrimSpace(requestID)
|
|
var match *pendingInteractiveRequest
|
|
for _, pending := range adapterSession.pendingRequests {
|
|
if strings.TrimSpace(pending.requestID) != requestID {
|
|
continue
|
|
}
|
|
if match != nil {
|
|
return nil
|
|
}
|
|
match = pending
|
|
}
|
|
return match
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKAdapterSessionForAgentSessionIDLocked(agentSessionID string) *claudeSDKAdapterSession {
|
|
agentSessionID = strings.TrimSpace(agentSessionID)
|
|
if adapterSession := a.sessions[agentSessionID]; adapterSession != nil {
|
|
return adapterSession
|
|
}
|
|
for _, adapterSession := range a.sessions {
|
|
if _, ok := adapterSession.claudeSDKChildByAgentSessionID(agentSessionID); ok {
|
|
return adapterSession
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKAdapterSessionID(adapterSession *claudeSDKAdapterSession, fallback string) string {
|
|
if a == nil || adapterSession == nil {
|
|
return strings.TrimSpace(fallback)
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if agentSessionID := strings.TrimSpace(adapterSession.session.AgentSessionID); agentSessionID != "" {
|
|
return agentSessionID
|
|
}
|
|
for agentSessionID, candidate := range a.sessions {
|
|
if candidate == adapterSession {
|
|
return agentSessionID
|
|
}
|
|
}
|
|
return strings.TrimSpace(fallback)
|
|
}
|
|
|
|
func claudeSDKPendingRequestKey(turnID string, requestID string) string {
|
|
return strings.TrimSpace(turnID) + "\x00" + strings.TrimSpace(requestID)
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) recordTerminalInteractiveRequest(pending *pendingInteractiveRequest, state pendingInteractiveRequestState) {
|
|
if a == nil || pending == nil {
|
|
return
|
|
}
|
|
disposition := interactiveDispositionFromState(state)
|
|
key := newInteractiveRequestKey(pending.agentSessionID, pending.turnID, pending.requestID)
|
|
a.mu.Lock()
|
|
if adapterSession := a.claudeSDKAdapterSessionForAgentSessionIDLocked(key.agentSessionID); adapterSession != nil && adapterSession.pendingRequests != nil {
|
|
mapKey := claudeSDKPendingRequestKey(key.turnID, key.requestID)
|
|
if adapterSession.pendingRequests[mapKey] == pending {
|
|
delete(adapterSession.pendingRequests, mapKey)
|
|
}
|
|
}
|
|
a.terminalInteractions.put(key, disposition)
|
|
sink := a.interactiveDispositionSink
|
|
a.mu.Unlock()
|
|
if sink != nil {
|
|
sink(key.agentSessionID, key.turnID, key.requestID, disposition)
|
|
}
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) SetInteractiveDispositionSink(sink InteractiveDispositionSink) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
a.interactiveDispositionSink = sink
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) terminalInteractiveDisposition(agentSessionID string, turnID string, requestID string) InteractiveDisposition {
|
|
if a == nil {
|
|
return InteractiveDispositionUnknown
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.terminalInteractions.get(newInteractiveRequestKey(agentSessionID, turnID, requestID))
|
|
}
|
|
|
|
func (a *ClaudeCodeSDKAdapter) claudeSDKPendingInteractive(adapterSession *claudeSDKAdapterSession) *SessionInteractivePrompt {
|
|
if a == nil || adapterSession == nil {
|
|
return nil
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if len(adapterSession.pendingRequests) == 0 {
|
|
return nil
|
|
}
|
|
for _, pending := range adapterSession.pendingRequests {
|
|
if prompt := pending.snapshotPrompt(); prompt != nil {
|
|
return prompt
|
|
}
|
|
}
|
|
return nil
|
|
}
|