package agentruntime import ( "context" "strings" activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events" "github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical" ) func (c *Controller) UpdateSettings(ctx context.Context, input UpdateSettingsInput) (UpdateSettingsResult, error) { session, adapter, err := c.sessionAndAdapter(input.RoomID, input.AgentSessionID) if err != nil { return UpdateSettingsResult{}, err } if validator, ok := adapter.(SessionSettingsValidationAdapter); ok { if err := validator.ValidateSessionSettings(session, input.Settings); err != nil { return UpdateSettingsResult{}, err } } nextSession := session settings := normalizeSessionSettings(nextSession.Settings, nextSession.Provider, nextSession.PermissionModeID) if input.Settings.Model != nil { settings.Model = strings.TrimSpace(*input.Settings.Model) } if input.Settings.ReasoningEffort != nil { settings.ReasoningEffort = strings.TrimSpace(*input.Settings.ReasoningEffort) } if input.Settings.Speed != nil { settings.Speed = strings.TrimSpace(*input.Settings.Speed) } if input.Settings.PlanMode != nil { settings.PlanMode = *input.Settings.PlanMode } if input.Settings.BrowserUse != nil { value := *input.Settings.BrowserUse settings.BrowserUse = &value } if input.Settings.ComputerUse != nil { value := *input.Settings.ComputerUse settings.ComputerUse = &value } permissionChanged := false if input.Settings.PermissionModeID != nil { normalized := normalizePermissionModeIDWithFallback( nextSession.Provider, strings.TrimSpace(*input.Settings.PermissionModeID), nextSession.PermissionModeID, ) permissionChanged = normalized != nextSession.PermissionModeID settings.PermissionModeID = normalized nextSession.PermissionModeID = normalized } nextSession.Settings = cloneSessionSettings(settings) if newSessionAdapter, ok := adapter.(NewSessionSettingsAdapter); ok && newSessionAdapter.RequiresNewSessionForSettings(session, input.Settings) { return UpdateSettingsResult{}, ErrSessionSettingsRequireNewSession } if permissionChanged { if permissionAdapter, ok := adapter.(PermissionModeAdapter); ok { if err := permissionAdapter.ApplyPermissionMode(ctx, nextSession); err != nil { return UpdateSettingsResult{}, err } } } if liveSettingsAdapter, ok := adapter.(LiveSettingsAdapter); ok { if err := liveSettingsAdapter.ApplySessionSettings(ctx, nextSession, input.Settings); err != nil { return UpdateSettingsResult{}, err } } c.store(nextSession) return UpdateSettingsResult{ AgentSessionID: nextSession.AgentSessionID, Settings: settings, }, nil } // UpdateRetainedSettings changes only the Controller Session snapshot. It is // used while the provider connection is absent so the next just-in-time Resume // receives current canonical settings without reconnecting early. func (c *Controller) UpdateRetainedSettings(_ context.Context, input UpdateSettingsInput) (UpdateSettingsResult, error) { session, ok := c.get(strings.TrimSpace(input.RoomID), strings.TrimSpace(input.AgentSessionID)) if !ok { return UpdateSettingsResult{}, ErrSessionNotFound } settings := normalizeSessionSettings(session.Settings, session.Provider, session.PermissionModeID) if input.Settings.Model != nil { settings.Model = strings.TrimSpace(*input.Settings.Model) } if input.Settings.ReasoningEffort != nil { settings.ReasoningEffort = strings.TrimSpace(*input.Settings.ReasoningEffort) } if input.Settings.Speed != nil { settings.Speed = strings.TrimSpace(*input.Settings.Speed) } if input.Settings.PlanMode != nil { settings.PlanMode = *input.Settings.PlanMode } if input.Settings.BrowserUse != nil { value := *input.Settings.BrowserUse settings.BrowserUse = &value } if input.Settings.ComputerUse != nil { value := *input.Settings.ComputerUse settings.ComputerUse = &value } if input.Settings.PermissionModeID != nil { settings.PermissionModeID = normalizePermissionModeIDWithFallback( session.Provider, strings.TrimSpace(*input.Settings.PermissionModeID), session.PermissionModeID, ) session.PermissionModeID = settings.PermissionModeID } session.Settings = cloneSessionSettings(settings) c.store(session) return UpdateSettingsResult{AgentSessionID: session.AgentSessionID, Settings: settings}, nil } func shouldAdvanceSessionUpdatedAtFromEvents(events []activityshared.Event) bool { for _, event := range events { switch event.Type { case activityshared.EventTurnStarted, activityshared.EventTurnCompleted, activityshared.EventTurnFailed: return true case activityshared.EventTurnUpdated: switch strings.TrimSpace(event.Payload.TurnPhase) { case string(activityshared.TurnPhaseWaitingApproval), string(activityshared.TurnPhaseWaitingInput), string(activityshared.SessionStatusWaiting): return true } } } return false } func (c *Controller) State(roomID, agentSessionID string) (SessionStateSnapshot, error) { session, adapter, err := c.sessionAndAdapter(roomID, agentSessionID) if err != nil { return SessionStateSnapshot{}, err } snapshot := SessionStateSnapshot{ RoomID: session.RoomID, AgentSessionID: session.AgentSessionID, AgentTargetID: session.AgentTargetID, Provider: session.Provider, ProviderSessionID: session.ProviderSessionID, Resumable: session.Resumable, Status: session.Status, TurnLifecycle: cloneRuntimeTurnLifecycle(session.TurnLifecycle), SubmitAvailability: cloneRuntimeSubmitAvailability(session.SubmitAvailability), PermissionModeID: session.PermissionModeID, Settings: normalizeOptionalSessionSettings(session.Settings, session.Provider, session.PermissionModeID), RuntimeContext: sessionRuntimeContextSnapshot(session), UpdatedAtUnixMS: session.UpdatedAtUnixMS, } if snapshot.Settings != nil { snapshot.RuntimeContext["model"] = snapshot.Settings.Model snapshot.RuntimeContext["reasoningEffort"] = snapshot.Settings.ReasoningEffort snapshot.RuntimeContext["speed"] = snapshot.Settings.Speed snapshot.RuntimeContext["planMode"] = snapshot.Settings.PlanMode } if stateAdapter, ok := adapter.(StateAdapter); ok { override := stateAdapter.SessionState(session) if override.RoomID != "" { snapshot.RoomID = override.RoomID } if override.AgentSessionID != "" { snapshot.AgentSessionID = override.AgentSessionID } if override.AgentTargetID != "" { snapshot.AgentTargetID = override.AgentTargetID } if override.Provider != "" { snapshot.Provider = override.Provider } if override.ProviderSessionID != "" { snapshot.ProviderSessionID = override.ProviderSessionID } if override.Status != "" { snapshot.Status = override.Status } if override.TurnLifecycle != nil { snapshot.TurnLifecycle = cloneRuntimeTurnLifecycle(override.TurnLifecycle) } if override.SubmitAvailability != nil { snapshot.SubmitAvailability = cloneRuntimeSubmitAvailability(override.SubmitAvailability) } if override.PermissionModeID != "" { snapshot.PermissionModeID = normalizePermissionModeIDWithFallback( session.Provider, override.PermissionModeID, snapshot.PermissionModeID, ) } if override.Settings != nil { snapshot.Settings = normalizeOptionalSessionSettings(override.Settings, session.Provider, snapshot.PermissionModeID) } if override.Capabilities != nil { snapshot.Capabilities = canonical.CloneCapabilitySnapshot(override.Capabilities) } if override.AuthState != "" { snapshot.AuthState = override.AuthState } if override.RuntimeContext != nil { snapshot.RuntimeContext = mergeRuntimeContextPatch(snapshot.RuntimeContext, override.RuntimeContext) } if override.PendingInteractive != nil { snapshot.PendingInteractive = override.PendingInteractive } if override.UpdatedAtUnixMS > 0 { snapshot.UpdatedAtUnixMS = override.UpdatedAtUnixMS } } if snapshot.RuntimeContext == nil { snapshot.RuntimeContext = map[string]any{} } snapshot.RuntimeContext["permissionModeId"] = snapshot.PermissionModeID snapshot.RuntimeContext["visible"] = session.Visible if snapshot.Settings != nil { snapshot.RuntimeContext["model"] = snapshot.Settings.Model snapshot.RuntimeContext["reasoningEffort"] = snapshot.Settings.ReasoningEffort snapshot.RuntimeContext["speed"] = snapshot.Settings.Speed snapshot.RuntimeContext["planMode"] = snapshot.Settings.PlanMode } return snapshot, nil } func (c *Controller) sessionStateSnapshot(session Session) SessionStateSnapshot { snapshot, err := c.State(session.RoomID, session.AgentSessionID) if err == nil { return snapshot } return SessionStateSnapshot{ RoomID: session.RoomID, AgentSessionID: session.AgentSessionID, AgentTargetID: session.AgentTargetID, Provider: session.Provider, ProviderSessionID: session.ProviderSessionID, Resumable: session.Resumable, Status: session.Status, TurnLifecycle: cloneRuntimeTurnLifecycle(session.TurnLifecycle), SubmitAvailability: cloneRuntimeSubmitAvailability( session.SubmitAvailability, ), PermissionModeID: session.PermissionModeID, Settings: normalizeOptionalSessionSettings(session.Settings, session.Provider, session.PermissionModeID), RuntimeContext: sessionRuntimeContextSnapshot(session), UpdatedAtUnixMS: session.UpdatedAtUnixMS, } } func sessionRuntimeContextSnapshot(session Session) map[string]any { runtimeContext := clonePayload(session.RuntimeContext) if runtimeContext == nil { runtimeContext = map[string]any{} } // Session capabilities have a typed snapshot contract. Strip legacy // runtime-context copies so partial provider state cannot become an // accidental second source of truth. delete(runtimeContext, "capabilities") delete(runtimeContext, "capabilitiesReported") runtimeContext["cwd"] = session.CWD runtimeContext["title"] = session.Title runtimeContext["permissionModeId"] = session.PermissionModeID runtimeContext["visible"] = session.Visible return runtimeContext }